CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
cli.test.ts603 linesDownload Raw Back to src
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 
basant307/AI_Governance_Project · CoolFace