mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* fix(cli): add bootstrap fast paths * fix(cli): address bootstrap review feedback * test(core): make MCP retry backoff test deterministic * fix(cli): address bootstrap validation feedback * fix(cli): keep global-flag MCP invocations on full parser * fix(cli): harden bootstrap review gaps * fix(cli): copy package wrapper from script directory * fix(cli): cover bootstrap review edge cases * test(cli): cover bootstrap fallback paths * fix(cli): minimize wrapper version imports --------- Co-authored-by: 易良 <1204183885@qq.com>
602 lines
20 KiB
TypeScript
602 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { Argv } from 'yargs';
|
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
copyFileSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { FatalError } from '@qwen-code/qwen-code-core';
|
|
import { AlreadyReportedError } from './utils/errors.js';
|
|
import {
|
|
MCP_COMMANDS,
|
|
TOP_LEVEL_COMMANDS,
|
|
handleCriticalError,
|
|
isExpectedPtyRaceError,
|
|
resolveBootstrapRoute,
|
|
runCliEntry,
|
|
runCliEntryPoint,
|
|
} from './cli.js';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
main: vi.fn(),
|
|
tryRunServeFastPath: vi.fn(),
|
|
initStartupProfiler: vi.fn(),
|
|
initCpuProfiler: vi.fn(),
|
|
mcpHandler: vi.fn(),
|
|
mcpBuilder: vi.fn(),
|
|
mcpListHandler: vi.fn(),
|
|
mcpAddHandler: vi.fn(),
|
|
getCliVersion: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./gemini.js', () => ({
|
|
main: mocks.main,
|
|
}));
|
|
|
|
vi.mock('./serve/fast-path.js', () => ({
|
|
tryRunServeFastPath: mocks.tryRunServeFastPath,
|
|
}));
|
|
|
|
vi.mock('./utils/startupProfiler.js', () => ({
|
|
initStartupProfiler: mocks.initStartupProfiler,
|
|
}));
|
|
|
|
vi.mock('./utils/cpuProfiler.js', () => ({
|
|
initCpuProfiler: mocks.initCpuProfiler,
|
|
}));
|
|
|
|
vi.mock('./utils/version.js', () => ({
|
|
getCliVersion: mocks.getCliVersion,
|
|
}));
|
|
|
|
vi.mock('./commands/mcp.js', () => ({
|
|
mcpCommand: {
|
|
command: 'mcp',
|
|
describe: 'Manage MCP servers',
|
|
builder: (yargs: Argv) => {
|
|
mocks.mcpBuilder();
|
|
return yargs
|
|
.command({
|
|
command: 'list',
|
|
describe: 'List all configured MCP servers',
|
|
handler: mocks.mcpListHandler,
|
|
})
|
|
.command({
|
|
command: 'add <name>',
|
|
describe: 'Add a server',
|
|
handler: mocks.mcpAddHandler,
|
|
})
|
|
.demandCommand(1, 'You need at least one command before continuing.');
|
|
},
|
|
handler: mocks.mcpHandler,
|
|
},
|
|
}));
|
|
|
|
describe('resolveBootstrapRoute', () => {
|
|
it('routes top-level help, version, serve, and mcp correctly', async () => {
|
|
expect(resolveBootstrapRoute(['--help'])).toBe('help');
|
|
expect(resolveBootstrapRoute(['--version'])).toBe('version');
|
|
expect(resolveBootstrapRoute(['mcp', '--version'])).toBe('version');
|
|
expect(resolveBootstrapRoute(['serve', '--help'])).toBe('serve');
|
|
expect(resolveBootstrapRoute(['mcp', '--help'])).toBe('mcp');
|
|
});
|
|
|
|
it('keeps bundled entrypoint paths out of the route detection', async () => {
|
|
expect(resolveBootstrapRoute(['/repo/dist/cli.js', '--help'])).toBe('help');
|
|
expect(
|
|
resolveBootstrapRoute(['C:\\repo\\dist\\cli.js', 'mcp', '--help']),
|
|
).toBe('mcp');
|
|
});
|
|
|
|
it('falls back to the default route for normal interactive startup', async () => {
|
|
expect(resolveBootstrapRoute([])).toBe('default');
|
|
expect(resolveBootstrapRoute(['--model', 'gpt-4', 'Hello'])).toBe(
|
|
'default',
|
|
);
|
|
expect(resolveBootstrapRoute(['--safe-mode', 'mcp', 'list'])).toBe(
|
|
'default',
|
|
);
|
|
});
|
|
|
|
it('does not treat values for global flags as positional commands or bootstrap flags', () => {
|
|
expect(resolveBootstrapRoute(['--model', 'gpt-4', '--help'])).toBe('help');
|
|
expect(resolveBootstrapRoute(['-p', 'hello', '--help'])).toBe('help');
|
|
expect(resolveBootstrapRoute(['--model', '-v'])).toBe('default');
|
|
});
|
|
|
|
it('does not treat flags after -- as bootstrap flags', () => {
|
|
expect(resolveBootstrapRoute(['--', '--version'])).toBe('default');
|
|
expect(resolveBootstrapRoute(['mcp', '--', '--version'])).toBe('mcp');
|
|
});
|
|
});
|
|
|
|
describe('runCliEntry', () => {
|
|
const savedEnv = {
|
|
CLI_VERSION: process.env['CLI_VERSION'],
|
|
};
|
|
|
|
let stdout: string[];
|
|
let stderr: string[];
|
|
let savedExitCode: string | number | null | undefined;
|
|
|
|
beforeEach(() => {
|
|
stdout = [];
|
|
stderr = [];
|
|
savedExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
vi.clearAllMocks();
|
|
mocks.tryRunServeFastPath.mockResolvedValue(false);
|
|
mocks.getCliVersion.mockResolvedValue('fallback-version');
|
|
process.env['CLI_VERSION'] = '9.9.9';
|
|
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
stdout.push(String(chunk));
|
|
return true;
|
|
});
|
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
|
stderr.push(String(chunk));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exitCode = savedExitCode;
|
|
if (savedEnv.CLI_VERSION === undefined) {
|
|
delete process.env['CLI_VERSION'];
|
|
} else {
|
|
process.env['CLI_VERSION'] = savedEnv.CLI_VERSION;
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('prints the version without loading the full CLI graph', async () => {
|
|
await runCliEntry(['--version']);
|
|
|
|
expect(stdout.join('')).toContain('9.9.9');
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to getCliVersion when CLI_VERSION is unset', async () => {
|
|
delete process.env['CLI_VERSION'];
|
|
|
|
await runCliEntry(['--version']);
|
|
|
|
expect(stdout.join('')).toContain('fallback-version');
|
|
expect(mocks.getCliVersion).toHaveBeenCalledTimes(1);
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prints top-level help without loading the full CLI graph', async () => {
|
|
await runCliEntry(['--help']);
|
|
|
|
const helpText = stdout.join('');
|
|
expect(helpText).toContain('Usage: qwen [options] [command]');
|
|
expect(helpText).toContain('Manage Qwen Code hooks');
|
|
expect(helpText).toContain('Manage MCP servers');
|
|
expect(helpText).toContain('Run Qwen Code as a local HTTP daemon');
|
|
expect(helpText).toContain('--model');
|
|
expect(helpText).toContain('-p, --prompt');
|
|
expect(helpText).toContain('--safe-mode');
|
|
expect(helpText).toContain('-s, --sandbox');
|
|
expect(helpText).toContain('-o, --output-format');
|
|
expect(helpText).toContain('-r, --resume');
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes the MCP help path without booting gemini', async () => {
|
|
await runCliEntry(['mcp', '--help']);
|
|
|
|
expect(stdout.join('')).toContain('Manage MCP servers');
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.tryRunServeFastPath).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.mcpBuilder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not execute MCP subcommands when showing subcommand help', async () => {
|
|
await runCliEntry(['mcp', 'list', '--help']);
|
|
|
|
const helpText = stdout.join('');
|
|
expect(helpText).toContain('List all configured MCP servers');
|
|
expect(mocks.mcpListHandler).not.toHaveBeenCalled();
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('executes MCP subcommands through the fast path', async () => {
|
|
await runCliEntry(['mcp', 'list']);
|
|
|
|
expect(mocks.mcpListHandler).toHaveBeenCalledTimes(1);
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('executes MCP subcommands after -- through the fast path', async () => {
|
|
await runCliEntry(['mcp', '--', 'list']);
|
|
|
|
expect(mocks.mcpListHandler).toHaveBeenCalledTimes(1);
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the full CLI when global flags precede MCP commands', async () => {
|
|
await runCliEntry(['--safe-mode', 'mcp', 'list']);
|
|
|
|
expect(mocks.main).toHaveBeenCalledTimes(1);
|
|
expect(mocks.mcpListHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails MCP fast-path validation without loading the full CLI', async () => {
|
|
await runCliEntry(['mcp', 'doesnotexist']);
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(stderr.join('')).toContain('Unknown command: doesnotexist');
|
|
expect(mocks.mcpListHandler).not.toHaveBeenCalled();
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
expect(mocks.initStartupProfiler).not.toHaveBeenCalled();
|
|
expect(mocks.initCpuProfiler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not run MCP subcommands with unknown options', async () => {
|
|
await runCliEntry(['mcp', 'list', '--unknown']);
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(stderr.join('')).toContain('Unknown argument: unknown');
|
|
expect(mocks.mcpListHandler).not.toHaveBeenCalled();
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports routine MCP argument errors without loading the full CLI', async () => {
|
|
await runCliEntry(['mcp', 'add']);
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(stderr.join('')).toContain('Not enough non-option arguments');
|
|
expect(mocks.mcpAddHandler).not.toHaveBeenCalled();
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the serve fast path ahead of the full CLI startup', async () => {
|
|
mocks.tryRunServeFastPath.mockResolvedValue(true);
|
|
|
|
await runCliEntry(['serve']);
|
|
|
|
expect(mocks.tryRunServeFastPath).toHaveBeenCalledWith(['serve']);
|
|
expect(mocks.main).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('initializes profilers once when the serve fast path falls back', async () => {
|
|
mocks.tryRunServeFastPath.mockResolvedValue(false);
|
|
|
|
await runCliEntry(['serve']);
|
|
|
|
expect(mocks.tryRunServeFastPath).toHaveBeenCalledWith(['serve']);
|
|
expect(mocks.main).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('loads gemini on the default path', async () => {
|
|
await runCliEntry([]);
|
|
|
|
expect(mocks.main).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('bootstrap import boundaries', () => {
|
|
it('keeps fast-path-only dependencies out of static imports', () => {
|
|
const source = readFileSync('src/cli.ts', 'utf8');
|
|
|
|
expect(source).not.toContain("import yargs from 'yargs'");
|
|
expect(source).not.toContain("from '@qwen-code/qwen-code-core'");
|
|
expect(source).not.toContain("import './gemini.js'");
|
|
expect(source).not.toContain("import { main } from './gemini.js'");
|
|
});
|
|
|
|
it('initializes profilers during bootstrap module evaluation', () => {
|
|
const source = readFileSync('src/cli.ts', 'utf8');
|
|
|
|
expect(source).toContain(
|
|
"import { initStartupProfiler } from './utils/startupProfiler.js'",
|
|
);
|
|
expect(source).toContain(
|
|
"import { initCpuProfiler } from './utils/cpuProfiler.js'",
|
|
);
|
|
expect(source.indexOf('initStartupProfiler();')).toBeLessThan(
|
|
source.indexOf('export async function runCliEntry('),
|
|
);
|
|
expect(source.indexOf('initCpuProfiler();')).toBeLessThan(
|
|
source.indexOf('export async function runCliEntry('),
|
|
);
|
|
});
|
|
|
|
it('uses the bootstrap file as the production bundle entry', () => {
|
|
const source = readFileSync('../../esbuild.config.js', 'utf8');
|
|
|
|
expect(source).toContain("entryPoints: { cli: 'packages/cli/src/cli.ts' }");
|
|
});
|
|
|
|
it('keeps bootstrap fast paths in-process in the npm bin wrapper', () => {
|
|
const source = readFileSync('../../scripts/cli-entry.js', 'utf8');
|
|
|
|
expect(source).toContain('function isInProcessFastPath()');
|
|
expect(source).toContain("first === 'serve'");
|
|
expect(source).toContain("first === 'mcp'");
|
|
expect(source).toContain("hasFlag('--help', '-h')");
|
|
expect(source).toContain("hasFlag('--version', '-v')");
|
|
});
|
|
|
|
it('prints CLI_VERSION from the npm bin wrapper version shortcut', () => {
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
['../../scripts/cli-entry.js', '--version'],
|
|
{
|
|
encoding: 'utf8',
|
|
env: { ...process.env, CLI_VERSION: '7.7.7-test' },
|
|
},
|
|
);
|
|
|
|
expect(output).toBe('7.7.7-test\n');
|
|
});
|
|
|
|
it('reads package.json from the npm bin wrapper version shortcut', () => {
|
|
const expectedVersion = JSON.parse(
|
|
readFileSync('../../package.json', 'utf8'),
|
|
).version;
|
|
const env = { ...process.env };
|
|
delete env['CLI_VERSION'];
|
|
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
['../../scripts/cli-entry.js', '--version'],
|
|
{
|
|
encoding: 'utf8',
|
|
env,
|
|
},
|
|
);
|
|
|
|
expect(output).toBe(`${expectedVersion}\n`);
|
|
});
|
|
|
|
it('falls through to cli.js when wrapper package.json parsing fails', () => {
|
|
const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-entry-'));
|
|
try {
|
|
copyFileSync(
|
|
'../../scripts/cli-entry.js',
|
|
path.join(tempDir, 'cli-entry.mjs'),
|
|
);
|
|
writeFileSync(
|
|
path.join(tempDir, 'cli.js'),
|
|
"process.stdout.write('fallback-cli\\n');\n",
|
|
);
|
|
const env = { ...process.env };
|
|
delete env['CLI_VERSION'];
|
|
|
|
const output = execFileSync(
|
|
process.execPath,
|
|
[path.join(tempDir, 'cli-entry.mjs'), '--version'],
|
|
{
|
|
encoding: 'utf8',
|
|
env,
|
|
},
|
|
);
|
|
|
|
expect(output).toBe('fallback-cli\n');
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('copies the npm bin wrapper into the package instead of duplicating it', () => {
|
|
const source = readFileSync('../../scripts/prepare-package.js', 'utf8');
|
|
|
|
expect(source).toContain(
|
|
"fs.copyFileSync(path.join(__dirname, 'cli-entry.js'), cliEntryPath)",
|
|
);
|
|
expect(source).not.toContain('const cliEntryContent = `');
|
|
});
|
|
|
|
it('keeps bootstrap top-level help commands aligned with config registrations', () => {
|
|
const configSource = readFileSync('src/config/config.ts', 'utf8');
|
|
const commandNameByIdentifier = new Map([
|
|
['authCommand', 'auth'],
|
|
['channelCommand', 'channel'],
|
|
['extensionsCommand', 'extensions'],
|
|
['hooksCommand', 'hooks'],
|
|
['mcpCommand', 'mcp'],
|
|
['reviewCommand', 'review'],
|
|
['serveCommand', 'serve'],
|
|
['sessionsCommand', 'sessions'],
|
|
]);
|
|
const registeredIdentifiers = [
|
|
...configSource.matchAll(/\.command\((\w+Command)\)/g),
|
|
].map((match) => match[1]!);
|
|
const bootstrapCommands = new Set(
|
|
TOP_LEVEL_COMMANDS.map(([command]) => command.split(' ')[0]),
|
|
);
|
|
|
|
expect(registeredIdentifiers).toHaveLength(commandNameByIdentifier.size);
|
|
for (const identifier of registeredIdentifiers) {
|
|
const commandName = commandNameByIdentifier.get(identifier);
|
|
expect(commandName, `missing mapping for ${identifier}`).toBeDefined();
|
|
expect(bootstrapCommands).toContain(commandName);
|
|
}
|
|
});
|
|
|
|
it('keeps bootstrap MCP help commands aligned with MCP registrations', () => {
|
|
const mcpSource = readFileSync('src/commands/mcp.ts', 'utf8');
|
|
const commandNameByIdentifier = new Map([
|
|
['addCommand', 'add'],
|
|
['removeCommand', 'remove'],
|
|
['listCommand', 'list'],
|
|
['reconnectCommand', 'reconnect'],
|
|
['approveCommand', 'approve'],
|
|
['rejectCommand', 'reject'],
|
|
]);
|
|
const registeredIdentifiers = [
|
|
...mcpSource.matchAll(/\.command\((\w+Command)\)/g),
|
|
].map((match) => match[1]!);
|
|
const bootstrapCommands = new Set(
|
|
MCP_COMMANDS.map(([command]) => command.split(' ')[0]),
|
|
);
|
|
|
|
expect(registeredIdentifiers).toHaveLength(commandNameByIdentifier.size);
|
|
for (const identifier of registeredIdentifiers) {
|
|
const commandName = commandNameByIdentifier.get(identifier);
|
|
expect(commandName, `missing mapping for ${identifier}`).toBeDefined();
|
|
expect(bootstrapCommands).toContain(commandName);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('bootstrap error handling', () => {
|
|
const savedEnv = {
|
|
NO_COLOR: process.env['NO_COLOR'],
|
|
};
|
|
|
|
let stderr: string[];
|
|
|
|
beforeEach(() => {
|
|
stderr = [];
|
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
|
stderr.push(String(chunk));
|
|
return true;
|
|
});
|
|
vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
throw new Error(`process.exit:${String(code)}`);
|
|
}) as typeof process.exit);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedEnv.NO_COLOR === undefined) {
|
|
delete process.env['NO_COLOR'];
|
|
} else {
|
|
process.env['NO_COLOR'] = savedEnv.NO_COLOR;
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('prints FatalError messages and exits with their code', async () => {
|
|
process.env['NO_COLOR'] = '1';
|
|
|
|
await expect(
|
|
handleCriticalError(new FatalError('fatal boom', 42)),
|
|
).rejects.toThrow('process.exit:42');
|
|
|
|
const output = stderr.join('');
|
|
expect(output).toContain('fatal boom');
|
|
expect(output).not.toContain('\x1b[31m');
|
|
});
|
|
|
|
it('prints FatalError messages in red when color is enabled', async () => {
|
|
delete process.env['NO_COLOR'];
|
|
|
|
await expect(
|
|
handleCriticalError(new FatalError('fatal color', 42)),
|
|
).rejects.toThrow('process.exit:42');
|
|
|
|
expect(stderr.join('')).toContain('\x1b[31mfatal color\x1b[0m');
|
|
});
|
|
|
|
it('exits AlreadyReportedError without printing another error', async () => {
|
|
await expect(
|
|
handleCriticalError(new AlreadyReportedError('already printed', 7)),
|
|
).rejects.toThrow('process.exit:7');
|
|
|
|
expect(stderr.join('')).toBe('');
|
|
});
|
|
|
|
it('prints unexpected errors with the generic critical header', async () => {
|
|
await expect(
|
|
handleCriticalError(new Error('generic boom')),
|
|
).rejects.toThrow('process.exit:1');
|
|
|
|
const output = stderr.join('');
|
|
expect(output).toContain('An unexpected critical error occurred:');
|
|
expect(output).toContain('generic boom');
|
|
});
|
|
|
|
it('recognizes expected PTY race errors', () => {
|
|
expect(
|
|
isExpectedPtyRaceError(
|
|
Object.assign(new Error('read EIO'), { code: 'EIO' }),
|
|
),
|
|
).toBe(true);
|
|
expect(isExpectedPtyRaceError(new Error('read EAGAIN'))).toBe(true);
|
|
expect(
|
|
isExpectedPtyRaceError(
|
|
new Error('Cannot resize a pty that has already exited'),
|
|
),
|
|
).toBe(true);
|
|
expect(isExpectedPtyRaceError(new Error('other failure'))).toBe(false);
|
|
});
|
|
|
|
it('wires uncaughtException PTY race suppression without exiting', async () => {
|
|
let uncaughtHandler: ((error: Error) => void) | undefined;
|
|
vi.spyOn(process, 'on').mockImplementation(((
|
|
event: string | symbol,
|
|
listener: (...args: unknown[]) => void,
|
|
) => {
|
|
if (event === 'uncaughtException') {
|
|
uncaughtHandler = listener as (error: Error) => void;
|
|
}
|
|
return process;
|
|
}) as typeof process.on);
|
|
|
|
await runCliEntryPoint(vi.fn(async () => {}));
|
|
|
|
expect(uncaughtHandler).toBeDefined();
|
|
uncaughtHandler?.(Object.assign(new Error('read EIO'), { code: 'EIO' }));
|
|
expect(process.exit).not.toHaveBeenCalled();
|
|
expect(stderr.join('')).toBe('');
|
|
});
|
|
|
|
it('routes run failures through the critical error handler', async () => {
|
|
const error = new Error('run failed');
|
|
const run = vi.fn(async () => {
|
|
throw error;
|
|
});
|
|
const handleError = vi.fn(async () => {});
|
|
|
|
await runCliEntryPoint(run, handleError);
|
|
|
|
expect(handleError).toHaveBeenCalledWith(error);
|
|
});
|
|
|
|
it('reports when the critical error handler itself fails', async () => {
|
|
const run = vi.fn(async () => {
|
|
throw new Error('run failed');
|
|
});
|
|
const handleError = vi.fn(async () => {
|
|
throw new Error('handler failed');
|
|
});
|
|
|
|
await expect(runCliEntryPoint(run, handleError)).rejects.toThrow(
|
|
'process.exit:1',
|
|
);
|
|
|
|
const output = stderr.join('');
|
|
expect(output).toContain('Original error:');
|
|
expect(output).toContain('run failed');
|
|
expect(output).toContain('Error handler failed:');
|
|
expect(output).toContain('handler failed');
|
|
});
|
|
});
|