basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import { spawn, spawnSync } from 'node:child_process';8import * as fs from 'node:fs';9import * as os from 'node:os';10import * as path from 'node:path';11import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';12import yargs, { type Argv } from 'yargs';13import { serveCommand, maybeOpenWebShellBrowser } from './serve.js';14 15const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());16const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));17const mockRunQwenServe = vi.hoisted(() => vi.fn());18vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {19 const actual =20 await importOriginal<typeof import('@qwen-code/qwen-code-core')>();21 return {22 ...actual,23 openBrowserSecurely: mockOpenBrowserSecurely,24 shouldLaunchBrowser: mockShouldLaunchBrowser,25 };26});27vi.mock('../serve/run-qwen-serve.js', () => ({28 runQwenServe: mockRunQwenServe,29}));30 31function buildParser(): Argv {32 return (serveCommand.builder as (argv: Argv) => Argv)(33 yargs([]).exitProcess(false).fail(false).locale('en'),34 );35}36 37describe('serve command args', () => {38 it('parses --enable-session-shell', () => {39 const parsed = buildParser().parseSync('--enable-session-shell');40 expect(parsed['enable-session-shell']).toBe(true);41 });42 43 it('defaults direct session shell to disabled', () => {44 const parsed = buildParser().parseSync('');45 expect(parsed['enable-session-shell']).toBe(false);46 });47 48 it('accepts --experimental-lsp in strict parser mode', () => {49 const parsed = buildParser().strict().parseSync('--experimental-lsp');50 expect(parsed['experimentalLsp']).toBe(true);51 });52 53 it('parses --permission-response-timeout-ms as a number', () => {54 const parsed = buildParser().parseSync(55 '--permission-response-timeout-ms 60000',56 );57 expect(parsed['permission-response-timeout-ms']).toBe(60000);58 });59 60 it('parses --max-total-sessions as a number', () => {61 const parsed = buildParser().parseSync('--max-total-sessions 42');62 expect(parsed['max-total-sessions']).toBe(42);63 });64 65 it('leaves --permission-response-timeout-ms unset by default', () => {66 const parsed = buildParser().parseSync('');67 expect(parsed['permission-response-timeout-ms']).toBeUndefined();68 });69 70 it('parses --experimental-lsp for daemon child opt-in', () => {71 const parsed = buildParser().parseSync('--experimental-lsp');72 expect(parsed['experimentalLsp']).toBe(true);73 });74 75 it('registers --experimental-lsp as an explicit serve option', () => {76 const options = (77 buildParser() as Argv & {78 getOptions(): { key: Record<string, boolean> };79 }80 ).getOptions();81 expect(options.key['experimental-lsp']).toBe(true);82 });83 84 it('parses --web (default true) and --no-web', () => {85 expect(buildParser().parseSync('')['web']).toBe(true);86 expect(buildParser().parseSync('--no-web')['web']).toBe(false);87 });88 89 it('parses --open (default false)', () => {90 expect(buildParser().parseSync('')['open']).toBe(false);91 expect(buildParser().parseSync('--open')['open']).toBe(true);92 });93 94 it('parses repeatable --channel values', () => {95 const parsed = buildParser().parseSync(96 '--channel telegram --channel feishu',97 );98 99 expect(parsed['channel']).toEqual(['telegram', 'feishu']);100 });101 102 it('parses a single --workspace value as a single-element array', () => {103 const parsed = buildParser().parseSync('--workspace /tmp/primary');104 105 expect(parsed['workspace']).toEqual(['/tmp/primary']);106 });107 108 it('parses repeatable --workspace values as an array', () => {109 const parsed = buildParser().parseSync(110 '--workspace /tmp/primary --workspace /tmp/secondary',111 );112 113 expect(parsed['workspace']).toEqual(['/tmp/primary', '/tmp/secondary']);114 });115 116 it('rejects valueless --workspace forms', () => {117 for (const input of [118 '--workspace',119 '--workspace=',120 '--workspace /tmp/primary --workspace',121 ]) {122 expect(() => buildParser().parseSync(input)).toThrow(123 /Not enough arguments following: workspace/,124 );125 }126 });127 128 it('preserves repeatable --workspace values in command mode', () => {129 let captured: unknown;130 yargs([])131 .exitProcess(false)132 .fail(false)133 .locale('en')134 .command({135 ...serveCommand,136 handler: (argv) => {137 captured = argv.workspace;138 },139 })140 .parseSync('serve --workspace /tmp/primary --workspace /tmp/secondary');141 142 expect(captured).toEqual(['/tmp/primary', '/tmp/secondary']);143 });144});145 146describe('serve rate limit env parsing', () => {147 const originalEnv = process.env;148 149 beforeEach(() => {150 vi.clearAllMocks();151 process.env = { ...originalEnv, QWEN_CODE_SUPPRESS_YOLO_WARNING: '1' };152 });153 154 afterEach(() => {155 process.env = originalEnv;156 vi.restoreAllMocks();157 });158 159 async function invokeServeHandler() {160 const handler = serveCommand.handler;161 if (!handler) throw new Error('serve handler missing');162 const argv = buildParser().parseSync('--rate-limit --no-web');163 await handler(argv as Parameters<typeof handler>[0]);164 }165 166 async function startServeHandler() {167 const handler = serveCommand.handler;168 if (!handler) throw new Error('serve handler missing');169 const argv = buildParser().parseSync('--rate-limit --no-web');170 void handler(argv as Parameters<typeof handler>[0]);171 await vi.waitFor(() => {172 expect(mockRunQwenServe).toHaveBeenCalled();173 });174 }175 176 async function startServeHandlerWithArgs(args: string) {177 const handler = serveCommand.handler;178 if (!handler) throw new Error('serve handler missing');179 const argv = buildParser().parseSync(args);180 void handler(argv as Parameters<typeof handler>[0]);181 await vi.waitFor(() => {182 expect(mockRunQwenServe).toHaveBeenCalled();183 });184 }185 186 it.each([187 ['QWEN_SERVE_RATE_LIMIT_PROMPT', '0x10'],188 ['QWEN_SERVE_RATE_LIMIT_MUTATION', '1e3'],189 ['QWEN_SERVE_RATE_LIMIT_READ', '2.5'],190 ['QWEN_SERVE_RATE_LIMIT_WINDOW_MS', '0x3e8'],191 ])('rejects non-decimal %s=%s', async (key, value) => {192 process.env[key] = value;193 vi.spyOn(process, 'exit').mockImplementation((code) => {194 throw new Error(`process.exit(${code}) called`);195 });196 197 await expect(invokeServeHandler()).rejects.toThrow(198 'process.exit(1) called',199 );200 expect(mockRunQwenServe).not.toHaveBeenCalled();201 });202 203 it('passes decimal env values to runQwenServe', async () => {204 process.env['QWEN_SERVE_RATE_LIMIT_PROMPT'] = '11';205 process.env['QWEN_SERVE_RATE_LIMIT_MUTATION'] = ' 31 ';206 process.env['QWEN_SERVE_RATE_LIMIT_READ'] = '121';207 process.env['QWEN_SERVE_RATE_LIMIT_WINDOW_MS'] = '60000';208 mockRunQwenServe.mockResolvedValueOnce({209 url: 'http://127.0.0.1:4170/',210 webShellMounted: false,211 });212 213 await startServeHandler();214 215 expect(mockRunQwenServe).toHaveBeenCalledWith(216 expect.objectContaining({217 rateLimit: true,218 rateLimitPrompt: 11,219 rateLimitMutation: 31,220 rateLimitRead: 121,221 rateLimitWindowMs: 60000,222 }),223 );224 });225 226 it('passes normalized named channels to runQwenServe', async () => {227 mockRunQwenServe.mockResolvedValueOnce({228 url: 'http://127.0.0.1:4170/',229 webShellMounted: false,230 });231 232 await startServeHandlerWithArgs(233 '--no-web --channel telegram --channel telegram --channel feishu',234 );235 236 expect(mockRunQwenServe).toHaveBeenCalledWith(237 expect.objectContaining({238 channelSelection: { mode: 'names', names: ['telegram', 'feishu'] },239 }),240 );241 });242 243 it('passes --max-total-sessions to runQwenServe', async () => {244 mockRunQwenServe.mockResolvedValueOnce({245 url: 'http://127.0.0.1:4170/',246 webShellMounted: false,247 });248 249 await startServeHandlerWithArgs('--no-web --max-total-sessions 42');250 251 expect(mockRunQwenServe).toHaveBeenCalledWith(252 expect.objectContaining({ maxTotalSessions: 42 }),253 );254 });255 256 it('passes --channel all as an all-channel selection', async () => {257 mockRunQwenServe.mockResolvedValueOnce({258 url: 'http://127.0.0.1:4170/',259 webShellMounted: false,260 });261 262 await startServeHandlerWithArgs('--no-web --channel all');263 264 expect(mockRunQwenServe).toHaveBeenCalledWith(265 expect.objectContaining({266 channelSelection: { mode: 'all' },267 }),268 );269 });270 271 it('rejects --channel all mixed with concrete channels', async () => {272 vi.spyOn(process, 'exit').mockImplementation((code) => {273 throw new Error(`process.exit(${code}) called`);274 });275 276 const handler = serveCommand.handler;277 if (!handler) throw new Error('serve handler missing');278 const argv = buildParser().parseSync(279 '--no-web --channel all --channel telegram',280 );281 282 await expect(283 handler(argv as Parameters<typeof handler>[0]),284 ).rejects.toThrow('process.exit(1) called');285 expect(mockRunQwenServe).not.toHaveBeenCalled();286 });287});288 289describe('maybeOpenWebShellBrowser', () => {290 beforeEach(() => {291 vi.clearAllMocks();292 mockShouldLaunchBrowser.mockReturnValue(true);293 });294 afterEach(() => {295 vi.restoreAllMocks();296 });297 298 const firstOpenedUrl = () =>299 String(mockOpenBrowserSecurely.mock.calls[0]?.[0]);300 301 it('does nothing when --open is false', async () => {302 await maybeOpenWebShellBrowser(303 { url: 'http://127.0.0.1:4170/', webShellMounted: true },304 false,305 );306 expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();307 });308 309 it('does nothing when the Web Shell is not mounted', async () => {310 await maybeOpenWebShellBrowser(311 { url: 'http://127.0.0.1:4170/', webShellMounted: false },312 true,313 );314 expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();315 });316 317 it('does nothing when shouldLaunchBrowser() is false', async () => {318 mockShouldLaunchBrowser.mockReturnValue(false);319 await maybeOpenWebShellBrowser(320 { url: 'http://127.0.0.1:4170/', webShellMounted: true },321 true,322 );323 expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();324 });325 326 it('rewrites a wildcard bind host to loopback', async () => {327 await maybeOpenWebShellBrowser(328 { url: 'http://0.0.0.0:4170/', webShellMounted: true },329 true,330 );331 expect(firstOpenedUrl()).toContain('127.0.0.1');332 expect(firstOpenedUrl()).not.toContain('0.0.0.0');333 });334 335 it('puts the token in the URL fragment, not the query', async () => {336 await maybeOpenWebShellBrowser(337 {338 url: 'http://127.0.0.1:4170/',339 webShellMounted: true,340 resolvedToken: 'secret',341 },342 true,343 );344 expect(firstOpenedUrl()).toContain('#token=secret');345 expect(firstOpenedUrl()).not.toContain('?token=');346 });347 348 it('skips --open when the runtime failed to mount', async () => {349 const stderrWrites: string[] = [];350 vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {351 stderrWrites.push(String(chunk));352 return true;353 });354 355 await maybeOpenWebShellBrowser(356 {357 url: 'http://127.0.0.1:4170/',358 webShellMounted: true,359 runtimeReady: Promise.reject(new Error('runtime boom')),360 },361 true,362 );363 364 expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();365 expect(stderrWrites.join('')).toContain(366 'qwen serve: Web Shell runtime not ready; skipping --open: runtime boom',367 );368 });369 370 it('swallows openBrowserSecurely failures (never throws)', async () => {371 mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('boom'));372 await expect(373 maybeOpenWebShellBrowser(374 { url: 'http://127.0.0.1:4170/', webShellMounted: true },375 true,376 ),377 ).resolves.toBeUndefined();378 });379});380 381describe('serve startup import boundary', () => {382 it('reaches listening through the dev entrypoint without loading interactive Ink internals first', async () => {383 const workspace = fs.realpathSync(384 fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-')),385 );386 const qwenHome = fs.realpathSync(387 fs.mkdtempSync(path.join(os.tmpdir(), 'qws-import-boundary-home-')),388 );389 const root = path.resolve(process.cwd(), '../..');390 const childEnv: NodeJS.ProcessEnv = {391 ...process.env,392 QWEN_CODE_NO_RELAUNCH: '1',393 QWEN_CODE_SUPPRESS_YOLO_WARNING: '1',394 QWEN_HOME: qwenHome,395 QWEN_RUNTIME_DIR: workspace,396 QWEN_SERVE_RATE_LIMIT: '0',397 };398 delete childEnv['VITEST_WORKER_ID'];399 const child = spawn(400 process.execPath,401 [402 path.join(root, 'scripts/dev.js'),403 'serve',404 '--port',405 '0',406 '--hostname',407 '127.0.0.1',408 '--workspace',409 workspace,410 '--no-web',411 '--no-open',412 '--rate-limit-prompt',413 '0',414 '--rate-limit-window-ms',415 '1',416 ],417 {418 cwd: root,419 detached: process.platform !== 'win32',420 env: childEnv,421 stdio: ['ignore', 'pipe', 'pipe'],422 },423 );424 425 let stdout = '';426 let stderr = '';427 let childExited = false;428 const exited = new Promise<void>((resolve) => {429 child.once('exit', () => {430 childExited = true;431 resolve();432 });433 });434 const waitForExit = (ms: number) =>435 Promise.race([436 exited,437 new Promise<'timeout'>((resolve) => setTimeout(resolve, ms, 'timeout')),438 ]);439 const cleanup = async () => {440 if (child.pid === undefined) return;441 const childPid = child.pid;442 const signalProcessTree = (signal: NodeJS.Signals) => {443 if (process.platform === 'win32') {444 spawnSync('taskkill', ['/pid', String(childPid), '/T', '/F']);445 return;446 }447 process.kill(-childPid, signal);448 };449 try {450 signalProcessTree('SIGTERM');451 } catch {452 // Process may have already exited.453 }454 if (!childExited) {455 await waitForExit(2_000);456 }457 if (process.platform !== 'win32') {458 try {459 signalProcessTree('SIGKILL');460 } catch {461 // Process may have already exited.462 }463 if (!childExited) {464 await waitForExit(2_000);465 }466 }467 };468 const removeTempDir = async (dir: string) => {469 for (let attempt = 0; attempt < 5; attempt++) {470 try {471 fs.rmSync(dir, { recursive: true, force: true });472 return;473 } catch (err) {474 if (attempt === 4) throw err;475 await new Promise((resolve) => setTimeout(resolve, 100));476 }477 }478 };479 const processGroupHasMembers = (pgid: number): boolean => {480 if (process.platform === 'win32') return false;481 const result = spawnSync('ps', ['-o', 'pid=', '-g', String(pgid)], {482 encoding: 'utf8',483 });484 if (result.status !== 0) return false;485 return result.stdout486 .split(/\s+/)487 .some((pid) => pid.length > 0 && Number(pid) > 0);488 };489 const waitForProcessGroupExit = async (pgid: number) => {490 if (process.platform === 'win32') return;491 for (let attempt = 0; attempt < 20; attempt++) {492 if (!processGroupHasMembers(pgid)) return;493 await new Promise((resolve) => setTimeout(resolve, 100));494 }495 throw new Error(`serve process group ${pgid} did not exit`);496 };497 498 try {499 const reachedListening = await new Promise<boolean>((resolve, reject) => {500 const timeout = setTimeout(() => {501 void cleanup();502 reject(503 new Error(504 `serve did not reach listening\nstdout:\n${stdout}\nstderr:\n${stderr}`,505 ),506 );507 }, 15_000);508 509 child.stdout.on('data', (chunk: Buffer) => {510 stdout += chunk.toString('utf8');511 if (stdout.includes('qwen serve listening on')) {512 clearTimeout(timeout);513 void cleanup();514 resolve(true);515 }516 });517 child.stderr.on('data', (chunk: Buffer) => {518 stderr += chunk.toString('utf8');519 if (520 stderr.includes('ERR_PACKAGE_PATH_NOT_EXPORTED') ||521 stderr.includes('ink/dom') ||522 stderr.includes('ink/components/CursorContext')523 ) {524 clearTimeout(timeout);525 void cleanup();526 reject(new Error(stderr));527 }528 });529 child.on('error', (err) => {530 clearTimeout(timeout);531 reject(err);532 });533 child.on('exit', (code, signal) => {534 if (stdout.includes('qwen serve listening on')) return;535 clearTimeout(timeout);536 reject(537 new Error(538 `serve exited before listening: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`,539 ),540 );541 });542 });543 544 expect(reachedListening).toBe(true);545 } finally {546 await cleanup();547 if (child.pid !== undefined) {548 await waitForProcessGroupExit(child.pid);549 }550 await removeTempDir(workspace);551 await removeTempDir(qwenHome);552 }553 }, 20_000);554});555 