Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 232 additions & 1 deletion packages/cli/src/serve/fast-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import { afterEach, describe, expect, it, vi } from 'vitest';
import yargs, { type Argv } from 'yargs';
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
Expand All @@ -16,7 +18,8 @@ import {
writeFileSync,
} from 'node:fs';
import * as os from 'node:os';
import { join } from 'node:path';
import { dirname, join, relative, resolve } from 'node:path';
import * as ts from 'typescript';
import { QWEN_DIR, Storage } from '@qwen-code/qwen-code-core';

import {
Expand Down Expand Up @@ -66,6 +69,195 @@ const originalRateLimitPrompt = process.env['QWEN_SERVE_RATE_LIMIT_PROMPT'];
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>;
externalValueImports: Set<string>;
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, '/');
}

function moduleSpecifierText(
specifier: ts.Expression | undefined,
): string | undefined {
if (!specifier || !ts.isStringLiteral(specifier)) return undefined;
return specifier.text;
}

function importDeclarationHasRuntimeValue(node: ts.ImportDeclaration): boolean {
const clause = node.importClause;
if (!clause) return true;
if (clause.isTypeOnly) return false;
if (clause.name) return true;
const bindings = clause.namedBindings;
if (!bindings) return false;
if (ts.isNamespaceImport(bindings)) return true;
if (bindings.elements.length === 0) return true;
return bindings.elements.some((element) => !element.isTypeOnly);
}

function exportDeclarationHasRuntimeValue(node: ts.ExportDeclaration): boolean {
if (node.isTypeOnly) return false;
const clause = node.exportClause;
if (!clause) return true;
if (ts.isNamespaceExport(clause)) return true;
if (clause.elements.length === 0) return true;
return clause.elements.some((element) => !element.isTypeOnly);
}

function resolveLocalSourceImport(
importer: string,
specifier: string,
): string | undefined {
const basePath = resolve(dirname(importer), specifier);
const candidates = specifier.endsWith('.js')
? [`${basePath.slice(0, -3)}.ts`, `${basePath.slice(0, -3)}.tsx`]
: [
`${basePath}.ts`,
`${basePath}.tsx`,
join(basePath, 'index.ts'),
join(basePath, 'index.tsx'),
];
return candidates.find((candidate) => existsSync(candidate));
}

function collectStaticSourceGraph(entryFile: string): StaticSourceGraph {
const visited = new Set<string>();
const localFiles = new Set<string>();
const externalValueImports = new Set<string>();
const unresolvedLocalImports: string[] = [];

function visit(filePath: string): void {
const normalizedFilePath = resolve(filePath);
if (visited.has(normalizedFilePath)) return;
visited.add(normalizedFilePath);
localFiles.add(
normalizePathForTest(relative(cliPackageRoot, normalizedFilePath)),
);

const sourceText = readFileSync(normalizedFilePath, 'utf8');
const sourceFile = ts.createSourceFile(
normalizedFilePath,
sourceText,
ts.ScriptTarget.Latest,
true,
normalizedFilePath.endsWith('.tsx')
? ts.ScriptKind.TSX
: ts.ScriptKind.TS,
);

for (const statement of sourceFile.statements) {
let specifier: string | undefined;
let hasRuntimeValue = false;
if (ts.isImportDeclaration(statement)) {
specifier = moduleSpecifierText(statement.moduleSpecifier);
hasRuntimeValue = importDeclarationHasRuntimeValue(statement);
} else if (ts.isExportDeclaration(statement)) {
specifier = moduleSpecifierText(statement.moduleSpecifier);
hasRuntimeValue = exportDeclarationHasRuntimeValue(statement);
}
if (!specifier || !hasRuntimeValue) continue;
if (!specifier.startsWith('.')) {
externalValueImports.add(specifier);
continue;
}
const resolvedImport = resolveLocalSourceImport(
normalizedFilePath,
specifier,
);
if (!resolvedImport) {
unresolvedLocalImports.push(
`${normalizePathForTest(relative(cliPackageRoot, normalizedFilePath))} -> ${specifier}`,
);
continue;
}
visit(resolvedImport);
}
}

visit(entryFile);
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')], {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] execFileSync with stdio: 'pipe' captures stderr/stdout into the error object's Buffer properties, but the exception is never caught to log them. If esbuild fails (non-zero exit), the test fails at expect(existsSync(metafilePath)).toBe(true) with only "expected true to be false" — the actual esbuild error message is silently discarded.

Consider wrapping in a try/catch that surfaces the build diagnostic:

Suggested change
execFileSync(process.execPath, [resolve(repoRoot, 'esbuild.config.js')], {
try {
execFileSync(process.execPath, [resolve(repoRoot, 'esbuild.config.js')], {
cwd: repoRoot,
env: { ...process.env, DEV: 'true' },
stdio: 'pipe',
timeout: 30_000,
});
} catch (err) {
const stderr = (err as { stderr?: Buffer }).stderr?.toString() ?? '';
throw new Error(`esbuild rebuild failed:\n${stderr}`);
}

— qwen3.7-max via Qwen Code /review

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',
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The forbiddenInputs set is missing 'packages/acp-bridge/src/bridgeErrors.ts', while the source graph test at line 513 explicitly forbids '@qwen-code/acp-bridge/bridgeErrors'. The two guards enforce the same boundary from different angles (source-level vs bundle-level), but their forbidden lists disagree.

If a future change transitively pulls bridgeErrors.ts into the bundle, the source graph test catches it but this bundle metafile test does not — weakening the regression guard.

Suggested change
]);
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/bridgeErrors.ts',
'packages/acp-bridge/src/spawnChannel.ts',
]);

— qwen3.7-max via Qwen Code /review

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(
Expand Down Expand Up @@ -297,6 +489,45 @@ describe('CLI entry import boundary', () => {
expect(runServeSource).toContain("import('./server.js')");
expect(runServeSource).toContain("import('@qwen-code/acp-bridge/bridge')");
});

it('keeps the runQwenServe static source graph free of ACP runtime modules', () => {
const graph = collectStaticSourceGraph(
resolve(cliPackageRoot, 'src/serve/run-qwen-serve.ts'),
);

expect(graph.unresolvedLocalImports).toEqual([]);
const forbiddenLocalFiles = [...graph.localFiles].filter(
(filePath) => filePath === 'src/serve/acp-session-bridge.ts',
);
expect(
forbiddenLocalFiles,
`Unexpected static source graph files:\n${forbiddenLocalFiles.join('\n')}`,
).toEqual([]);

const forbiddenExternalImports = [
'@qwen-code/acp-bridge',
'@qwen-code/acp-bridge/bridge',
'@qwen-code/acp-bridge/spawnChannel',
'@qwen-code/acp-bridge/bridgeClient',
'@qwen-code/acp-bridge/bridgeErrors',
];
const forbiddenImports = [...graph.externalValueImports].filter(
(specifier) => forbiddenExternalImports.includes(specifier),
);
expect(
forbiddenImports,
`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', () => {
Expand Down
6 changes: 2 additions & 4 deletions packages/cli/src/serve/server/request-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@

import * as path from 'node:path';
import type { Request, Response } from 'express';
import type { AcpSessionBridge } from '@qwen-code/acp-bridge/bridgeTypes';
import { MAX_WORKSPACE_PATH_LENGTH } from '@qwen-code/acp-bridge/workspacePaths';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import {
MAX_WORKSPACE_PATH_LENGTH,
type AcpSessionBridge,
} from '../acp-session-bridge.js';
import type { WorkspaceRequestContext } from '../workspace-service/index.js';

export function sendJsonBodyParserError(res: Response, err: unknown): boolean {
Expand Down
Loading