mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-07 15:55:50 +00:00
* fix(cli): preserve review startup version in footers * fix(cli): keep review startup version dynamic in bundle * fix(cli): reset review version after managed update * test(cli): use indexed env access * fix(cli): harden review footer strip and version stamping (#8431) * fix(tests): sync qwen-resolve-workflow expectations with externalized review timeouts (#8431) The timeout externalization in #8460 replaced the hardcoded 300/240 values in qwen-code-pr-review.yml with the QWEN_REVIEW_JOB_TIMEOUT_MINUTES and QWEN_REVIEW_MAX_TIMEOUT_MINUTES repository variables but left scripts/tests/qwen-resolve-workflow.test.js asserting the old literals, so the full-profile Test job fails on any branch carrying that change. Update the three affected assertions to pin the externalized shape. * fix(cli): harden the review footer strip per review feedback (#8431) The strip regex kept a 2^(N-1) partition ambiguity for same-line footer runs (measured 5.3 s at n=20) and missed footers truncated before their closing `_`; forged footers also survived on the body channel through `bodyCriticals`, and the values interpolated into the footer were not shape-validated. Guard the repeated group so an iteration cannot span another footer's start, make the final `_` optional, strip body Criticals per entry, refuse footer-forging model ids and non-version stamps, refuse non-object comment entries, pin the CLI-glue test suite against an ambient startup stamp, and cross-assert the LGTM filter regex against the footer builder. * fix(cli): strip review footers before ledger carryover * fix(cli): align ledger footer regression expectation --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
225 lines
9.3 KiB
JavaScript
225 lines
9.3 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
const { spawnSyncMock, existsSyncMock, homedirMock, tmpdirMock } = vi.hoisted(
|
|
() => ({
|
|
spawnSyncMock: vi.fn(() => ({ status: 0, signal: null })),
|
|
existsSyncMock: vi.fn(() => false),
|
|
homedirMock: vi.fn(() => '/home/test-user'),
|
|
tmpdirMock: vi.fn(() => '/tmp'),
|
|
}),
|
|
);
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
spawnSync: spawnSyncMock,
|
|
}));
|
|
|
|
vi.mock('node:fs', async (importOriginal) => ({
|
|
...(await importOriginal()),
|
|
existsSync: existsSyncMock,
|
|
realpathSync: vi.fn((p) => p),
|
|
readFileSync: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })),
|
|
}));
|
|
|
|
vi.mock('node:os', async (importOriginal) => ({
|
|
...(await importOriginal()),
|
|
homedir: homedirMock,
|
|
tmpdir: tmpdirMock,
|
|
}));
|
|
|
|
const normalizePath = (path) => String(path).replaceAll('\\', '/');
|
|
|
|
describe('scripts/cli-entry.js production entry', () => {
|
|
const originalArgv = process.argv;
|
|
let exitSpy;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
homedirMock.mockReturnValue('/home/test-user');
|
|
tmpdirMock.mockReturnValue('/tmp');
|
|
// A non-fast-path command, so the entry takes the spawnSync branch (mocked)
|
|
// instead of importing the real dist/cli.js in-process.
|
|
process.argv = ['node', 'scripts/cli-entry.js', 'review', 'check'];
|
|
// The entry exits after its child returns; the import must survive that.
|
|
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.argv = originalArgv;
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it('stamps QWEN_CODE_CLI with its own path, overriding an inherited one', async () => {
|
|
// The dev and start launchers had this pin; the production entry — the one
|
|
// every npm install actually runs — did not, so a regression back to
|
|
// honouring an inherited value would route installed review subprocesses to
|
|
// an outer or stale CLI with every test green.
|
|
const inherited = process.env.QWEN_CODE_CLI;
|
|
process.env.QWEN_CODE_CLI = '/somewhere/else/entirely/qwen';
|
|
try {
|
|
await import('../cli-entry.js?stamps-own-cli');
|
|
expect(normalizePath(process.env.QWEN_CODE_CLI)).toMatch(
|
|
/scripts\/cli-entry\.js$/,
|
|
);
|
|
} finally {
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_CLI;
|
|
else process.env.QWEN_CODE_CLI = inherited;
|
|
}
|
|
});
|
|
|
|
it('preserves the startup version in child review commands', async () => {
|
|
const inherited = process.env.QWEN_CODE_STARTUP_VERSION;
|
|
process.env.QWEN_CODE_STARTUP_VERSION = '0.21.3';
|
|
try {
|
|
await import('../cli-entry.js?stamps-version');
|
|
expect(process.env.QWEN_CODE_STARTUP_VERSION).toBe('0.21.3');
|
|
// And the value rides the spawned child's env — the hop that actually
|
|
// reaches the submit handler — not merely the parent's copy.
|
|
const spawnEnv = spawnSyncMock.mock.calls.at(-1)?.[2]?.env;
|
|
expect(spawnEnv.QWEN_CODE_STARTUP_VERSION).toBe('0.21.3');
|
|
} finally {
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
else process.env.QWEN_CODE_STARTUP_VERSION = inherited;
|
|
}
|
|
});
|
|
|
|
it('initializes the version for a fresh CLI process', async () => {
|
|
const inherited = process.env.QWEN_CODE_STARTUP_VERSION;
|
|
delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
try {
|
|
await import('../cli-entry.js?initializes-version');
|
|
expect(process.env.QWEN_CODE_STARTUP_VERSION).toBe('0.0.0-test');
|
|
} finally {
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
else process.env.QWEN_CODE_STARTUP_VERSION = inherited;
|
|
}
|
|
});
|
|
|
|
it('clears the startup version before a managed-update relaunch', async () => {
|
|
// Behavioural, not source-text: the update child exits 44, and the
|
|
// relaunch through the launcher must NOT inherit the old session's
|
|
// stamp, so the new build stamps its own version.
|
|
const inherited = process.env.QWEN_CODE_STARTUP_VERSION;
|
|
const inheritedShim = process.env.QWEN_CODE_LAUNCHER_PATH;
|
|
process.env.QWEN_CODE_STARTUP_VERSION = '0.21.3';
|
|
process.env.QWEN_CODE_LAUNCHER_PATH = '/opt/qwen-standalone/bin/qwen';
|
|
existsSyncMock.mockImplementation(
|
|
(p) => normalizePath(p) === '/opt/qwen-standalone/bin/qwen',
|
|
);
|
|
// Snapshot the env AT call time: the mock records the object by
|
|
// reference, so asserting on it later would let a delete that happens
|
|
// AFTER the spawn mutate the record and hide the regression.
|
|
const spawnEnvs = [];
|
|
const spawnImpl = spawnSyncMock.getMockImplementation();
|
|
spawnSyncMock.mockImplementation((_cmd, _args, opts) => {
|
|
spawnEnvs.push({ ...opts.env });
|
|
return spawnEnvs.length === 1
|
|
? { status: 44, signal: null }
|
|
: { status: 0, signal: null };
|
|
});
|
|
try {
|
|
await import('../cli-entry.js?clears-version-on-relaunch');
|
|
expect(spawnEnvs).toHaveLength(2);
|
|
// The pre-update child inherits the session's stamp...
|
|
expect(spawnEnvs[0].QWEN_CODE_STARTUP_VERSION).toBe('0.21.3');
|
|
// ...and the post-update relaunch does not.
|
|
expect('QWEN_CODE_STARTUP_VERSION' in spawnEnvs[1]).toBe(false);
|
|
} finally {
|
|
spawnSyncMock.mockImplementation(spawnImpl);
|
|
existsSyncMock.mockImplementation(() => false);
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
else process.env.QWEN_CODE_STARTUP_VERSION = inherited;
|
|
if (inheritedShim === undefined)
|
|
delete process.env.QWEN_CODE_LAUNCHER_PATH;
|
|
else process.env.QWEN_CODE_LAUNCHER_PATH = inheritedShim;
|
|
}
|
|
});
|
|
|
|
it('leaves the startup version unset when package metadata is unreadable', async () => {
|
|
const inherited = process.env.QWEN_CODE_STARTUP_VERSION;
|
|
delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
const readFileSyncMock = vi.mocked(readFileSync);
|
|
const impl = readFileSyncMock.getMockImplementation();
|
|
readFileSyncMock.mockImplementation(() => {
|
|
throw new Error('unreadable');
|
|
});
|
|
try {
|
|
await import('../cli-entry.js?unreadable-metadata');
|
|
expect(process.env.QWEN_CODE_STARTUP_VERSION).toBeUndefined();
|
|
} finally {
|
|
readFileSyncMock.mockImplementation(impl);
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
else process.env.QWEN_CODE_STARTUP_VERSION = inherited;
|
|
}
|
|
});
|
|
|
|
it('stamps unknown when the package metadata has no version', async () => {
|
|
const inherited = process.env.QWEN_CODE_STARTUP_VERSION;
|
|
delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
const readFileSyncMock = vi.mocked(readFileSync);
|
|
const impl = readFileSyncMock.getMockImplementation();
|
|
readFileSyncMock.mockImplementation(() => '{}');
|
|
try {
|
|
await import('../cli-entry.js?versionless-metadata');
|
|
expect(process.env.QWEN_CODE_STARTUP_VERSION).toBe('unknown');
|
|
} finally {
|
|
readFileSyncMock.mockImplementation(impl);
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_STARTUP_VERSION;
|
|
else process.env.QWEN_CODE_STARTUP_VERSION = inherited;
|
|
}
|
|
});
|
|
|
|
it('prefers the standalone launcher shim, which carries the bundled Node', async () => {
|
|
// The standalone package launches this file through `bin/qwen`, a shim that
|
|
// selects the BUNDLED Node — the host may have none — and announces itself
|
|
// via QWEN_CODE_LAUNCHER_PATH. There, stamping this file would hand every
|
|
// subprocess a `#!/usr/bin/env node` script on a machine where that resolves
|
|
// to nothing. The shim is the entry that reaches this build; stamp it.
|
|
const inheritedCli = process.env.QWEN_CODE_CLI;
|
|
const inheritedShim = process.env.QWEN_CODE_LAUNCHER_PATH;
|
|
process.env.QWEN_CODE_LAUNCHER_PATH = '/opt/qwen-standalone/bin/qwen';
|
|
delete process.env.QWEN_CODE_CLI;
|
|
existsSyncMock.mockImplementation(
|
|
(p) => normalizePath(p) === '/opt/qwen-standalone/bin/qwen',
|
|
);
|
|
try {
|
|
await import('../cli-entry.js?stamps-shim');
|
|
expect(process.env.QWEN_CODE_CLI).toBe('/opt/qwen-standalone/bin/qwen');
|
|
// And the hint is CONSUMED, not leaked: the serve/mcp fast path never
|
|
// reaches the spawn branch that used to delete it, and a child qwen from
|
|
// a different checkout would read the leftover shim and republish it as
|
|
// its own entry — the wrong build, wearing this one's stamp.
|
|
expect('QWEN_CODE_LAUNCHER_PATH' in process.env).toBe(false);
|
|
} finally {
|
|
if (inheritedCli === undefined) delete process.env.QWEN_CODE_CLI;
|
|
else process.env.QWEN_CODE_CLI = inheritedCli;
|
|
if (inheritedShim === undefined)
|
|
delete process.env.QWEN_CODE_LAUNCHER_PATH;
|
|
else process.env.QWEN_CODE_LAUNCHER_PATH = inheritedShim;
|
|
}
|
|
});
|
|
|
|
it('falls back to tmpdir for tilde QWEN_HOME when homedir is unavailable', async () => {
|
|
const inheritedHome = process.env.QWEN_HOME;
|
|
homedirMock.mockImplementation(() => {
|
|
throw new Error('homedir unavailable');
|
|
});
|
|
process.env.QWEN_HOME = '~';
|
|
try {
|
|
await import('../cli-entry.js?tilde-home-fallback');
|
|
expect(normalizePath(process.env.QWEN_CODE_MANAGED_NPM_ROOT)).toBe(
|
|
'/tmp/updates/npm',
|
|
);
|
|
} finally {
|
|
if (inheritedHome === undefined) delete process.env.QWEN_HOME;
|
|
else process.env.QWEN_HOME = inheritedHome;
|
|
}
|
|
});
|
|
});
|