basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2026 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import type { Argv } from 'yargs';8import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';9import {10 copyFileSync,11 mkdtempSync,12 readFileSync,13 rmSync,14 writeFileSync,15} from 'node:fs';16import { execFileSync } from 'node:child_process';17import { tmpdir } from 'node:os';18import path from 'node:path';19import { FatalError } from '@qwen-code/qwen-code-core';20import { AlreadyReportedError } from './utils/errors.js';21import {22 MCP_COMMANDS,23 TOP_LEVEL_COMMANDS,24 handleCriticalError,25 isExpectedPtyRaceError,26 resolveBootstrapRoute,27 runCliEntry,28 runCliEntryPoint,29} from './cli.js';30 31const mocks = vi.hoisted(() => ({32 main: vi.fn(),33 tryRunServeFastPath: vi.fn(),34 initStartupProfiler: vi.fn(),35 initCpuProfiler: vi.fn(),36 mcpHandler: vi.fn(),37 mcpBuilder: vi.fn(),38 mcpListHandler: vi.fn(),39 mcpAddHandler: vi.fn(),40 getCliVersion: vi.fn(),41}));42 43vi.mock('./gemini.js', () => ({44 main: mocks.main,45}));46 47vi.mock('./serve/fast-path.js', () => ({48 tryRunServeFastPath: mocks.tryRunServeFastPath,49}));50 51vi.mock('./utils/startupProfiler.js', () => ({52 initStartupProfiler: mocks.initStartupProfiler,53}));54 55vi.mock('./utils/cpuProfiler.js', () => ({56 initCpuProfiler: mocks.initCpuProfiler,57}));58 59vi.mock('./utils/version.js', () => ({60 getCliVersion: mocks.getCliVersion,61}));62 63vi.mock('./commands/mcp.js', () => ({64 mcpCommand: {65 command: 'mcp',66 describe: 'Manage MCP servers',67 builder: (yargs: Argv) => {68 mocks.mcpBuilder();69 return yargs70 .command({71 command: 'list',72 describe: 'List all configured MCP servers',73 handler: mocks.mcpListHandler,74 })75 .command({76 command: 'add <name>',77 describe: 'Add a server',78 handler: mocks.mcpAddHandler,79 })80 .demandCommand(1, 'You need at least one command before continuing.');81 },82 handler: mocks.mcpHandler,83 },84}));85 86describe('resolveBootstrapRoute', () => {87 it('routes top-level help, version, serve, and mcp correctly', async () => {88 expect(resolveBootstrapRoute(['--help'])).toBe('help');89 expect(resolveBootstrapRoute(['--version'])).toBe('version');90 expect(resolveBootstrapRoute(['mcp', '--version'])).toBe('version');91 expect(resolveBootstrapRoute(['serve', '--help'])).toBe('serve');92 expect(resolveBootstrapRoute(['mcp', '--help'])).toBe('mcp');93 });94 95 it('keeps bundled entrypoint paths out of the route detection', async () => {96 expect(resolveBootstrapRoute(['/repo/dist/cli.js', '--help'])).toBe('help');97 expect(98 resolveBootstrapRoute(['C:\\repo\\dist\\cli.js', 'mcp', '--help']),99 ).toBe('mcp');100 });101 102 it('falls back to the default route for normal interactive startup', async () => {103 expect(resolveBootstrapRoute([])).toBe('default');104 expect(resolveBootstrapRoute(['--model', 'gpt-4', 'Hello'])).toBe(105 'default',106 );107 expect(resolveBootstrapRoute(['--safe-mode', 'mcp', 'list'])).toBe(108 'default',109 );110 });111 112 it('does not treat values for global flags as positional commands or bootstrap flags', () => {113 expect(resolveBootstrapRoute(['--model', 'gpt-4', '--help'])).toBe('help');114 expect(resolveBootstrapRoute(['-p', 'hello', '--help'])).toBe('help');115 expect(resolveBootstrapRoute(['--model', '-v'])).toBe('default');116 });117 118 it('does not treat flags after -- as bootstrap flags', () => {119 expect(resolveBootstrapRoute(['--', '--version'])).toBe('default');120 expect(resolveBootstrapRoute(['mcp', '--', '--version'])).toBe('mcp');121 });122});123 124describe('runCliEntry', () => {125 const savedEnv = {126 CLI_VERSION: process.env['CLI_VERSION'],127 };128 129 let stdout: string[];130 let stderr: string[];131 let savedExitCode: string | number | null | undefined;132 133 beforeEach(() => {134 stdout = [];135 stderr = [];136 savedExitCode = process.exitCode;137 process.exitCode = undefined;138 vi.clearAllMocks();139 mocks.tryRunServeFastPath.mockResolvedValue(false);140 mocks.getCliVersion.mockResolvedValue('fallback-version');141 process.env['CLI_VERSION'] = '9.9.9';142 vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {143 stdout.push(String(chunk));144 return true;145 });146 vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {147 stderr.push(String(chunk));148 return true;149 });150 });151 152 afterEach(() => {153 process.exitCode = savedExitCode;154 if (savedEnv.CLI_VERSION === undefined) {155 delete process.env['CLI_VERSION'];156 } else {157 process.env['CLI_VERSION'] = savedEnv.CLI_VERSION;158 }159 vi.restoreAllMocks();160 });161 162 it('prints the version without loading the full CLI graph', async () => {163 await runCliEntry(['--version']);164 165 expect(stdout.join('')).toContain('9.9.9');166 expect(mocks.main).not.toHaveBeenCalled();167 expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();168 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();169 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();170 });171 172 it('falls back to getCliVersion when CLI_VERSION is unset', async () => {173 delete process.env['CLI_VERSION'];174 175 await runCliEntry(['--version']);176 177 expect(stdout.join('')).toContain('fallback-version');178 expect(mocks.getCliVersion).toHaveBeenCalledTimes(1);179 expect(mocks.main).not.toHaveBeenCalled();180 expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();181 });182 183 it('prints top-level help without loading the full CLI graph', async () => {184 await runCliEntry(['--help']);185 186 const helpText = stdout.join('');187 expect(helpText).toContain('Usage: qwen [options] [command]');188 expect(helpText).toContain('Manage Qwen Code hooks');189 expect(helpText).toContain('Manage MCP servers');190 expect(helpText).toContain('Run Qwen Code as a local HTTP daemon');191 expect(helpText).toContain('--model');192 expect(helpText).toContain('-p, --prompt');193 expect(helpText).toContain('--safe-mode');194 expect(helpText).toContain('-s, --sandbox');195 expect(helpText).toContain('-o, --output-format');196 expect(helpText).toContain('-r, --resume');197 expect(mocks.main).not.toHaveBeenCalled();198 expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();199 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();200 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();201 });202 203 it('routes the MCP help path without booting gemini', async () => {204 await runCliEntry(['mcp', '--help']);205 206 expect(stdout.join('')).toContain('Manage MCP servers');207 expect(mocks.main).not.toHaveBeenCalled();208 expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();209 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();210 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();211 expect(mocks.mcpBuilder).not.toHaveBeenCalled();212 });213 214 it('does not execute MCP subcommands when showing subcommand help', async () => {215 await runCliEntry(['mcp', 'list', '--help']);216 217 const helpText = stdout.join('');218 expect(helpText).toContain('List all configured MCP servers');219 expect(mocks.mcpListHandler).not.toHaveBeenCalled();220 expect(mocks.main).not.toHaveBeenCalled();221 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();222 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();223 });224 225 it('executes MCP subcommands through the fast path', async () => {226 await runCliEntry(['mcp', 'list']);227 228 expect(mocks.mcpListHandler).toHaveBeenCalledTimes(1);229 expect(mocks.main).not.toHaveBeenCalled();230 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();231 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();232 });233 234 it('executes MCP subcommands after -- through the fast path', async () => {235 await runCliEntry(['mcp', '--', 'list']);236 237 expect(mocks.mcpListHandler).toHaveBeenCalledTimes(1);238 expect(mocks.main).not.toHaveBeenCalled();239 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();240 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();241 });242 243 it('uses the full CLI when global flags precede MCP commands', async () => {244 await runCliEntry(['--safe-mode', 'mcp', 'list']);245 246 expect(mocks.main).toHaveBeenCalledTimes(1);247 expect(mocks.mcpListHandler).not.toHaveBeenCalled();248 });249 250 it('fails MCP fast-path validation without loading the full CLI', async () => {251 await runCliEntry(['mcp', 'doesnotexist']);252 253 expect(process.exitCode).toBe(1);254 expect(stderr.join('')).toContain('Unknown command: doesnotexist');255 expect(mocks.mcpListHandler).not.toHaveBeenCalled();256 expect(mocks.main).not.toHaveBeenCalled();257 expect(mocks.initStartupProfiler).not.toHaveBeenCalled();258 expect(mocks.initCpuProfiler).not.toHaveBeenCalled();259 });260 261 it('does not run MCP subcommands with unknown options', async () => {262 await runCliEntry(['mcp', 'list', '--unknown']);263 264 expect(process.exitCode).toBe(1);265 expect(stderr.join('')).toContain('Unknown argument: unknown');266 expect(mocks.mcpListHandler).not.toHaveBeenCalled();267 expect(mocks.main).not.toHaveBeenCalled();268 });269 270 it('reports routine MCP argument errors without loading the full CLI', async () => {271 await runCliEntry(['mcp', 'add']);272 273 expect(process.exitCode).toBe(1);274 expect(stderr.join('')).toContain('Not enough non-option arguments');275 expect(mocks.mcpAddHandler).not.toHaveBeenCalled();276 expect(mocks.main).not.toHaveBeenCalled();277 });278 279 it('keeps the serve fast path ahead of the full CLI startup', async () => {280 mocks.tryRunServeFastPath.mockResolvedValue(true);281 282 await runCliEntry(['serve']);283 284 expect(mocks.tryRunServeFastPath).toHaveBeenCalledWith(['serve']);285 expect(mocks.main).not.toHaveBeenCalled();286 });287 288 it('initializes profilers once when the serve fast path falls back', async () => {289 mocks.tryRunServeFastPath.mockResolvedValue(false);290 291 await runCliEntry(['serve']);292 293 expect(mocks.tryRunServeFastPath).toHaveBeenCalledWith(['serve']);294 expect(mocks.main).toHaveBeenCalledTimes(1);295 });296 297 it('loads gemini on the default path', async () => {298 await runCliEntry([]);299 300 expect(mocks.main).toHaveBeenCalledTimes(1);301 });302});303 304describe('bootstrap import boundaries', () => {305 it('keeps fast-path-only dependencies out of static imports', () => {306 const source = readFileSync('src/cli.ts', 'utf8');307 308 expect(source).not.toContain("import yargs from 'yargs'");309 expect(source).not.toContain("from '@qwen-code/qwen-code-core'");310 expect(source).not.toContain("import './gemini.js'");311 expect(source).not.toContain("import { main } from './gemini.js'");312 });313 314 it('initializes profilers during bootstrap module evaluation', () => {315 const source = readFileSync('src/cli.ts', 'utf8');316 317 expect(source).toContain(318 "import { initStartupProfiler } from './utils/startupProfiler.js'",319 );320 expect(source).toContain(321 "import { initCpuProfiler } from './utils/cpuProfiler.js'",322 );323 expect(source.indexOf('initStartupProfiler();')).toBeLessThan(324 source.indexOf('export async function runCliEntry('),325 );326 expect(source.indexOf('initCpuProfiler();')).toBeLessThan(327 source.indexOf('export async function runCliEntry('),328 );329 });330 331 it('uses the bootstrap file as the production bundle entry', () => {332 const source = readFileSync('../../esbuild.config.js', 'utf8');333 334 expect(source).toContain("entryPoints: { cli: 'packages/cli/src/cli.ts' }");335 });336 337 it('keeps bootstrap fast paths in-process in the npm bin wrapper', () => {338 const source = readFileSync('../../scripts/cli-entry.js', 'utf8');339 340 expect(source).toContain('function isInProcessFastPath()');341 expect(source).toContain("first === 'serve'");342 expect(source).toContain("first === 'mcp'");343 expect(source).toContain("hasFlag('--help', '-h')");344 expect(source).toContain("hasFlag('--version', '-v')");345 });346 347 it('prints CLI_VERSION from the npm bin wrapper version shortcut', () => {348 const output = execFileSync(349 process.execPath,350 ['../../scripts/cli-entry.js', '--version'],351 {352 encoding: 'utf8',353 env: { ...process.env, CLI_VERSION: '7.7.7-test' },354 },355 );356 357 expect(output).toBe('7.7.7-test\n');358 });359 360 it('reads package.json from the npm bin wrapper version shortcut', () => {361 const expectedVersion = JSON.parse(362 readFileSync('../../package.json', 'utf8'),363 ).version;364 const env = { ...process.env };365 delete env['CLI_VERSION'];366 367 const output = execFileSync(368 process.execPath,369 ['../../scripts/cli-entry.js', '--version'],370 {371 encoding: 'utf8',372 env,373 },374 );375 376 expect(output).toBe(`${expectedVersion}\n`);377 });378 379 it('falls through to cli.js when wrapper package.json parsing fails', () => {380 const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-entry-'));381 try {382 copyFileSync(383 '../../scripts/cli-entry.js',384 path.join(tempDir, 'cli-entry.mjs'),385 );386 writeFileSync(387 path.join(tempDir, 'cli.js'),388 "process.stdout.write('fallback-cli\\n');\n",389 );390 const env = { ...process.env };391 delete env['CLI_VERSION'];392 393 const output = execFileSync(394 process.execPath,395 [path.join(tempDir, 'cli-entry.mjs'), '--version'],396 {397 encoding: 'utf8',398 env,399 },400 );401 402 expect(output).toBe('fallback-cli\n');403 } finally {404 rmSync(tempDir, { recursive: true, force: true });405 }406 });407 408 it('copies the npm bin wrapper into the package instead of duplicating it', () => {409 const source = readFileSync('../../scripts/prepare-package.js', 'utf8');410 411 expect(source).toContain(412 "fs.copyFileSync(path.join(__dirname, 'cli-entry.js'), cliEntryPath)",413 );414 expect(source).not.toContain('const cliEntryContent = `');415 });416 417 it('keeps bootstrap top-level help commands aligned with config registrations', () => {418 const configSource = readFileSync('src/config/config.ts', 'utf8');419 const commandNameByIdentifier = new Map([420 ['authCommand', 'auth'],421 ['channelCommand', 'channel'],422 ['extensionsCommand', 'extensions'],423 ['hooksCommand', 'hooks'],424 ['mcpCommand', 'mcp'],425 ['reviewCommand', 'review'],426 ['serveCommand', 'serve'],427 ['sessionsCommand', 'sessions'],428 ]);429 const registeredIdentifiers = [430 ...configSource.matchAll(/\.command\((\w+Command)\)/g),431 ].map((match) => match[1]!);432 const bootstrapCommands = new Set(433 TOP_LEVEL_COMMANDS.map(([command]) => command.split(' ')[0]),434 );435 436 expect(registeredIdentifiers).toHaveLength(commandNameByIdentifier.size);437 for (const identifier of registeredIdentifiers) {438 const commandName = commandNameByIdentifier.get(identifier);439 expect(commandName, `missing mapping for ${identifier}`).toBeDefined();440 expect(bootstrapCommands).toContain(commandName);441 }442 });443 444 it('keeps bootstrap MCP help commands aligned with MCP registrations', () => {445 const mcpSource = readFileSync('src/commands/mcp.ts', 'utf8');446 const commandNameByIdentifier = new Map([447 ['addCommand', 'add'],448 ['removeCommand', 'remove'],449 ['listCommand', 'list'],450 ['reconnectCommand', 'reconnect'],451 ['approveCommand', 'approve'],452 ['rejectCommand', 'reject'],453 ]);454 const registeredIdentifiers = [455 ...mcpSource.matchAll(/\.command\((\w+Command)\)/g),456 ].map((match) => match[1]!);457 const bootstrapCommands = new Set(458 MCP_COMMANDS.map(([command]) => command.split(' ')[0]),459 );460 461 expect(registeredIdentifiers).toHaveLength(commandNameByIdentifier.size);462 for (const identifier of registeredIdentifiers) {463 const commandName = commandNameByIdentifier.get(identifier);464 expect(commandName, `missing mapping for ${identifier}`).toBeDefined();465 expect(bootstrapCommands).toContain(commandName);466 }467 });468});469 470describe('bootstrap error handling', () => {471 const savedEnv = {472 NO_COLOR: process.env['NO_COLOR'],473 };474 475 let stderr: string[];476 477 beforeEach(() => {478 stderr = [];479 vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {480 stderr.push(String(chunk));481 return true;482 });483 vi.spyOn(process, 'exit').mockImplementation(((code) => {484 throw new Error(`process.exit:${String(code)}`);485 }) as typeof process.exit);486 });487 488 afterEach(() => {489 if (savedEnv.NO_COLOR === undefined) {490 delete process.env['NO_COLOR'];491 } else {492 process.env['NO_COLOR'] = savedEnv.NO_COLOR;493 }494 vi.restoreAllMocks();495 });496 497 it('prints FatalError messages and exits with their code', async () => {498 process.env['NO_COLOR'] = '1';499 500 await expect(501 handleCriticalError(new FatalError('fatal boom', 42)),502 ).rejects.toThrow('process.exit:42');503 504 const output = stderr.join('');505 expect(output).toContain('fatal boom');506 expect(output).not.toContain('\x1b[31m');507 });508 509 it('prints FatalError messages in red when color is enabled', async () => {510 delete process.env['NO_COLOR'];511 512 await expect(513 handleCriticalError(new FatalError('fatal color', 42)),514 ).rejects.toThrow('process.exit:42');515 516 expect(stderr.join('')).toContain('\x1b[31mfatal color\x1b[0m');517 });518 519 it('exits AlreadyReportedError without printing another error', async () => {520 await expect(521 handleCriticalError(new AlreadyReportedError('already printed', 7)),522 ).rejects.toThrow('process.exit:7');523 524 expect(stderr.join('')).toBe('');525 });526 527 it('prints unexpected errors with the generic critical header', async () => {528 await expect(529 handleCriticalError(new Error('generic boom')),530 ).rejects.toThrow('process.exit:1');531 532 const output = stderr.join('');533 expect(output).toContain('An unexpected critical error occurred:');534 expect(output).toContain('generic boom');535 });536 537 it('recognizes expected PTY race errors', () => {538 expect(539 isExpectedPtyRaceError(540 Object.assign(new Error('read EIO'), { code: 'EIO' }),541 ),542 ).toBe(true);543 expect(isExpectedPtyRaceError(new Error('read EAGAIN'))).toBe(true);544 expect(545 isExpectedPtyRaceError(546 new Error('Cannot resize a pty that has already exited'),547 ),548 ).toBe(true);549 expect(isExpectedPtyRaceError(new Error('other failure'))).toBe(false);550 });551 552 it('wires uncaughtException PTY race suppression without exiting', async () => {553 let uncaughtHandler: ((error: Error) => void) | undefined;554 vi.spyOn(process, 'on').mockImplementation(((555 event: string | symbol,556 listener: (...args: unknown[]) => void,557 ) => {558 if (event === 'uncaughtException') {559 uncaughtHandler = listener as (error: Error) => void;560 }561 return process;562 }) as typeof process.on);563 564 await runCliEntryPoint(vi.fn(async () => {}));565 566 expect(uncaughtHandler).toBeDefined();567 uncaughtHandler?.(Object.assign(new Error('read EIO'), { code: 'EIO' }));568 expect(process.exit).not.toHaveBeenCalled();569 expect(stderr.join('')).toBe('');570 });571 572 it('routes run failures through the critical error handler', async () => {573 const error = new Error('run failed');574 const run = vi.fn(async () => {575 throw error;576 });577 const handleError = vi.fn(async () => {});578 579 await runCliEntryPoint(run, handleError);580 581 expect(handleError).toHaveBeenCalledWith(error);582 });583 584 it('reports when the critical error handler itself fails', async () => {585 const run = vi.fn(async () => {586 throw new Error('run failed');587 });588 const handleError = vi.fn(async () => {589 throw new Error('handler failed');590 });591 592 await expect(runCliEntryPoint(run, handleError)).rejects.toThrow(593 'process.exit:1',594 );595 596 const output = stderr.join('');597 expect(output).toContain('Original error:');598 expect(output).toContain('run failed');599 expect(output).toContain('Error handler failed:');600 expect(output).toContain('handler failed');601 });602});603 