Files
qwen-code/packages/cli/src/llm.test.tsx
T
1853b1505f fix(subscription): isolate activation and restore sign-in recovery (#16453)
* fix(web-shell): wait for session before checking subscription commands

* fix(cli): keep signed-out subscription startup in the composer

* fix(subscription): preserve model selection across sign-in

* fix(subscription): align sign-in with model selection scope

* fix(subscription): apply reasoning defaults across models

* fix(subscription): tag Qwen web links by client

* fix(subscription): prefix Qwen web client tags

* fix(subscription): attribute Qwen web links by paywall scene

* fix(subscription): recover historical sessions with saved API models

* fix(cli): remove the signed-out subscription startup notice

* fix(subscription): support standalone desktop activation and expose errors

* fix(subscription): isolate activation and session model selection

* fix(subscription): enforce auth policy and restore home login

* test(cli): preserve the default initializer mock on restore

---------

Co-authored-by: luoyun <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-11-06 20:11:52 +08:00

5250 lines
177 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
afterAll,
type MockInstance,
} from 'vitest';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
createNonInteractivePromptId,
main,
registerLspHotReload,
setupUncaughtExceptionHandler,
setupUnhandledRejectionHandler,
validateDnsResolutionOrder,
} from './llm.js';
import { startInteractiveUI } from './ui/startInteractiveUI.js';
import { clearCiEnv } from './test-utils/ci-env.js';
import type { CliArgs } from './config/config.js';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { ChatRecord, Config } from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
InputFormat,
OutputFormat,
Storage,
} from '@qwen-code/qwen-code-core';
import { MODEL_CATALOG_REFRESH_ENV } from '@qwen-code/qwen-code-core/models/model-catalog-refresh.js';
import { EXTERNAL_TOOL_GUARD_REQUIRED_VALUE } from '@qwen-code/acp-bridge/externalToolGuard';
const mockPrepareFileWatchersForProcessExit = vi.hoisted(() => vi.fn());
vi.mock(
'@qwen-code/qwen-code-core/utils/file-watcher-cleanup.js',
async (importOriginal) => ({
...(await importOriginal<
typeof import('@qwen-code/qwen-code-core/utils/file-watcher-cleanup.js')
>()),
prepareFileWatchersForProcessExit: mockPrepareFileWatchersForProcessExit,
}),
);
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockConsumeLastRenderError = vi.hoisted(() => vi.fn());
const mockHandleListExtensions = vi.hoisted(() => vi.fn());
const mockStartEarlyStartupPrefetches = vi.hoisted(() => vi.fn());
const mockStartPostRenderPrefetches = vi.hoisted(() => vi.fn());
const mockRunAcpAgent = vi.hoisted(() => vi.fn());
const mockStartNonInteractiveOpenAILogHousekeeping = vi.hoisted(() => vi.fn());
const mockStopNonInteractiveOpenAILogHousekeeping = vi.hoisted(() =>
vi.fn(async () => {}),
);
const mockAssertFullIcuAvailable = vi.hoisted(() => vi.fn());
const mockUpdateBeforeRelaunch = vi.hoisted(() => vi.fn());
const mockGetInstallationInfo = vi.hoisted(() => vi.fn());
const mockRegisterSession = vi.hoisted(
() =>
(..._args: unknown[]) =>
Promise.resolve(true),
);
const lspConfigWatcherMock = vi.hoisted(() => ({
instances: [] as Array<{
listener?: (event: unknown) => void | Promise<void>;
startWatching: ReturnType<typeof vi.fn>;
stopWatching: ReturnType<typeof vi.fn>;
}>,
}));
const mockSelectTuiRenderer = vi.hoisted(() =>
vi.fn(() => ({
renderer: 'ink' as 'ink' | 'opentui',
reason: 'no renderer requested',
strict: false,
})),
);
const mockStartOpenTuiUI = vi.hoisted(() => vi.fn());
const sessionRegistryConfigStub = {
getTargetDir: () => '/tmp/project',
trackSessionRegistration: (registration: Promise<boolean>) => {
void registration.catch(() => undefined);
},
unregisterSessionRegistry: async () => {},
};
// main() writes best-effort state under ~/.qwen; some runners — including
// the review-address verification gate's clean child — inherit a HOME the
// test process cannot write to. Ordinary CI already overrides HOME, so
// point it at a scratch directory for this whole file.
const savedHome = process.env['HOME'];
const llmTestHome = mkdtempSync(join(tmpdir(), 'qwen-llm-test-home-'));
process.env['HOME'] = llmTestHome;
afterAll(() => {
if (savedHome === undefined) {
delete process.env['HOME'];
} else {
process.env['HOME'] = savedHome;
}
rmSync(llmTestHome, { recursive: true, force: true });
});
describe('gemini import boundary', () => {
it('does not statically import ACP or noninteractive auth branches', () => {
const source = readFileSync('src/llm.tsx', 'utf8');
expect(source).not.toContain(
"import { runAcpAgent } from './acp-integration/acpAgent.js'",
);
expect(source).not.toContain(
"import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'",
);
expect(source).not.toContain(
"import { initializeApp } from './core/initializer.js'",
);
expect(source).toMatch(
/await import\(\s*['"]\.\/acp-integration\/acpAgent\.js['"]\s*\)/,
);
expect(source).toMatch(
/await import\(\s*['"]\.\/validateNonInterActiveAuth\.js['"]\s*\)/,
);
expect(source).toMatch(
/await import\(\s*['"]\.\/core\/initializer\.js['"]\s*\)/,
);
});
});
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
constructor(readonly code?: string | number | null | undefined) {
super('PROCESS_EXIT_MOCKED');
this.name = 'MockProcessExitError';
}
}
// Mock dependencies
vi.mock('./config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
createMinimalSettings: vi.fn(),
};
});
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
registerSession: (...args: unknown[]) => mockRegisterSession(...args),
};
});
vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockResolvedValue({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
isLspEnabled: () => false,
getLspClient: () => undefined,
getWarnings: vi.fn(() => []),
isSafeMode: vi.fn(() => false),
getModelsConfig: vi.fn(() => ({ getCurrentAuthType: () => null })),
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
buildDisabledSkillNamesProvider: vi.fn(() => () => new Set<string>()),
buildEnabledSkillNamesProvider: vi.fn(() => () => new Set<string>()),
// Mirrors SESSION_ID_REGEX in ./config/config.ts; keep them in sync.
isValidSessionId: vi.fn((value: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i.test(
value,
),
),
}));
vi.mock('read-package-up', () => ({
readPackageUp: vi.fn().mockResolvedValue({
packageJson: { name: 'test-pkg', version: 'test-version' },
path: '/fake/path/package.json',
}),
}));
vi.mock('update-notifier', () => ({
default: vi.fn(() => ({
notify: vi.fn(),
})),
}));
vi.mock('./utils/events.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils/events.js')>();
return {
...actual,
appEvents: {
emit: vi.fn(),
},
};
});
vi.mock('./serve/sandbox.js', () => ({
sandbox_command: vi.fn(() => ''), // Default to no sandbox command
start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
}));
vi.mock('./utils/stdioHelpers.js', () => ({
writeStderrLine: mockWriteStderrLine,
writeStderrLineSafe: vi.fn(),
writeStdoutLine: mockWriteStdoutLine,
clearScreen: vi.fn(),
}));
vi.mock('./utils/relaunch.js', () => ({
exitWhenSupervisorExits: vi.fn(),
relaunchAppInChildProcess: vi.fn(),
relaunchOnExitCode: vi.fn((fn: () => Promise<number>) => fn()),
}));
vi.mock('./utils/processUtils.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./utils/processUtils.js')>()),
superviseInProcess: vi.fn(),
}));
vi.mock('./config/environment.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config/environment.js')>()),
hasLoadedEnvironmentValues: vi.fn(() => false),
}));
vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn(),
}));
vi.mock('./core/initializer.js', () => ({
initializeApp: vi.fn(async () => ({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
})),
}));
vi.mock('./startup/startup-prefetch.js', () => ({
startEarlyStartupPrefetches: (...args: unknown[]) =>
mockStartEarlyStartupPrefetches(...args),
startPostRenderPrefetches: (...args: unknown[]) =>
mockStartPostRenderPrefetches(...args),
}));
vi.mock('./ui/update-relaunch.js', () => ({
updateBeforeRelaunch: (...args: unknown[]) =>
mockUpdateBeforeRelaunch(...args),
}));
vi.mock('./utils/installationInfo.js', () => ({
getInstallationInfo: (...args: unknown[]) => mockGetInstallationInfo(...args),
}));
vi.mock('./acp-integration/acpAgent.js', () => ({
runAcpAgent: (...args: unknown[]) => mockRunAcpAgent(...args),
}));
vi.mock('./services/housekeeping/scheduler.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('./services/housekeeping/scheduler.js')
>();
return {
...actual,
startNonInteractiveOpenAILogHousekeeping: (...args: unknown[]) =>
mockStartNonInteractiveOpenAILogHousekeeping(...args),
stopNonInteractiveOpenAILogHousekeeping: () =>
mockStopNonInteractiveOpenAILogHousekeeping(),
};
});
vi.mock('./startup/icu-check.js', () => ({
assertFullIcuAvailable: mockAssertFullIcuAvailable,
}));
vi.mock('./commands/extensions/list.js', () => ({
handleList: mockHandleListExtensions,
}));
vi.mock('./ui/AppContainer.js', () => ({
AppContainer: () => null,
}));
// Stub the settings watcher: main() constructs one and calls startWatching()
// in non-bare mode. The real implementation reads settings.user/.workspace
// paths and arms chokidar file watchers, neither of which these main()-flow
// tests supply or want as a side effect.
vi.mock('./config/settingsWatcher.js', () => ({
SettingsWatcher: class {
startWatching() {}
stopWatching() {}
addChangeListener() {
return () => {};
}
},
}));
vi.mock('./config/lsp-config-watcher.js', () => ({
LspConfigWatcher: class {
listener?: (event: unknown) => void | Promise<void>;
startWatching = vi.fn(
(listener: (event: unknown) => void | Promise<void>) => {
this.listener = listener;
},
);
stopWatching = vi.fn();
constructor() {
lspConfigWatcherMock.instances.push(this);
}
},
}));
vi.mock('./config/extension-file-watcher.js', () => ({
ExtensionFileWatcher: class {
startWatching() {}
restartWatching() {}
stopWatching() {}
},
}));
// The OpenTUI entry touches the native FFI at module scope — it must never
// load inside this suite, so the mock is total (no importOriginal).
vi.mock('./ui/opentui/start-opentui-ui.js', () => ({
startOpenTuiUI: mockStartOpenTuiUI,
}));
// Only the gate function is replaced; the real module supplies the env-var
// name constants the dispatch error messages are asserted against. The
// default keeps every pre-existing main() test on the ink path.
vi.mock('./ui/opentui/renderer-selection.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./ui/opentui/renderer-selection.js')>();
return {
...actual,
selectTuiRenderer: mockSelectTuiRenderer,
};
});
function withLspDisabledConfig<T extends object>(
config: T,
): T & {
isLspEnabled: () => boolean;
getLspClient: () => undefined;
} {
return {
isLspEnabled: () => false,
getLspClient: () => undefined,
...config,
};
}
describe('llm.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
let originalEnvQwenSandboxImage: string | undefined;
let originalEnvQwenCodeSimple: string | undefined;
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
[];
beforeEach(() => {
mockStartNonInteractiveOpenAILogHousekeeping.mockClear();
mockStopNonInteractiveOpenAILogHousekeeping.mockClear();
lspConfigWatcherMock.instances.length = 0;
mockUpdateBeforeRelaunch.mockResolvedValue(true);
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm install -g @qwen-code/qwen-code@latest',
});
// Store and clear sandbox-related env variables to ensure a consistent test environment
originalEnvGeminiSandbox = process.env['QWEN_SANDBOX'];
originalEnvSandbox = process.env['SANDBOX'];
// QWEN_SANDBOX_IMAGE selects the custom-image relaunch branch in main(),
// which skips the host-update capability computation; CI environments that
// export a resolved sandbox image (e.g. the autofix runner) would otherwise
// flip these tests' code path.
originalEnvQwenSandboxImage = process.env['QWEN_SANDBOX_IMAGE'];
originalEnvQwenCodeSimple = process.env['QWEN_CODE_SIMPLE'];
delete process.env['QWEN_SANDBOX'];
delete process.env['SANDBOX'];
delete process.env['QWEN_SANDBOX_IMAGE'];
delete process.env['QWEN_CODE_SIMPLE'];
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
});
afterEach(() => {
// Restore original env variables
if (originalEnvGeminiSandbox !== undefined) {
process.env['QWEN_SANDBOX'] = originalEnvGeminiSandbox;
} else {
delete process.env['QWEN_SANDBOX'];
}
if (originalEnvSandbox !== undefined) {
process.env['SANDBOX'] = originalEnvSandbox;
} else {
delete process.env['SANDBOX'];
}
if (originalEnvQwenSandboxImage !== undefined) {
process.env['QWEN_SANDBOX_IMAGE'] = originalEnvQwenSandboxImage;
} else {
delete process.env['QWEN_SANDBOX_IMAGE'];
}
if (originalEnvQwenCodeSimple !== undefined) {
process.env['QWEN_CODE_SIMPLE'] = originalEnvQwenCodeSimple;
} else {
delete process.env['QWEN_CODE_SIMPLE'];
}
const currentListeners = process.listeners('unhandledRejection');
for (const listener of currentListeners) {
if (!initialUnhandledRejectionListeners.includes(listener)) {
process.removeListener('unhandledRejection', listener);
}
}
vi.restoreAllMocks();
});
it.each([
[false, undefined, undefined],
[true, undefined, undefined],
[false, 'private-parent', undefined],
[true, 'private-parent', undefined],
[false, '', undefined],
[true, '', undefined],
[false, undefined, false],
[false, undefined, true],
] as const)(
'H01 opts into ancestor trust at sandbox=%s from provenance=%s with workspace override=%s',
async (sandbox, capability, workspaceTrustOverride) => {
const originalArgv = process.argv;
process.argv = ['node', 'script.js', '--acp'];
vi.stubEnv('QWEN_CODE_PRIVATE_ACP_CAPABILITY', capability);
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '1');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(parseArguments).mockResolvedValue({
acp: true,
extensions: [],
} as unknown as CliArgs);
vi.mocked(loadSandboxConfig).mockResolvedValue(
sandbox ? { command: 'sandbox-exec', image: 'fixture' } : undefined,
);
vi.mocked(loadSettings).mockImplementation(() => {
if (capability === undefined) {
process.env['QWEN_CODE_PRIVATE_ACP_CAPABILITY'] =
'forged-by-settings';
} else {
delete process.env['QWEN_CODE_PRIVATE_ACP_CAPABILITY'];
}
return {
errors: [],
merged: {
advanced: {},
security: {
auth: {},
...(workspaceTrustOverride !== undefined
? { folderTrust: { enabled: workspaceTrustOverride } }
: {}),
},
ui: {},
},
setValue: vi.fn(),
forScope: (scope: string) => ({
settings:
workspaceTrustOverride !== undefined && scope === 'User'
? {
security: {
folderTrust: { enabled: !workspaceTrustOverride },
},
}
: {},
originalSettings: {},
path: '',
}),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never;
});
const reachedConfig = new Error('ancestor-config-construction');
vi.mocked(loadCliConfig).mockClear();
vi.mocked(loadCliConfig).mockRejectedValueOnce(reachedConfig);
try {
await expect(main()).rejects.toBe(reachedConfig);
const provider =
vi.mocked(loadCliConfig).mock.calls[0]?.[9]
?.workflowAncestorTrustProvider;
if (capability === undefined) {
expect(provider).toBeTypeOf('function');
await expect(
provider?.([llmTestHome], join(llmTestHome, 'project')),
).resolves.toEqual([workspaceTrustOverride !== false]);
} else {
expect(provider).toBeUndefined();
}
} finally {
process.argv = originalArgv;
vi.unstubAllEnvs();
}
},
);
it('relaunches before config load and preserves only private managed ACP activation', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.stubEnv('QWEN_CODE_PRIVATE_ACP_CAPABILITY', 'private-capability');
vi.stubEnv(
'QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD',
EXTERNAL_TOOL_GUARD_REQUIRED_VALUE,
);
vi.stubEnv('QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN', 'guard-secret');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
const callOrder: string[] = [];
vi.mocked(relaunchAppInChildProcess).mockImplementation(
async (_memoryArgs, _extraArgs, options) => {
callOrder.push('relaunch');
expect(
process.env['QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'],
).toBeUndefined();
expect(process.env['QWEN_CODE_PRIVATE_ACP_CAPABILITY']).toBeUndefined();
expect(
process.env['QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD'],
).toBeUndefined();
expect(options?.childEnv).toEqual({
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD:
EXTERNAL_TOOL_GUARD_REQUIRED_VALUE,
QWEN_CODE_PRIVATE_RELAUNCH_ENV_PROVENANCE:
'{"dotEnv":[],"settingsEnv":[]}',
});
},
);
vi.mocked(loadCliConfig).mockImplementation(async () => {
callOrder.push('loadCliConfig');
return {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config;
});
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: { autoConfigureMemory: true },
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
try {
try {
await main();
} catch (e) {
// Mocked process exit throws an error.
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
vi.unstubAllEnvs();
}
// It is critical that we call relaunch before loadCliConfig to avoid
// loading config in the outer process when we are going to relaunch.
// By ensuring we don't load the config we also ensure we don't trigger any
// operations that might require loading the config such as such as
// initializing mcp servers.
// For the sandbox case we still have to load a partial cli config.
// we can authorize outside the sandbox.
expect(callOrder).toEqual(['relaunch', 'loadCliConfig']);
expect(relaunchAppInChildProcess).toHaveBeenCalledWith(
expect.any(Array),
[],
expect.objectContaining({
childEnv: {
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
QWEN_CODE_PRIVATE_EXTERNAL_TOOL_GUARD:
EXTERNAL_TOOL_GUARD_REQUIRED_VALUE,
QWEN_CODE_PRIVATE_RELAUNCH_ENV_PROVENANCE:
'{"dotEnv":[],"settingsEnv":[]}',
},
onUpdateRelaunch: expect.any(Function),
}),
);
processExitSpy.mockRestore();
});
it('carries the Conversations marker through the relaunch only with the private capability', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.stubEnv('QWEN_CODE_PRIVATE_ACP_CAPABILITY', 'private-capability');
vi.stubEnv('QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME', '1');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
vi.mocked(relaunchAppInChildProcess).mockImplementation(
async (_memoryArgs, _extraArgs, options) => {
expect(
process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'],
).toBeUndefined();
expect(options?.childEnv).toEqual({
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME: '1',
QWEN_CODE_PRIVATE_RELAUNCH_ENV_PROVENANCE:
'{"dotEnv":[],"settingsEnv":[]}',
});
},
);
vi.mocked(loadCliConfig).mockImplementation(async () => {
// A user-level .env must not reintroduce the marker after settings and
// environment loading; the accepted value already lives in the relaunch
// payload.
expect(
process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'],
).toBeUndefined();
return {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config;
});
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: { autoConfigureMemory: true },
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
try {
try {
await main();
} catch (e) {
// Mocked process exit throws an error.
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
vi.unstubAllEnvs();
}
expect(relaunchAppInChildProcess).toHaveBeenCalledWith(
expect.any(Array),
[],
expect.objectContaining({
childEnv: {
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME: '1',
QWEN_CODE_PRIVATE_RELAUNCH_ENV_PROVENANCE:
'{"dotEnv":[],"settingsEnv":[]}',
},
onUpdateRelaunch: expect.any(Function),
}),
);
processExitSpy.mockRestore();
});
it('does not accept the Conversations marker without the private capability', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.stubEnv('QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME', '1');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
vi.mocked(relaunchAppInChildProcess).mockImplementation(
async (_memoryArgs, _extraArgs, options) => {
expect(options?.childEnv ?? {}).not.toHaveProperty(
'QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME',
);
},
);
vi.mocked(loadCliConfig).mockImplementation(
async () =>
({
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
}) as unknown as Config,
);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: { autoConfigureMemory: true },
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
try {
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
vi.unstubAllEnvs();
}
expect(relaunchAppInChildProcess).toHaveBeenCalled();
processExitSpy.mockRestore();
});
const managedHost = { acp: true, acpExecutionEngine: 'managed' };
const privateParent = {
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
};
it.each([
['without the private capability', managedHost, {}],
['outside ACP mode', { acpExecutionEngine: 'managed' }, privateParent],
[
'in the Conversations runtime',
managedHost,
{ ...privateParent, QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME: '1' },
],
[
'given twice',
{ acp: true, acpExecutionEngine: ['managed', 'managed'] },
privateParent,
],
])(
'refuses a Managed engine host %s',
async (_label, args, env: Record<string, string>) => {
const { loadSettings } = await import('./config/settings.js');
const { parseArguments } = await import('./config/config.js');
vi.mocked(parseArguments).mockResolvedValue(args as unknown as CliArgs);
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
try {
await expect(main()).rejects.toThrow(
'--acp-execution-engine is reserved for hosts spawned by qwen serve.',
);
expect(loadSettings).not.toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
}
},
);
it('rejects a Conversations marker whose value is not the exact enable value', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.stubEnv('QWEN_CODE_PRIVATE_ACP_CAPABILITY', 'private-capability');
// A stray marker exported while debugging the daemon must not be accepted.
vi.stubEnv('QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME', 'yes');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
vi.mocked(relaunchAppInChildProcess).mockImplementation(
async (_memoryArgs, _extraArgs, options) => {
expect(options?.childEnv).toEqual({
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
QWEN_CODE_PRIVATE_RELAUNCH_ENV_PROVENANCE:
'{"dotEnv":[],"settingsEnv":[]}',
});
},
);
vi.mocked(loadCliConfig).mockImplementation(
async () =>
({
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
}) as unknown as Config,
);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: { autoConfigureMemory: true },
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
try {
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
vi.unstubAllEnvs();
}
expect(relaunchAppInChildProcess).toHaveBeenCalled();
processExitSpy.mockRestore();
});
it('scrubs a marker re-introduced by environment loading before the relaunch', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
// No marker at entry: this is an ordinary ACP session, so provenance is
// denied and `privateAcpChildEnv` carries only the capability.
delete process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'];
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
vi.mocked(loadSettings).mockImplementation(() => {
// A home-scoped `.env` is applied during settings/environment loading,
// after the entry-point capture. The relaunch env is
// `{ ...process.env, ...childEnv }`, so scrubbing process.env is the
// only defense on this branch.
process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'] = '1';
return {
errors: [],
merged: {
advanced: { autoConfigureMemory: true },
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never;
});
vi.mocked(relaunchAppInChildProcess).mockImplementation(
async (_memoryArgs, _extraArgs, options) => {
expect(
process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'],
).toBeUndefined();
expect(options?.childEnv ?? {}).not.toHaveProperty(
'QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME',
);
},
);
vi.mocked(loadCliConfig).mockImplementation(
async () =>
({
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
}) as unknown as Config,
);
try {
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
delete process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'];
vi.unstubAllEnvs();
}
expect(relaunchAppInChildProcess).toHaveBeenCalled();
processExitSpy.mockRestore();
});
it('scrubs a re-injected Conversations marker before the sandbox handoff', async () => {
const originalArgv = process.argv;
process.argv = ['node', 'script.js', '--acp', '-p', 'hello'];
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { start_sandbox } = await import('./serve/sandbox.js');
vi.mocked(start_sandbox).mockClear();
vi.mocked(parseArguments).mockResolvedValue({
acp: true,
prompt: 'hello',
} as unknown as CliArgs);
vi.stubEnv('QWEN_CODE_PRIVATE_ACP_CAPABILITY', 'private-capability');
// No marker at entry, so the parent denies Conversations provenance.
delete process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'];
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'sandbox-exec',
image: 'ghcr.io/qwenlm/qwen-code:1.0.0',
});
vi.mocked(loadCliConfig).mockImplementation(async () => {
// Auth validation re-runs the environment load after the post-settings
// scrub; a home-scoped `.env` re-applies the marker here.
process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'] = '1';
return {
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => '123e4567-e89b-12d3-a456-426614174000',
} as unknown as Config;
});
let markerAtSpawn: string | undefined;
vi.mocked(start_sandbox).mockImplementation((async () => {
markerAtSpawn = process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'];
return 0;
}) as unknown as typeof start_sandbox);
try {
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
process.argv = originalArgv;
delete process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'];
vi.unstubAllEnvs();
processExitSpy.mockRestore();
}
// The seatbelt spawn merges `{ ...process.env, ...childEnv }`, so a marker
// left in process.env would reach a child that also inherits the
// capability and `--acp` — accepting provenance the parent denied.
expect(start_sandbox).toHaveBeenCalledOnce();
expect(markerAtSpawn).toBeUndefined();
const childEnv = vi.mocked(start_sandbox).mock.calls[0]?.[4];
expect(childEnv ?? {}).not.toHaveProperty(
'QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME',
);
});
// The accepted path through the sandbox handoff: `serve/sandbox.ts` merges
// `{ ...process.env, ...childEnv }` with childEnv last, so an accepted
// marker must ride in the childEnv argument — the pre-spawn scrub keeps it
// out of process.env by design.
it('carries the Conversations marker into the sandbox handoff when provenance is accepted', async () => {
const originalArgv = process.argv;
process.argv = ['node', 'script.js', '--acp'];
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { start_sandbox } = await import('./serve/sandbox.js');
vi.mocked(start_sandbox).mockClear();
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.stubEnv('QWEN_CODE_PRIVATE_ACP_CAPABILITY', 'private-capability');
vi.stubEnv('QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME', '1');
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'sandbox-exec',
image: 'ghcr.io/qwenlm/qwen-code:1.0.0',
});
vi.mocked(loadCliConfig).mockImplementation(
async () =>
({
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => '123e4567-e89b-12d3-a456-426614174000',
}) as unknown as Config,
);
vi.mocked(start_sandbox).mockImplementation(
(async () => 0) as unknown as typeof start_sandbox,
);
try {
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
process.argv = originalArgv;
delete process.env['QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME'];
vi.unstubAllEnvs();
processExitSpy.mockRestore();
}
expect(start_sandbox).toHaveBeenCalledOnce();
expect(vi.mocked(start_sandbox).mock.calls[0]?.[4]).toEqual({
QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability',
QWEN_CODE_PRIVATE_CONVERSATIONS_RUNTIME: '1',
});
});
// The container mounts only the user settings dir and reads passthrough
// vars from `process.env`, so an operator opt-out that lives in a System
// settings file never reaches the sandboxed CLI through settings: the
// handoff itself has to carry it.
it.each([
['model opt-out', { model: { catalogRefresh: false } }, 'off'],
[
'usage-statistics opt-out',
{ privacy: { usageStatisticsEnabled: false } },
'off',
],
['no opt-out', {}, undefined],
])(
'carries the operator catalog refresh opt-out into the sandbox handoff (%s)',
async (_label, extraSettings, expected) => {
const originalArgv = process.argv;
process.argv = ['node', 'script.js', '--acp'];
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { start_sandbox } = await import('./serve/sandbox.js');
vi.mocked(start_sandbox).mockClear();
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
delete process.env[MODEL_CATALOG_REFRESH_ENV];
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
...extraSettings,
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'ghcr.io/qwenlm/qwen-code:1.0.0',
});
vi.mocked(loadCliConfig).mockImplementation(
async () =>
({
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => '123e4567-e89b-12d3-a456-426614174000',
}) as unknown as Config,
);
let refreshAtSpawn: string | undefined;
vi.mocked(start_sandbox).mockImplementation((async () => {
refreshAtSpawn = process.env[MODEL_CATALOG_REFRESH_ENV];
return 0;
}) as unknown as typeof start_sandbox);
try {
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
} finally {
process.argv = originalArgv;
delete process.env[MODEL_CATALOG_REFRESH_ENV];
processExitSpy.mockRestore();
}
expect(start_sandbox).toHaveBeenCalledOnce();
expect(refreshAtSpawn).toBe(expected);
},
);
it('follows its relaunch supervisor before reading any arguments', async () => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { parseArguments } = await import('./config/config.js');
const { exitWhenSupervisorExits } = await import('./utils/relaunch.js');
vi.mocked(parseArguments).mockRejectedValueOnce(
new MockProcessExitError(1),
);
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(exitWhenSupervisorExits).toHaveBeenCalledOnce();
expect(
vi.mocked(exitWhenSupervisorExits).mock.invocationCallOrder[0],
).toBeLessThan(vi.mocked(parseArguments).mock.invocationCallOrder[0]!);
});
it('handles --list-extensions before sandbox and app config startup', async () => {
vi.clearAllMocks();
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
vi.mocked(parseArguments).mockResolvedValue({
listExtensions: true,
} as unknown as CliArgs);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
mockHandleListExtensions.mockResolvedValue(undefined);
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(mockHandleListExtensions).toHaveBeenCalledOnce();
expect(processExitSpy).toHaveBeenCalledWith(0);
expect(loadSandboxConfig).not.toHaveBeenCalled();
expect(loadCliConfig).not.toHaveBeenCalled();
processExitSpy.mockRestore();
});
it.each([
['before the ACP relaunch', { acp: true }, {}, undefined, '1'],
[
'in the relaunched ACP process',
{ acp: true },
{ QWEN_CODE_NO_RELAUNCH: 'true' },
undefined,
undefined,
],
[
'in the sandboxed ACP process',
{ acp: true },
{ SANDBOX: 'sandbox-exec' },
undefined,
undefined,
],
[
'outside managed ACP startup',
{},
{ QWEN_CODE_NO_RELAUNCH: 'true' },
'1',
'1',
],
[
'ACP without bootstrap marker',
{ acp: true },
{ QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE: undefined },
'1',
undefined,
],
])(
'manages Electron bootstrap env %s',
async (_name, argv, extraEnv, expectedElectron, expectedMarker) => {
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1');
vi.stubEnv('QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE', '1');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
for (const [key, value] of Object.entries(extraEnv)) {
vi.stubEnv(key, value);
}
const { parseArguments } = await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
vi.mocked(parseArguments).mockResolvedValue(argv as CliArgs);
vi.mocked(loadSettings).mockImplementation(() => {
expect(process.env['ELECTRON_RUN_AS_NODE']).toBe(expectedElectron);
expect(process.env['QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE']).toBe(
expectedMarker,
);
throw new Error('stop after env check');
});
try {
await expect(main()).rejects.toThrow('stop after env check');
} finally {
vi.unstubAllEnvs();
}
},
);
// Regression for #8653: every daemon-hosted session runs in an ACP child,
// so the scrub at that boundary must not silently disappear. The positive
// case fails if the call is deleted; the negative cases pin the gate —
// only daemon-stamped children (QWEN_CODE_SERVE) scrub, direct editor ACP
// integrations and non-ACP launches keep their own loader vars.
it.each([
['scrubs for a daemon-spawned child', { acp: true } as CliArgs, '1', true],
[
'keeps for a direct (editor) ACP child',
{ acp: true } as CliArgs,
'',
false,
],
[
'keeps when the daemon marker is zero',
{ acp: true } as CliArgs,
'0',
false,
],
[
'keeps when the daemon marker is false',
{ acp: true } as CliArgs,
'false',
false,
],
['keeps for a non-ACP launch', {} as CliArgs, '', false],
])(
'inherited loader env vars past the ACP handoff (%s)',
async (_mode, argv, daemonMarker, expectScrubbed) => {
vi.stubEnv(
'NODE_OPTIONS',
'--import file:///other-checkout/register.mjs',
);
vi.stubEnv('NODE_PATH', '/other-checkout/node_modules');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', 'true');
if (daemonMarker !== undefined) {
vi.stubEnv('QWEN_CODE_SERVE', daemonMarker);
}
const { parseArguments, loadCliConfig } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
vi.mocked(parseArguments).mockResolvedValue(argv);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
// loadCliConfig is the first expensive call past the relaunch/sandbox
// handoff — the scrub must have run by here.
vi.mocked(loadCliConfig).mockImplementation(async () => {
if (expectScrubbed) {
expect(process.env['NODE_OPTIONS']).toBeUndefined();
expect(process.env['NODE_PATH']).toBeUndefined();
} else {
expect(process.env['NODE_OPTIONS']).toBe(
'--import file:///other-checkout/register.mjs',
);
}
throw new Error('stop after loader env scrub check');
});
try {
await expect(main()).rejects.toThrow(
'stop after loader env scrub check',
);
} finally {
vi.unstubAllEnvs();
}
},
);
// Regression for #8653 (placement): relaunchAppInChildProcess spawns with
// {...process.env} captured at call time, so scrubbing BEFORE the handoff
// would strip the loader the respawned child still needs to boot. The
// scrubbed child re-runs the scrub itself after its own handoff.
it('keeps inherited loader env vars present at the relaunch handoff', async () => {
vi.stubEnv('NODE_OPTIONS', '--import file:///other-checkout/register.mjs');
vi.stubEnv('NODE_PATH', '/other-checkout/node_modules');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
vi.stubEnv('QWEN_CODE_SERVE', '1');
const { parseArguments } = await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => {
expect(process.env['NODE_OPTIONS']).toBe(
'--import file:///other-checkout/register.mjs',
);
expect(process.env['NODE_PATH']).toBe('/other-checkout/node_modules');
throw new Error('stop after loader env handoff check');
});
try {
await expect(main()).rejects.toThrow(
'stop after loader env handoff check',
);
} finally {
vi.unstubAllEnvs();
}
});
// Pins the process-replacement predicate in llm.tsx: a one-shot headless
// prompt replaces the already-loaded process, while supervisor-backed
// modes (ACP, -i, stream-json / file / json-fd input) keep the parent.
// The slash-command row pins the contract: a headless `/update` updates
// standalone installs in-process or prints manual instructions and never
// emits a relaunch exit code, so it keeps the execve optimization like any
// other one-shot prompt.
describe('relaunch routing', () => {
// 'in-process': no relaunch at all; otherwise the replaceProcess flag
// passed to the supervised relaunch.
const rows: Array<{
label: string;
argv: Partial<CliArgs>;
dualOutputInputFile?: string;
envFileValues?: boolean;
expected: 'in-process' | boolean;
}> = [
{
label: 'plain one-shot prompt',
argv: { prompt: 'summarize this repository' },
expected: 'in-process',
},
{
label: 'headless slash-command prompt',
argv: { prompt: '/update' },
expected: 'in-process',
},
{
label: 'acp mode',
argv: { acp: true, prompt: 'hi' },
expected: false,
},
{
label: 'interactive prompt (-i)',
argv: { prompt: 'hi', promptInteractive: 'follow-up' },
expected: 'in-process',
},
{
label: 'file input',
argv: { prompt: 'hi', inputFile: 'input.txt' },
expected: false,
},
{
label: 'json-fd input',
argv: { prompt: 'hi', jsonFd: 3 },
expected: false,
},
{
label: 'stream-json input',
argv: { prompt: 'hi', inputFormat: InputFormat.STREAM_JSON },
expected: false,
},
{
label: 'dual-output file input',
argv: { prompt: 'hi' },
dualOutputInputFile: 'session.jsonl',
expected: false,
},
{
// In-session restarts re-exec this process in place instead of
// going through a supervising parent.
label: 'plain interactive launch (no prompt)',
argv: {},
expected: 'in-process',
},
{
// Modules loaded before .env / settings.env were applied read the old
// environment; only a fresh image sees those values.
label: 'plain one-shot prompt with env-file values',
argv: { prompt: 'hi' },
envFileValues: true,
expected: true,
},
{
label: 'plain interactive launch with env-file values',
argv: {},
envFileValues: true,
expected: false,
},
];
it.each(rows)(
'routes $label to $expected',
async ({ argv, dualOutputInputFile, envFileValues, expected }) => {
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
const { parseArguments } = await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import(
'./utils/relaunch.js'
);
const { superviseInProcess } = await import('./utils/processUtils.js');
const { hasLoadedEnvironmentValues } = await import(
'./config/environment.js'
);
vi.mocked(hasLoadedEnvironmentValues).mockReturnValue(
envFileValues ?? false,
);
vi.mocked(parseArguments).mockResolvedValue(argv as CliArgs);
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
dualOutput: dualOutputInputFile
? { inputFile: dualOutputInputFile }
: undefined,
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
let route: 'in-process' | boolean | undefined;
vi.mocked(relaunchAppInChildProcess).mockImplementation(
async (_memoryArgs, _extraArgs, options) => {
route = options?.replaceProcess;
throw new Error('stop after routing check');
},
);
vi.mocked(superviseInProcess).mockImplementation(() => {
route = 'in-process';
throw new Error('stop after routing check');
});
try {
await expect(main()).rejects.toThrow('stop after routing check');
} finally {
vi.mocked(hasLoadedEnvironmentValues).mockReturnValue(false);
vi.unstubAllEnvs();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
}
expect(route).toBe(expected);
},
);
});
// The synchronous 'auto' baseline runs `defaults read` on macOS, which
// blocks the event loop; a run that renders no theme colors must not pay it.
it.each([
{ stdoutIsTTY: false, promptInteractive: undefined, expectAuto: false },
{ stdoutIsTTY: true, promptInteractive: undefined, expectAuto: false },
{ stdoutIsTTY: true, promptInteractive: 'true', expectAuto: true },
])(
'resolves the auto theme baseline only when the run can render it (stdoutIsTTY=$stdoutIsTTY, promptInteractive=$promptInteractive)',
async ({ stdoutIsTTY, promptInteractive, expectAuto }) => {
const stubIsTTY = (
stream: { isTTY?: unknown },
value: boolean | undefined,
): (() => void) => {
const original = Object.getOwnPropertyDescriptor(stream, 'isTTY');
Object.defineProperty(stream, 'isTTY', { value, configurable: true });
return () => {
if (original) {
Object.defineProperty(stream, 'isTTY', original);
} else {
delete stream.isTTY;
}
};
};
const restoreStdoutIsTTY = stubIsTTY(process.stdout, stdoutIsTTY);
// `-i` exits early unless stdin is a terminal.
const restoreStdinIsTTY = stubIsTTY(process.stdin, true);
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
const { parseArguments } = await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const { themeManager, AUTO_THEME_NAME } = await import(
'./ui/themes/theme-manager.js'
);
const setActiveTheme = vi
.spyOn(themeManager, 'setActiveTheme')
.mockReturnValue(true);
vi.mocked(parseArguments).mockResolvedValue({
prompt: 'hi',
outputFormat: 'json',
promptInteractive,
} as CliArgs);
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => {
throw new Error('stop after theme baseline');
});
// A plain one-shot run supervises itself in-process instead.
const { superviseInProcess } = await import('./utils/processUtils.js');
vi.mocked(superviseInProcess).mockImplementation(() => {
throw new Error('stop after theme baseline');
});
try {
await expect(main()).rejects.toThrow('stop after theme baseline');
expect(
setActiveTheme.mock.calls.some(([name]) => name === AUTO_THEME_NAME),
).toBe(expectAuto);
} finally {
setActiveTheme.mockRestore();
vi.unstubAllEnvs();
restoreStdoutIsTTY();
restoreStdinIsTTY();
}
},
);
// Regression for #8653 (sandbox hop): getSandboxPassthroughEnvArgs
// forwards the QWEN_CODE_SERVE stamp into the container, so the sandboxed
// stage of a daemon-spawned ACP child must still scrub.
it('scrubs inherited loader env vars in the sandboxed ACP stage', async () => {
vi.stubEnv('NODE_OPTIONS', '--import file:///other-checkout/register.mjs');
vi.stubEnv('NODE_PATH', '/other-checkout/node_modules');
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', '');
vi.stubEnv('QWEN_CODE_SERVE', '1');
const { parseArguments, loadCliConfig } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
vi.mocked(parseArguments).mockResolvedValue({ acp: true } as CliArgs);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadCliConfig).mockImplementation(async () => {
expect(process.env['NODE_OPTIONS']).toBeUndefined();
expect(process.env['NODE_PATH']).toBeUndefined();
throw new Error('stop after sandboxed loader env scrub check');
});
try {
await expect(main()).rejects.toThrow(
'stop after sandboxed loader env scrub check',
);
} finally {
vi.unstubAllEnvs();
}
});
it('keeps the external Guard token available to command parsing and scrubs it before settings load', async () => {
vi.stubEnv('QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN', 'guard-secret');
const { parseArguments } = await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
vi.mocked(parseArguments).mockImplementation(async () => {
expect(process.env['QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN']).toBe(
'guard-secret',
);
return {} as CliArgs;
});
vi.mocked(loadSettings).mockImplementation(() => {
expect(
process.env['QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'],
).toBeUndefined();
throw new Error('stop after Guard token env check');
});
try {
await expect(main()).rejects.toThrow('stop after Guard token env check');
} finally {
vi.unstubAllEnvs();
}
});
it('should skip full settings discovery in bare mode', async () => {
const originalArgv = process.argv;
process.argv = ['node', 'script.js', '--bare'];
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings, createMinimalSettings } = await import(
'./config/settings.js'
);
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
const nonInteractiveModule = await import('./nonInteractiveCli.js');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const minimalSettings = {
errors: [],
merged: {},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
};
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => 'bare prompt',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => 'test-session-id',
} as unknown as Config;
vi.mocked(parseArguments).mockResolvedValue({
bare: true,
} as unknown as CliArgs);
vi.mocked(createMinimalSettings).mockReturnValue(minimalSettings as never);
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
vi.mocked(relaunchAppInChildProcess).mockResolvedValue(undefined);
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
vi.spyOn(nonInteractiveModule, 'runNonInteractive').mockResolvedValue(0);
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
process.argv = originalArgv;
processExitSpy.mockRestore();
}
expect(createMinimalSettings).toHaveBeenCalledOnce();
expect(loadSettings).not.toHaveBeenCalled();
expect(loadCliConfig).toHaveBeenCalledWith(
{},
expect.objectContaining({ bare: true }),
process.cwd(),
undefined,
{
userHooks: undefined,
projectHooks: undefined,
},
expect.any(Function),
undefined,
// settingsWatcher: not started in bare mode
undefined,
undefined,
{ workflowAncestorTrustProvider: expect.any(Function) },
expect.any(Function),
// projectHooksApprovalProvider: live settings for the YOLO-exit re-gate
expect.any(Function),
);
});
describe('registerLspHotReload', () => {
it('does not register a watcher when LSP is disabled', () => {
const registerCleanup = vi.fn();
registerLspHotReload(
withLspDisabledConfig({
getProjectRoot: () => '/workspace',
}) as unknown as Config,
registerCleanup,
);
expect(lspConfigWatcherMock.instances).toHaveLength(0);
expect(registerCleanup).not.toHaveBeenCalled();
});
it('does not register a watcher when the client cannot reinitialize', () => {
const registerCleanup = vi.fn();
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({}),
getProjectRoot: () => '/workspace',
} as unknown as Config,
registerCleanup,
);
expect(lspConfigWatcherMock.instances).toHaveLength(0);
expect(registerCleanup).not.toHaveBeenCalled();
});
it('emits an LSP status update after successful reload', async () => {
const registerCleanup = vi.fn();
const reinitializeLsp = vi.fn(async () => ({
reconcile: {
added: ['clangd'],
removed: [],
restarted: [],
unchanged: [],
failed: [],
},
skipped: [],
}));
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
registerCleanup,
);
await lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
});
expect(reinitializeLsp).toHaveBeenCalledOnce();
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);
});
it('emits an LSP status update when reload is skipped by the config', async () => {
const reinitializeLsp = vi.fn(async () => undefined);
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
});
expect(reinitializeLsp).toHaveBeenCalledOnce();
expect(appEvents.emit).not.toHaveBeenCalledWith(
AppEvent.LogError,
expect.any(String),
);
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);
});
it('emits a user-visible error and rejects when reload fails', async () => {
const reinitializeLsp = vi.fn(async () => {
throw new Error('invalid lsp json');
});
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await expect(
lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
}),
).rejects.toThrow('invalid lsp json');
expect(appEvents.emit).toHaveBeenCalledWith(
AppEvent.LogError,
'Failed to reload LSP server settings: invalid lsp json. Some LSP servers may have been partially updated. Run with --debug for details.',
);
});
it('emits a user-visible error and rejects when reload has failed servers', async () => {
const reinitializeLsp = vi.fn(async () => ({
reconcile: {
added: [],
removed: [],
restarted: [],
unchanged: [],
failed: ['clangd'],
},
skipped: [],
}));
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await expect(
lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'modified',
}),
).rejects.toThrow('LSP reload partially completed');
expect(appEvents.emit).toHaveBeenCalledWith(
AppEvent.LogError,
'LSP reload partially completed: changed=<none>, failed=clangd. Run with --debug for details.',
);
expect(appEvents.emit).toHaveBeenCalledWith(AppEvent.LspStatusChanged);
});
it('surfaces invalid config without reinitializing LSP', async () => {
const reinitializeLsp = vi.fn();
registerLspHotReload(
{
isLspEnabled: () => true,
getLspClient: () => ({ reinitialize: vi.fn() }),
getProjectRoot: () => '/workspace',
reinitializeLsp,
} as unknown as Config,
vi.fn(),
);
await lspConfigWatcherMock.instances[0]?.listener?.({
path: '/workspace/.lsp.json',
changeType: 'invalid',
error:
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
});
expect(reinitializeLsp).not.toHaveBeenCalled();
expect(appEvents.emit).toHaveBeenCalledWith(
AppEvent.LogError,
'Invalid JSON in .lsp.json; existing LSP runtime state is unchanged.',
);
});
});
it('writes non-interactive warnings discovered during config initialization', async () => {
const originalNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const nonInteractiveModule = await import('./nonInteractiveCli.js');
const initializerModule = await import('./core/initializer.js');
const startupWarningsModule = await import('./utils/startupWarnings.js');
const userStartupWarningsModule = await import(
'./utils/userStartupWarnings.js'
);
mockWriteStderrLine.mockClear();
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
const cleanupRegistrationStart = vi.mocked(cleanupModule.registerCleanup)
.mock.calls.length;
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
vi.spyOn(
userStartupWarningsModule,
'getUserStartupWarnings',
).mockResolvedValue([]);
const runNonInteractiveSpy = vi
.spyOn(nonInteractiveModule, 'runNonInteractive')
.mockResolvedValue(0);
let initialized = false;
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => 'hello',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn().mockImplementation(async () => {
initialized = true;
}),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getFailedMcpServerNames: () => [],
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => (initialized ? ['late memory warning'] : []),
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getContentGeneratorConfig: () => undefined,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getProxy: () => undefined,
} as unknown as Config;
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as unknown as CliArgs);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
vi.spyOn(validatorModule, 'validateNonInteractiveAuth').mockResolvedValue(
configStub,
);
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
}
expect(mockWriteStderrLine).toHaveBeenCalledWith('late memory warning');
expect(initializerModule.initializeApp).toHaveBeenCalledWith(
configStub,
expect.any(Object),
{ deferIdeConnection: false },
);
expect(mockStartNonInteractiveOpenAILogHousekeeping).toHaveBeenCalledWith(
configStub,
expect.any(Object),
);
const housekeepingCleanup = vi.mocked(cleanupModule.registerCleanup).mock
.calls[cleanupRegistrationStart]?.[0];
expect(housekeepingCleanup).toBeTypeOf('function');
await housekeepingCleanup?.();
expect(mockStopNonInteractiveOpenAILogHousekeeping).toHaveBeenCalledOnce();
const runFailure = new Error('headless run failed');
runNonInteractiveSpy.mockRejectedValueOnce(runFailure);
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
const secondProcessExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
try {
await expect(main()).rejects.toBe(runFailure);
} finally {
secondProcessExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
}
expect(runExitCleanupMock).toHaveBeenCalledTimes(2);
expect(mockStartNonInteractiveOpenAILogHousekeeping).toHaveBeenCalledTimes(
2,
);
});
it.each([
['pending', true, 'Run qwen interactively'],
['rejected', true, 'not asked about again'],
['approved', false, undefined],
] as const)(
'warns on stderr only when project hooks were withheld (%s)',
async (state, pending, expected) => {
const dir = mkdtempSync(join(tmpdir(), 'llm-hooks-gate-'));
const root = join(dir, 'repo');
const hooks = {
SessionStart: [{ hooks: [{ type: 'command', command: './x.sh' }] }],
};
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', 'true');
vi.stubEnv('QWEN_CODE_HOOK_APPROVALS_PATH', join(dir, 'approvals.json'));
const {
getProjectHooksForApproval,
loadHookApprovals,
resetHookApprovalsForTesting,
} = await import('./config/hook-approvals.js');
resetHookApprovalsForTesting();
const settings = {
errors: [],
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => hooks,
user: { originalSettings: {} },
workspace: {
path: join(root, '.qwen', 'settings.json'),
originalSettings: { hooks },
settings: { hooks },
},
};
if (state !== 'pending') {
await loadHookApprovals().setState(
getProjectHooksForApproval(settings as never)!,
state,
);
}
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const nonInteractiveModule = await import('./nonInteractiveCli.js');
const initializerModule = await import('./core/initializer.js');
mockWriteStderrLine.mockClear();
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
const runNonInteractiveSpy = vi
.spyOn(nonInteractiveModule, 'runNonInteractive')
.mockResolvedValue(0);
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => pending,
// The execution accessor serves the block exactly when the gate
// is not pending; the trust warning must stay quiet for it.
getProjectHooks: () => (pending ? undefined : hooks),
getQuestion: () => 'hello',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getFailedMcpServerNames: () => [],
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getContentGeneratorConfig: () => undefined,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getProxy: () => undefined,
} as unknown as Config;
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as unknown as CliArgs);
vi.mocked(loadSettings).mockReturnValue(settings as never);
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
vi.spyOn(validatorModule, 'validateNonInteractiveAuth').mockResolvedValue(
configStub,
);
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) throw error;
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
vi.unstubAllEnvs();
resetHookApprovalsForTesting();
rmSync(dir, { recursive: true, force: true });
}
if (expected) {
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining(expected),
);
} else {
expect(mockWriteStderrLine).not.toHaveBeenCalledWith(
expect.stringContaining('.qwen/settings.json were not loaded'),
);
}
// Warn and continue: the prompt still runs, only the hooks are withheld.
expect(runNonInteractiveSpy).toHaveBeenCalledTimes(1);
},
);
it('creates non-interactive prompt ids that preserve session correlation', () => {
expect(createNonInteractivePromptId('test-session-id')).toBe(
'test-session-id########0',
);
});
it('continues the prompt id chain when the -p run resumes a session', () => {
// Every headless `-p` process would otherwise mint `########0` again, and
// loadSession keeps only the last file-history snapshot per promptId, so
// the earlier run's /rewind target for turn 0 would be dropped.
const records = [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'user',
cwd: '/tmp',
version: 'test',
message: { role: 'user', parts: [{ text: 'first turn' }] },
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'system',
subtype: 'ui_telemetry',
cwd: '/tmp',
version: 'test',
systemPayload: { uiEvent: { prompt_id: 'test-session-id########3' } },
},
] as unknown as ChatRecord[];
expect(createNonInteractivePromptId('test-session-id', records)).toBe(
'test-session-id########4',
);
});
it('continues past a claimed turn 0 that the user-turn fallback cannot see', () => {
// computeInitialTurnFromHistory returns 0 here — highest claimed turn is
// 0, and the only user record has blank text so its fallback count stays
// 0 (`-p ' '` reaches the model, `llm.tsx` only rejects a falsy input).
// Seeding from that 0 would re-mint `########0`, the very collision this
// function exists to prevent.
const records = [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'user',
cwd: '/tmp',
version: 'test',
message: { role: 'user', parts: [{ text: ' ' }] },
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'system',
subtype: 'ui_telemetry',
cwd: '/tmp',
version: 'test',
systemPayload: { uiEvent: { prompt_id: 'test-session-id########0' } },
},
] as unknown as ChatRecord[];
expect(createNonInteractivePromptId('test-session-id', records)).toBe(
'test-session-id########1',
);
});
it('passes the resumed transcript through to the -p prompt id', async () => {
// Pins the call site, not just the function: without the
// `getResumedSessionData()` argument in main(), the shipped `-p` path
// reverts to `########0` on every resume while the unit tests above stay
// green.
const originalNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const nonInteractiveModule = await import('./nonInteractiveCli.js');
const initializerModule = await import('./core/initializer.js');
const startupWarningsModule = await import('./utils/startupWarnings.js');
const userStartupWarningsModule = await import(
'./utils/userStartupWarnings.js'
);
vi.mocked(cleanupModule.runExitCleanup).mockResolvedValue(undefined);
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
vi.spyOn(
userStartupWarningsModule,
'getUserStartupWarnings',
).mockResolvedValue([]);
const runNonInteractiveSpy = vi
.spyOn(nonInteractiveModule, 'runNonInteractive')
.mockResolvedValue(0);
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => 'hello',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getFailedMcpServerNames: () => [],
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getContentGeneratorConfig: () => undefined,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getProxy: () => undefined,
getResumedSessionData: () => ({
conversation: {
sessionId: 'test-session-id',
messages: [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'system',
subtype: 'ui_telemetry',
cwd: '/tmp',
version: 'test',
systemPayload: {
uiEvent: { prompt_id: 'test-session-id########3' },
},
},
],
},
}),
} as unknown as Config;
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as unknown as CliArgs);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
vi.spyOn(validatorModule, 'validateNonInteractiveAuth').mockResolvedValue(
configStub,
);
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
}
expect(runNonInteractiveSpy).toHaveBeenCalledTimes(1);
expect(runNonInteractiveSpy.mock.calls[0]?.[3]).toBe(
'test-session-id########4',
);
});
const runSandboxRelaunch = async (
argv: string[],
sessionId = '123e4567-e89b-12d3-a456-426614174000',
command: 'docker' | 'podman' | 'sandbox-exec' = 'sandbox-exec',
): Promise<string[]> => {
const originalArgv = process.argv;
process.argv = argv;
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { start_sandbox } = await import('./serve/sandbox.js');
const { relaunchOnExitCode } = await import('./utils/relaunch.js');
vi.mocked(start_sandbox).mockClear();
vi.mocked(relaunchOnExitCode).mockClear();
vi.mocked(parseArguments).mockResolvedValue({
debug: true,
prompt: 'hello',
extensions: [],
} as unknown as CliArgs);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command,
image: 'ghcr.io/qwenlm/qwen-code:1.0.0',
});
vi.mocked(loadCliConfig).mockResolvedValue({
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getSessionId: () => sessionId,
} as unknown as Config);
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
process.argv = originalArgv;
processExitSpy.mockRestore();
}
expect(start_sandbox).toHaveBeenCalledOnce();
expect(relaunchOnExitCode).toHaveBeenCalledWith(expect.any(Function), {
onUpdateRelaunch: expect.any(Function),
});
return vi.mocked(start_sandbox).mock.calls[0]![3]!;
};
it('passes the outer session ID into the sandbox child process', async () => {
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
const sandboxArgs = await runSandboxRelaunch(
['node', 'script.js', '--debug', '-p', 'hello'],
sessionId,
);
const idx = sandboxArgs.indexOf('--sandbox-session-id');
expect(idx).not.toBe(-1);
expect(sandboxArgs[idx + 1]).toBe(sessionId);
expect(sandboxArgs).not.toContain('--session-id');
});
it('starts a fresh CLI after the host update completes', async () => {
await runSandboxRelaunch(['node', 'script.js', '--debug', '-p', 'hello']);
const { relaunchOnExitCode } = await import('./utils/relaunch.js');
const [, options] = vi.mocked(relaunchOnExitCode).mock.calls[0]!;
await expect(options?.onUpdateRelaunch?.(true)).resolves.toBe(44);
expect(mockUpdateBeforeRelaunch).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
true,
);
});
it('passes host update capability into a container sandbox', async () => {
const originalCapability = process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'];
try {
await runSandboxRelaunch(
['node', 'script.js', '--debug', '-p', 'hello'],
'',
'docker',
);
expect(mockGetInstallationInfo).toHaveBeenCalledWith(
expect.any(String),
true,
);
expect(process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']).toBe('true');
} finally {
if (originalCapability === undefined) {
delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'];
} else {
process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = originalCapability;
}
}
});
it('does not pass an empty session ID into the sandbox child process', async () => {
const sandboxArgs = await runSandboxRelaunch(
['node', 'script.js', '--debug', '-p', 'hello'],
'',
);
expect(sandboxArgs).not.toContain('--sandbox-session-id');
expect(sandboxArgs).not.toContain('--session-id');
});
it.each([
['--continue', ['node', 'script.js', '--debug', '--continue']],
['-c', ['node', 'script.js', '--debug', '-c']],
['--resume', ['node', 'script.js', '--debug', '--resume', 'session-id']],
['-r', ['node', 'script.js', '--debug', '-r', 'session-id']],
[
'--session-id',
[
'node',
'script.js',
'--debug',
'--session-id',
'123e4567-e89b-12d3-a456-426614174999',
],
],
])(
'does not inject sandbox session ID when argv contains %s',
async (_flag, argv) => {
const sandboxArgs = await runSandboxRelaunch(argv);
expect(sandboxArgs).not.toContain('--sandbox-session-id');
},
);
it('inserts the sandbox session ID before the argument separator', async () => {
const sessionId = '123e4567-e89b-12d3-a456-426614174000';
const sandboxArgs = await runSandboxRelaunch(
['node', 'script.js', '--debug', '--', '--not-a-cli-flag'],
sessionId,
);
expect(sandboxArgs).toEqual([
'node',
'script.js',
'--debug',
'--sandbox-session-id',
sessionId,
'--',
'--not-a-cli-flag',
]);
});
it('should log unhandled promise rejections and open debug console on first error', async () => {
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const appEventsMock = vi.mocked(appEvents);
const rejectionError = new Error('Test unhandled rejection');
setupUnhandledRejectionHandler();
// Simulate an unhandled rejection.
// We are not using Promise.reject here as vitest will catch it.
// Instead we will dispatch the event manually.
process.emit('unhandledRejection', rejectionError, Promise.resolve());
// We need to wait for the rejection handler to be called.
await new Promise(process.nextTick);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
expect(appEventsMock.emit).toHaveBeenCalledWith(
AppEvent.LogError,
expect.stringContaining('Unhandled Promise Rejection'),
);
expect(appEventsMock.emit).toHaveBeenCalledWith(
AppEvent.LogError,
expect.stringContaining('Please file a bug report using the /bug tool.'),
);
// Simulate a second rejection
const secondRejectionError = new Error('Second test unhandled rejection');
process.emit('unhandledRejection', secondRejectionError, Promise.resolve());
await new Promise(process.nextTick);
// Ensure emit was only called once for OpenDebugConsole
const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(
(call) => call[0] === AppEvent.OpenDebugConsole,
);
expect(openDebugConsoleCalls.length).toBe(1);
// Avoid the process.exit error from being thrown.
processExitSpy.mockRestore();
});
it('starts housekeeping and cleans up stream-json success and failure', async () => {
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
const originalIsRaw = Object.getOwnPropertyDescriptor(
process.stdin,
'isRaw',
);
Object.defineProperty(process.stdin, 'isTTY', {
value: false, // 在 stream-json 模式下应为 false
configurable: true,
});
Object.defineProperty(process.stdin, 'isRaw', {
value: false,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const streamJsonModule = await import('./nonInteractive/session.js');
const initializerModule = await import('./core/initializer.js');
const startupWarningsModule = await import('./utils/startupWarnings.js');
const userStartupWarningsModule = await import(
'./utils/userStartupWarnings.js'
);
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => () => {});
const cleanupRegistrationStart = vi.mocked(cleanupModule.registerCleanup)
.mock.calls.length;
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
vi.spyOn(
userStartupWarningsModule,
'getUserStartupWarnings',
).mockResolvedValue([]);
const validatedConfig = { validated: true } as unknown as Config;
const validateAuthSpy = vi
.spyOn(validatorModule, 'validateNonInteractiveAuth')
.mockResolvedValue(validatedConfig);
const runStreamJsonSpy = vi
.spyOn(streamJsonModule, 'runNonInteractiveStreamJson')
.mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as never);
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => ' hello stream ',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getInputFormat: () => 'stream-json',
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getOutputFormat: () => OutputFormat.TEXT,
} as unknown as Config;
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
process.env['SANDBOX'] = '1';
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalIsRaw) {
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
} else {
delete (process.stdin as { isRaw?: unknown }).isRaw;
}
delete process.env['SANDBOX'];
}
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
const [configArg, inputArg, settingsArg] = runStreamJsonSpy.mock.calls[0];
expect(configArg).toBe(validatedConfig);
expect(inputArg).toBe('hello stream');
// Regression guard: PR-A's progressive-MCP refactor previously
// dropped the `settings` argument here, which silently fell back to
// `createMinimalSettings()` inside `runNonInteractiveStreamJson`.
// The parallel `runNonInteractive` path still received settings, so
// stream-json sessions lost any user-configured permission /
// approval / hook setup.
expect(settingsArg).toBeDefined();
expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,
configStub,
expect.any(Object),
);
expect(initializerModule.initializeApp).toHaveBeenCalledWith(
configStub,
expect.any(Object),
{ deferIdeConnection: false },
);
expect(mockStartNonInteractiveOpenAILogHousekeeping).toHaveBeenCalledWith(
validatedConfig,
settingsArg,
);
const housekeepingCleanup = vi.mocked(cleanupModule.registerCleanup).mock
.calls[cleanupRegistrationStart]?.[0];
expect(housekeepingCleanup).toBeTypeOf('function');
await housekeepingCleanup?.();
expect(mockStopNonInteractiveOpenAILogHousekeeping).toHaveBeenCalledOnce();
const streamFailure = new Error('stream failed');
runStreamJsonSpy.mockRejectedValueOnce(streamFailure);
Object.defineProperty(process.stdin, 'isTTY', {
value: false,
configurable: true,
});
Object.defineProperty(process.stdin, 'isRaw', {
value: false,
configurable: true,
});
const secondProcessExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
process.env['SANDBOX'] = '1';
try {
await expect(main()).rejects.toBe(streamFailure);
} finally {
secondProcessExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalIsRaw) {
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
} else {
delete (process.stdin as { isRaw?: unknown }).isRaw;
}
delete process.env['SANDBOX'];
}
expect(runExitCleanupMock).toHaveBeenCalledTimes(2);
expect(mockStartNonInteractiveOpenAILogHousekeeping).toHaveBeenCalledTimes(
2,
);
});
});
describe('llm.tsx OpenTUI renderer dispatch', () => {
let originalEnvNoRelaunch: string | undefined;
let initialSigintListeners: NodeJS.SignalsListener[];
let initialSigtermListeners: NodeJS.SignalsListener[];
beforeEach(() => {
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
initialSigintListeners = process.listeners(
'SIGINT',
) as NodeJS.SignalsListener[];
initialSigtermListeners = process.listeners(
'SIGTERM',
) as NodeJS.SignalsListener[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(process.stdin as any).setRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).setRawMode = vi.fn();
}
vi.spyOn(process.stdin, 'setRawMode');
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isRaw', {
value: false,
configurable: true,
});
});
afterEach(() => {
for (const listener of process.listeners('SIGINT')) {
if (
!initialSigintListeners.includes(listener as NodeJS.SignalsListener)
) {
process.removeListener('SIGINT', listener);
}
}
for (const listener of process.listeners('SIGTERM')) {
if (
!initialSigtermListeners.includes(listener as NodeJS.SignalsListener)
) {
process.removeListener('SIGTERM', listener);
}
}
if (originalEnvNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
vi.restoreAllMocks();
});
// Drives main() to the renderer dispatch: interactive config, no
// relaunch, TTY stdin — the same harness the kitty-protocol tests use.
const interactiveMainSetup = async (screenReader = false) => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const initializerModule = await import('./core/initializer.js');
const initializeAppSpy = vi
.spyOn(initializerModule, 'initializeApp')
.mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => screenReader,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
isTelemetryInitializationDeferred: () => true,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
disableMcpServer: undefined,
mcpConfig: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
extensions: undefined,
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
maxSessionTurns: undefined,
maxWallTime: undefined,
maxToolCalls: undefined,
maxSubagentDepth: undefined,
experimentalLsp: undefined,
restoreAskUserQuestion: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
fallbackModel: undefined,
});
return initializeAppSpy;
};
const selectOpentui = (strict: boolean) => {
mockSelectTuiRenderer.mockReturnValueOnce({
renderer: 'opentui',
reason: 'requested via QWEN_TUI_RENDERER=opentui',
strict,
});
};
it('strict mode fails loudly when the OpenTUI entry declines to start', async () => {
await interactiveMainSetup();
selectOpentui(true);
mockStartOpenTuiUI.mockResolvedValue(false);
await expect(main()).rejects.toThrow(
/QWEN_TUI_RENDERER_STRICT forbids the ink fallback/,
);
// The dispatch rejected before the ink UI could mount.
expect(mockStartPostRenderPrefetches).not.toHaveBeenCalled();
});
it('strict mode rethrows OpenTUI boot failures', async () => {
await interactiveMainSetup();
selectOpentui(true);
const bootError = new Error('opentui boot exploded');
mockStartOpenTuiUI.mockRejectedValue(bootError);
await expect(main()).rejects.toThrow('opentui boot exploded');
expect(mockStartPostRenderPrefetches).not.toHaveBeenCalled();
});
it('falls back to ink without strict mode when the OpenTUI entry declines', async () => {
await interactiveMainSetup();
selectOpentui(false);
// The entry writes its own stderr warning when it declines; llm.tsx
// only falls through to startInteractiveUI, proven by the post-render
// prefetch hook inside it running.
mockStartOpenTuiUI.mockResolvedValue(false);
await main();
expect(mockStartPostRenderPrefetches).toHaveBeenCalled();
});
it('probes ICU on the interactive path before the UI renders', async () => {
await interactiveMainSetup();
mockAssertFullIcuAvailable.mockClear();
mockStartPostRenderPrefetches.mockClear();
await main();
expect(mockAssertFullIcuAvailable).toHaveBeenCalledTimes(1);
const probeOrder = mockAssertFullIcuAvailable.mock.invocationCallOrder[0]!;
const renderOrder =
mockStartPostRenderPrefetches.mock.invocationCallOrder[0]!;
expect(probeOrder).toBeLessThan(renderOrder);
});
it('never probes ICU on the headless path', async () => {
const { loadCliConfig } = await import('./config/config.js');
await interactiveMainSetup();
// Same fully-mocked setup as the interactive case, one bit flipped: the
// non-interactive branch must stay untouched by the ICU gate.
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
isTelemetryInitializationDeferred: () => true,
} as unknown as Config);
mockAssertFullIcuAvailable.mockClear();
try {
await main();
} catch {
// The headless tail (auth validation, session plumbing) is not the
// subject here; the gate sits at configConstructionEnd, before it.
}
expect(mockAssertFullIcuAvailable).not.toHaveBeenCalled();
});
it('hands the screen-reader flag to the renderer gate', async () => {
await interactiveMainSetup(/* screenReader */ true);
selectOpentui(false);
mockStartOpenTuiUI.mockResolvedValue(false);
await main();
// The gate is what keeps a screen-reader session on ink; dropping this
// argument would silently serve OpenTUI, which has no SR render path.
expect(mockSelectTuiRenderer).toHaveBeenLastCalledWith(
undefined,
undefined,
expect.anything(),
true,
);
});
});
describe('llm.tsx main function kitty protocol', () => {
let originalEnvNoRelaunch: string | undefined;
let setRawModeSpy: MockInstance<
(mode: boolean) => NodeJS.ReadStream & { fd: 0 }
>;
let initialSigintListeners: NodeJS.SignalsListener[];
let initialSigtermListeners: NodeJS.SignalsListener[];
beforeEach(() => {
// Set no relaunch in tests since process spawning causing issues in tests
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
initialSigintListeners = process.listeners(
'SIGINT',
) as NodeJS.SignalsListener[];
initialSigtermListeners = process.listeners(
'SIGTERM',
) as NodeJS.SignalsListener[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(process.stdin as any).setRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).setRawMode = vi.fn();
}
setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode');
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isRaw', {
value: false,
configurable: true,
});
});
afterEach(() => {
for (const listener of process.listeners('SIGINT')) {
if (!initialSigintListeners.includes(listener)) {
process.removeListener('SIGINT', listener as NodeJS.SignalsListener);
}
}
for (const listener of process.listeners('SIGTERM')) {
if (!initialSigtermListeners.includes(listener)) {
process.removeListener('SIGTERM', listener as NodeJS.SignalsListener);
}
}
// Restore original env variables
if (originalEnvNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
vi.restoreAllMocks();
});
it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {
const { detectAndEnableKittyProtocol } = await import(
'./ui/utils/kittyProtocolDetector.js'
);
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const initializerModule = await import('./core/initializer.js');
const initializeAppSpy = vi
.spyOn(initializerModule, 'initializeApp')
.mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
isTelemetryInitializationDeferred: () => true,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
disableMcpServer: undefined,
mcpConfig: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
extensions: undefined,
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
maxSessionTurns: undefined,
maxWallTime: undefined,
maxToolCalls: undefined,
maxSubagentDepth: undefined,
experimentalLsp: undefined,
restoreAskUserQuestion: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
fallbackModel: undefined,
});
await main();
expect(setRawModeSpy).toHaveBeenCalledWith(true);
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
expect(initializeAppSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
deferIdeConnection: true,
},
);
expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
connectIde: true,
initializeTelemetry: true,
},
);
expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith(
expect.any(Object),
);
});
it('should await IDE connection when interactive mode has an initial prompt', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const initializerModule = await import('./core/initializer.js');
const initializeAppSpy = vi
.spyOn(initializerModule, 'initializeApp')
.mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getQuestion: () => 'hello from prompt-interactive',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
isTelemetryInitializationDeferred: () => false,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
disableMcpServer: undefined,
mcpConfig: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
extensions: undefined,
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
maxSessionTurns: undefined,
maxWallTime: undefined,
maxToolCalls: undefined,
maxSubagentDepth: undefined,
experimentalLsp: undefined,
restoreAskUserQuestion: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
fallbackModel: undefined,
});
await main();
expect(initializeAppSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
deferIdeConnection: false,
},
);
expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
connectIde: false,
initializeTelemetry: false,
},
);
expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith(
expect.any(Object),
);
});
it('should await IDE connection when interactive mode has an input file', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const initializerModule = await import('./core/initializer.js');
const initializeAppSpy = vi
.spyOn(initializerModule, 'initializeApp')
.mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getQuestion: () => '',
getInputFile: () => '/tmp/qwen-input.jsonl',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
isTelemetryInitializationDeferred: () => true,
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
disableMcpServer: undefined,
mcpConfig: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
extensions: undefined,
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
maxSessionTurns: undefined,
maxWallTime: undefined,
maxToolCalls: undefined,
maxSubagentDepth: undefined,
experimentalLsp: undefined,
restoreAskUserQuestion: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
fallbackModel: undefined,
});
await main();
expect(initializeAppSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
deferIdeConnection: false,
},
);
expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
connectIde: false,
initializeTelemetry: true,
},
);
});
it('should not defer IDE connection when Zed integration is enabled', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const initializerModule = await import('./core/initializer.js');
const initializeAppSpy = vi
.spyOn(initializerModule, 'initializeApp')
.mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => true,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
outputStyle: undefined,
query: undefined,
yolo: undefined,
bare: undefined,
approvalMode: undefined,
telemetry: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
disableMcpServer: undefined,
mcpConfig: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
extensions: undefined,
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
continue: undefined,
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
maxSessionTurns: undefined,
maxWallTime: undefined,
maxToolCalls: undefined,
maxSubagentDepth: undefined,
experimentalLsp: undefined,
restoreAskUserQuestion: undefined,
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
fallbackModel: undefined,
});
// Mock process.exit to throw instead of terminating the process
const originalExit = process.exit;
process.exit = ((code?: string | number | null | undefined) => {
throw new MockProcessExitError(code);
}) as unknown as typeof process.exit;
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
} finally {
process.exit = originalExit;
}
expect(initializeAppSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{
deferIdeConnection: false,
},
);
expect(mockRunAcpAgent).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
expect.any(Object),
{
privateParentCapability: undefined,
},
);
expect(mockStartEarlyStartupPrefetches).toHaveBeenCalledWith(
expect.any(Object),
);
const acpFailure = new Error('ACP failed');
mockRunAcpAgent.mockRejectedValueOnce(acpFailure);
process.exit = ((code?: string | number | null | undefined) => {
throw new MockProcessExitError(code);
}) as unknown as typeof process.exit;
try {
await expect(main()).rejects.toBe(acpFailure);
} finally {
process.exit = originalExit;
}
expect(cleanupModule.runExitCleanup).toHaveBeenCalledTimes(2);
expect(mockPrepareFileWatchersForProcessExit).toHaveBeenCalledTimes(2);
for (const index of [0, 1]) {
expect(
mockPrepareFileWatchersForProcessExit.mock.invocationCallOrder[index],
).toBeLessThan(
vi.mocked(cleanupModule.runExitCleanup).mock.invocationCallOrder[index],
);
}
});
// Shared config/settings mocks for the interactive signal-handler tests.
function applyInteractiveSigintConfigMocks(
loadCliConfig: unknown,
loadSettings: unknown,
) {
vi.mocked(
loadCliConfig as (typeof import('./config/config.js'))['loadCliConfig'],
).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({
getCurrentAuthType: () => null,
getGenerationConfig: () => ({}),
}),
getProxy: () => undefined,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
isTelemetryInitializationDeferred: () => true,
} as unknown as Config);
vi.mocked(
loadSettings as (typeof import('./config/settings.js'))['loadSettings'],
).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
}
it('exits on interactive SIGINT only after a second press inside the confirm window', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
const realProcessOn = process.on.bind(process);
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
eventName: string | symbol,
listener: (...args: unknown[]) => void,
) => {
if (eventName === 'SIGTERM' || eventName === 'SIGINT') {
// Keep only the first (named) handler per signal; the swallow
// listener registered when cleanup begins is tracked separately.
if (!signalHandlers.has(eventName as string)) {
signalHandlers.set(eventName as string, listener);
}
return process;
}
return realProcessOn(
eventName as string,
listener as (...args: unknown[]) => void,
);
}) as typeof process.on);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
applyInteractiveSigintConfigMocks(loadCliConfig, loadSettings);
vi.mocked(parseArguments).mockResolvedValue({
extensions: undefined,
} as never);
const nowSpy = vi.spyOn(Date, 'now');
try {
await main();
// First SIGINT: no cleanup, no exit — just the press-again hint.
mockWriteStderrLine.mockClear();
nowSpy.mockReturnValue(100_000);
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
expect(runExitCleanupMock).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Press Ctrl+C again to exit.',
);
// `when-exit` re-raises the signal microseconds later — the repeat is
// the same press, not a confirmation.
nowSpy.mockReturnValue(100_002);
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
expect(runExitCleanupMock).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
// Second real press inside the confirm window: cleanup once, exit 130.
nowSpy.mockReturnValue(100_400);
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
await Promise.resolve();
expect(setRawModeSpy).toHaveBeenCalledWith(false);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(130);
// Cleanup registered a stand-in SIGINT listener so a stray Ctrl+C
// while cleanup runs cannot fall back to Node's default handler
// (#6776).
expect(
processOnSpy.mock.calls.filter(([event]) => event === 'SIGINT').length,
).toBeGreaterThan(1);
// Further SIGINTs during cleanup are ignored (single cleanup pass).
nowSpy.mockReturnValue(100_800);
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
} finally {
nowSpy.mockRestore();
processOnSpy.mockRestore();
processExitSpy.mockRestore();
}
});
it('re-arms the SIGINT confirm window after it expires', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
const realProcessOn = process.on.bind(process);
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
eventName: string | symbol,
listener: (...args: unknown[]) => void,
) => {
if (eventName === 'SIGTERM' || eventName === 'SIGINT') {
if (!signalHandlers.has(eventName as string)) {
signalHandlers.set(eventName as string, listener);
}
return process;
}
return realProcessOn(
eventName as string,
listener as (...args: unknown[]) => void,
);
}) as typeof process.on);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
applyInteractiveSigintConfigMocks(loadCliConfig, loadSettings);
vi.mocked(parseArguments).mockResolvedValue({
extensions: undefined,
} as never);
const nowSpy = vi.spyOn(Date, 'now');
try {
await main();
nowSpy.mockReturnValue(100_000);
signalHandlers.get('SIGINT')?.();
// 1.5s later — outside the window, so this press re-arms instead of
// exiting…
nowSpy.mockReturnValue(101_500);
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
expect(runExitCleanupMock).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
// …and a press right after it lands inside the fresh window.
nowSpy.mockReturnValue(101_900);
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
await Promise.resolve();
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(130);
} finally {
nowSpy.mockRestore();
processOnSpy.mockRestore();
processExitSpy.mockRestore();
}
});
it('still exits on the first SIGTERM with code 143', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
const realProcessOn = process.on.bind(process);
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
eventName: string | symbol,
listener: (...args: unknown[]) => void,
) => {
if (eventName === 'SIGTERM' || eventName === 'SIGINT') {
if (!signalHandlers.has(eventName as string)) {
signalHandlers.set(eventName as string, listener);
}
return process;
}
return realProcessOn(
eventName as string,
listener as (...args: unknown[]) => void,
);
}) as typeof process.on);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
applyInteractiveSigintConfigMocks(loadCliConfig, loadSettings);
vi.mocked(parseArguments).mockResolvedValue({
extensions: undefined,
} as never);
await main();
signalHandlers.get('SIGTERM')?.();
await Promise.resolve();
await Promise.resolve();
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(143);
processOnSpy.mockRestore();
processExitSpy.mockRestore();
});
it('still exits on SIGHUP with code 129', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
const realProcessOn = process.on.bind(process);
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
eventName: string | symbol,
listener: (...args: unknown[]) => void,
) => {
if (
eventName === 'SIGTERM' ||
eventName === 'SIGINT' ||
eventName === 'SIGHUP'
) {
if (!signalHandlers.has(eventName as string)) {
signalHandlers.set(eventName as string, listener);
}
return process;
}
return realProcessOn(
eventName as string,
listener as (...args: unknown[]) => void,
);
}) as typeof process.on);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
applyInteractiveSigintConfigMocks(loadCliConfig, loadSettings);
vi.mocked(parseArguments).mockResolvedValue({
extensions: undefined,
} as never);
await main();
signalHandlers.get('SIGHUP')?.();
await Promise.resolve();
await Promise.resolve();
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(129);
processOnSpy.mockRestore();
processExitSpy.mockRestore();
});
it('rejects --json-schema when running in interactive (TUI) mode', async () => {
// The synthetic structured_output tool only terminates the run inside
// runNonInteractive. In TUI mode it's an inert tool that prints
// "accepted" and leaves the chat alive — silently stranding the run.
// llm.tsx must reject this combination at runtime (parse-time
// gating can't catch the no-prompt-on-TTY case because stdin
// availability isn't probed yet at parse time).
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const callOrder: string[] = [];
const exitCodes: Array<string | number | null | undefined> = [];
mockWriteStderrLine.mockClear();
mockWriteStderrLine.mockImplementation(() => {
callOrder.push('writeStderrLine');
});
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockReset();
runExitCleanupMock.mockImplementation(async () => {
callOrder.push('runExitCleanup');
});
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(((
code?: string | number | null,
) => {
callOrder.push('processExit');
exitCodes.push(code);
throw new MockProcessExitError(code);
}) as unknown as typeof process.exit);
vi.mocked(loadCliConfig).mockResolvedValue({
...sessionRegistryConfigStub,
isInteractive: () => true,
getJsonSchema: () => ({ type: 'object' }),
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
shutdown: vi.fn(),
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({} as never);
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
// The headless-only message must reach stderr…
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('--json-schema is a headless-only flag'),
);
// …runExitCleanup must run before exit so MCP subprocesses /
// telemetry exporters registered earlier get torn down…
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
// …and exit must be 1, not 0.
expect(processExitSpy).toHaveBeenCalledWith(1);
// Order: stderr → cleanup → exit. A regression that swapped any of
// these (cleanup before stderr; exit without cleanup; exit 0
// instead of 1) would silently strand TUI users.
expect(callOrder).toEqual([
'writeStderrLine',
'runExitCleanup',
'processExit',
]);
expect(exitCodes).toEqual([1]);
processExitSpy.mockRestore();
});
});
describe('validateDnsResolutionOrder', () => {
beforeEach(() => {
mockWriteStderrLine.mockClear();
});
it('should return "ipv4first" when the input is "ipv4first"', () => {
expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first');
expect(mockWriteStderrLine).not.toHaveBeenCalled();
});
it('should return "verbatim" when the input is "verbatim"', () => {
expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim');
expect(mockWriteStderrLine).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" when the input is undefined', () => {
expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first');
expect(mockWriteStderrLine).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" and log a warning for an invalid string', () => {
expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first');
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".',
);
});
});
describe('setupUncaughtExceptionHandler', () => {
let tmpDir: string;
let installedHandler: ((error: unknown) => void) | undefined;
let exitSpy: MockInstance;
let debugLogPathSpy: MockInstance;
const makeConfig = (abortAll: () => void, running: unknown[] = []) =>
({
...sessionRegistryConfigStub,
getSessionId: () => 'uncaught-test-session',
getMonitorRegistry: () => ({ abortAll, getRunning: () => running }),
}) as unknown as Config;
const installHandler = (config: Config): ((error: unknown) => void) => {
const before = new Set(process.listeners('uncaughtException'));
setupUncaughtExceptionHandler(config);
const installed = process
.listeners('uncaughtException')
.filter((listener) => !before.has(listener))
.pop();
if (!installed) {
throw new Error('uncaughtException handler was not installed');
}
installedHandler = installed as (error: unknown) => void;
return installedHandler;
};
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'qwen-uncaught-'));
debugLogPathSpy = vi
.spyOn(Storage, 'getDebugLogPath')
.mockReturnValue(join(tmpDir, 'debug.txt'));
exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as typeof process.exit);
});
afterEach(() => {
if (installedHandler) {
process.removeListener('uncaughtException', installedHandler);
installedHandler = undefined;
}
exitSpy.mockRestore();
debugLogPathSpy.mockRestore();
rmSync(tmpDir, { recursive: true, force: true });
});
it('reaps running monitors before exiting on an uncaught exception', () => {
const abortAll = vi.fn();
const handler = installHandler(makeConfig(abortAll, [{}, {}]));
handler(new Error('boom'));
expect(abortAll).toHaveBeenCalledWith({ notify: false });
// The reap must land before exit: a reordering that exits first would
// silently skip it, and a mocked no-op exit cannot catch that by itself.
expect(abortAll.mock.invocationCallOrder[0]).toBeLessThan(
(exitSpy.mock.invocationCallOrder[0] as number) ?? 0,
);
expect(exitSpy).toHaveBeenCalledWith(1);
const log = readFileSync(join(tmpDir, 'debug.txt'), 'utf8');
expect(log).toContain('[UNCAUGHT_EXCEPTION] boom');
// A successful reap also leaves a synchronous record — without it a crash
// log cannot tell "reap skipped a monitor" from "signalled but ignored".
expect(log).toContain('[MONITOR_REAP] reaped=2');
});
it('leaves monitors alone for expected PTY teardown races', () => {
const abortAll = vi.fn();
const handler = installHandler(makeConfig(abortAll));
handler(new Error('read EIO'));
expect(abortAll).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
it('still exits when the monitor registry throws during reap', () => {
const abortAll = vi.fn(() => {
throw new Error('registry broken');
});
const handler = installHandler(makeConfig(abortAll));
expect(() => handler(new Error('boom'))).not.toThrow();
expect(exitSpy).toHaveBeenCalledWith(1);
// A failed reap means monitors still leak; that fact must be recorded,
// not swallowed (the crash line above cannot contain it).
const log = readFileSync(join(tmpDir, 'debug.txt'), 'utf8');
expect(log).toContain('[MONITOR_REAP_FAILED]');
expect(log).toContain('registry broken');
});
});
describe('startInteractiveUI', () => {
// Mock dependencies
const mockConfig = {
...sessionRegistryConfigStub,
getSessionId: () => 'test-session-id',
getProjectRoot: () => '/root',
getScreenReader: () => false,
isTelemetryInitializationDeferred: () => true,
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockSettings = {
merged: {
ui: {
hideWindowTitle: false,
},
// Messaging is on by default, and on it binds an inbox and arms its
// own exit cleanup. These tests are about startup order and render
// options; the messaging path is covered in startInteractiveUI.test.
agents: { crossSessionMessaging: false },
},
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as LoadedSettings;
const mockStartupWarnings = ['warning1'];
const mockWorkspaceRoot = '/root';
vi.mock('./utils/version.js', () => ({
getCliVersion: vi.fn(() => Promise.resolve('1.0.0')),
}));
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
disableKittyProtocol: vi.fn(),
pushKittyProtocolFlags: vi.fn(),
}));
vi.mock('./utils/cleanup.js', () => ({
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
registerCleanup: vi.fn(),
runExitCleanup: vi.fn(() => Promise.resolve()),
}));
vi.mock('ink', () => ({
render: vi.fn().mockReturnValue({ unmount: vi.fn() }),
}));
vi.mock('./ui/components/shared/ErrorBoundary.js', async (importOriginal) => {
const original =
await importOriginal<
typeof import('./ui/components/shared/ErrorBoundary.js')
>();
return {
...original,
consumeLastRenderError: mockConsumeLastRenderError,
};
});
let initialExitListeners: NodeJS.ExitListener[] = [];
let originalStdoutIsTTY: boolean | undefined;
let restoreCiEnv = () => {};
beforeEach(() => {
vi.clearAllMocks();
restoreCiEnv = clearCiEnv();
vi.stubEnv('TERM', 'xterm-256color');
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
configurable: true,
});
initialExitListeners = process.listeners('exit') as NodeJS.ExitListener[];
});
afterEach(() => {
if (originalStdoutIsTTY === undefined) {
delete (process.stdout as { isTTY?: unknown }).isTTY;
} else {
Object.defineProperty(process.stdout, 'isTTY', {
value: originalStdoutIsTTY,
configurable: true,
});
}
vi.unstubAllEnvs();
restoreCiEnv();
const currentExitListeners = process.listeners(
'exit',
) as NodeJS.ExitListener[];
for (const listener of currentExitListeners) {
if (!initialExitListeners.includes(listener)) {
process.removeListener('exit', listener);
}
}
});
it('should render the UI with proper React context and exitOnCtrlC disabled', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
// Verify render was called with correct options
expect(renderSpy).toHaveBeenCalledTimes(1);
const [reactElement, options] = renderSpy.mock.calls[0];
// Verify render options
expect(options).toEqual({
exitOnCtrlC: false,
isScreenReaderEnabled: false,
alternateScreen: true,
maxFps: 60,
});
// Verify React element structure is valid (but don't deep dive into JSX internals)
expect(reactElement).toBeDefined();
});
it('should not use alternate screen when VP mode is explicitly disabled', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const legacySettings = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
useTerminalBuffer: false,
},
},
} as LoadedSettings;
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
legacySettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
const [, options] = renderSpy.mock.calls[0];
expect(options).toMatchObject({ alternateScreen: false });
expect(options).not.toHaveProperty('maxFps');
});
it('should not use alternate screen when stdout is not interactive', async () => {
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
});
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
const [, options] = renderSpy.mock.calls[0];
expect(options).toMatchObject({ alternateScreen: false });
});
it('should not use alternate screen when TERM is dumb', async () => {
vi.stubEnv('TERM', 'dumb');
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
const [, options] = renderSpy.mock.calls[0];
expect(options).toMatchObject({ alternateScreen: false });
});
it('should not use alternate screen in CI with a TTY stdout', async () => {
vi.stubEnv('CI', 'true');
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
const [, options] = renderSpy.mock.calls[0];
expect(options).toMatchObject({ alternateScreen: false });
});
it('should not use alternate screen in screen reader mode when VP mode is unset', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const screenReaderConfig = {
...mockConfig,
getScreenReader: () => true,
} as Config;
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
screenReaderConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
const [, options] = renderSpy.mock.calls[0];
expect(options).toMatchObject({
isScreenReaderEnabled: true,
alternateScreen: false,
});
});
it('should not use alternate screen in screen reader mode even when VP mode is explicitly enabled', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
const screenReaderConfig = {
...mockConfig,
getScreenReader: () => true,
} as Config;
const vpSettings = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
useTerminalBuffer: true,
},
},
} as LoadedSettings;
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
screenReaderConfig,
vpSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
const [, options] = renderSpy.mock.calls[0];
expect(options).toMatchObject({
isScreenReaderEnabled: true,
alternateScreen: false,
});
});
it('should perform all startup tasks in correct order', async () => {
const { getCliVersion } = await import('./utils/version.js');
const { registerCleanup } = await import('./utils/cleanup.js');
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
// Verify all startup tasks were called
expect(getCliVersion).toHaveBeenCalledTimes(1);
expect(registerCleanup).toHaveBeenCalledTimes(2);
// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
expect(typeof cleanupFn).toBe('function');
expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith(
mockConfig,
mockSettings,
{ connectIde: false, initializeTelemetry: true },
);
});
it('delegates disabled auto-update settings to post-render prefetch', async () => {
const settingsWithAutoUpdateDisabled = {
merged: {
general: {
enableAutoUpdate: false,
},
ui: {
hideWindowTitle: false,
},
// Off for the reason `mockSettings` gives: this is not about messaging.
agents: { crossSessionMessaging: false },
},
} as LoadedSettings;
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
mockConfig,
settingsWithAutoUpdateDisabled,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith(
mockConfig,
settingsWithAutoUpdateDisabled,
{ connectIde: false, initializeTelemetry: true },
);
});
it('can skip post-render IDE connection after prompt-interactive awaited it', async () => {
const promptInteractiveConfig = {
...mockConfig,
isTelemetryInitializationDeferred: () => false,
} as unknown as Config;
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
};
await startInteractiveUI(
promptInteractiveConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
{ postRenderConnectIde: false },
);
expect(mockStartPostRenderPrefetches).toHaveBeenCalledWith(
promptInteractiveConfig,
mockSettings,
{ connectIde: false, initializeTelemetry: false },
);
});
// Regression for #6776: the kitty keyboard flags are tracked per screen
// (main vs alternate). The protocol is enabled on the main screen before
// render, so the pop must be written after Ink unmounts — i.e. after the
// alternate screen (when enabled) has been left — or the main screen's
// flags survive the exit and the shell receives kitty escape codes.
it('disables the Kitty keyboard protocol only after Ink has unmounted', async () => {
const unmount = vi.fn();
const { render } = await import('ink');
vi.mocked(render).mockReturnValue({ unmount } as never);
const { disableKittyProtocol } = await import(
'./ui/utils/kittyProtocolDetector.js'
);
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
},
);
const { registerCleanup } = await import('./utils/cleanup.js');
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0]?.[0] as
| (() => Promise<void> | void)
| undefined;
expect(cleanupFn).toBeTypeOf('function');
await cleanupFn?.();
expect(unmount).toHaveBeenCalledTimes(1);
expect(disableKittyProtocol).toHaveBeenCalledTimes(1);
expect(
vi.mocked(disableKittyProtocol).mock.invocationCallOrder[0],
).toBeGreaterThan(unmount.mock.invocationCallOrder[0]);
});
it('echoes a stored render error to stderr on cleanup (VP exit-time echo)', async () => {
const unmount = vi.fn();
const { render } = await import('ink');
vi.mocked(render).mockReturnValue({ unmount } as never);
mockConsumeLastRenderError.mockReturnValue(new Error('render boom'));
mockWriteStderrLine.mockClear();
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
},
);
const { registerCleanup } = await import('./utils/cleanup.js');
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0]?.[0] as
| (() => Promise<void> | void)
| undefined;
expect(cleanupFn).toBeTypeOf('function');
await cleanupFn?.();
expect(unmount).toHaveBeenCalledTimes(1);
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'\nRendering error: render boom',
);
});
it('does not echo when no render error was stored', async () => {
const unmount = vi.fn();
const { render } = await import('ink');
vi.mocked(render).mockReturnValue({ unmount } as never);
mockConsumeLastRenderError.mockReturnValue(undefined);
mockWriteStderrLine.mockClear();
await startInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
},
);
const { registerCleanup } = await import('./utils/cleanup.js');
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0]?.[0] as
| (() => Promise<void> | void)
| undefined;
await cleanupFn?.();
expect(mockWriteStderrLine).not.toHaveBeenCalledWith(
expect.stringContaining('Rendering error'),
);
});
// The quit screen's resume hint is drawn on the alternate screen in VP
// mode and discarded on teardown, so cleanup echoes the command to the
// main screen. Pin the echo's gate, message shape, and paste-safe ID gate.
describe('exit-time resume echo', () => {
async function runCleanup(config: Config): Promise<void> {
const unmount = vi.fn();
const { render } = await import('ink');
vi.mocked(render).mockReturnValue({ unmount } as never);
mockConsumeLastRenderError.mockReturnValue(undefined);
await startInteractiveUI(
config,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
},
);
const { registerCleanup } = await import('./utils/cleanup.js');
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0]?.[0] as
| (() => Promise<void> | void)
| undefined;
expect(cleanupFn).toBeTypeOf('function');
await cleanupFn?.();
}
function makeRecordingConfig(sessionId: string, sessionFile: string) {
return {
...mockConfig,
getChatRecordingService: () => ({}),
getSessionId: () => sessionId,
getTranscriptPath: () => sessionFile,
} as unknown as Config;
}
it.each([
['a canonical session ID', 'b2a1c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'],
// Arena's agent-suffixed IDs are also accepted by --resume.
[
'an agent-suffixed session ID',
'b2a1c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d-agent-qwen',
],
])('echoes the resume command for %s', async (_, sessionId) => {
const projectDir = mkdtempSync(join(tmpdir(), 'resume-echo-'));
mkdirSync(join(projectDir, 'chats'), { recursive: true });
const sessionFile = join(projectDir, 'chats', `${sessionId}.jsonl`);
writeFileSync(sessionFile, '{"type":"message"}\n');
try {
await runCleanup(makeRecordingConfig(sessionId, sessionFile));
// Match only the locale-independent command part: earlier main()
// tests run the real initializeI18n('auto') and leave the machine
// locale's dictionary in the i18n module state.
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
expect.stringContaining(`qwen --resume ${sessionId}`),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it('does not echo when the transcript file is empty', async () => {
const sessionId = '99999999-8888-4777-a666-555555555555';
const projectDir = mkdtempSync(join(tmpdir(), 'resume-echo-'));
mkdirSync(join(projectDir, 'chats'), { recursive: true });
const sessionFile = join(projectDir, 'chats', `${sessionId}.jsonl`);
writeFileSync(sessionFile, '');
try {
await runCleanup(makeRecordingConfig(sessionId, sessionFile));
expect(mockWriteStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('qwen --resume'),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it('does not echo when the session file is missing', async () => {
const sessionId = '11111111-2222-4333-8444-555555555555';
const projectDir = mkdtempSync(join(tmpdir(), 'resume-echo-'));
const sessionFile = join(projectDir, 'chats', `${sessionId}.jsonl`);
try {
await runCleanup(makeRecordingConfig(sessionId, sessionFile));
expect(mockWriteStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('qwen --resume'),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
// The ID is echoed as paste-into-shell text, and resume reads session
// IDs from transcript contents, so a crafted transcript must not be
// able to smuggle extra commands or CLI flags past the echo.
it.each([
['newline', 'evil\nrm -rf ~'],
['escape sequence', 'evil\u001B]52;c;pwned\u0007session'],
['leading dash', '-cafebabe0123456789abcdef01234567'],
['non-UUID token', 'abc123'],
])('does not echo a session ID with a %s', async (_, sessionId) => {
// Pair each hostile ID with a real non-empty transcript under a
// benign filename so only the ID gate can suppress the echo; a
// missing file would mask a regression of the gate itself.
const projectDir = mkdtempSync(join(tmpdir(), 'resume-echo-'));
mkdirSync(join(projectDir, 'chats'), { recursive: true });
const sessionFile = join(projectDir, 'chats', 'benign.jsonl');
writeFileSync(sessionFile, '{"type":"message"}\n');
try {
await runCleanup(makeRecordingConfig(sessionId, sessionFile));
expect(mockWriteStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('qwen --resume'),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it('does not echo when chat recording is disabled', async () => {
const sessionId = 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee';
const projectDir = mkdtempSync(join(tmpdir(), 'resume-echo-'));
mkdirSync(join(projectDir, 'chats'), { recursive: true });
const sessionFile = join(projectDir, 'chats', `${sessionId}.jsonl`);
writeFileSync(sessionFile, '{"type":"message"}\n');
try {
await runCleanup({
...makeRecordingConfig(sessionId, sessionFile),
getChatRecordingService: () => undefined,
} as unknown as Config);
expect(mockWriteStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('qwen --resume'),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it('does not echo when stdout is not a TTY', async () => {
Object.defineProperty(process.stdout, 'isTTY', {
value: false,
configurable: true,
});
const sessionId = '0f0e0d0c-0b0a-4908-8706-050403020100';
const projectDir = mkdtempSync(join(tmpdir(), 'resume-echo-'));
mkdirSync(join(projectDir, 'chats'), { recursive: true });
const sessionFile = join(projectDir, 'chats', `${sessionId}.jsonl`);
writeFileSync(sessionFile, '{"type":"message"}\n');
try {
await runCleanup(makeRecordingConfig(sessionId, sessionFile));
expect(mockWriteStdoutLine).not.toHaveBeenCalledWith(
expect.stringContaining('qwen --resume'),
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
});
describe('periodic memory-pressure check', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// Regression: the tool scheduler only checks after a tool call, so a
// long conversation with no tool calls would never reclaim and could
// OOM on quit. The interactive UI must run a periodic check itself.
it('runs performCheck on an interval without any tool calls', async () => {
const performCheck = vi.fn();
const config = {
...mockConfig,
getMemoryPressureMonitor: () => ({ performCheck }),
} as unknown as Config;
const settings = {
// Off for the reason `mockSettings` gives: this is not about messaging.
merged: {
ui: { hideWindowTitle: true },
agents: { crossSessionMessaging: false },
},
} as unknown as LoadedSettings;
await startInteractiveUI(
config,
settings,
mockStartupWarnings,
mockWorkspaceRoot,
{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
},
);
expect(performCheck).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(30_000);
expect(performCheck).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_000);
expect(performCheck).toHaveBeenCalledTimes(2);
});
it('clears the interval and runs a final check before unmount', async () => {
const performCheck = vi.fn();
const unmount = vi.fn();
const config = {
...mockConfig,
getMemoryPressureMonitor: () => ({ performCheck }),
} as unknown as Config;
const settings = {
// Off for the reason `mockSettings` gives: this is not about messaging.
merged: {
ui: { hideWindowTitle: true },
agents: { crossSessionMessaging: false },
},
} as unknown as LoadedSettings;
// An earlier describe's vi.restoreAllMocks() wipes the shared ink
// render mock's return value in the full run, so re-arm it here.
const { render } = await import('ink');
vi.mocked(render).mockReturnValue({ unmount } as never);
await startInteractiveUI(
config,
settings,
mockStartupWarnings,
mockWorkspaceRoot,
{
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
},
);
await vi.advanceTimersByTimeAsync(30_000);
const beforeCleanup = performCheck.mock.calls.length;
expect(beforeCleanup).toBeGreaterThan(0);
const { registerCleanup } = await import('./utils/cleanup.js');
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0]?.[0] as
| (() => Promise<void> | void)
| undefined;
expect(cleanupFn).toBeTypeOf('function');
await cleanupFn?.();
// Final pre-unmount reclaim ran, then Ink was unmounted, and the
// interval was cleared (no further checks fire).
expect(performCheck.mock.calls.length).toBeGreaterThan(beforeCleanup);
expect(unmount).toHaveBeenCalledTimes(1);
const afterCleanup = performCheck.mock.calls.length;
await vi.advanceTimersByTimeAsync(90_000);
expect(performCheck.mock.calls.length).toBe(afterCleanup);
});
});
});