mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
fix(cli): Guard serve fast-path bundle closure (#5995)
* fix(cli): guard serve fast-path bundle closure Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): tighten serve fast-path bundle guard Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5995) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5995) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#5995) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
f11bb31df2
commit
565e4bc437
6 changed files with 725 additions and 87 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -247,6 +247,10 @@ jobs:
|
|||
fi
|
||||
echo "Settings schema is up-to-date"
|
||||
|
||||
- name: 'Check serve fast-path bundle closure'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
run: 'npm run check:serve-fast-path-bundle'
|
||||
|
||||
- name: 'Run tests and generate reports'
|
||||
id: 'unit_tests'
|
||||
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}"
|
||||
|
|
@ -428,7 +432,6 @@ jobs:
|
|||
os: '${{ matrix.os }}'
|
||||
github_token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
|
||||
# Integration tests run only in the merge queue, not on every PR push.
|
||||
# They are the suite that previously ran *only* in the nightly Release
|
||||
# pipeline (`release.yml`), so regressions stayed hidden until release
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@
|
|||
"telemetry": "node scripts/telemetry.js",
|
||||
"check:lockfile": "node scripts/check-lockfile.js",
|
||||
"check:desktop-isolation": "node scripts/check-desktop-isolation.js",
|
||||
"check:serve-fast-path-bundle": "npm run build -- --cli-only && cross-env DEV=true npm run bundle && node scripts/check-serve-fast-path-bundle.js",
|
||||
"desktop-openwork-sync": "bun run scripts/desktop-openwork-sync.ts",
|
||||
"clean": "node scripts/clean.js",
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
|
|
@ -70,7 +69,6 @@ const originalCloudShell = process.env['CLOUD_SHELL'];
|
|||
const originalGoogleCloudProject = process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
const originalCwd = process.cwd();
|
||||
const cliPackageRoot = process.cwd();
|
||||
const repoRoot = resolve(cliPackageRoot, '../..');
|
||||
|
||||
interface StaticSourceGraph {
|
||||
localFiles: Set<string>;
|
||||
|
|
@ -78,18 +76,6 @@ interface StaticSourceGraph {
|
|||
unresolvedLocalImports: string[];
|
||||
}
|
||||
|
||||
interface EsbuildMetafileOutput {
|
||||
inputs?: Record<string, unknown>;
|
||||
imports?: Array<{
|
||||
path: string;
|
||||
kind?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface EsbuildMetafile {
|
||||
outputs: Record<string, EsbuildMetafileOutput>;
|
||||
}
|
||||
|
||||
function normalizePathForTest(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
|
@ -196,69 +182,6 @@ function collectStaticSourceGraph(entryFile: string): StaticSourceGraph {
|
|||
return { localFiles, externalValueImports, unresolvedLocalImports };
|
||||
}
|
||||
|
||||
function collectBundledRunServeStaticRuntimeOffenders(): string[] {
|
||||
const metafilePath = resolve(repoRoot, 'dist/esbuild.json');
|
||||
rmSync(metafilePath, { force: true });
|
||||
execFileSync(process.execPath, [resolve(repoRoot, 'esbuild.config.js')], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, DEV: 'true' },
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(existsSync(metafilePath)).toBe(true);
|
||||
const metafile = JSON.parse(
|
||||
readFileSync(metafilePath, 'utf8'),
|
||||
) as EsbuildMetafile;
|
||||
const outputs = new Map(
|
||||
Object.entries(metafile.outputs).map(([outputPath, output]) => [
|
||||
normalizePathForTest(outputPath),
|
||||
output,
|
||||
]),
|
||||
);
|
||||
const runServeOutput = [...outputs.entries()].find(([, output]) =>
|
||||
Object.keys(output.inputs ?? {}).some(
|
||||
(input) =>
|
||||
normalizePathForTest(input) ===
|
||||
'packages/cli/src/serve/run-qwen-serve.ts',
|
||||
),
|
||||
);
|
||||
expect(runServeOutput).toBeDefined();
|
||||
const queue = [runServeOutput![0]];
|
||||
const staticClosure = new Set(queue);
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const output = outputs.get(queue[i]);
|
||||
for (const bundledImport of output?.imports ?? []) {
|
||||
if (bundledImport.kind !== 'import-statement') continue;
|
||||
const importedOutput = normalizePathForTest(bundledImport.path);
|
||||
if (!outputs.has(importedOutput) || staticClosure.has(importedOutput)) {
|
||||
continue;
|
||||
}
|
||||
staticClosure.add(importedOutput);
|
||||
queue.push(importedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
const forbiddenInputs = new Set([
|
||||
'packages/cli/src/serve/acp-session-bridge.ts',
|
||||
'packages/acp-bridge/src/bridge.ts',
|
||||
'packages/acp-bridge/src/bridgeClient.ts',
|
||||
'packages/acp-bridge/src/spawnChannel.ts',
|
||||
]);
|
||||
const offenders: string[] = [];
|
||||
for (const outputPath of staticClosure) {
|
||||
const output = outputs.get(outputPath);
|
||||
for (const input of Object.keys(output?.inputs ?? {})) {
|
||||
const normalizedInput = normalizePathForTest(input);
|
||||
if (forbiddenInputs.has(normalizedInput)) {
|
||||
offenders.push(`${outputPath} -> ${normalizedInput}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
function useTempQwenHome(): string {
|
||||
tempQwenHome = realpathSync(
|
||||
mkdtempSync(join(os.tmpdir(), 'qws-fast-path-home-')),
|
||||
|
|
@ -490,6 +413,23 @@ describe('CLI entry import boundary', () => {
|
|||
expect(runServeSource).toContain("import('@qwen-code/acp-bridge/bridge')");
|
||||
});
|
||||
|
||||
it('keeps request helpers from value-importing the ACP compatibility shim', () => {
|
||||
const requestHelpersSource = readFileSync(
|
||||
'src/serve/server/request-helpers.ts',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(requestHelpersSource).not.toMatch(
|
||||
/from ['"]\.\.\/acp-session-bridge\.js['"]/,
|
||||
);
|
||||
expect(requestHelpersSource).toContain(
|
||||
"import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes';",
|
||||
);
|
||||
expect(requestHelpersSource).toContain(
|
||||
"import { MAX_WORKSPACE_PATH_LENGTH } from '@qwen-code/acp-bridge/workspacePaths';",
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the runQwenServe static source graph free of ACP runtime modules', () => {
|
||||
const graph = collectStaticSourceGraph(
|
||||
resolve(cliPackageRoot, 'src/serve/run-qwen-serve.ts'),
|
||||
|
|
@ -519,15 +459,6 @@ describe('CLI entry import boundary', () => {
|
|||
`Unexpected ACP runtime imports:\n${forbiddenImports.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps bundled runQwenServe static imports free of ACP runtime modules', () => {
|
||||
const offenders = collectBundledRunServeStaticRuntimeOffenders();
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
`Unexpected ACP runtime inputs in static bundle closure:\n${offenders.join('\n')}`,
|
||||
).toEqual([]);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('serve fast path argument parsing', () => {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ export default defineConfig({
|
|||
__dirname,
|
||||
'../acp-bridge/src/bridgeFileSystem.ts',
|
||||
),
|
||||
'@qwen-code/acp-bridge/eventBus': path.resolve(
|
||||
__dirname,
|
||||
'../acp-bridge/src/eventBus.ts',
|
||||
),
|
||||
'@qwen-code/acp-bridge/workspacePaths': path.resolve(
|
||||
__dirname,
|
||||
'../acp-bridge/src/workspacePaths.ts',
|
||||
|
|
|
|||
315
scripts/check-serve-fast-path-bundle.js
Normal file
315
scripts/check-serve-fast-path-bundle.js
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DEFAULT_METAFILE_PATH = resolve('dist/esbuild.json');
|
||||
const METAFILE_BUILD_COMMAND =
|
||||
'npm run build -- --cli-only && npx cross-env DEV=true npm run bundle';
|
||||
const SERVE_PRE_LISTEN_ROOTS = [
|
||||
{
|
||||
label: 'serve fast path entry',
|
||||
suffixes: [
|
||||
'packages/cli/src/serve/fast-path.ts',
|
||||
'packages/cli/dist/src/serve/fast-path.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'serve fast path settings',
|
||||
suffixes: [
|
||||
'packages/cli/src/serve/fast-path-settings.ts',
|
||||
'packages/cli/dist/src/serve/fast-path-settings.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'run qwen serve entry',
|
||||
suffixes: [
|
||||
'packages/cli/src/serve/run-qwen-serve.ts',
|
||||
'packages/cli/dist/src/serve/run-qwen-serve.js',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FORBIDDEN_SOURCE_INPUTS = [
|
||||
{
|
||||
label: 'Serve ACP compatibility shim',
|
||||
suffixes: [
|
||||
'packages/cli/src/serve/acp-session-bridge.ts',
|
||||
'packages/cli/dist/src/serve/acp-session-bridge.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ACP bridge runtime',
|
||||
suffixes: [
|
||||
'packages/acp-bridge/src/bridge.ts',
|
||||
'packages/acp-bridge/dist/bridge.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ACP bridge client runtime',
|
||||
suffixes: [
|
||||
'packages/acp-bridge/src/bridgeClient.ts',
|
||||
'packages/acp-bridge/dist/bridgeClient.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ACP spawnChannel runtime',
|
||||
suffixes: [
|
||||
'packages/acp-bridge/src/spawnChannel.ts',
|
||||
'packages/acp-bridge/dist/spawnChannel.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ACP permission mediator runtime',
|
||||
suffixes: [
|
||||
'packages/acp-bridge/src/permissionMediator.ts',
|
||||
'packages/acp-bridge/dist/permissionMediator.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ACP compaction engine runtime',
|
||||
suffixes: [
|
||||
'packages/acp-bridge/src/compactionEngine.ts',
|
||||
'packages/acp-bridge/dist/compactionEngine.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Core shell tool runtime',
|
||||
suffixes: [
|
||||
'packages/core/src/tools/shell.ts',
|
||||
'packages/core/dist/src/tools/shell.js',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FORBIDDEN_VENDOR_PACKAGES = [
|
||||
{ label: 'glob vendor package', packageName: 'glob' },
|
||||
{ label: 'chokidar vendor package', packageName: 'chokidar' },
|
||||
{ label: '@iarna/toml vendor package', packageName: '@iarna/toml' },
|
||||
{ label: 'fzf vendor package', packageName: 'fzf' },
|
||||
];
|
||||
|
||||
export function normalizeMetafilePath(filePath) {
|
||||
return filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function inputMatchesSuffix(input, suffix) {
|
||||
const normalizedInput = normalizeMetafilePath(input);
|
||||
return normalizedInput === suffix || normalizedInput.endsWith(`/${suffix}`);
|
||||
}
|
||||
|
||||
function inputMatchesAnySuffix(input, suffixes) {
|
||||
return suffixes.some((suffix) => inputMatchesSuffix(input, suffix));
|
||||
}
|
||||
|
||||
function inputMatchesPackage(input, packageName) {
|
||||
const normalizedInput = normalizeMetafilePath(input);
|
||||
const marker = `node_modules/${packageName}/`;
|
||||
return (
|
||||
normalizedInput === `node_modules/${packageName}` ||
|
||||
normalizedInput.includes(marker)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOutputs(metafile) {
|
||||
return new Map(
|
||||
Object.entries(metafile.outputs ?? {}).map(([outputPath, output]) => [
|
||||
normalizeMetafilePath(outputPath),
|
||||
output,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function findServePreListenRootOutputs(outputs) {
|
||||
const rootOutputs = [];
|
||||
const missingRoots = [];
|
||||
|
||||
for (const root of SERVE_PRE_LISTEN_ROOTS) {
|
||||
let matchedOutput;
|
||||
for (const [outputPath, output] of outputs) {
|
||||
for (const input of Object.keys(output.inputs ?? {})) {
|
||||
if (inputMatchesAnySuffix(input, root.suffixes)) {
|
||||
matchedOutput = outputPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedOutput) break;
|
||||
}
|
||||
|
||||
if (matchedOutput) {
|
||||
rootOutputs.push(matchedOutput);
|
||||
} else {
|
||||
missingRoots.push(`${root.label} (${root.suffixes.join(' or ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingRoots.length > 0) {
|
||||
throw new Error(
|
||||
'Could not find bundled outputs for serve pre-listen roots:\n' +
|
||||
missingRoots.map((root) => `- ${root}`).join('\n') +
|
||||
`\nRun \`${METAFILE_BUILD_COMMAND}\` to produce the metafile.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(rootOutputs)];
|
||||
}
|
||||
|
||||
function collectStaticClosure(outputs, entryOutputs) {
|
||||
const queue = [...entryOutputs];
|
||||
const closure = new Set(queue);
|
||||
const parent = new Map();
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const outputPath = queue[i];
|
||||
const output = outputs.get(outputPath);
|
||||
for (const bundledImport of output?.imports ?? []) {
|
||||
if (bundledImport.external) continue;
|
||||
if (bundledImport.kind === 'dynamic-import') continue;
|
||||
|
||||
const importedOutput = normalizeMetafilePath(bundledImport.path);
|
||||
if (!outputs.has(importedOutput) || closure.has(importedOutput)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
closure.add(importedOutput);
|
||||
parent.set(importedOutput, outputPath);
|
||||
queue.push(importedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
return { closure, parent };
|
||||
}
|
||||
|
||||
function buildImportPath(entryOutputs, outputPath, parent) {
|
||||
const roots = new Set(entryOutputs);
|
||||
const reversed = [outputPath];
|
||||
let current = outputPath;
|
||||
while (!roots.has(current)) {
|
||||
current = parent.get(current);
|
||||
if (!current) break;
|
||||
reversed.push(current);
|
||||
}
|
||||
return reversed.reverse();
|
||||
}
|
||||
|
||||
export function findServeFastPathBundleOffenders(metafile) {
|
||||
const outputs = normalizeOutputs(metafile);
|
||||
const entryOutputs = findServePreListenRootOutputs(outputs);
|
||||
const { closure, parent } = collectStaticClosure(outputs, entryOutputs);
|
||||
const offenders = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const outputPath of closure) {
|
||||
const output = outputs.get(outputPath);
|
||||
const inputs = Object.keys(output?.inputs ?? {});
|
||||
|
||||
for (const input of inputs) {
|
||||
const sourceMatch = FORBIDDEN_SOURCE_INPUTS.find(({ suffixes }) =>
|
||||
inputMatchesAnySuffix(input, suffixes),
|
||||
);
|
||||
if (sourceMatch) {
|
||||
addOffender(
|
||||
sourceMatch.label,
|
||||
normalizeMetafilePath(input),
|
||||
outputPath,
|
||||
);
|
||||
}
|
||||
|
||||
const vendorMatch = FORBIDDEN_VENDOR_PACKAGES.find(({ packageName }) =>
|
||||
inputMatchesPackage(input, packageName),
|
||||
);
|
||||
if (vendorMatch) {
|
||||
addOffender(
|
||||
vendorMatch.label,
|
||||
normalizeMetafilePath(input),
|
||||
outputPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return offenders;
|
||||
|
||||
function addOffender(label, matchedInput, outputPath) {
|
||||
const key = `${label}\0${matchedInput}\0${outputPath}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
offenders.push({
|
||||
label,
|
||||
matchedInput,
|
||||
outputPath,
|
||||
bytes: outputs.get(outputPath)?.bytes ?? 0,
|
||||
importPath: buildImportPath(entryOutputs, outputPath, parent),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function formatServeFastPathBundleOffenders(offenders) {
|
||||
return offenders
|
||||
.map((offender) => {
|
||||
const importPath = offender.importPath.join(' -> ');
|
||||
return [
|
||||
`- ${offender.label}`,
|
||||
` input: ${offender.matchedInput}`,
|
||||
` output: ${offender.outputPath} (${offender.bytes} bytes)`,
|
||||
` static path: ${importPath}`,
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function checkServeFastPathBundle({
|
||||
metafilePath = DEFAULT_METAFILE_PATH,
|
||||
} = {}) {
|
||||
if (!existsSync(metafilePath)) {
|
||||
throw new Error(
|
||||
`Missing esbuild metafile at ${metafilePath}. ` +
|
||||
`Run \`${METAFILE_BUILD_COMMAND}\` to produce it.`,
|
||||
);
|
||||
}
|
||||
|
||||
let metafile;
|
||||
try {
|
||||
metafile = JSON.parse(readFileSync(metafilePath, 'utf8'));
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Invalid esbuild metafile at ${metafilePath}: ${reason}. ` +
|
||||
`Run \`${METAFILE_BUILD_COMMAND}\` to regenerate it.`,
|
||||
);
|
||||
}
|
||||
const offenders = findServeFastPathBundleOffenders(metafile);
|
||||
return { ok: offenders.length === 0, offenders };
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const result = checkServeFastPathBundle();
|
||||
if (result.ok) {
|
||||
console.log('Serve fast-path bundle closure check passed.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'Serve fast-path bundle closure includes pre-listen runtime modules:\n' +
|
||||
formatServeFastPathBundleOffenders(result.offenders),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
||||
) {
|
||||
main();
|
||||
}
|
||||
384
scripts/tests/serve-fast-path-bundle-check.test.js
Normal file
384
scripts/tests/serve-fast-path-bundle-check.test.js
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
checkServeFastPathBundle,
|
||||
findServeFastPathBundleOffenders,
|
||||
formatServeFastPathBundleOffenders,
|
||||
normalizeMetafilePath,
|
||||
} from '../check-serve-fast-path-bundle.js';
|
||||
|
||||
const checkScriptPath = fileURLToPath(
|
||||
new URL('../check-serve-fast-path-bundle.js', import.meta.url),
|
||||
);
|
||||
|
||||
function makeMetafile(outputs) {
|
||||
return {
|
||||
outputs: {
|
||||
'dist/chunks/fast-path.js': output({
|
||||
inputs: ['packages/cli/src/serve/fast-path.ts'],
|
||||
}),
|
||||
'dist/chunks/fast-path-settings.js': output({
|
||||
inputs: ['packages/cli/src/serve/fast-path-settings.ts'],
|
||||
}),
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
}),
|
||||
...outputs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function output({ inputs = [], imports = [], bytes = 1 } = {}) {
|
||||
return {
|
||||
bytes,
|
||||
inputs: Object.fromEntries(
|
||||
inputs.map((input) => [input, { bytesInOutput: 1 }]),
|
||||
),
|
||||
imports,
|
||||
};
|
||||
}
|
||||
|
||||
function writeMetafile(tempDir, metafile) {
|
||||
mkdirSync(join(tempDir, 'dist'));
|
||||
const metafilePath = join(tempDir, 'dist', 'esbuild.json');
|
||||
writeFileSync(metafilePath, JSON.stringify(metafile));
|
||||
return metafilePath;
|
||||
}
|
||||
|
||||
function staticImport(path) {
|
||||
return { path, kind: 'import-statement' };
|
||||
}
|
||||
|
||||
function dynamicImport(path) {
|
||||
return { path, kind: 'dynamic-import' };
|
||||
}
|
||||
|
||||
describe('serve fast-path bundle check', () => {
|
||||
it('reports forbidden source files reached through static imports', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [staticImport('dist/chunks/acp-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/acp-runtime.js': output({
|
||||
bytes: 179_129,
|
||||
inputs: ['packages/acp-bridge/src/bridgeClient.ts'],
|
||||
}),
|
||||
});
|
||||
|
||||
const offenders = findServeFastPathBundleOffenders(metafile);
|
||||
const diagnostic = formatServeFastPathBundleOffenders(offenders);
|
||||
|
||||
expect(offenders).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'ACP bridge client runtime',
|
||||
matchedInput: 'packages/acp-bridge/src/bridgeClient.ts',
|
||||
outputPath: 'dist/chunks/acp-runtime.js',
|
||||
bytes: 179_129,
|
||||
importPath: [
|
||||
'dist/chunks/run-qwen-serve.js',
|
||||
'dist/chunks/acp-runtime.js',
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(diagnostic).toContain('- ACP bridge client runtime');
|
||||
expect(diagnostic).toContain(
|
||||
'output: dist/chunks/acp-runtime.js (179129 bytes)',
|
||||
);
|
||||
expect(diagnostic).toContain(
|
||||
'static path: dist/chunks/run-qwen-serve.js -> dist/chunks/acp-runtime.js',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports forbidden built package files reached through static imports', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/dist/src/serve/run-qwen-serve.js'],
|
||||
imports: [staticImport('dist/chunks/acp-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/acp-runtime.js': output({
|
||||
inputs: ['packages/acp-bridge/dist/bridge.js'],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findServeFastPathBundleOffenders(metafile)).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'ACP bridge runtime',
|
||||
matchedInput: 'packages/acp-bridge/dist/bridge.js',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('checks fast-path modules that run before runQwenServe listens', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/fast-path.js': output({
|
||||
inputs: ['packages/cli/src/serve/fast-path.ts'],
|
||||
imports: [staticImport('dist/chunks/core-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/core-runtime.js': output({
|
||||
inputs: ['packages/core/dist/src/tools/shell.js'],
|
||||
}),
|
||||
});
|
||||
|
||||
const offenders = findServeFastPathBundleOffenders(metafile);
|
||||
|
||||
expect(offenders).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'Core shell tool runtime',
|
||||
matchedInput: 'packages/core/dist/src/tools/shell.js',
|
||||
importPath: ['dist/chunks/fast-path.js', 'dist/chunks/core-runtime.js'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows forbidden runtime files behind dynamic imports', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [dynamicImport('dist/chunks/bridge.js')],
|
||||
}),
|
||||
'dist/chunks/bridge.js': output({
|
||||
inputs: ['packages/acp-bridge/src/bridge.ts'],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findServeFastPathBundleOffenders(metafile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores external imports in the static closure', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [
|
||||
{
|
||||
path: 'dist/chunks/acp-runtime.js',
|
||||
kind: 'import-statement',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'dist/chunks/acp-runtime.js': output({
|
||||
inputs: ['packages/acp-bridge/src/bridge.ts'],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findServeFastPathBundleOffenders(metafile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports vendor packages reached through the core runtime chunk', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [staticImport('dist/chunks/core-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/core-runtime.js': output({
|
||||
bytes: 6_015_919,
|
||||
inputs: [
|
||||
'packages/core/src/tools/shell.ts',
|
||||
'node_modules/.pnpm/glob@10.5.0/node_modules/glob/dist/esm/index.js',
|
||||
'node_modules/.pnpm/@iarna+toml@2.2.5/node_modules/@iarna/toml/toml.js',
|
||||
'node_modules/chokidar/esm/index.js',
|
||||
'node_modules/fzf/dist/fzf.es.js',
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const offenders = findServeFastPathBundleOffenders(metafile);
|
||||
|
||||
expect(offenders.map((offender) => offender.label)).toEqual([
|
||||
'Core shell tool runtime',
|
||||
'glob vendor package',
|
||||
'@iarna/toml vendor package',
|
||||
'chokidar vendor package',
|
||||
'fzf vendor package',
|
||||
]);
|
||||
expect(offenders[0].importPath).toEqual([
|
||||
'dist/chunks/run-qwen-serve.js',
|
||||
'dist/chunks/core-runtime.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches normalized source suffixes without accepting partial names', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist\\chunks\\run-qwen-serve.js': output({
|
||||
inputs: ['..\\..\\packages\\cli\\src\\serve\\run-qwen-serve.ts'],
|
||||
imports: [staticImport('dist\\chunks\\false-positive.js')],
|
||||
}),
|
||||
'dist\\chunks\\false-positive.js': output({
|
||||
inputs: ['packages/acp-bridge/src/not-bridge.ts'],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(normalizeMetafilePath('dist\\chunks\\run-qwen-serve.js')).toBe(
|
||||
'dist/chunks/run-qwen-serve.js',
|
||||
);
|
||||
expect(findServeFastPathBundleOffenders(metafile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when serve pre-listen roots are missing', () => {
|
||||
const metafile = {
|
||||
outputs: {
|
||||
'dist/chunks/unrelated.js': output({
|
||||
inputs: ['packages/cli/src/unrelated.ts'],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => findServeFastPathBundleOffenders(metafile)).toThrow(
|
||||
/Could not find bundled outputs for serve pre-listen roots/,
|
||||
);
|
||||
expect(() => findServeFastPathBundleOffenders(metafile)).toThrow(
|
||||
/npm run build -- --cli-only && npx cross-env DEV=true npm run bundle/,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports each matched input for the same forbidden label and output', () => {
|
||||
const metafile = makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [staticImport('dist/chunks/acp-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/acp-runtime.js': output({
|
||||
inputs: [
|
||||
'packages/acp-bridge/src/bridge.ts',
|
||||
'packages/acp-bridge/dist/bridge.js',
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const offenders = findServeFastPathBundleOffenders(metafile);
|
||||
|
||||
expect(offenders).toEqual([
|
||||
expect.objectContaining({
|
||||
label: 'ACP bridge runtime',
|
||||
matchedInput: 'packages/acp-bridge/src/bridge.ts',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'ACP bridge runtime',
|
||||
matchedInput: 'packages/acp-bridge/dist/bridge.js',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a metafile path and returns bundle offenders', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
const metafilePath = writeMetafile(
|
||||
tempDir,
|
||||
makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [staticImport('dist/chunks/acp-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/acp-runtime.js': output({
|
||||
inputs: ['packages/acp-bridge/src/bridge.ts'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(checkServeFastPathBundle({ metafilePath })).toEqual({
|
||||
ok: false,
|
||||
offenders: [
|
||||
expect.objectContaining({
|
||||
label: 'ACP bridge runtime',
|
||||
matchedInput: 'packages/acp-bridge/src/bridge.ts',
|
||||
}),
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the checked metafile path is missing', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
expect(() =>
|
||||
checkServeFastPathBundle({
|
||||
metafilePath: join(tempDir, 'dist', 'esbuild.json'),
|
||||
}),
|
||||
).toThrow(/Missing esbuild metafile/);
|
||||
expect(() =>
|
||||
checkServeFastPathBundle({
|
||||
metafilePath: join(tempDir, 'dist', 'esbuild.json'),
|
||||
}),
|
||||
).toThrow(
|
||||
/npm run build -- --cli-only && npx cross-env DEV=true npm run bundle/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws with context when the metafile is invalid JSON', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
mkdirSync(join(tempDir, 'dist'));
|
||||
const metafilePath = join(tempDir, 'dist', 'esbuild.json');
|
||||
writeFileSync(metafilePath, '{');
|
||||
|
||||
expect(() => checkServeFastPathBundle({ metafilePath })).toThrow(
|
||||
/Invalid esbuild metafile at .*dist[/\\]esbuild\.json/,
|
||||
);
|
||||
expect(() => checkServeFastPathBundle({ metafilePath })).toThrow(
|
||||
/Run `npm run build -- --cli-only && npx cross-env DEV=true npm run bundle` to regenerate it/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('exits non-zero with CLI diagnostics for bundle offenders', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
writeMetafile(
|
||||
tempDir,
|
||||
makeMetafile({
|
||||
'dist/chunks/run-qwen-serve.js': output({
|
||||
inputs: ['packages/cli/src/serve/run-qwen-serve.ts'],
|
||||
imports: [staticImport('dist/chunks/acp-runtime.js')],
|
||||
}),
|
||||
'dist/chunks/acp-runtime.js': output({
|
||||
bytes: 179_129,
|
||||
inputs: ['packages/acp-bridge/src/bridge.ts'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
execFileSync(process.execPath, [checkScriptPath], {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
).toThrow(/Serve fast-path bundle closure includes pre-listen runtime/);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('exits non-zero when the metafile is missing', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
|
||||
try {
|
||||
expect(() =>
|
||||
execFileSync(process.execPath, [checkScriptPath], {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}),
|
||||
).toThrow(/Missing esbuild metafile/);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue