mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-24 08:05:39 +00:00
* fix(devx): fail with actionable message when unit-test build prerequisites are missing (#9149) Package-local unit tests in packages/cli import workspace packages (acp-bridge, sdk, web-templates, channels/*) through their built dist/ output plus the generated git-commit.ts. In a fresh worktree that shares the main checkout's node_modules, or after a deep clean, those artifacts do not exist and vitest fails during collection with resolution errors that blame the wrong thing. Add a vitest globalSetup guard that checks the prerequisites up front and exits with a message naming every missing piece and the command that fixes it (npm run build). Document the prerequisite in the AGENTS.md unit-testing section. * fix(devx): address R1 review findings on the unit-test prerequisite guard - normalize win32 path separators so the guard works on Windows (R1-1) - use Copyright 2026 Qwen Team header (R1-2) - drop over-included packages/sdk-typescript; every sdk import in the cli test graph is an aliased /daemon* subpath (R1-3) - add a sync assertion test: every builtin channel dynamically imported by channel-registry.ts must stay listed in DIST_PREREQUISITES (R1-4) - cover the vitest-invoked entry point via exported checkAndReport (R1-6) - mirror real manifest shapes (exports.default / import variants) in the test fixtures (R1-7) - report missing/unreadable package manifests through the normal exit path instead of crashing with a raw stack trace (R1-8) - derive the package key from vitest-s resolved project root, so vitest run --root packages/cli from elsewhere is covered (R1-9) - probe every exports entry targeting dist/, not only the . entry, so missing unaliased subpath builds are reported too (R2-1) * fix(devx): extend the unit-test prerequisite guard to packages/core Issue #9149's scope names packages/cli AND packages/core, but the guard only covered cli: eight core test files (providers/__tests__/presets/*, provider-config.test.ts) import the bare '@qwen-code/qwen-code-core' specifier, which resolves through the package's own exports to dist/index.js — on a fresh checkout 'cd packages/core && npx vitest run src/path/to/file.test.ts' (the AGENTS.md-documented command) still died with the opaque 'Failed to resolve entry for package' error. - Add 'packages/core': ['packages/core'] to DIST_PREREQUISITES - Wire the same globalSetup guard into packages/core/vitest.config.ts - Skip wildcard pattern exports entries ('./dist/*') in distEntryFiles: core's manifest carries them and they name no individual file — probing them literally would block core test runs even fully built - Generalize the fixture builder to every DIST/GENERATED_PREREQUISITES entry, add coverage for the core dist requirement and the wildcard skip, and move the 'no known prerequisites' example off packages/core - Note the core self-import in AGENTS.md Probe-verified both arms at this commit: dist moved aside -> the guard prints the actionable message and stops the run; dist restored -> the test file passes (11/11). * fix(devx): harden the prerequisite probe and its drift tests - Probe manifest 'main' entries spelled without a leading './': all guarded manifests use "main": "dist/index.js", which the old startsWith('./dist/') predicate never matched, so the documented main probe silently collected nothing. Normalize before the prefix check. - Tolerate digits in builtin channel names in the sync test's registry regex ('channel-[a-z0-9-]+'), or a future channel with a digit in its npm name escapes the drift check. - Add the reverse sync assertion: every listed packages/channels/* prerequisite must map back to a channel-registry import (channel-base excepted as the channels' build dependency), so removing a builtin channel cannot leave a stale entry that hard-blocks cli test runs with a misleading 'fresh checkout' message. - Cover the main-entry probe with a fixture test. * fix(devx): fail loud on stale probes, align key derivation under symlinks Round-3 review findings on the prerequisite guard: - R3-1: a listed package whose manifest enumerates zero ./dist/ targets (require-only or nested-condition entries) passed the probe silently — report 'exposes no dist/ entry files to check (guard probe may be stale)' instead, so a stale probe cannot resurrect the raw resolution error this guard exists to replace. - R3-3: when SOME dist entry files exist, the missing one is no longer diagnosed as 'has not been built' + a plain npm-run-build prescription (a successful build can legitimately leave a stale exports entry); the message now says the build output is incomplete or exports points at a file the build does not emit, and to check the package's exports entries when rebuilding does not help. - Key derivation now realpaths both cwd and root (with a fallback to the raw path): repoRoot descends from import.meta.url, which Node resolves through symlinks, while vitest resolves root with a plain path.resolve — comparing them raw let a symlinked ancestor silently disable the guard. - R3-2: the channel drift-check character class now tolerates digits, underscores and dots per npm naming rules. - R3-4: renamed the test that claimed win32-separator coverage it never exercised; its body is the degenerate repo-root silent-yield path and the comment now says so. - R1-6 (partial): added default-export coverage — project.config.root extraction and the process.cwd() fallback, asserting no exit in a built repo. The exit-1 arm of the default export stays untested: it needs a root-injectable seam the entry point deliberately does not have; checkAndReport's return-1 and message remain covered directly. Tests: 21/21; mutation probes confirm the zero-enumeration and symlink tests catch their regressions. * fix(devx): hermetic guard tests, alias-aware probe, explicit gitlab build - drive the default-export tests against a hermetic fixture checkout via QWEN_VITEST_GUARD_ROOT (in-process and subprocess), so they hold on an unbuilt worktree instead of depending on the real repository state - skip dist targets the consumer aliases to TypeScript source when probing, so a missing-but-aliased dist file no longer blocks runs that would pass - make the remedy message context-aware: the git-commit hint appears only when a generated file is missing, and a stale-probe line gets its own note - add packages/channels/gitlab to buildOrder: it is a cli channel-registry builtin like its siblings and used to build only transitively - add drift tests pinning the globalSetup wiring in both vitest configs * fix(devx): ignore commented vitest aliases * fix(devx): anchor the vitest globalSetup guard to the config file R1-1: a relative globalSetup path is resolved against vitest's root (the process cwd without --root), not the config file's directory, so the prerequisite guard only loaded when vitest happened to run from inside the package. `npx vitest run --config packages/<pkg>/vitest.config.ts` from the repository root died with "Cannot find module .../vitest-global-setup.js" before any test — the cause-hiding failure class this guard replaces. Resolve it with path.resolve(__dirname, ...) in both packages/cli and packages/core configs, and update the wiring-sync assertion (now robust to prettier line-wrapping; flip-verified red when reverted to a bare string). --------- Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Vitest globalSetup guard for package-local unit tests.
|
|
*
|
|
* In a fresh clone or a new worktree, workspace packages such as
|
|
* `@qwen-code/acp-bridge`, `@qwen-code/web-templates` and the channel
|
|
* packages have no `dist/` output until `npm run build` has run, and
|
|
* `src/generated/git-commit.ts` does not exist until `npm run generate`
|
|
* has run. Unit tests that import them then fail during collection with
|
|
* module-resolution errors that name neither the cause nor the fix.
|
|
*
|
|
* This guard checks those prerequisites up front and fails with a message
|
|
* that names both the missing pieces and the command that creates them.
|
|
* See https://github.com/QwenLM/qwen-code/issues/9149.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
export const repoRoot = path.resolve(__dirname, '..');
|
|
|
|
// Per-package prerequisites: for each key, the workspace packages whose
|
|
// built `dist/` output that package's tests import through package.json
|
|
// `main`/`exports` entries — i.e. packages that are NOT fully aliased to
|
|
// TypeScript source in that package's vitest.config.ts. `packages/core`
|
|
// lists itself: several core test files import the bare
|
|
// `@qwen-code/qwen-code-core` specifier, which resolves through the
|
|
// package's own exports to `dist/index.js`.
|
|
// Verified against a clean checkout: each missing entry below produces a
|
|
// "Failed to resolve" collection error. When you add a cross-package import
|
|
// that is not source-aliased, add its package here as well; the sync test in
|
|
// scripts/tests/vitest-global-setup.test.js asserts the builtin channels of
|
|
// channel-registry.ts stay covered.
|
|
export const DIST_PREREQUISITES = {
|
|
'packages/core': ['packages/core'],
|
|
'packages/cli': [
|
|
'packages/acp-bridge',
|
|
'packages/web-templates',
|
|
'packages/channels/base',
|
|
'packages/channels/dingtalk',
|
|
'packages/channels/feishu',
|
|
'packages/channels/github',
|
|
'packages/channels/gitlab',
|
|
'packages/channels/qqbot',
|
|
'packages/channels/telegram',
|
|
'packages/channels/wecom',
|
|
'packages/channels/weixin',
|
|
],
|
|
};
|
|
|
|
// Generated files that unit tests import but that a fresh checkout does
|
|
// not contain (`scripts/generate-git-commit-info.js` produces them; the
|
|
// root `npm run build` runs it).
|
|
export const GENERATED_PREREQUISITES = {
|
|
'packages/cli': ['packages/cli/src/generated/git-commit.ts'],
|
|
};
|
|
|
|
// Normalize win32 backslash separators so keys derived from Windows paths
|
|
// match the forward-slash keys above instead of silently disabling the guard.
|
|
export function normalizePackageKey(relPath) {
|
|
return relPath.split(/[\\/]/).join('/').replace(/\/+$/, '');
|
|
}
|
|
|
|
function readManifest(packageDir) {
|
|
return JSON.parse(
|
|
readFileSync(path.join(packageDir, 'package.json'), 'utf8'),
|
|
);
|
|
}
|
|
|
|
// Specifiers aliased to TypeScript source in a consumer's vitest config
|
|
// (keys of `resolve.alias`, e.g. `'@qwen-code/acp-bridge/bridgeErrors'`).
|
|
// Dist targets behind an aliased specifier are never resolved from dist/
|
|
// during test collection, so probing them would block runs that pass.
|
|
// Alias keys are matched as quoted object keys containing a `/` — the only
|
|
// such keys in these configs are specifier aliases. An unreadable config
|
|
// yields an empty set (probe everything).
|
|
export function aliasedSpecifiers(configPath) {
|
|
let source;
|
|
try {
|
|
source = readFileSync(configPath, 'utf8');
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
// ponytail: lexical comment strip for checked-in vitest configs; use a TS
|
|
// parser if generated configs or exotic string literals need support.
|
|
const uncommented = source.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, '');
|
|
return new Set(
|
|
[...uncommented.matchAll(/'([^']*\/[^']*)':/g)].map((match) => match[1]),
|
|
);
|
|
}
|
|
|
|
// Every file under `dist/` that the manifest's `exports`/`main` entries
|
|
// point at, each paired with the import specifier it serves. Checking all
|
|
// of them (not only the '.' entry) also covers unaliased subpath imports
|
|
// such as `@qwen-code/acp-bridge/sessionRestoreTimeout`; a package whose
|
|
// dist is missing any listed file would still break test collection. Note
|
|
// that dist files reachable only through a root-index re-export are not
|
|
// listed in `exports` and remain outside this probe.
|
|
export function distEntryFiles(manifest, packageDir) {
|
|
const files = [];
|
|
const name = manifest.name;
|
|
const collect = (specifier, target) => {
|
|
// Wildcard pattern entries (`"./dist/*": "./dist/*"`) name no individual
|
|
// file and cannot be enumerated here; skip them. Normalize targets without
|
|
// a leading `./` — all guarded manifests spell `"main": "dist/index.js"`.
|
|
if (typeof target !== 'string' || target.includes('*')) return;
|
|
const normalized = target.startsWith('./') ? target : `./${target}`;
|
|
if (normalized.startsWith('./dist/')) {
|
|
files.push({ specifier, file: path.join(packageDir, normalized) });
|
|
}
|
|
};
|
|
for (const [key, entry] of Object.entries(manifest.exports ?? {})) {
|
|
const specifier =
|
|
key === '.' ? name : `${name}/${key.replace(/^\.\//, '')}`;
|
|
if (typeof entry === 'string') collect(specifier, entry);
|
|
else if (entry && typeof entry === 'object')
|
|
collect(specifier, entry.import ?? entry.default);
|
|
}
|
|
collect(name, manifest.main);
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Returns human-readable lines describing the missing prerequisites for
|
|
* `packageRelPath` (e.g. `packages/cli`) under `root`, or an empty array
|
|
* when everything is in place (or the package has no known prerequisites).
|
|
*/
|
|
export function findMissingPrerequisites(packageRelPath, root = repoRoot) {
|
|
const key = normalizePackageKey(packageRelPath);
|
|
const distPackages = DIST_PREREQUISITES[key];
|
|
const generatedFiles = GENERATED_PREREQUISITES[key];
|
|
if (!distPackages && !generatedFiles) {
|
|
return [];
|
|
}
|
|
|
|
const aliased = aliasedSpecifiers(path.join(root, key, 'vitest.config.ts'));
|
|
const missing = [];
|
|
for (const rel of distPackages ?? []) {
|
|
const packageDir = path.join(root, rel);
|
|
let name;
|
|
let enumerated;
|
|
try {
|
|
const manifest = readManifest(packageDir);
|
|
name = manifest.name;
|
|
enumerated = distEntryFiles(manifest, packageDir);
|
|
} catch {
|
|
// A missing directory or unreadable manifest is itself a missing
|
|
// prerequisite; report it through the normal exit path instead of
|
|
// crashing the guard with a raw filesystem stack trace.
|
|
missing.push(
|
|
` - ${rel}: package directory or package.json is missing/unreadable`,
|
|
);
|
|
continue;
|
|
}
|
|
if (enumerated.length === 0) {
|
|
// Zero enumeration means the manifest exposes no ./dist/ target the
|
|
// probe understands (e.g. a require-only or nested-condition entry).
|
|
// Fail loud instead of silently passing — a stale probe must not
|
|
// resurrect the raw resolution error this guard exists to replace.
|
|
missing.push(
|
|
` - ${rel}: package.json exposes no dist/ entry files to check` +
|
|
' (guard probe may be stale)',
|
|
);
|
|
continue;
|
|
}
|
|
// Entries served by a source alias in the consumer's vitest config never
|
|
// resolve from dist/ during collection; probing them would fail runs
|
|
// whose aliased dist files simply have not been (re)built.
|
|
const entryFiles = enumerated
|
|
.filter(({ specifier }) => !aliased.has(specifier))
|
|
.map(({ file }) => file);
|
|
if (entryFiles.length === 0) {
|
|
continue;
|
|
}
|
|
const absent = entryFiles.find((file) => !existsSync(file));
|
|
if (absent) {
|
|
const partiallyBuilt = entryFiles.some((file) => existsSync(file));
|
|
missing.push(
|
|
partiallyBuilt
|
|
? ` - ${rel}: workspace package "${name}" build output is` +
|
|
' incomplete or package.json exports points at a file the' +
|
|
` build does not emit (missing ${path.relative(root, absent)})` +
|
|
' — re-run "npm run build"; if it still fails, check the' +
|
|
" package's exports entries"
|
|
: ` - ${rel}: workspace package "${name}" has not been built` +
|
|
` (missing ${path.relative(root, absent)})`,
|
|
);
|
|
}
|
|
}
|
|
for (const rel of generatedFiles ?? []) {
|
|
if (!existsSync(path.join(root, rel))) {
|
|
missing.push(` - ${rel}: generated file does not exist`);
|
|
}
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
export function formatPrerequisiteMessage(missing) {
|
|
const hasGenerated = missing.some((line) =>
|
|
line.includes('generated file does not exist'),
|
|
);
|
|
const hasStaleProbe = missing.some((line) =>
|
|
line.includes('guard probe may be stale'),
|
|
);
|
|
const lines = [
|
|
'',
|
|
'Unit-test build prerequisites are missing (fresh checkout detected):',
|
|
'',
|
|
...missing,
|
|
'',
|
|
'Package-local unit tests import these workspace packages through',
|
|
'their built dist/ output, which a fresh clone or new worktree does',
|
|
'not have. From the repository root, run:',
|
|
'',
|
|
' npm run build',
|
|
'',
|
|
];
|
|
if (hasGenerated) {
|
|
lines.push(
|
|
'To only regenerate git-commit.ts, run "npm run generate" instead.',
|
|
'',
|
|
);
|
|
}
|
|
if (hasStaleProbe) {
|
|
lines.push(
|
|
'Note: a "guard probe may be stale" line above means the guard itself',
|
|
'needs updating to match the package manifest; "npm run build" alone',
|
|
'will not clear it.',
|
|
'',
|
|
);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function realpathOrSelf(p) {
|
|
try {
|
|
return realpathSync(p);
|
|
} catch {
|
|
return p;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks prerequisites for `cwd` against `root`, prints the actionable
|
|
* message when something is missing, and returns the intended exit code
|
|
* (0 = ready, 1 = missing prerequisites).
|
|
*/
|
|
export function checkAndReport({ cwd = process.cwd(), root = repoRoot } = {}) {
|
|
// Realpath both ends before deriving the key: `repoRoot` descends from
|
|
// import.meta.url, which Node resolves through symlinks, while vitest
|
|
// resolves `root` with a plain path.resolve — comparing them raw lets a
|
|
// symlinked ancestor spell the same directory two ways and silently
|
|
// disable the guard.
|
|
const realRoot = realpathOrSelf(root);
|
|
const missing = findMissingPrerequisites(
|
|
path.relative(realRoot, realpathOrSelf(cwd)),
|
|
realRoot,
|
|
);
|
|
if (missing.length === 0) {
|
|
return 0;
|
|
}
|
|
console.error(formatPrerequisiteMessage(missing));
|
|
return 1;
|
|
}
|
|
|
|
export default function checkUnitTestPrerequisites(project) {
|
|
// Vitest passes the TestProject; its resolved root stays correct even when
|
|
// vitest is launched as `vitest run --root packages/cli` from elsewhere.
|
|
// Fall back to process.cwd() when invoked outside vitest.
|
|
const cwd = project?.config?.root ?? process.cwd();
|
|
// QWEN_VITEST_GUARD_ROOT lets the tests exercise this entry point against
|
|
// a hermetic fixture checkout; production never sets it.
|
|
const root = process.env['QWEN_VITEST_GUARD_ROOT'] || undefined;
|
|
const exitCode = checkAndReport({ cwd, root });
|
|
if (exitCode !== 0) {
|
|
// Exit directly instead of throwing: a thrown error surfaces as an
|
|
// "Unhandled Error" after vitest's reporter has already printed a
|
|
// misleading "No test files found" line, which is exactly the confusion
|
|
// this guard exists to remove.
|
|
process.exit(exitCode);
|
|
}
|
|
}
|