diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f63325e1ea..fc32cc34ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 4d04da224f..72d9016af2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index 30019a37e7..fe5df12cec 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -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; @@ -78,18 +76,6 @@ interface StaticSourceGraph { unresolvedLocalImports: string[]; } -interface EsbuildMetafileOutput { - inputs?: Record; - imports?: Array<{ - path: string; - kind?: string; - }>; -} - -interface EsbuildMetafile { - outputs: Record; -} - 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', () => { diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 6490348c47..0e34558ed4 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -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', diff --git a/scripts/check-serve-fast-path-bundle.js b/scripts/check-serve-fast-path-bundle.js new file mode 100644 index 0000000000..e05c8e2419 --- /dev/null +++ b/scripts/check-serve-fast-path-bundle.js @@ -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(); +} diff --git a/scripts/tests/serve-fast-path-bundle-check.test.js b/scripts/tests/serve-fast-path-bundle-check.test.js new file mode 100644 index 0000000000..4c467e29c3 --- /dev/null +++ b/scripts/tests/serve-fast-path-bundle-check.test.js @@ -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 }); + } + }); +});