Files
qwen-code/scripts/vitest-global-setup.js
T
612a552959 feat(channels): add IMAP and SMTP email channel (#12939)
* feat(channels): add IMAP and SMTP email channel

* test(channels): include email in managed channel catalog expectations

---------

Co-authored-by: destire-mio <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-10-03 02:54:25 +00:00

293 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/dws',
'packages/channels/email',
'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);
}
}