Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions packages/cli/src/ui/utils/updateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,18 +649,32 @@ describe('classifyUpdateCheckError', () => {
);
});

it.each([
'ENOTFOUND',
'ECONNREFUSED',
'EAI_AGAIN',
'ETIMEDOUT',
'ENETUNREACH',
])('classifies %s errors as offline', (code) => {
const error = new Error(`request failed`) as NodeJS.ErrnoException;
error.code = code;
expect(classifyUpdateCheckError(error)).toBe('offline');
it('classifies execFile timeouts as timeout', () => {
const error = Object.assign(new Error('Command failed: npm view'), {
code: null,
killed: true,
signal: 'SIGTERM',
});

expect(classifyUpdateCheckError(error)).toBe('timeout');
});

it('classifies ETIMEDOUT errors as timeout', () => {
const error = new Error('request failed') as NodeJS.ErrnoException;
error.code = 'ETIMEDOUT';

expect(classifyUpdateCheckError(error)).toBe('timeout');
});

it.each(['ENOTFOUND', 'ECONNREFUSED', 'EAI_AGAIN', 'ENETUNREACH'])(
'classifies %s errors as offline',
(code) => {
const error = new Error(`request failed`) as NodeJS.ErrnoException;
error.code = code;
expect(classifyUpdateCheckError(error)).toBe('offline');
},
);

it('classifies network codes embedded in the message as offline', () => {
// npm child-process failures surface the code inside stderr text only.
expect(
Expand All @@ -670,6 +684,15 @@ describe('classifyUpdateCheckError', () => {
).toBe('offline');
});

it('classifies network codes from the error cause as offline', () => {
const cause = new Error('getaddrinfo failed') as NodeJS.ErrnoException;
cause.code = 'ENOTFOUND';

expect(
classifyUpdateCheckError(new TypeError('fetch failed', { cause })),
).toBe('offline');
});

it('classifies other errors as registry', () => {
expect(classifyUpdateCheckError(new Error('404 Not Found'))).toBe(
'registry',
Expand Down
24 changes: 17 additions & 7 deletions packages/cli/src/ui/utils/updateCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ const NETWORK_ERROR_CODES = [
'ENOTFOUND',
'ECONNREFUSED',
'EAI_AGAIN',
'ETIMEDOUT',
'ENETUNREACH',
];

Expand All @@ -66,16 +65,27 @@ const NETWORK_ERROR_CODES = [
export function classifyUpdateCheckError(
error: unknown,
): UpdateCheckFailureReason {
if (error instanceof UpdateCheckTimeoutError) return 'timeout';
if (error instanceof Error) {
const code = (error as NodeJS.ErrnoException).code;
const errors = [error];
if (error.cause instanceof Error) errors.push(error.cause);
const matchesCode = (code: string) =>
errors.some(
(error) =>
(error as NodeJS.ErrnoException).code === code ||
error.message.includes(code),
);
Comment on lines +71 to +76

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Variable shadowing — the .some() callback parameter error shadows the outer function parameter error. Three distinct bindings named error coexist in this function (outer param at line 68, matchesCode callback here, and UpdateCheckTimeoutError .some() callback at line 80).

Concrete cost: a maintainer editing either callback body to reference the original error (e.g. error.name, error.stack, or a log statement) would silently reference the array element instead — producing a logic bug that passes existing tests because the array element is usually the same object.

Suggested change
const matchesCode = (code: string) =>
errors.some(
(error) =>
(error as NodeJS.ErrnoException).code === code ||
error.message.includes(code),
);
const matchesCode = (code: string) =>
errors.some(
(e) =>
(e as NodeJS.ErrnoException).code === code ||
e.message.includes(code),
);

Apply the same rename on line 80: errors.some((e) => e instanceof UpdateCheckTimeoutError).

— qwen3.7-max via Qwen Code /review


if (
NETWORK_ERROR_CODES.some(
(netCode) => code === netCode || error.message.includes(netCode),
)
errors.some((error) => error instanceof UpdateCheckTimeoutError) ||
('killed' in error &&
error.killed === true &&
'signal' in error &&
error.signal === 'SIGTERM') ||
matchesCode('ETIMEDOUT')
) {
return 'offline';
return 'timeout';
}
if (NETWORK_ERROR_CODES.some(matchesCode)) return 'offline';
}
return 'registry';
}
Expand Down
Loading