mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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]>
5250 lines
177 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|