mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
* perf(dev): add pnpm worktree bootstrap foundation Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(dev): harden worktree bootstrap process handling Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * perf(dev): prefer cache-only worktree installs Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(dev): preserve cached install cancellation Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(build): harden pnpm worktree bootstrap Validate pnpm lock updates in releases and exercise real installs and builds across supported hosts. Preserve npm release compatibility and keep dependency-only setup from rewriting npm-layout notices. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * chore: record merge attribution Record the required attribution for the Stage 1 merge without rewriting published history. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(dev): keep worktree bootstrap clean Use a bootstrap-scoped notice guard because nested npm lifecycle commands replace npm_lifecycle_event. This preserves explicit notice generation while preventing dependency setup from rewriting tracked output. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * ci: track pnpm smoke workflow size Register the new workflow in the repository size ratchet as required by the main CI gate. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ci): drop stale importers from the new pnpm lockfile The lockfile was generated before the WebShell cutover (#9811) removed @qwen-code/webui from web-shell and the tailwind tooling plus @qwen-code/webui from vscode-ide-companion, so frozen-lockfile installs fail on all three smoke platforms with ERR_PNPM_OUTDATED_LOCKFILE. Drop the five stale importer entries so the lockfile matches the current package.json manifests; verified with pnpm 11.24.0 install --frozen-lockfile --lockfile-only. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ci): refresh pnpm lockfile after merging main The merge of origin/main added remend@^1.3.1 to packages/cli/package.json without updating pnpm-lock.yaml, breaking the pnpm Worktree Smoke workflow frozen-lockfile install. Regenerated with pnpm install. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-conflict/jmtid3rse9h * fix(scripts): resolve the path variable case-insensitively on Windows A spread of process.env is an ordinary object, and native Windows shells expose the path variable as `Path`, so `env.PATH` was undefined there and findOnPath never located corepack — the offline-first Corepack bootstrap silently degraded to npx on exactly the hosts it exists for. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(scripts): pin the worktree bootstrap guards against mutation - Split the fallback log on /\r?\n/ like the sibling cmd.exe mocks, so the assertion holds on the Windows lane's CRLF output. - Add a win32 variant bootstrapping with the native `Path` casing to pin the case-insensitive lookup. - Assert the smoke workflow's fail-fast flag, the install-before-clean step order, and the no-build guard via a substring on the raw job text. - Extend the pnpmfile rewrite fixture to devDependencies and optionalDependencies, which the committed lockfile already uses. - Assert the notice-skip guard by effect (writeFile never called) and add the flag-absent companion test with I/O stubbed. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * chore(deps): sync pnpm-lock.yaml with qwen-live ACP backend deps Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-conflict/jmtjyz3tdco * fix(dev): stop patch-package from corrupting the pnpm store The root postinstall runs patch-package, which rewrites files under node_modules in place. With pnpm's default import method those files are hard links into the content-addressable store, so the patch rewrites the store entry too and its contents stop matching the sha512 it is filed under. Every subsequent fresh worktree then fails its --offline stage with ERR_PNPM_NO_OFFLINE_TARBALL, silently falls back to the registry (exit 0), and the fallback's postinstall corrupts the store again, so the offline path never hits. The advertised offline timing was only reachable on a same-tree reinstall, which needs no relinking. Set packageImportMethod to clone-or-copy: a copy-on-write clone where the filesystem supports it, a plain copy elsewhere. Either way patch-package edits only the worktree's own copy and the store entry stays intact, so later worktrees resolve entirely from the store. Verified on an isolated fixture store: under the default method the node_modules file and its store entry share an inode, and an in-place edit moves the store file's sha512 off its address, after which a fresh offline install fails with ERR_PNPM_NO_OFFLINE_TARBALL; under clone-or-copy the same edit leaves the store hash unchanged and the fresh offline install reports reused 1, downloaded 0. Also add pnpm-lock.yaml to .prettierignore. prettier reflows it into a shape pnpm does not emit, so `npm run format` and `pnpm install` fight over the file; .yamllint.yml already ignores it for the same reason. Both facts are pinned by tests in scripts/tests/package-scripts.test.js. Claude-Session: https://claude.ai/code/session_012797rgiteWJxLT9TLkKq8G * fix(ci): refresh pnpm-lock.yaml for qwen-live prompts deps Main added prompts/@types/prompts (and the ACP sdk) to packages/qwen-live/package.json after this branch generated its lockfile, so the pnpm Worktree Smoke frozen-lockfile install failed with ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 11.24.0 (--lockfile-only); frozen-lockfile and supply-chain policy checks pass. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-conflict/jmtlc9sozfg * fix(pnpm): close review findings on worktree bootstrap - Declare @qwen-code/qwen-code-core in vscode-ide-companion so the pnpm linker materializes it; regenerate both lockfiles and assert the link in the smoke workflow (R5-1) - Pin @types/node 20.19.1 in packages/core so the pnpm layout compiles against the same types npm hoists (D6-1) - Accept and pin the corepack +sha512 integrity suffix on packageManager; strip it for the npx fallback spec (D6-2) - Cover every workspace member in the pnpmfile rewrite set, mirror the npm channel list in pnpm-workspace.yaml, and cross-check both in tests - Run setup-worktree.js installs with cwd pinned to the checkout, gate the smoke clean-check on git status --porcelain, stop post-merge runs from cancelling each other, and extend check-lockfile.js to pnpm-lock.yaml * merge(main): realign pnpm workspace set and lockfiles Main removed the webui and cua-driver packages; drop both from the pnpm rewrite set and regenerate the pnpm lockfile against the merged manifests (picks up playwright, react-markdown, and the other main-side dependency changes) so the frozen bootstrap matches what the PR merge ref will run. * style(ci): quote smoke workflow scalars per yamllint * fix(dev): require Corepack for pnpm bootstrap * fix(dev): close pnpm bootstrap review blockers --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
1267 lines
44 KiB
JavaScript
1267 lines
44 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { parse } from 'yaml';
|
|
|
|
import { hooks as pnpmHooks, workspacePackageNames } from '../../.pnpmfile.mjs';
|
|
import { getPinnedPnpmPackage } from '../pnpm-package.js';
|
|
|
|
import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(__dirname, '../..');
|
|
|
|
function readPackageJson() {
|
|
return JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
}
|
|
|
|
function readWorkflow(relativePath) {
|
|
return readFileSync(path.join(root, relativePath), 'utf8');
|
|
}
|
|
|
|
describe('package scripts', () => {
|
|
it('accepts only an exact pnpm package-manager version', () => {
|
|
expect(getPinnedPnpmPackage({ packageManager: 'pnpm@11.24.0' })).toBe(
|
|
'pnpm@11.24.0',
|
|
);
|
|
// `corepack use pnpm@x.y.z` appends the tarball's integrity hash; the
|
|
// bootstrap must accept the string corepack itself writes.
|
|
const hashed =
|
|
'pnpm@11.24.0+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000';
|
|
expect(getPinnedPnpmPackage({ packageManager: hashed })).toBe(hashed);
|
|
expect(() =>
|
|
getPinnedPnpmPackage({ packageManager: 'pnpm@latest' }),
|
|
).toThrow('packageManager must pin an exact pnpm version');
|
|
expect(() =>
|
|
getPinnedPnpmPackage({
|
|
packageManager: 'pnpm@11.24.0+sha512.not-hex',
|
|
}),
|
|
).toThrow('packageManager must pin an exact pnpm version');
|
|
});
|
|
|
|
it('pins pnpm with the corepack integrity hash', () => {
|
|
expect(readPackageJson().packageManager).toMatch(
|
|
/^pnpm@\d+\.\d+\.\d+\+sha512\.[0-9a-f]{128}$/,
|
|
);
|
|
});
|
|
|
|
it('keeps internal pnpm workspaces independent of manifest versions', () => {
|
|
const packageJson = {
|
|
dependencies: {
|
|
'@qwen-code/qwen-code-core': 'file:../core',
|
|
'@qwen-code/channel-base': '0.22.4',
|
|
fixture: 'file:../fixture',
|
|
},
|
|
devDependencies: {
|
|
'@qwen-code/acp-bridge': 'file:../acp-bridge',
|
|
},
|
|
optionalDependencies: {
|
|
'@qwen-code/sdk': '0.22.4',
|
|
},
|
|
};
|
|
|
|
expect(pnpmHooks.readPackage(packageJson)).toEqual({
|
|
dependencies: {
|
|
'@qwen-code/qwen-code-core': 'workspace:*',
|
|
'@qwen-code/channel-base': 'workspace:*',
|
|
fixture: 'file:../fixture',
|
|
},
|
|
devDependencies: {
|
|
'@qwen-code/acp-bridge': 'workspace:*',
|
|
},
|
|
optionalDependencies: {
|
|
'@qwen-code/sdk': 'workspace:*',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('keeps the pnpm rewrite set in sync with the workspace manifests', () => {
|
|
const { workspaces } = readPackageJson();
|
|
const negated = workspaces
|
|
.filter((entry) => entry.startsWith('!'))
|
|
.map((entry) => entry.slice(1));
|
|
const directories = [];
|
|
for (const pattern of workspaces) {
|
|
if (pattern.startsWith('!')) continue;
|
|
if (pattern.endsWith('/*')) {
|
|
const parent = pattern.slice(0, -2);
|
|
for (const entry of readdirSync(path.join(root, parent))) {
|
|
directories.push(`${parent}/${entry}`);
|
|
}
|
|
} else {
|
|
directories.push(pattern);
|
|
}
|
|
}
|
|
const names = directories
|
|
.filter((directory) => !negated.includes(directory))
|
|
.map((directory) => {
|
|
const manifestPath = path.join(root, directory, 'package.json');
|
|
if (!existsSync(manifestPath)) return undefined;
|
|
return JSON.parse(readFileSync(manifestPath, 'utf8')).name;
|
|
})
|
|
.filter((name) => name !== undefined);
|
|
|
|
// A member missing from the set keeps its release version or file:
|
|
// specifier under pnpm, which is exactly the lockfile staleness the
|
|
// rewrite exists to prevent.
|
|
expect([...workspacePackageNames].sort()).toEqual(
|
|
[...new Set(names)].sort(),
|
|
);
|
|
});
|
|
|
|
it('mirrors the npm workspace boundaries in pnpm-workspace.yaml', () => {
|
|
const workspace = parse(readWorkflow('pnpm-workspace.yaml'));
|
|
|
|
expect([...workspace.packages].sort()).toEqual(
|
|
[...readPackageJson().workspaces].sort(),
|
|
);
|
|
});
|
|
|
|
it('mirrors npm overrides in pnpm-workspace.yaml', () => {
|
|
const workspace = parse(readWorkflow('pnpm-workspace.yaml'));
|
|
const { cliui, ...npmOverrides } = readPackageJson().overrides;
|
|
|
|
expect(workspace.overrides).toMatchObject({
|
|
...npmOverrides,
|
|
'cliui>wrap-ansi': cliui['wrap-ansi'],
|
|
});
|
|
});
|
|
|
|
it('checks both lockfiles for integrity', () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/check-lockfile.js')],
|
|
{ cwd: root, encoding: 'utf8' },
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('Lockfile check passed.');
|
|
expect(result.stdout).toContain('pnpm lockfile check passed.');
|
|
});
|
|
|
|
it('keeps the internal release-age exception independent of the version', () => {
|
|
const workspace = parse(
|
|
readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'),
|
|
);
|
|
|
|
expect(workspace.minimumReleaseAgeExclude).toEqual([
|
|
'@qwen-code/channel-base',
|
|
]);
|
|
});
|
|
|
|
it('imports packages without hard links so patch-package cannot corrupt the store', () => {
|
|
const workspace = parse(
|
|
readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'),
|
|
);
|
|
|
|
// The root postinstall rewrites node_modules files in place. Under
|
|
// 'auto' or 'hardlink' those files are hard links into the
|
|
// content-addressable store, so the rewrite corrupts the store entry and
|
|
// every later worktree misses its --offline stage.
|
|
expect(readPackageJson().scripts.postinstall).toBe('patch-package');
|
|
expect(['clone-or-copy', 'copy', 'clone']).toContain(
|
|
workspace.packageImportMethod,
|
|
);
|
|
});
|
|
|
|
it('keeps the pnpm lockfile out of prettier so formatting cannot fight pnpm', () => {
|
|
const ignored = readFileSync(path.join(root, '.prettierignore'), 'utf8')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line !== '' && !line.startsWith('#'));
|
|
|
|
expect(ignored).toContain('pnpm-lock.yaml');
|
|
});
|
|
|
|
it('keeps channel workspace lock entries independent of release versions', () => {
|
|
const lockfile = parse(
|
|
readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'),
|
|
);
|
|
const specifiers = Object.values(lockfile.importers).flatMap((importer) =>
|
|
['dependencies', 'devDependencies', 'optionalDependencies'].flatMap(
|
|
(field) => {
|
|
const entry = importer[field]?.['@qwen-code/channel-base'];
|
|
return entry ? [entry.specifier] : [];
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(specifiers.length).toBeGreaterThan(0);
|
|
expect(new Set(specifiers)).toEqual(new Set(['workspace:*']));
|
|
});
|
|
|
|
it('bootstraps worktrees with frozen pnpm dependencies and skips prepare', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-setup-'));
|
|
const commandDir = path.join(binDir, 'runner bin');
|
|
const logFile = path.join(binDir, 'corepack.log');
|
|
mkdirSync(commandDir);
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(commandDir, 'corepack.cmd'),
|
|
'@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(commandDir, 'corepack'),
|
|
'#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n',
|
|
);
|
|
chmodSync(path.join(commandDir, 'corepack'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/setup-worktree.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
WORKTREE_SETUP_LOG: logFile,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(logFile, 'utf8').trim()).toBe(
|
|
'1 1 pnpm install --frozen-lockfile --offline',
|
|
);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform !== 'win32')(
|
|
'resolves the path variable under its native Windows casing',
|
|
() => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-path-'));
|
|
const commandDir = path.join(binDir, 'runner bin');
|
|
const logFile = path.join(binDir, 'corepack.log');
|
|
mkdirSync(commandDir);
|
|
|
|
try {
|
|
writeFileSync(
|
|
path.join(commandDir, 'corepack.cmd'),
|
|
'@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n',
|
|
);
|
|
|
|
// Native shells expose the path variable as `Path`; a case-sensitive
|
|
// `env.PATH` read on the spread object would miss it and report
|
|
// Corepack unavailable.
|
|
const env = { ...process.env, WORKTREE_SETUP_LOG: logFile };
|
|
delete env.PATH;
|
|
delete env.Path;
|
|
env.Path = `${commandDir}${path.delimiter}${process.env.Path ?? process.env.PATH ?? ''}`;
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/setup-worktree.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env,
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(logFile, 'utf8').trim()).toBe(
|
|
'1 1 pnpm install --frozen-lockfile --offline',
|
|
);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('fails closed when Corepack is unavailable', () => {
|
|
const binDir = mkdtempSync(
|
|
path.join(tmpdir(), 'qwen-worktree-no-corepack-'),
|
|
);
|
|
const env = { ...process.env, PATH: binDir };
|
|
for (const name of Object.keys(env)) {
|
|
if (name !== 'PATH' && name.toUpperCase() === 'PATH') delete env[name];
|
|
}
|
|
|
|
try {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/setup-worktree.js')],
|
|
{ cwd: root, encoding: 'utf8', env },
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('Corepack is required');
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('falls back to registry access when the pnpm store is incomplete', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-fallback-'));
|
|
const logFile = path.join(binDir, 'corepack.log');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'corepack.cmd'),
|
|
'@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n@if "%4"=="--offline" exit /b 1\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'corepack'),
|
|
'#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n[ "$4" != "--offline" ]\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'corepack'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/setup-worktree.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: binDir,
|
|
WORKTREE_SETUP_LOG: logFile,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
|
'1 1 pnpm install --frozen-lockfile --offline',
|
|
'1 1 pnpm install --frozen-lockfile --prefer-offline',
|
|
]);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves a worktree bootstrap interrupt status', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-signal-'));
|
|
const logFile = path.join(binDir, 'corepack.log');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'corepack.cmd'),
|
|
'@echo called>>"%WORKTREE_SETUP_LOG%"\r\n@exit /b 130\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'corepack'),
|
|
'#!/bin/sh\necho called >> "$WORKTREE_SETUP_LOG"\nkill -INT $$\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'corepack'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/setup-worktree.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
WORKTREE_SETUP_LOG: logFile,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(130);
|
|
expect(readFileSync(logFile, 'utf8').trim()).toBe('called');
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not couple Node REPL to Qwen release versions', () => {
|
|
const versionScript = readFileSync(
|
|
path.join(root, 'scripts/version.js'),
|
|
'utf8',
|
|
);
|
|
|
|
expect(versionScript).toContain(
|
|
'const workspacesToExclude = [\n' +
|
|
" '@qwen-code/sdk',\n" +
|
|
" '@qwen-code/mobile-mcp',\n" +
|
|
" '@qwen-code/node-repl-mcp',\n" +
|
|
" '@qwen-code/qwen-live',\n" +
|
|
'];',
|
|
);
|
|
});
|
|
|
|
it('smoke-tests the real worktree bootstrap on every supported host', () => {
|
|
const workflow = parse(
|
|
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
|
|
);
|
|
const job = workflow.jobs.install;
|
|
|
|
expect(job.strategy.matrix.os).toEqual([
|
|
'ubuntu-latest',
|
|
'macos-latest',
|
|
'windows-latest',
|
|
]);
|
|
expect(job.strategy['fail-fast']).toBe(false);
|
|
expect(
|
|
job.steps.find(
|
|
(step) => step.name === 'Install frozen pnpm worktree dependencies',
|
|
)?.run,
|
|
).toBe('node scripts/setup-worktree.js');
|
|
|
|
// The pnpm linker only materializes declared dependencies, so a
|
|
// workspace import that npm's hoisting hides breaks typecheck and tests
|
|
// in the bootstrapped tree; resolve one through the worst offender.
|
|
const resolveCheck = job.steps.find(
|
|
(step) => step.name === 'Verify workspace links resolve',
|
|
);
|
|
expect(resolveCheck?.run).toContain(
|
|
'@qwen-code/qwen-code-core/package.json',
|
|
);
|
|
expect(resolveCheck?.run).toContain('packages/vscode-ide-companion');
|
|
|
|
// `git diff --exit-code` misses untracked files; the install must leave
|
|
// the porcelain output empty on every host, including Windows (hence
|
|
// the explicit POSIX shell).
|
|
const cleanCheck = job.steps.find(
|
|
(step) => step.name === 'Ensure bootstrap keeps the worktree clean',
|
|
);
|
|
expect(cleanCheck?.shell).toBe('bash');
|
|
expect(cleanCheck?.run).toContain('git status --porcelain');
|
|
|
|
// The checks only mean anything after the install; pin the order.
|
|
const stepNames = job.steps.map((step) => step.name);
|
|
expect(
|
|
stepNames.indexOf('Install frozen pnpm worktree dependencies'),
|
|
).toBeLessThan(stepNames.indexOf('Verify workspace links resolve'));
|
|
expect(stepNames.indexOf('Verify workspace links resolve')).toBeLessThan(
|
|
stepNames.indexOf('Ensure bootstrap keeps the worktree clean'),
|
|
);
|
|
|
|
// A post-merge run is the only witness of a regression on main, so
|
|
// consecutive merges must not cancel each other.
|
|
expect(workflow.concurrency['cancel-in-progress']).toBe(
|
|
"${{ github.event_name == 'pull_request' }}",
|
|
);
|
|
|
|
// Substring check on the raw job text: an exact `step.run` match is
|
|
// bypassed by any other spelling of a build step (block scalar,
|
|
// compound command).
|
|
const installJob = getWorkflowJob(
|
|
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
|
|
'install',
|
|
);
|
|
expect(installJob).not.toContain('npm run build');
|
|
expect(installJob).not.toContain('node scripts/build.js');
|
|
});
|
|
|
|
it('runs the pnpm smoke workflow when a dependency input changes', () => {
|
|
const workflow = parse(
|
|
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
|
|
);
|
|
const expectedPaths = [
|
|
'.github/workflows/pnpm-worktree-smoke.yml',
|
|
'.npmrc',
|
|
'.pnpmfile.mjs',
|
|
'package.json',
|
|
'packages/*/package.json',
|
|
'!packages/desktop-shell/package.json',
|
|
'!packages/live-host/package.json',
|
|
'packages/channels/*/package.json',
|
|
'integrations/*/package.json',
|
|
'patches/**',
|
|
'packages/audio-capture/install.js',
|
|
'packages/core/scripts/postinstall.js',
|
|
'packages/vscode-ide-companion/scripts/generate-notices.js',
|
|
'pnpm-lock.yaml',
|
|
'pnpm-workspace.yaml',
|
|
'scripts/generate-git-commit-info.js',
|
|
'scripts/prepare.js',
|
|
'scripts/pnpm-package.js',
|
|
'scripts/setup-worktree.js',
|
|
];
|
|
|
|
expect(workflow.on.pull_request.paths).toEqual(expectedPaths);
|
|
expect(workflow.on.push.paths).toEqual(expectedPaths);
|
|
});
|
|
|
|
it('builds the standalone qwen-live daemon in the root build order', () => {
|
|
const buildScript = readFileSync(
|
|
path.join(root, 'scripts/build.js'),
|
|
'utf8',
|
|
);
|
|
|
|
// The qwen-live e2e harness spawns packages/qwen-live/dist/index.js and
|
|
// the workspace unit tests run from src, so this pin is what catches the
|
|
// root build silently dropping the package.
|
|
const startIndex = buildScript.indexOf('const buildOrder = [');
|
|
expect(startIndex).toBeGreaterThan(-1);
|
|
const buildOrder = buildScript.slice(
|
|
startIndex,
|
|
buildScript.indexOf('];', startIndex),
|
|
);
|
|
expect(buildOrder).toContain("'packages/qwen-live',");
|
|
});
|
|
|
|
it('keeps the Mem0 Extension manifest aligned with release versions', () => {
|
|
const versionScript = readFileSync(
|
|
path.join(root, 'scripts/version.js'),
|
|
'utf8',
|
|
);
|
|
|
|
expect(versionScript).toContain(
|
|
"'integrations/external-context-mem0/qwen-extension.json'",
|
|
);
|
|
expect(versionScript).toContain(
|
|
'const mem0Manifest = readJson(mem0ManifestPath);',
|
|
);
|
|
expect(versionScript).toContain('mem0Manifest.version = newVersion');
|
|
expect(versionScript).toContain(
|
|
'writeJson(mem0ManifestPath, mem0Manifest);',
|
|
);
|
|
expect(versionScript).toContain(
|
|
"'npx prettier --experimental-cli --write integrations/external-context-mem0/qwen-extension.json'",
|
|
);
|
|
});
|
|
|
|
it('lets the CI unit lane retry a contended attempt', () => {
|
|
// Identical work measures 6.7min or 36min on this fleet depending only on
|
|
// which host the job lands on, and the same commit run three times failed
|
|
// three disjoint test sets (#10490). A retry lets that pass; a real break
|
|
// still fails every attempt. The release lane already runs this way.
|
|
const packageJson = readPackageJson();
|
|
// `test:ci` ends in `&&`, so args appended to it would reach only
|
|
// `test:scripts`. The workspaces half is its own script, ending in `--`,
|
|
// so a flag appended to it reaches every workspace's vitest.
|
|
expect(packageJson.scripts['test:ci:workspaces']).toMatch(/--$/);
|
|
expect(packageJson.scripts['test:ci:workspaces']).toContain(
|
|
'npm run test:ci --workspaces --if-present',
|
|
);
|
|
expect(packageJson.scripts['test:ci']).toBe(
|
|
'npm run test:ci:workspaces && npm run test:scripts',
|
|
);
|
|
|
|
const step = getWorkflowStep(
|
|
getWorkflowJob(readWorkflow('.github/workflows/ci.yml'), 'test'),
|
|
'Run tests and generate reports',
|
|
);
|
|
expect(step).toContain(
|
|
'VITEST_RETRY: "${{ vars.QWEN_CI_VITEST_RETRY || \'2\' }}"',
|
|
);
|
|
// 'off' must omit the flag rather than pass --retry=0, which would
|
|
// outrank a workspace's own config-level retry (sdk-typescript).
|
|
expect(step).toContain('[ "${VITEST_RETRY}" != \'off\' ]');
|
|
expect(step).toContain('retry_arg=(--retry="${VITEST_RETRY}")');
|
|
expect(step).toContain('npm run test:ci:workspaces -- "${retry_arg[@]}"');
|
|
expect(step).toContain('npm run test:scripts -- "${retry_arg[@]}"');
|
|
});
|
|
|
|
it('bounds the CI unit step so a hang fails instead of stalling', () => {
|
|
// #10490's run 3 was cancelled at ~60 minutes, leaving behind
|
|
// orphaned test processes; a stall reads as a timeout, not a failure.
|
|
const step = getWorkflowStep(
|
|
getWorkflowJob(readWorkflow('.github/workflows/ci.yml'), 'test'),
|
|
'Run tests and generate reports',
|
|
);
|
|
expect(step).toContain('timeout-minutes: 110');
|
|
});
|
|
|
|
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts['test:ci']).not.toContain(
|
|
'npm run check:serve-fast-path-bundle',
|
|
);
|
|
expect(packageJson.scripts.preflight).toContain(
|
|
'npm run check:serve-fast-path-bundle',
|
|
);
|
|
});
|
|
|
|
it('limits SDK integration tests through the forks pool', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts['test:integration:sdk:sandbox:none']).toContain(
|
|
'--poolOptions.forks.maxForks 2',
|
|
);
|
|
expect(
|
|
packageJson.scripts['test:integration:sdk:sandbox:docker'],
|
|
).toContain('--poolOptions.forks.maxForks 2');
|
|
});
|
|
|
|
it('cleans package build artifacts before checking the serve fast path bundle', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts['check:serve-fast-path-bundle']).toBe(
|
|
[
|
|
'node scripts/clean-package-build-artifacts.js',
|
|
'&& npm run build -- --cli-only',
|
|
'&& cross-env DEV=true npm run bundle',
|
|
'&& node scripts/check-serve-fast-path-bundle.js',
|
|
].join(' '),
|
|
);
|
|
expect(packageJson.scripts['check:serve-fast-path-bundle']).not.toContain(
|
|
'npm run clean',
|
|
);
|
|
});
|
|
|
|
it('defines a release test script that disables workspace coverage', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts['test:release']).toBe(
|
|
'npm run test:release:workspaces && npm run test:scripts',
|
|
);
|
|
expect(packageJson.scripts['test:release:workspaces']).toBe(
|
|
[
|
|
'cross-env NODE_OPTIONS="--max-old-space-size=3072"',
|
|
'npm run test:ci --workspaces --if-present -- --coverage.enabled=false',
|
|
].join(' '),
|
|
);
|
|
|
|
// No workspace forces coverage from its test:ci script any more; the
|
|
// configs decide, and only a post-merge run flips their switch on. A
|
|
// reintroduced `--coverage` flag would override the switch on every
|
|
// pull-request run.
|
|
for (const workspace of [
|
|
'packages/vscode-ide-companion',
|
|
'packages/web-shell',
|
|
]) {
|
|
const workspacePackageJson = JSON.parse(
|
|
readFileSync(path.join(root, workspace, 'package.json'), 'utf8'),
|
|
);
|
|
expect(workspacePackageJson.scripts['test:ci']).not.toContain(
|
|
'--coverage',
|
|
);
|
|
const vitestConfig = readFileSync(
|
|
path.join(root, workspace, 'vitest.config.ts'),
|
|
'utf8',
|
|
);
|
|
expect(vitestConfig).toContain(
|
|
"enabled: process.env['QWEN_CI_COVERAGE'] === '1'",
|
|
);
|
|
}
|
|
});
|
|
|
|
it('skips build/bundle/husky but still generates git-commit info when CI builds explicitly', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts.prepare).toBe('node scripts/prepare.js');
|
|
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-skip-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
writeFileSync(logFile, '');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky.cmd'),
|
|
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
'@echo(npm %*>>"%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky'),
|
|
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '1',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('Skipping prepare');
|
|
// git-commit info is still generated so a later per-workspace build or
|
|
// typecheck (e.g. the review tooling's) doesn't fail on the missing
|
|
// module; the heavy build/bundle/husky are skipped.
|
|
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
|
'npm run generate',
|
|
]);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('runs prepare steps in order when CI does not skip prepare', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-bin-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky.cmd'),
|
|
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
'@echo(npm %*>>"%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky'),
|
|
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
|
'husky',
|
|
'npm run build',
|
|
'npm run bundle',
|
|
]);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exits when a prepare step fails', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-fail-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
writeFileSync(logFile, '');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(path.join(binDir, 'husky.cmd'), '@exit /b 7\r\n');
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
'@echo npm %* >> "%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nexit 7\n');
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(7);
|
|
expect(result.stderr).toContain('prepare: husky exited with status 7');
|
|
expect(readFileSync(logFile, 'utf8')).toBe('');
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reports the failing prepare step after earlier steps succeed', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-late-fail-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
writeFileSync(logFile, '');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky.cmd'),
|
|
'@echo(husky>>"%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
[
|
|
'@echo(npm %*>>"%PREPARE_LOG_FILE%"',
|
|
'@if "%1 %2"=="run build" exit /b 7',
|
|
'@exit /b 0',
|
|
'',
|
|
].join('\r\n'),
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky'),
|
|
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
[
|
|
'#!/bin/sh',
|
|
'echo "npm $*" >> "$PREPARE_LOG_FILE"',
|
|
'if [ "$1 $2" = "run build" ]; then exit 7; fi',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(7);
|
|
expect(result.stderr).toContain(
|
|
'prepare: npm run build exited with status 7',
|
|
);
|
|
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
|
'husky',
|
|
'npm run build',
|
|
]);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'reports when a prepare command is killed by a signal',
|
|
() => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-signal-'));
|
|
|
|
try {
|
|
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nkill -TERM $$\n');
|
|
writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\nexit 0\n');
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
'prepare: husky killed by signal SIGTERM',
|
|
);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'reports when a prepare command cannot be spawned',
|
|
() => {
|
|
const missingBinDir = mkdtempSync(
|
|
path.join(tmpdir(), 'qwen-prepare-missing-bin-'),
|
|
);
|
|
|
|
try {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: missingBinDir,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('prepare: husky failed:');
|
|
} finally {
|
|
rmSync(missingBinDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('wires release quality checks to fast explicit validation steps', () => {
|
|
const workflow = readWorkflow('.github/workflows/release.yml');
|
|
const buildJob = getWorkflowJob(workflow, 'quality_build');
|
|
const workspaceTestJob = getWorkflowJob(workflow, 'workspace_tests');
|
|
const buildStep = getWorkflowStep(buildJob, 'Build Project');
|
|
const serveFastPathStep = getWorkflowStep(
|
|
buildJob,
|
|
'Check Serve Fast Path Bundle',
|
|
);
|
|
const packStep = getWorkflowStep(buildJob, 'Pack Build Outputs');
|
|
const uploadStep = getWorkflowStep(buildJob, 'Upload Build Outputs');
|
|
const verifyPackageStep = getWorkflowStep(
|
|
buildJob,
|
|
'Verify Prepared Package',
|
|
);
|
|
const workspaceTestStep = getWorkflowStep(
|
|
workspaceTestJob,
|
|
'Run Workspace Tests',
|
|
);
|
|
const scriptsTestStep = getWorkflowStep(
|
|
getWorkflowJob(workflow, 'quality_scripts'),
|
|
'Run Script Tests',
|
|
);
|
|
|
|
expect(buildJob).toContain("name: 'Check Serve Fast Path Bundle'");
|
|
expect(buildJob).toContain('npm run check:serve-fast-path-bundle');
|
|
expect(buildJob.indexOf(serveFastPathStep)).toBeLessThan(
|
|
buildJob.indexOf(buildStep),
|
|
);
|
|
expect(buildJob.indexOf(uploadStep)).toBeGreaterThan(
|
|
buildJob.indexOf(packStep),
|
|
);
|
|
expect(buildJob.indexOf(verifyPackageStep)).toBeGreaterThan(
|
|
buildJob.indexOf(uploadStep),
|
|
);
|
|
expect(verifyPackageStep).toContain('npm run bundle');
|
|
expect(verifyPackageStep).toContain('dist/review-sources.sha256');
|
|
expect(verifyPackageStep).toContain('npm run prepare:package');
|
|
expect(workspaceTestStep).toContain('npm run test:release:workspaces');
|
|
expect(workspaceTestStep).not.toContain('npm run test:ci');
|
|
expect(scriptsTestStep).toContain('npm run test:scripts');
|
|
for (const cappedStep of [workspaceTestStep, scriptsTestStep]) {
|
|
for (const name of ['VITEST_MAX_THREADS', 'VITEST_MAX_FORKS']) {
|
|
expect(cappedStep).toContain(
|
|
`${name}: "\${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"`,
|
|
);
|
|
}
|
|
for (const name of ['VITEST_MIN_THREADS', 'VITEST_MIN_FORKS']) {
|
|
expect(cappedStep).toContain(
|
|
`${name}: "\${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('skips release install-time prepare and builds before publish bundling', () => {
|
|
const workflow = readWorkflow('.github/workflows/release.yml');
|
|
expect(workflow.slice(0, workflow.indexOf('jobs:'))).not.toContain(
|
|
'CI_BOT_PAT',
|
|
);
|
|
const installSteps =
|
|
workflow.match(
|
|
/ {6}- (?:&[^\n]+\n {8})?name: 'Install Dependencies'[\s\S]*?(?=\n {6}- (?:&[^\n]+\n {8})?name: '|\n {4}[A-Za-z0-9_-]+:|$)/g,
|
|
) || [];
|
|
|
|
// The validation jobs reuse the anchored install step; only the anchor
|
|
// definition plus prepare and publish appear as full textual copies.
|
|
expect(installSteps.length).toBe(3);
|
|
for (const installStep of installSteps) {
|
|
expect(installStep).toContain(
|
|
'npm ci --ignore-scripts --no-audit --progress=false',
|
|
);
|
|
expect(installStep).toContain('npm run postinstall');
|
|
expect(installStep).toContain('npm run generate');
|
|
expect(installStep).not.toContain('QWEN_SKIP_PREPARE');
|
|
expect(installStep).not.toContain('CI_BOT_PAT');
|
|
}
|
|
|
|
for (const jobName of ['integration_none', 'integration_docker']) {
|
|
const integrationJob = getWorkflowJob(workflow, jobName);
|
|
const buildStep = getWorkflowStep(integrationJob, 'Build Bundle');
|
|
expect(buildStep).toContain('npm run build\n npm run bundle');
|
|
}
|
|
|
|
const publishJob = getWorkflowJob(workflow, 'publish');
|
|
expect(publishJob.slice(0, publishJob.indexOf('steps:'))).not.toContain(
|
|
'CI_BOT_PAT',
|
|
);
|
|
const checkoutStep = getWorkflowStep(publishJob, 'Checkout');
|
|
const gitConfigStep = getWorkflowStep(publishJob, 'Configure Git User');
|
|
const commitStep = getWorkflowStep(
|
|
publishJob,
|
|
'Commit and Conditionally Push package versions',
|
|
);
|
|
const buildStep = getWorkflowStep(
|
|
publishJob,
|
|
'Build Bundle and Prepare Package',
|
|
);
|
|
|
|
expect(checkoutStep).toContain('persist-credentials: false');
|
|
expect(gitConfigStep).toContain('git config core.hooksPath .husky');
|
|
expect(publishJob.indexOf(gitConfigStep)).toBeLessThan(
|
|
publishJob.indexOf(commitStep),
|
|
);
|
|
expect(commitStep).toContain("CI_BOT_PAT: '${{ secrets.CI_BOT_PAT }}'");
|
|
expect(commitStep).toContain('export GH_TOKEN="${CI_BOT_PAT}"');
|
|
expect(commitStep).toContain('gh auth setup-git');
|
|
const exportTokenIdx = commitStep.indexOf(
|
|
'export GH_TOKEN="${CI_BOT_PAT}"',
|
|
);
|
|
const setupGitIdx = commitStep.indexOf('gh auth setup-git', exportTokenIdx);
|
|
expect(setupGitIdx).toBeGreaterThan(exportTokenIdx);
|
|
expect(setupGitIdx).toBeLessThan(
|
|
commitStep.indexOf('git push --force --set-upstream'),
|
|
);
|
|
expect(buildStep).toContain('npm run build\n npm run bundle');
|
|
});
|
|
|
|
it('skips npm packages whose release version is already published', () => {
|
|
const workflow = readWorkflow('.github/workflows/release.yml');
|
|
const publishJob = getWorkflowJob(workflow, 'publish');
|
|
|
|
for (const stepName of [
|
|
'Publish @qwen-code/external-context-mem0',
|
|
'Publish @qwen-code/audio-capture',
|
|
'Publish @qwen-code/qwen-code',
|
|
'Publish @qwen-code/channel-base',
|
|
'Publish remaining channel packages',
|
|
]) {
|
|
const publishStep = getWorkflowStep(publishJob, stepName);
|
|
expect(publishStep).toContain(
|
|
"RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'",
|
|
);
|
|
expect(publishStep).toContain(
|
|
'PACKAGE_NAME="$(node -p "require(\'./package.json\').name")"',
|
|
);
|
|
expect(publishStep).toContain('PUBLISH_ARGS+=(--dry-run)');
|
|
expect(publishStep).toContain(
|
|
'npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version',
|
|
);
|
|
expect(publishStep).toContain('already published; skipping');
|
|
expect(publishStep).toContain('exit 0');
|
|
expect(publishStep).toContain(
|
|
'npm publish --provenance "${PUBLISH_ARGS[@]}"',
|
|
);
|
|
}
|
|
|
|
// The channel loop must wrap each iteration in a subshell so that
|
|
// `exit 0` skips only the current channel, not the entire step.
|
|
const channelStep = getWorkflowStep(
|
|
publishJob,
|
|
'Publish remaining channel packages',
|
|
);
|
|
expect(channelStep).toContain('(\n');
|
|
expect(channelStep).toContain(')');
|
|
// A fully-skipped publish must be visible, not silently green.
|
|
expect(channelStep).toContain(
|
|
'Every channel package was already published; nothing shipped',
|
|
);
|
|
});
|
|
|
|
it('meets npm trusted publishing requirements', () => {
|
|
for (const [workflowPath, jobName, publishStepName] of [
|
|
[
|
|
'.github/workflows/release.yml',
|
|
'publish',
|
|
'Publish @qwen-code/external-context-mem0',
|
|
],
|
|
[
|
|
'.github/workflows/release.yml',
|
|
'publish',
|
|
'Publish @qwen-code/audio-capture',
|
|
],
|
|
[
|
|
'.github/workflows/release-sdk.yml',
|
|
'release-sdk',
|
|
'Publish @qwen-code/sdk',
|
|
],
|
|
[
|
|
'.github/workflows/cd-cua-driver.yml',
|
|
'publish-sdk',
|
|
'Publish immutable SDK tarball',
|
|
],
|
|
[
|
|
'.github/workflows/cd-cua-driver.yml',
|
|
'publish-node-repl',
|
|
'Publish immutable Node REPL tarball',
|
|
],
|
|
['.github/workflows/cd-mobile-mcp.yml', 'build-and-publish', 'Publish'],
|
|
]) {
|
|
const publishJob = getWorkflowJob(readWorkflow(workflowPath), jobName);
|
|
const installStep = getWorkflowStep(publishJob, 'Install npm 11');
|
|
const publishStep = getWorkflowStep(publishJob, publishStepName);
|
|
expect(installStep).toContain('npm install --global npm@11.19.0');
|
|
expect(publishJob).toContain("id-token: 'write'");
|
|
expect(publishStep).toContain('--provenance');
|
|
expect(publishJob).toContain("name: 'production-release'");
|
|
expect(publishJob.indexOf(installStep)).toBeLessThan(
|
|
publishJob.indexOf(publishStep),
|
|
);
|
|
}
|
|
|
|
for (const packageDirectory of [
|
|
'integrations/external-context-mem0',
|
|
'packages/audio-capture',
|
|
'packages/cli',
|
|
'packages/channels/base',
|
|
'packages/channels/dingtalk',
|
|
'packages/channels/dws',
|
|
'packages/channels/feishu',
|
|
'packages/channels/github',
|
|
'packages/channels/qqbot',
|
|
'packages/channels/telegram',
|
|
'packages/channels/wecom',
|
|
'packages/channels/weixin',
|
|
'packages/cua-driver/typescript',
|
|
'packages/mobile-mcp',
|
|
'packages/node-repl',
|
|
'packages/sdk-typescript',
|
|
]) {
|
|
const packageJson = JSON.parse(
|
|
readFileSync(path.join(root, packageDirectory, 'package.json'), 'utf8'),
|
|
);
|
|
expect(packageJson.repository?.url?.replace(/^git\+/, '')).toBe(
|
|
'https://github.com/QwenLM/qwen-code.git',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('fast-tracks trusted autofix issue triggers before LLM assessment', () => {
|
|
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
|
|
const issueJob = getWorkflowJob(workflow, 'issue-autofix');
|
|
const scanStep = getWorkflowStep(issueJob, 'Find candidate issues');
|
|
const fastTrackStep = getWorkflowStep(issueJob, 'Fast-track decision');
|
|
const assessStep = getWorkflowStep(issueJob, 'Assess candidates');
|
|
|
|
expect(issueJob.indexOf(scanStep)).toBeLessThan(
|
|
issueJob.indexOf(fastTrackStep),
|
|
);
|
|
expect(issueJob.indexOf(fastTrackStep)).toBeLessThan(
|
|
issueJob.indexOf(assessStep),
|
|
);
|
|
expect(fastTrackStep).toContain("id: 'fasttrack'");
|
|
expect(fastTrackStep).toContain('FAST_TRACK=false');
|
|
expect(fastTrackStep).toContain('FAST_TRACK=true');
|
|
expect(fastTrackStep).toContain('fast_tracked=false');
|
|
expect(fastTrackStep).toContain('-n "${FORCED_ISSUE}"');
|
|
expect(fastTrackStep).toContain(
|
|
'Fast-tracked: trusted trigger bypasses LLM assessment.',
|
|
);
|
|
expect(assessStep).toContain(
|
|
"steps.fasttrack.outputs.fast_tracked != 'true'",
|
|
);
|
|
});
|
|
|
|
it('skips autofix install-time prepare without disabling dependency scripts', () => {
|
|
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
|
|
|
|
// review-address restores the shared build-cli bundle instead of
|
|
// compiling, so its install step is npm ci only; the other two jobs
|
|
// still build from sources. Husky hooks are re-armed after the
|
|
// prepare-skip only where git commits happen (build-cli never commits).
|
|
for (const [jobName, stepName, armsHooks] of [
|
|
['issue-autofix', 'Install dependencies and build', true],
|
|
['build-cli', 'Install dependencies and build', false],
|
|
['review-address', 'Install dependencies', true],
|
|
]) {
|
|
const job = getWorkflowJob(workflow, jobName);
|
|
const installStep = getWorkflowStep(job, stepName);
|
|
|
|
expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'");
|
|
expect(installStep).toContain(
|
|
'npm ci --prefer-offline --no-audit --progress=false',
|
|
);
|
|
if (armsHooks) {
|
|
expect(installStep).toContain('git config core.hooksPath .husky');
|
|
}
|
|
expect(installStep).not.toContain('--ignore-scripts');
|
|
}
|
|
});
|
|
|
|
it('runs changed autofix tests instead of full touched-package suites', () => {
|
|
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
|
|
const reviewVerificationRunner = readWorkflow(
|
|
'.github/scripts/run-autofix-review-verification.sh',
|
|
);
|
|
const reviewJob = getWorkflowJob(workflow, 'review-address');
|
|
|
|
for (const verificationBody of [
|
|
getWorkflowStep(
|
|
getWorkflowJob(workflow, 'issue-autofix'),
|
|
'Verification gate',
|
|
),
|
|
reviewVerificationRunner,
|
|
]) {
|
|
expect(verificationBody).toContain(
|
|
'npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests',
|
|
);
|
|
expect(verificationBody).toContain(
|
|
'bash "${RUNNER_TEMP}/resolve-owning-packages.sh"',
|
|
);
|
|
expect(verificationBody).toContain('pkg.scripts?.test');
|
|
expect(verificationBody).toContain('!= *vitest*');
|
|
expect(verificationBody).not.toContain(
|
|
'npm run test --workspace "${p}" --if-present\n',
|
|
);
|
|
}
|
|
|
|
expect(getWorkflowStep(reviewJob, 'Verification gate')).toContain(
|
|
'bash --norc "${RUNNER_TEMP}/run-autofix-review-verification.sh"',
|
|
);
|
|
});
|
|
});
|