basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';8import * as os from 'node:os';9import * as path from 'node:path';10import {11 ToolNames,12 DEFAULT_QWEN_MODEL,13 OutputFormat,14 NativeLspService,15 Storage,16} from '@qwen-code/qwen-code-core';17import { loadCliConfig, parseArguments, type CliArgs } from './config.js';18import type { Settings } from './settings.js';19import * as ServerConfig from '@qwen-code/qwen-code-core';20import { isWorkspaceTrusted } from './trustedFolders.js';21import { resetMcpApprovalsForTesting } from './mcpApprovals.js';22 23const mockWriteStderrLine = vi.hoisted(() => vi.fn());24const mockWriteStdoutLine = vi.hoisted(() => vi.fn());25const mockSessionServiceInstance = vi.hoisted(() => ({26 loadLastSession: vi.fn(),27 loadSession: vi.fn(),28 forkSession: vi.fn(),29 sessionExists: vi.fn(),30}));31const mockSessionServiceCtor = vi.hoisted(() =>32 vi.fn(() => mockSessionServiceInstance),33);34const mockConfigConstructorParams = vi.hoisted(() => vi.fn());35 36vi.mock('../utils/stdioHelpers.js', () => ({37 writeStderrLine: mockWriteStderrLine,38 writeStdoutLine: mockWriteStdoutLine,39 clearScreen: vi.fn(),40}));41 42const createNativeLspServiceInstance = () => ({43 discoverAndPrepare: vi.fn(),44 start: vi.fn(),45 definitions: vi.fn().mockResolvedValue([]),46 references: vi.fn().mockResolvedValue([]),47 workspaceSymbols: vi.fn().mockResolvedValue([]),48 hover: vi.fn().mockResolvedValue(null),49 documentSymbols: vi.fn().mockResolvedValue([]),50 implementations: vi.fn().mockResolvedValue([]),51 prepareCallHierarchy: vi.fn().mockResolvedValue([]),52 incomingCalls: vi.fn().mockResolvedValue([]),53 outgoingCalls: vi.fn().mockResolvedValue([]),54 diagnostics: vi.fn().mockResolvedValue([]),55 workspaceDiagnostics: vi.fn().mockResolvedValue([]),56 codeActions: vi.fn().mockResolvedValue([]),57 applyWorkspaceEdit: vi.fn().mockResolvedValue(false),58 getStatusSnapshot: vi.fn().mockReturnValue({59 enabled: true,60 configuredServers: 1,61 readyServers: 1,62 failedServers: 0,63 inProgressServers: 0,64 notStartedServers: 0,65 servers: [66 {67 name: 'typescript',68 status: 'READY',69 languages: ['typescript'],70 transport: 'stdio',71 },72 ],73 }),74});75 76vi.mock('./trustedFolders.js', () => ({77 isWorkspaceTrusted: vi78 .fn()79 .mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted80}));81 82const nativeLspServiceMock = vi.mocked(NativeLspService);83const getLastLspInstance = () => {84 const results = nativeLspServiceMock.mock.results;85 if (results.length === 0) {86 return undefined;87 }88 return results[results.length - 1]?.value as ReturnType<89 typeof createNativeLspServiceInstance90 >;91};92 93vi.mock('fs', async (importOriginal) => {94 const actualFs = await importOriginal<typeof import('fs')>();95 const pathMod = await import('node:path');96 const mockHome = '/mock/home/user';97 const MOCK_CWD1 = process.cwd();98 const MOCK_CWD2 = pathMod.resolve(pathMod.sep, 'home', 'user', 'project');99 100 const mockPaths = new Set([101 MOCK_CWD1,102 MOCK_CWD2,103 pathMod.resolve(pathMod.sep, 'cli', 'path1'),104 pathMod.resolve(pathMod.sep, 'settings', 'path1'),105 pathMod.join(mockHome, 'settings', 'path2'),106 pathMod.join(MOCK_CWD2, 'cli', 'path2'),107 pathMod.join(MOCK_CWD2, 'settings', 'path3'),108 ]);109 110 return {111 ...actualFs,112 mkdirSync: vi.fn(),113 writeFileSync: vi.fn(),114 existsSync: vi.fn((p) => mockPaths.has(p.toString())),115 statSync: vi.fn((p) => {116 if (mockPaths.has(p.toString())) {117 return { isDirectory: () => true } as unknown as import('fs').Stats;118 }119 return (actualFs as typeof import('fs')).statSync(p as unknown as string);120 }),121 realpathSync: vi.fn((p) => p),122 };123});124 125vi.mock('os', async (importOriginal) => {126 const actualOs = await importOriginal<typeof os>();127 return {128 ...actualOs,129 homedir: vi.fn(() => '/mock/home/user'),130 };131});132 133vi.mock('open', () => ({134 default: vi.fn(),135}));136 137vi.mock('read-package-up', () => ({138 readPackageUp: vi.fn(() =>139 Promise.resolve({140 packageJson: {141 version: 'test-version',142 config: { sandboxImageUri: 'pkg-default-image' },143 },144 }),145 ),146}));147 148vi.mock('command-exists', () => ({149 default: {150 sync: vi.fn(() => true),151 },152}));153 154vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {155 const actualServer = await importOriginal<typeof ServerConfig>();156 const SkillManagerMock = vi.fn();157 SkillManagerMock.prototype.startWatching = vi158 .fn()159 .mockResolvedValue(undefined);160 SkillManagerMock.prototype.stopWatching = vi.fn();161 SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]);162 SkillManagerMock.prototype.addChangeListener = vi.fn();163 SkillManagerMock.prototype.removeChangeListener = vi.fn();164 class ConfigWithParamCapture extends actualServer.Config {165 constructor(...args: ConstructorParameters<typeof actualServer.Config>) {166 mockConfigConstructorParams(args[0]);167 super(...args);168 }169 }170 return {171 ...actualServer,172 Config: ConfigWithParamCapture,173 NativeLspService: vi174 .fn()175 .mockImplementation(() => createNativeLspServiceInstance()),176 SessionService: mockSessionServiceCtor,177 SkillManager: SkillManagerMock,178 IdeClient: {179 getInstance: vi.fn().mockResolvedValue({180 getConnectionStatus: vi.fn(),181 initialize: vi.fn(),182 shutdown: vi.fn(),183 }),184 },185 loadEnvironment: vi.fn(),186 loadServerHierarchicalMemory: vi.fn(187 (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>188 Promise.resolve({189 memoryContent: extensionPaths?.join(',') || '',190 fileCount: extensionPaths?.length || 0,191 ruleCount: 0,192 conditionalRules: [],193 projectRoot: cwd || '/tmp',194 }),195 ),196 DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {197 respectGitIgnore: false,198 respectQwenIgnore: true,199 },200 DEFAULT_FILE_FILTERING_OPTIONS: {201 respectGitIgnore: true,202 respectQwenIgnore: true,203 },204 };205});206 207describe('parseArguments', () => {208 const originalArgv = process.argv;209 210 afterEach(() => {211 process.argv = originalArgv;212 });213 214 it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {215 process.argv = [216 'node',217 'script.js',218 '--prompt',219 'test prompt',220 '--prompt-interactive',221 'interactive prompt',222 ];223 224 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {225 throw new Error('process.exit called');226 });227 mockWriteStderrLine.mockClear();228 229 await expect(parseArguments()).rejects.toThrow('process.exit called');230 231 expect(mockWriteStderrLine).toHaveBeenCalledWith(232 expect.stringContaining(233 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',234 ),235 );236 237 mockExit.mockRestore();238 });239 240 it('should throw an error when using short flags -p and -i together', async () => {241 process.argv = [242 'node',243 'script.js',244 '-p',245 'test prompt',246 '-i',247 'interactive prompt',248 ];249 250 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {251 throw new Error('process.exit called');252 });253 mockWriteStderrLine.mockClear();254 255 await expect(parseArguments()).rejects.toThrow('process.exit called');256 257 expect(mockWriteStderrLine).toHaveBeenCalledWith(258 expect.stringContaining(259 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',260 ),261 );262 263 mockExit.mockRestore();264 });265 266 it('should allow --prompt without --prompt-interactive', async () => {267 process.argv = ['node', 'script.js', '--prompt', 'test prompt'];268 const argv = await parseArguments();269 expect(argv.prompt).toBe('test prompt');270 expect(argv.promptInteractive).toBeUndefined();271 });272 273 it('should allow --prompt-interactive without --prompt', async () => {274 process.argv = [275 'node',276 'script.js',277 '--prompt-interactive',278 'interactive prompt',279 ];280 const argv = await parseArguments();281 expect(argv.promptInteractive).toBe('interactive prompt');282 expect(argv.prompt).toBeUndefined();283 });284 285 it('should allow -i flag as alias for --prompt-interactive', async () => {286 process.argv = ['node', 'script.js', '-i', 'interactive prompt'];287 const argv = await parseArguments();288 expect(argv.promptInteractive).toBe('interactive prompt');289 expect(argv.prompt).toBeUndefined();290 });291 292 it('parses --insecure as a boolean flag (default false)', async () => {293 process.argv = ['node', 'script.js'];294 const defaultArgv = await parseArguments();295 expect(defaultArgv.insecure).toBe(false);296 297 process.argv = ['node', 'script.js', '--insecure'];298 const argv = await parseArguments();299 expect(argv.insecure).toBe(true);300 });301 302 it('rejects --json-schema combined with --acp', async () => {303 // ACP runs an independent turn loop (runAcpAgent) that doesn't honour304 // the synthetic structured_output terminal contract. The yargs check305 // must reject the combination at parse time so users get an actionable306 // error instead of silently watching the run never terminate.307 process.argv = [308 'node',309 'script.js',310 '--acp',311 '--json-schema',312 '{"type":"object"}',313 ];314 315 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {316 throw new Error('process.exit called');317 });318 mockWriteStderrLine.mockClear();319 320 await expect(parseArguments()).rejects.toThrow('process.exit called');321 322 expect(mockWriteStderrLine).toHaveBeenCalledWith(323 expect.stringContaining('--json-schema cannot be used with --acp'),324 );325 326 mockExit.mockRestore();327 });328 329 it('rejects --json-schema combined with --experimental-acp (deprecated alias)', async () => {330 // --experimental-acp is the deprecated alias; the same mutual-331 // exclusion logic must apply or users get the silent-no-terminate332 // behaviour the --acp check was added to prevent.333 process.argv = [334 'node',335 'script.js',336 '--experimental-acp',337 '--json-schema',338 '{"type":"object"}',339 ];340 341 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {342 throw new Error('process.exit called');343 });344 mockWriteStderrLine.mockClear();345 346 await expect(parseArguments()).rejects.toThrow('process.exit called');347 348 expect(mockWriteStderrLine).toHaveBeenCalledWith(349 expect.stringContaining('--json-schema cannot be used with --acp'),350 );351 352 mockExit.mockRestore();353 });354 355 it('rejects --json-schema combined with --prompt-interactive (-i)', async () => {356 // The interactive flow doesn't honour the synthetic-tool terminal357 // contract — `structured_output` would just print "accepted" and358 // leave the chat alive. The yargs check must reject this at parse359 // time so users get an actionable message instead of a silently360 // misbehaving run.361 process.argv = [362 'node',363 'script.js',364 '-i',365 'do work then submit',366 '--json-schema',367 '{"type":"object"}',368 ];369 370 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {371 throw new Error('process.exit called');372 });373 mockWriteStderrLine.mockClear();374 375 await expect(parseArguments()).rejects.toThrow('process.exit called');376 377 expect(mockWriteStderrLine).toHaveBeenCalledWith(378 expect.stringContaining(379 'structured output only terminates the non-interactive flow',380 ),381 );382 383 mockExit.mockRestore();384 });385 386 it('rejects --json-schema combined with --input-format stream-json', async () => {387 // The "first valid structured_output call ends the session"388 // contract is incompatible with the long-lived stream-json input389 // protocol. Also load-bearing: gemini.tsx's390 // `process.exit(process.exitCode ?? 0)` plumbing in the stream-json391 // branch explicitly relies on this rejection holding. Pair with392 // --output-format stream-json because input/output formats must393 // match (a separate yargs check fires first otherwise).394 process.argv = [395 'node',396 'script.js',397 '--input-format',398 'stream-json',399 '--output-format',400 'stream-json',401 '--json-schema',402 '{"type":"object"}',403 ];404 405 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {406 throw new Error('process.exit called');407 });408 mockWriteStderrLine.mockClear();409 410 await expect(parseArguments()).rejects.toThrow('process.exit called');411 412 expect(mockWriteStderrLine).toHaveBeenCalledWith(413 expect.stringContaining('first structured_output call ends the session'),414 );415 416 mockExit.mockRestore();417 });418 419 it('should parse --system-prompt', async () => {420 process.argv = [421 'node',422 'script.js',423 '--system-prompt',424 'You are a test system prompt.',425 ];426 const argv = await parseArguments();427 expect(argv.systemPrompt).toBe('You are a test system prompt.');428 expect(argv.appendSystemPrompt).toBeUndefined();429 });430 431 it('should parse --append-system-prompt', async () => {432 process.argv = [433 'node',434 'script.js',435 '--append-system-prompt',436 'Be extra concise.',437 ];438 const argv = await parseArguments();439 expect(argv.appendSystemPrompt).toBe('Be extra concise.');440 expect(argv.systemPrompt).toBeUndefined();441 });442 443 it('should allow -r flag as alias for --resume', async () => {444 process.argv = [445 'node',446 'script.js',447 '-r',448 '123e4567-e89b-12d3-a456-426614174000',449 ];450 const argv = await parseArguments();451 expect(argv.resume).toBe('123e4567-e89b-12d3-a456-426614174000');452 });453 454 it('should allow -c flag as alias for --continue', async () => {455 process.argv = ['node', 'script.js', '-c'];456 const argv = await parseArguments();457 expect(argv.continue).toBe(true);458 });459 460 it('should parse --fork-session with --resume', async () => {461 process.argv = [462 'node',463 'script.js',464 '--resume',465 '123e4567-e89b-12d3-a456-426614174000',466 '--fork-session',467 ];468 const argv = await parseArguments();469 expect(argv.resume).toBe('123e4567-e89b-12d3-a456-426614174000');470 expect(argv.forkSession).toBe(true);471 });472 473 it('should parse --fork-session with the --resume picker form', async () => {474 process.argv = ['node', 'script.js', '--resume', '--fork-session'];475 const argv = await parseArguments();476 // Empty string is the existing yargs shape for picker form: --resume477 // without an explicit session ID.478 expect(argv.resume).toBe('');479 expect(argv.forkSession).toBe(true);480 });481 482 it('should reject --fork-session without --resume or --continue', async () => {483 process.argv = ['node', 'script.js', '--fork-session'];484 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {485 throw new Error('process.exit called');486 });487 mockWriteStderrLine.mockClear();488 489 await expect(parseArguments()).rejects.toThrow('process.exit called');490 491 expect(mockWriteStderrLine).toHaveBeenCalledWith(492 expect.stringContaining(493 '--fork-session must be used with --resume or --continue',494 ),495 );496 497 mockExit.mockRestore();498 });499 500 it('should convert positional query argument to prompt by default', async () => {501 process.argv = ['node', 'script.js', 'Hi Gemini'];502 const argv = await parseArguments();503 expect(argv.query).toBe('Hi Gemini');504 expect(argv.prompt).toBe('Hi Gemini');505 expect(argv.promptInteractive).toBeUndefined();506 });507 508 it('should map @path to prompt (one-shot) when it starts with @', async () => {509 process.argv = ['node', 'script.js', '@path ./file.md'];510 const argv = await parseArguments();511 expect(argv.query).toBe('@path ./file.md');512 expect(argv.prompt).toBe('@path ./file.md');513 expect(argv.promptInteractive).toBeUndefined();514 });515 516 it('should map @path to prompt even when config flags are present', async () => {517 // @path queries should now go to one-shot mode regardless of other flags518 process.argv = [519 'node',520 'script.js',521 '@path',522 './file.md',523 '--model',524 'gemini-1.5-pro',525 ];526 const argv = await parseArguments();527 expect(argv.query).toBe('@path ./file.md');528 expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot529 expect(argv.promptInteractive).toBeUndefined();530 expect(argv.model).toBe('gemini-1.5-pro');531 });532 533 it('maps unquoted positional @path + arg to prompt (one-shot)', async () => {534 // Simulate: gemini @path ./file.md535 process.argv = ['node', 'script.js', '@path', './file.md'];536 const argv = await parseArguments();537 // After normalization, query is a single string538 expect(argv.query).toBe('@path ./file.md');539 // And it's mapped to one-shot prompt when no -p/-i flags are set540 expect(argv.prompt).toBe('@path ./file.md');541 expect(argv.promptInteractive).toBeUndefined();542 });543 544 it('should handle multiple @path arguments in a single command (one-shot)', async () => {545 // Simulate: gemini @path ./file1.md @path ./file2.md546 process.argv = [547 'node',548 'script.js',549 '@path',550 './file1.md',551 '@path',552 './file2.md',553 ];554 const argv = await parseArguments();555 // After normalization, all arguments are joined with spaces556 expect(argv.query).toBe('@path ./file1.md @path ./file2.md');557 // And it's mapped to one-shot prompt558 expect(argv.prompt).toBe('@path ./file1.md @path ./file2.md');559 expect(argv.promptInteractive).toBeUndefined();560 });561 562 it('should handle mixed quoted and unquoted @path arguments (one-shot)', async () => {563 // Simulate: gemini "@path ./file1.md" @path ./file2.md "additional text"564 process.argv = [565 'node',566 'script.js',567 '@path ./file1.md',568 '@path',569 './file2.md',570 'additional text',571 ];572 const argv = await parseArguments();573 // After normalization, all arguments are joined with spaces574 expect(argv.query).toBe(575 '@path ./file1.md @path ./file2.md additional text',576 );577 // And it's mapped to one-shot prompt578 expect(argv.prompt).toBe(579 '@path ./file1.md @path ./file2.md additional text',580 );581 expect(argv.promptInteractive).toBeUndefined();582 });583 584 it('should map @path to prompt with ambient flags (debug, telemetry)', async () => {585 // Ambient flags like debug, telemetry should NOT affect routing586 process.argv = [587 'node',588 'script.js',589 '@path',590 './file.md',591 '--debug',592 '--telemetry',593 ];594 const argv = await parseArguments();595 expect(argv.query).toBe('@path ./file.md');596 expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot597 expect(argv.promptInteractive).toBeUndefined();598 expect(argv.debug).toBe(true);599 expect(argv.telemetry).toBe(true);600 });601 602 it('should map any @command to prompt (one-shot)', async () => {603 // Test that all @commands now go to one-shot mode604 const testCases = [605 '@path ./file.md',606 '@include src/',607 '@search pattern',608 '@web query',609 '@git status',610 ];611 612 for (const testQuery of testCases) {613 process.argv = ['node', 'script.js', testQuery];614 const argv = await parseArguments();615 expect(argv.query).toBe(testQuery);616 expect(argv.prompt).toBe(testQuery);617 expect(argv.promptInteractive).toBeUndefined();618 }619 });620 621 it('should handle @command with leading whitespace', async () => {622 // Test that trim() + routing handles leading whitespace correctly623 process.argv = ['node', 'script.js', ' @path ./file.md'];624 const argv = await parseArguments();625 expect(argv.query).toBe(' @path ./file.md');626 expect(argv.prompt).toBe(' @path ./file.md');627 expect(argv.promptInteractive).toBeUndefined();628 });629 630 it('should throw an error when both --yolo and --approval-mode are used together', async () => {631 process.argv = [632 'node',633 'script.js',634 '--yolo',635 '--approval-mode',636 'default',637 ];638 639 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {640 throw new Error('process.exit called');641 });642 mockWriteStderrLine.mockClear();643 644 await expect(parseArguments()).rejects.toThrow('process.exit called');645 646 expect(mockWriteStderrLine).toHaveBeenCalledWith(647 expect.stringContaining(648 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',649 ),650 );651 652 mockExit.mockRestore();653 });654 655 it('should throw an error when using short flags -y and --approval-mode together', async () => {656 process.argv = ['node', 'script.js', '-y', '--approval-mode', 'yolo'];657 658 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {659 throw new Error('process.exit called');660 });661 mockWriteStderrLine.mockClear();662 663 await expect(parseArguments()).rejects.toThrow('process.exit called');664 665 expect(mockWriteStderrLine).toHaveBeenCalledWith(666 expect.stringContaining(667 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',668 ),669 );670 671 mockExit.mockRestore();672 });673 674 it('should allow --system-prompt and --append-system-prompt together', async () => {675 process.argv = [676 'node',677 'script.js',678 '--system-prompt',679 'Override prompt',680 '--append-system-prompt',681 'Append prompt',682 ];683 684 const argv = await parseArguments();685 expect(argv.systemPrompt).toBe('Override prompt');686 expect(argv.appendSystemPrompt).toBe('Append prompt');687 });688 689 it('should throw an error when include-partial-messages is used without stream-json output', async () => {690 process.argv = ['node', 'script.js', '--include-partial-messages'];691 692 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {693 throw new Error('process.exit called');694 });695 mockWriteStderrLine.mockClear();696 697 await expect(parseArguments()).rejects.toThrow('process.exit called');698 699 expect(mockWriteStderrLine).toHaveBeenCalledWith(700 expect.stringContaining(701 '--include-partial-messages requires --output-format stream-json',702 ),703 );704 705 mockExit.mockRestore();706 });707 708 it('should reject --json-schema with no prompt source when stdin is a TTY', async () => {709 // True interactive invocation with no prompt anywhere → fail fast.710 process.argv = ['node', 'script.js', '--json-schema', '{"type":"object"}'];711 712 const originalIsTTY = process.stdin.isTTY;713 process.stdin.isTTY = true;714 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {715 throw new Error('process.exit called');716 });717 mockWriteStderrLine.mockClear();718 719 try {720 await expect(parseArguments()).rejects.toThrow('process.exit called');721 expect(mockWriteStderrLine).toHaveBeenCalledWith(722 expect.stringContaining(723 '--json-schema only applies to non-interactive mode',724 ),725 );726 } finally {727 mockExit.mockRestore();728 process.stdin.isTTY = originalIsTTY;729 }730 });731 732 it('should accept --json-schema with no -p / positional when stdin is piped', async () => {733 // `echo "..." | qwen --json-schema ...` — input arrives via the734 // pipe, so the prompt-presence check must not block the run.735 process.argv = ['node', 'script.js', '--json-schema', '{"type":"object"}'];736 737 const originalIsTTY = process.stdin.isTTY;738 process.stdin.isTTY = false;739 try {740 const argv = await parseArguments();741 expect(argv.jsonSchema).toBe('{"type":"object"}');742 expect(argv.prompt).toBeUndefined();743 } finally {744 process.stdin.isTTY = originalIsTTY;745 }746 });747 748 it('should throw when --json-schema is combined with --input-format stream-json', async () => {749 // stream-json input runs through runNonInteractiveStreamJson which750 // doesn't honor the structured-output single-shot termination751 // contract — reject the combination at parse time so the user sees752 // the mismatch immediately.753 process.argv = [754 'node',755 'script.js',756 '-p',757 'hi',758 '--output-format',759 'stream-json',760 '--input-format',761 'stream-json',762 '--json-schema',763 '{"type":"object"}',764 ];765 766 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {767 throw new Error('process.exit called');768 });769 mockWriteStderrLine.mockClear();770 771 await expect(parseArguments()).rejects.toThrow('process.exit called');772 773 expect(mockWriteStderrLine).toHaveBeenCalledWith(774 expect.stringContaining(775 '--json-schema cannot be used with --input-format stream-json',776 ),777 );778 779 mockExit.mockRestore();780 });781 782 it('should parse stream-json formats and include-partial-messages flag', async () => {783 process.argv = [784 'node',785 'script.js',786 '--output-format',787 'stream-json',788 '--input-format',789 'stream-json',790 '--include-partial-messages',791 ];792 793 const argv = await parseArguments();794 795 expect(argv.outputFormat).toBe('stream-json');796 expect(argv.inputFormat).toBe('stream-json');797 expect(argv.includePartialMessages).toBe(true);798 });799 800 it('should allow --approval-mode without --yolo', async () => {801 process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];802 const argv = await parseArguments();803 expect(argv.approvalMode).toBe('auto-edit');804 expect(argv.yolo).toBe(false);805 });806 807 it('should allow --yolo without --approval-mode', async () => {808 process.argv = ['node', 'script.js', '--yolo'];809 const argv = await parseArguments();810 expect(argv.yolo).toBe(true);811 expect(argv.approvalMode).toBeUndefined();812 });813 814 it('should accept desktop as a channel identifier', async () => {815 process.argv = ['node', 'script.js', '--channel', 'desktop'];816 const argv = await parseArguments();817 expect(argv.channel).toBe('desktop');818 });819 820 it('should default ACP mode to the ACP channel when no channel is provided', async () => {821 process.argv = ['node', 'script.js', '--acp'];822 const argv = await parseArguments();823 expect(argv.channel).toBe('ACP');824 });825 826 it('keeps an explicit --channel when combined with --acp (the desktop invocation)', async () => {827 process.argv = ['node', 'script.js', '--acp', '--channel', 'desktop'];828 const argv = await parseArguments();829 // The `!result['channel']` guard must not override an explicitly provided830 // channel with the ACP default.831 expect(argv.channel).toBe('desktop');832 expect(argv.acp).toBe(true);833 });834 835 it('should reject invalid --approval-mode values', async () => {836 process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];837 838 const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {839 throw new Error('process.exit called');840 });841 mockWriteStderrLine.mockClear();842 843 await expect(parseArguments()).rejects.toThrow('process.exit called');844 845 expect(mockWriteStderrLine).toHaveBeenCalledWith(846 expect.stringContaining('Invalid values:'),847 );848 849 mockExit.mockRestore();850 });851 852 it('should support comma-separated values for --allowed-tools', async () => {853 process.argv = [854 'node',855 'script.js',856 '--allowed-tools',857 'read_file,ShellTool(git status)',858 ];859 const argv = await parseArguments();860 expect(argv.allowedTools).toEqual(['read_file', 'ShellTool(git status)']);861 });862 863 it('should support comma-separated values for --allowed-mcp-server-names', async () => {864 process.argv = [865 'node',866 'script.js',867 '--allowed-mcp-server-names',868 'server1,server2',869 ];870 const argv = await parseArguments();871 expect(argv.allowedMcpServerNames).toEqual(['server1', 'server2']);872 });873 874 it('should support comma-separated values for --extensions', async () => {875 process.argv = ['node', 'script.js', '--extensions', 'ext1,ext2'];876 const argv = await parseArguments();877 expect(argv.extensions).toEqual(['ext1', 'ext2']);878 });879 880 it('should parse --bare', async () => {881 process.argv = ['node', 'script.js', '--bare'];882 const argv = await parseArguments();883 expect(argv.bare).toBe(true);884 });885 886 describe('--fallback-model flag', () => {887 it('parses a single fallback model', async () => {888 process.argv = ['node', 'script.js', '--fallback-model', 'qwen-plus'];889 const argv = await parseArguments();890 expect(argv.fallbackModel).toEqual(['qwen-plus']);891 });892 893 it('parses repeated --fallback-model flags', async () => {894 process.argv = [895 'node',896 'script.js',897 '--fallback-model',898 'qwen-plus',899 '--fallback-model',900 'qwen-turbo',901 ];902 const argv = await parseArguments();903 expect(argv.fallbackModel).toEqual(['qwen-plus', 'qwen-turbo']);904 });905 906 it('splits comma-separated values in a single flag', async () => {907 process.argv = [908 'node',909 'script.js',910 '--fallback-model',911 'qwen-plus,qwen-turbo',912 ];913 const argv = await parseArguments();914 expect(argv.fallbackModel).toEqual(['qwen-plus', 'qwen-turbo']);915 });916 917 it('combines repeated flags with comma-separated values', async () => {918 process.argv = [919 'node',920 'script.js',921 '--fallback-model',922 'qwen-plus,qwen-turbo',923 '--fallback-model',924 'qwen-max',925 ];926 const argv = await parseArguments();927 expect(argv.fallbackModel).toEqual([928 'qwen-plus',929 'qwen-turbo',930 'qwen-max',931 ]);932 });933 934 it('trims whitespace around comma-separated values', async () => {935 process.argv = [936 'node',937 'script.js',938 '--fallback-model',939 ' qwen-plus , qwen-turbo ',940 ];941 const argv = await parseArguments();942 expect(argv.fallbackModel).toEqual(['qwen-plus', 'qwen-turbo']);943 });944 945 it('defaults to undefined when not provided', async () => {946 process.argv = ['node', 'script.js'];947 const argv = await parseArguments();948 expect(argv.fallbackModel).toBeUndefined();949 });950 });951});952 953describe('loadCliConfig', () => {954 const originalArgv = process.argv;955 956 beforeEach(() => {957 vi.resetAllMocks();958 nativeLspServiceMock.mockReset();959 nativeLspServiceMock.mockImplementation(960 () => createNativeLspServiceInstance() as unknown as NativeLspService,961 );962 mockSessionServiceCtor.mockImplementation(() => mockSessionServiceInstance);963 mockSessionServiceInstance.loadLastSession.mockResolvedValue(undefined);964 mockSessionServiceInstance.loadSession.mockResolvedValue(undefined);965 mockSessionServiceInstance.forkSession.mockResolvedValue({966 filePath: '/mock/fork.jsonl',967 copiedCount: 1,968 });969 mockSessionServiceInstance.sessionExists.mockResolvedValue(false);970 vi.mocked(os.homedir).mockReturnValue('/mock/home/user');971 vi.stubEnv('GEMINI_API_KEY', 'test-api-key');972 resetMcpApprovalsForTesting();973 });974 975 afterEach(() => {976 process.argv = originalArgv;977 vi.unstubAllEnvs();978 resetMcpApprovalsForTesting();979 vi.restoreAllMocks();980 });981 982 it('should reset context file names to QWEN.md and AGENTS.md by default', async () => {983 process.argv = ['node', 'script.js'];984 const argv = await parseArguments();985 const settings: Settings = {};986 const setGeminiMdFilenameSpy = vi.spyOn(987 ServerConfig,988 'setGeminiMdFilename',989 );990 991 await loadCliConfig(settings, argv);992 993 expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);994 expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([995 ServerConfig.DEFAULT_CONTEXT_FILENAME,996 ServerConfig.AGENT_CONTEXT_FILENAME,997 ]);998 });999 1000 it('should use configured context file name when settings.context.fileName is set', async () => {1001 process.argv = ['node', 'script.js'];1002 const argv = await parseArguments();1003 const settings: Settings = {1004 context: {1005 fileName: 'CUSTOM_AGENTS.md',1006 },1007 };1008 const setGeminiMdFilenameSpy = vi.spyOn(1009 ServerConfig,1010 'setGeminiMdFilename',1011 );1012 1013 await loadCliConfig(settings, argv);1014 1015 expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);1016 expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md');1017 });1018 1019 it('should propagate stream-json formats to config', async () => {1020 process.argv = [1021 'node',1022 'script.js',1023 '--output-format',1024 'stream-json',1025 '--input-format',1026 'stream-json',1027 '--include-partial-messages',1028 ];1029 const argv = await parseArguments();1030 const settings: Settings = {};1031 const config = await loadCliConfig(settings, argv);1032 1033 expect(config.getOutputFormat()).toBe('stream-json');1034 expect(config.getInputFormat()).toBe('stream-json');1035 expect(config.getIncludePartialMessages()).toBe(true);1036 });1037 1038 it('should prefer CLI fallback models over settings fallback models', async () => {1039 process.argv = ['node', 'script.js', '--fallback-model', 'cli-a,cli-b'];1040 const argv = await parseArguments();1041 const config = await loadCliConfig({ modelFallbacks: 'settings-a' }, argv);1042 1043 expect(config.getModelFallbacks()).toEqual(['cli-a', 'cli-b']);1044 });1045 1046 it('should use settings fallback models when the CLI flag is absent', async () => {1047 process.argv = ['node', 'script.js'];1048 const argv = await parseArguments();1049 const config = await loadCliConfig(1050 { modelFallbacks: ' settings-a , settings-b ' },1051 argv,1052 );1053 1054 expect(config.getModelFallbacks()).toEqual(['settings-a', 'settings-b']);1055 });1056 1057 it('passes agents.maxParallelAgents from settings to core config', async () => {1058 process.argv = ['node', 'script.js'];1059 const argv = await parseArguments();1060 const config = await loadCliConfig(1061 { agents: { maxParallelAgents: 2 } },1062 argv,1063 );1064 1065 expect(config.getAgentsSettings().maxParallelAgents).toBe(2);1066 });1067 1068 it('should ignore blank settings fallback models', async () => {1069 process.argv = ['node', 'script.js'];1070 const argv = await parseArguments();1071 const config = await loadCliConfig({ modelFallbacks: ' ' }, argv);1072 1073 expect(config.getModelFallbacks()).toEqual([]);1074 });1075 1076 it('should enable runtime sleep prevention by default', async () => {1077 process.argv = ['node', 'script.js'];1078 const argv = await parseArguments();1079 const config = await loadCliConfig({}, argv);1080 1081 expect(config.getPreventSystemSleepEnabled()).toBe(true);1082 });1083 1084 describe('--insecure flag', () => {1085 const savedEnv: Record<string, string | undefined> = {};1086 let errorSpy: ReturnType<typeof vi.spyOn>;1087 1088 beforeEach(() => {1089 for (const key of ['QWEN_TLS_INSECURE', 'NODE_TLS_REJECT_UNAUTHORIZED']) {1090 savedEnv[key] = process.env[key];1091 delete process.env[key];1092 }1093 // Silence (and capture) the intentional MITM warning loadCliConfig emits.1094 errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});1095 });1096 1097 afterEach(() => {1098 errorSpy.mockRestore();1099 for (const [key, value] of Object.entries(savedEnv)) {1100 if (value === undefined) delete process.env[key];1101 else process.env[key] = value;1102 }1103 });1104 1105 it('sets QWEN_TLS_INSECURE=1 and NODE_TLS_REJECT_UNAUTHORIZED=0 when --insecure is passed', async () => {1106 process.argv = ['node', 'script.js', '--insecure'];1107 const argv = await parseArguments();1108 await loadCliConfig({}, argv);1109 expect(process.env['QWEN_TLS_INSECURE']).toBe('1');1110 expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBe('0');1111 expect(errorSpy).toHaveBeenCalled();1112 });1113 1114 it('leaves TLS env vars unset without --insecure', async () => {1115 process.argv = ['node', 'script.js'];1116 const argv = await parseArguments();1117 await loadCliConfig({}, argv);1118 expect(process.env['QWEN_TLS_INSECURE']).toBeUndefined();1119 expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBeUndefined();1120 expect(errorSpy).not.toHaveBeenCalled();1121 });1122 1123 it('propagates a pre-set QWEN_TLS_INSECURE to NODE_TLS_REJECT_UNAUTHORIZED=0', async () => {1124 process.env['QWEN_TLS_INSECURE'] = '1';1125 process.argv = ['node', 'script.js'];1126 const argv = await parseArguments();1127 await loadCliConfig({}, argv);1128 expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBe('0');1129 expect(errorSpy).toHaveBeenCalled();1130 });1131 1132 it('skips re-assignment and warning when NODE_TLS_REJECT_UNAUTHORIZED is already 0', async () => {1133 process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';1134 process.argv = ['node', 'script.js', '--insecure'];1135 const argv = await parseArguments();1136 await loadCliConfig({}, argv);1137 expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBe('0');1138 expect(errorSpy).not.toHaveBeenCalled();1139 });1140 });1141 1142 it('should propagate runtime sleep prevention setting', async () => {1143 process.argv = ['node', 'script.js'];1144 const argv = await parseArguments();1145 const config = await loadCliConfig(1146 {1147 general: {1148 preventSystemSleep: false,1149 },1150 },1151 argv,1152 );1153 1154 expect(config.getPreventSystemSleepEnabled()).toBe(false);1155 });1156 1157 it('should propagate artifact auto-open setting', async () => {1158 process.argv = ['node', 'script.js'];1159 const argv = await parseArguments();1160 const config = await loadCliConfig(1161 {1162 artifact: {1163 autoOpen: false,1164 },1165 },1166 argv,1167 );1168 1169 expect(config.shouldAutoOpenArtifact()).toBe(false);1170 });1171 1172 it('places session-injected (ACP/IDE) MCP servers at the top precedence tier', async () => {1173 process.argv = ['node', 'script.js'];1174 const argv = await parseArguments();1175 const settings: Settings = {1176 mcpServers: {1177 shared: { command: 'settings-cmd' },1178 'settings-only': { command: 'settings-only-cmd' },1179 },1180 };1181 const sessionMcpServers = {1182 shared: new ServerConfig.MCPServerConfig('session-cmd'),1183 'ide-only': new ServerConfig.MCPServerConfig('ide-cmd'),1184 };1185 1186 const config = await loadCliConfig(1187 settings,1188 argv,1189 process.cwd(),1190 undefined,1191 undefined,1192 undefined,1193 sessionMcpServers,1194 );1195 1196 const servers = config.getMcpServers() ?? {};1197 // Session source wins a name clash with settings.1198 expect(servers['shared'].command).toBe('session-cmd');1199 // Both session-only and settings-only servers survive.1200 expect(servers['ide-only'].command).toBe('ide-cmd');