Skip to content
8 changes: 6 additions & 2 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -967,9 +967,13 @@ describe('gemini.tsx main function', () => {
const { relaunchOnExitCode } = await import('./utils/relaunch.js');
const [, options] = vi.mocked(relaunchOnExitCode).mock.calls[0]!;

await expect(options?.onUpdateRelaunch?.()).resolves.toBe(44);
await expect(options?.onUpdateRelaunch?.(true)).resolves.toBe(44);

expect(mockUpdateBeforeRelaunch).toHaveBeenCalledTimes(1);
expect(mockUpdateBeforeRelaunch).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
true,
);
});

it('passes host update capability into a container sandbox', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ export async function main() {
? getNodeMemoryArgs(isDebugMode)
: [];
const updateProjectRoot = process.cwd();
const onUpdateRelaunch = async () => {
const onUpdateRelaunch = async (relaunchOnFailure: boolean) => {
await initializeI18n(
resolveLanguageSetting(settings.merged.general?.language as string),
);
Expand All @@ -410,6 +410,7 @@ export async function main() {
const shouldRelaunch = await updateBeforeRelaunch(
settings,
updateProjectRoot,
relaunchOnFailure,
);
return shouldRelaunch ? UPDATE_COMPLETE_EXIT_CODE : 0;
};
Expand Down
251 changes: 247 additions & 4 deletions packages/cli/src/ui/utils/updateCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
checkForUpdates,
checkForUpdatesDetailed,
fetchGlobalNpmUpdateInfo,
FETCH_TIMEOUT_MS,
isGlobalNpmInstallation,
runGlobalNpm,
UpdateCheckTimeoutError,
} from './updateCheck.js';

Expand Down Expand Up @@ -203,6 +206,242 @@ describe('checkForUpdates', () => {
});
});

it('checks npm updates in the global npm context', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '"1.1.0"\n',
stderr: '',
});

await expect(
fetchGlobalNpmUpdateInfo(
'@qwen-code/qwen-code',
'1.0.0',
'latest',
run as unknown as NonNullable<
Parameters<typeof fetchGlobalNpmUpdateInfo>[3]
>,
),
).resolves.toMatchObject({ current: '1.0.0', latest: '1.1.0' });
const npmArgs = [
'view',
'@qwen-code/qwen-code',
'dist-tags.latest',
'--json',
'--global',
];
expect(run).toHaveBeenCalledWith(
process.execPath,
[expect.stringMatching(/npm-cli\.js$/), ...npmArgs],
expect.objectContaining({
timeout: FETCH_TIMEOUT_MS,
}),
);
});

it('selects the global npm registry for global npm installs', async () => {
getPackageJson.mockResolvedValue({
name: '@qwen-code/qwen-code',
version: '1.0.0',
});
const detectGlobalNpm = vi.fn().mockResolvedValue(true);
const fetchGlobalNpm = vi.fn().mockResolvedValue({
current: '1.0.0',
latest: '1.1.0',
});

await expect(
checkForUpdatesDetailed(detectGlobalNpm, fetchGlobalNpm),
).resolves.toMatchObject({
status: 'update',
info: { update: { current: '1.0.0', latest: '1.1.0' } },
});

expect(detectGlobalNpm).toHaveBeenCalledOnce();
expect(fetchGlobalNpm).toHaveBeenCalledWith(
'@qwen-code/qwen-code',
'1.0.0',
'latest',
);
expect(updateNotifier).not.toHaveBeenCalled();
});

it('does not treat pnpm installs as global npm installs', async () => {
const run = vi.fn();

await expect(
isGlobalNpmInstallation(
'/home/user/.pnpm/@qwen-code+qwen-code/node_modules/@qwen-code/qwen-code/dist/index.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
),
).resolves.toBe(false);
expect(run).not.toHaveBeenCalled();
});

it('resolves a bin symlink before matching the npm package path', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '/usr/local/lib/node_modules\n',
stderr: '',
});
const canonicalize = vi.fn(async (candidate: string) =>
candidate === '/usr/local/bin/qwen'
? '/usr/local/lib/node_modules/@qwen-code/qwen-code/dist/cli.js'
: '/usr/local/lib/node_modules',
);

await expect(
isGlobalNpmInstallation(
'/usr/local/bin/qwen',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
canonicalize as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[2]
>,
),
).resolves.toBe(true);
});

it('runs the Windows npm CLI through Node without a shell', async () => {
const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"', stderr: '' });
const resolveNpmCliPath = vi
.fn()
.mockReturnValue(
'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js',
);

await runGlobalNpm(
['view', '@qwen-code/qwen-code'],
run as unknown as NonNullable<Parameters<typeof runGlobalNpm>[1]>,
'win32',
'C:\\Program Files\\nodejs\\node.exe',
resolveNpmCliPath,
);

expect(run).toHaveBeenCalledWith(
'C:\\Program Files\\nodejs\\node.exe',
[
'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js',
'view',
'@qwen-code/qwen-code',
],
expect.not.objectContaining({ shell: true }),
);
expect(resolveNpmCliPath).toHaveBeenCalledWith(
'C:\\Program Files\\nodejs\\node.exe',
'win32',
);
});

it('canonicalizes the global npm root before comparing paths', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '/linked/node_modules\n',
stderr: '',
});
const canonicalize = vi.fn(async (candidate: string) =>
candidate === '/linked/node_modules'
? '/real/node_modules'
: '/real/node_modules/@qwen-code/qwen-code/cli.js',
);

await expect(
isGlobalNpmInstallation(
'/linked/node_modules/@qwen-code/qwen-code/cli.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
canonicalize as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[2]
>,
),
).resolves.toBe(true);
expect(run).toHaveBeenCalledWith(
process.execPath,
[expect.stringMatching(/npm-cli\.js$/), 'root', '--global'],
expect.objectContaining({ timeout: FETCH_TIMEOUT_MS }),
);
});

it('does not treat a missing global npm root as a global install', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '/missing/node_modules\n',
stderr: '',
});
const canonicalize = vi.fn(async (candidate: string) => {
if (candidate === '/missing/node_modules') {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return '/local/node_modules/@qwen-code/qwen-code/cli.js';
});

await expect(
isGlobalNpmInstallation(
'/local/node_modules/@qwen-code/qwen-code/cli.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
canonicalize as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[2]
>,
),
).resolves.toBe(false);
});

it('does not treat local npm installs as global npm installs', async () => {
const run = vi.fn().mockResolvedValue({
stdout: '/global/node_modules\n',
stderr: '',
});
const canonicalize = vi.fn(async (candidate: string) =>
candidate === '/global/node_modules'
? '/global/node_modules'
: '/repo/node_modules/@qwen-code/qwen-code/cli.js',
);

await expect(
isGlobalNpmInstallation(
'/repo/node_modules/@qwen-code/qwen-code/cli.js',
run as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[1]
>,
canonicalize as unknown as NonNullable<
Parameters<typeof isGlobalNpmInstallation>[2]
>,
),
).resolves.toBe(false);
});

it('does not fall back when the global npm query fails', async () => {
const run = vi.fn().mockRejectedValue(new Error('npm view failed'));

await expect(
fetchGlobalNpmUpdateInfo(
'@qwen-code/qwen-code',
'1.0.0',
'latest',
run as unknown as NonNullable<
Parameters<typeof fetchGlobalNpmUpdateInfo>[3]
>,
),
).rejects.toThrow('npm view failed');
});

it('treats an empty dist-tag response as no update', async () => {
const run = vi.fn().mockResolvedValue({ stdout: '\n', stderr: '' });

await expect(
fetchGlobalNpmUpdateInfo(
'@qwen-code/qwen-code',
'1.0.0',
'nightly',
run as unknown as NonNullable<
Parameters<typeof fetchGlobalNpmUpdateInfo>[3]
>,
),
).resolves.toMatchObject({ current: '1.0.0', latest: '1.0.0' });
});

it('should pass a non-optional package version to update-notifier', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
Expand Down Expand Up @@ -276,7 +515,11 @@ describe('checkForUpdates', () => {
fetchInfo: vi.fn().mockReturnValue(new Promise(() => {})),
});

const resultPromise = checkForUpdatesDetailed();
// Stub the global-npm probe: the real isGlobalNpmInstallation runs a
// real realpath() I/O before the timeout is armed, which races with the
// fake-timer advance below and makes this test hang non-deterministically
// on slow/loaded runners.
const resultPromise = checkForUpdatesDetailed(async () => false);
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1);
const result = await resultPromise;

Expand Down Expand Up @@ -304,7 +547,7 @@ describe('checkForUpdates', () => {
.mockResolvedValue({ current: '1.0.0', latest: '1.1.0' }),
});

const result = await checkForUpdatesDetailed();
const result = await checkForUpdatesDetailed(async () => false);

expect(result.status).toBe('update');
if (result.status === 'update') {
Expand Down Expand Up @@ -333,7 +576,7 @@ describe('checkForUpdates', () => {
}),
}));

const resultPromise = checkForUpdatesDetailed();
const resultPromise = checkForUpdatesDetailed(async () => false);
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1);
const result = await resultPromise;

Expand All @@ -358,7 +601,7 @@ describe('checkForUpdates', () => {
fetchInfo: () => new Promise(() => {}),
}));

const resultPromise = checkForUpdatesDetailed();
const resultPromise = checkForUpdatesDetailed(async () => false);
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1);
const result = await resultPromise;

Expand Down
Loading
Loading