Files
qwen-code/packages/cli/src/utils/relaunch.test.ts
2025-10-23 09:27:04 +08:00

346 lines
9.9 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { EventEmitter } from 'node:events';
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return {
...actual,
spawn: vi.fn(),
};
});
const mockedSpawn = vi.mocked(spawn);
// Import the functions initially
import { relaunchAppInChildProcess, relaunchOnExitCode } from './relaunch.js';
describe('relaunchOnExitCode', () => {
let processExitSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let stdinResumeSpy: MockInstance;
beforeEach(() => {
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('PROCESS_EXIT_CALLED');
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
stdinResumeSpy = vi
.spyOn(process.stdin, 'resume')
.mockImplementation(() => process.stdin);
vi.clearAllMocks();
});
afterEach(() => {
processExitSpy.mockRestore();
consoleErrorSpy.mockRestore();
stdinResumeSpy.mockRestore();
});
it('should exit with non-RELAUNCH_EXIT_CODE', async () => {
const runner = vi.fn().mockResolvedValue(0);
await expect(relaunchOnExitCode(runner)).rejects.toThrow(
'PROCESS_EXIT_CALLED',
);
expect(runner).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it('should continue running when RELAUNCH_EXIT_CODE is returned', async () => {
let callCount = 0;
const runner = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount === 1) return RELAUNCH_EXIT_CODE;
if (callCount === 2) return RELAUNCH_EXIT_CODE;
return 0; // Exit on third call
});
await expect(relaunchOnExitCode(runner)).rejects.toThrow(
'PROCESS_EXIT_CALLED',
);
expect(runner).toHaveBeenCalledTimes(3);
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it('should handle runner errors', async () => {
const error = new Error('Runner failed');
const runner = vi.fn().mockRejectedValue(error);
await expect(relaunchOnExitCode(runner)).rejects.toThrow(
'PROCESS_EXIT_CALLED',
);
expect(runner).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Fatal error: Failed to relaunch the CLI process.',
error,
);
expect(stdinResumeSpy).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
describe('relaunchAppInChildProcess', () => {
let processExitSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let stdinPauseSpy: MockInstance;
let stdinResumeSpy: MockInstance;
// Store original values to restore later
const originalEnv = { ...process.env };
const originalExecArgv = [...process.execArgv];
const originalArgv = [...process.argv];
const originalExecPath = process.execPath;
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
process.execPath = '/usr/bin/node';
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('PROCESS_EXIT_CALLED');
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
stdinPauseSpy = vi
.spyOn(process.stdin, 'pause')
.mockImplementation(() => process.stdin);
stdinResumeSpy = vi
.spyOn(process.stdin, 'resume')
.mockImplementation(() => process.stdin);
});
afterEach(() => {
process.env = { ...originalEnv };
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
process.execPath = originalExecPath;
processExitSpy.mockRestore();
consoleErrorSpy.mockRestore();
stdinPauseSpy.mockRestore();
stdinResumeSpy.mockRestore();
});
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
it('should return early without spawning a child process', async () => {
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
await relaunchAppInChildProcess(['--test'], ['--verbose']);
expect(mockedSpawn).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
});
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
beforeEach(() => {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
});
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
// Test the argument construction logic directly by extracting it into a testable function
// This tests the same logic that's used in relaunchAppInChildProcess
// Setup test data to verify argument ordering
const mockExecArgv = ['--inspect=9229', '--trace-warnings'];
const mockArgv = [
'/usr/bin/node',
'/path/to/cli.js',
'command',
'--flag=value',
'--verbose',
];
const additionalNodeArgs = [
'--max-old-space-size=4096',
'--experimental-modules',
];
const additionalScriptArgs = ['--model', 'gemini-1.5-pro', '--debug'];
// Extract the argument construction logic from relaunchAppInChildProcess
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
// Verify the argument construction follows the expected pattern:
// [...process.execArgv, ...additionalNodeArgs, script, ...additionalScriptArgs, ...scriptArgs]
const expectedArgs = [
// Original node execution arguments
'--inspect=9229',
'--trace-warnings',
// Additional node arguments passed to function
'--max-old-space-size=4096',
'--experimental-modules',
// The script path
'/path/to/cli.js',
// Additional script arguments passed to function
'--model',
'gemini-1.5-pro',
'--debug',
// Original script arguments (everything after the script in process.argv)
'command',
'--flag=value',
'--verbose',
];
expect(nodeArgs).toEqual(expectedArgs);
});
it('should handle empty additional arguments correctly', () => {
// Test edge cases with empty arrays
const mockExecArgv = ['--trace-warnings'];
const mockArgv = ['/usr/bin/node', '/app/cli.js', 'start'];
const additionalNodeArgs: string[] = [];
const additionalScriptArgs: string[] = [];
// Extract the argument construction logic
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
const expectedArgs = ['--trace-warnings', '/app/cli.js', 'start'];
expect(nodeArgs).toEqual(expectedArgs);
});
it('should handle complex argument patterns', () => {
// Test with various argument types including flags with values, boolean flags, etc.
const mockExecArgv = ['--max-old-space-size=8192'];
const mockArgv = [
'/usr/bin/node',
'/cli.js',
'--config=/path/to/config.json',
'--verbose',
'subcommand',
'--output',
'file.txt',
];
const additionalNodeArgs = ['--inspect-brk=9230'];
const additionalScriptArgs = ['--model=gpt-4', '--temperature=0.7'];
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
const expectedArgs = [
'--max-old-space-size=8192',
'--inspect-brk=9230',
'/cli.js',
'--model=gpt-4',
'--temperature=0.7',
'--config=/path/to/config.json',
'--verbose',
'subcommand',
'--output',
'file.txt',
];
expect(nodeArgs).toEqual(expectedArgs);
});
// Note: Additional integration tests for spawn behavior are complex due to module mocking
// limitations with ES modules. The core logic is tested in relaunchOnExitCode tests.
it('should handle null exit code from child process', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js'];
const mockChild = createMockChildProcess(0, false); // Don't auto-close
mockedSpawn.mockImplementation(() => {
// Emit close with null code immediately
setImmediate(() => {
mockChild.emit('close', null);
});
return mockChild;
});
// Start the relaunch process
const promise = relaunchAppInChildProcess([], []);
await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED');
// Should default to exit code 1
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
});
/**
* Creates a mock child process that emits events asynchronously
*/
function createMockChildProcess(
exitCode: number = 0,
autoClose: boolean = false,
): ChildProcess {
const mockChild = new EventEmitter() as ChildProcess;
Object.assign(mockChild, {
stdin: null,
stdout: null,
stderr: null,
stdio: [null, null, null],
pid: 12345,
killed: false,
exitCode: null,
signalCode: null,
spawnargs: [],
spawnfile: '',
kill: vi.fn(),
send: vi.fn(),
disconnect: vi.fn(),
unref: vi.fn(),
ref: vi.fn(),
});
if (autoClose) {
setImmediate(() => {
mockChild.emit('close', exitCode);
});
}
return mockChild;
}