qwen-code/scripts/vitest-global-setup.js
易良 2785480685
fix(devx): fail with actionable message when unit-test build prerequisites are missing (#9149) (#9171)
* 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>
2026-08-18 13:19:09 +00:00

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);
}
}