From ac6aecb6228bfbe8a23f7a4e0be406e97876b0a8 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 25 Nov 2025 11:45:34 +0800 Subject: [PATCH] refactor: update test structure and clean up unused code in cli and sdk --- .../controllers/permissionController.ts | 2 +- .../cli/src/nonInteractive/session.test.ts | 2 +- packages/cli/src/nonInteractive/types.ts | 6 - packages/core/src/config/config.ts | 7 - packages/sdk-typescript/src/index.ts | 2 +- .../sdk-typescript/src/query/createQuery.ts | 21 - .../src/transport/ProcessTransport.ts | 44 - packages/sdk-typescript/src/types/types.ts | 2 - packages/sdk-typescript/src/utils/Stream.ts | 12 - packages/sdk-typescript/src/utils/cliPath.ts | 21 - .../sdk-typescript/src/utils/jsonLines.ts | 4 - .../test/unit/ProcessTransport.test.ts | 1394 ++++++++++++++-- .../sdk-typescript/test/unit/Query.test.ts | 1481 +++++++++++++++-- .../sdk-typescript/test/unit/cliPath.test.ts | 23 - 14 files changed, 2620 insertions(+), 401 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 08c6d41f..37a9082f 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -442,7 +442,7 @@ export class PermissionController extends BaseController { // On error, use default cancel message // Only pass payload for exec and mcp types that support it const confirmationType = toolCall.confirmationDetails.type; - if (confirmationType === 'exec' || confirmationType === 'mcp') { + if (['edit', 'exec', 'mcp'].includes(confirmationType)) { const execOrMcpDetails = toolCall.confirmationDetails as | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails; diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts index 15f15954..61643fb3 100644 --- a/packages/cli/src/nonInteractive/session.test.ts +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -134,7 +134,7 @@ function createControlCancel(requestId: string): ControlCancelRequest { }; } -describe('runNonInteractiveStreamJson (refactored)', () => { +describe('runNonInteractiveStreamJson', () => { let config: Config; let mockInputReader: { read: () => AsyncGenerator< diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 2eec24c1..fb8dcf76 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -299,12 +299,6 @@ export interface CLIControlPermissionRequest { blocked_path: string | null; } -export enum AuthProviderType { - DYNAMIC_DISCOVERY = 'dynamic_discovery', - GOOGLE_CREDENTIALS = 'google_credentials', - SERVICE_ACCOUNT_IMPERSONATION = 'service_account_impersonation', -} - export interface CLIControlInitializeRequest { subtype: 'initialize'; hooks?: HookRegistration[] | null; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 65c39d8e..be84655f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -855,13 +855,6 @@ export class Config { return this.mcpServers; } - setMcpServers(servers: Record): void { - if (this.initialized) { - throw new Error('Cannot modify mcpServers after initialization'); - } - this.mcpServers = servers; - } - addMcpServers(servers: Record): void { if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 4c549fcb..23ba3f93 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -1,5 +1,5 @@ export { query } from './query/createQuery.js'; - +export { AbortError, isAbortError } from './types/errors.js'; export { Query } from './query/Query.js'; export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js'; diff --git a/packages/sdk-typescript/src/query/createQuery.ts b/packages/sdk-typescript/src/query/createQuery.ts index 4b87478e..e3907635 100644 --- a/packages/sdk-typescript/src/query/createQuery.ts +++ b/packages/sdk-typescript/src/query/createQuery.ts @@ -21,28 +21,20 @@ export function query({ prompt: string | AsyncIterable; options?: QueryOptions; }): Query { - // Validate options and obtain normalized executable metadata const parsedExecutable = validateOptions(options); - // Determine if this is a single-turn or multi-turn query - // Single-turn: string prompt (simple Q&A) - // Multi-turn: AsyncIterable prompt (streaming conversation) const isSingleTurn = typeof prompt === 'string'; - // Resolve CLI specification while preserving explicit runtime directives const pathToQwenExecutable = options.pathToQwenExecutable ?? parsedExecutable.executablePath; - // Use provided abortController or create a new one const abortController = options.abortController ?? new AbortController(); - // Create transport with abortController const transport = new ProcessTransport({ pathToQwenExecutable, cwd: options.cwd, model: options.model, permissionMode: options.permissionMode, - mcpServers: options.mcpServers, env: options.env, abortController, debug: options.debug, @@ -53,18 +45,14 @@ export function query({ authType: options.authType, }); - // Build query options with abortController const queryOptions: QueryOptions = { ...options, abortController, }; - // Create Query const queryInstance = new Query(transport, queryOptions, isSingleTurn); - // Handle prompt based on type if (isSingleTurn) { - // For single-turn queries, send the prompt directly via transport const stringPrompt = prompt as string; const message: CLIUserMessage = { type: 'user', @@ -95,16 +83,9 @@ export function query({ return queryInstance; } -/** - * Backward compatibility alias - * @deprecated Use query() instead - */ -export const createQuery = query; - function validateOptions( options: QueryOptions, ): ReturnType { - // Validate options using Zod schema const validationResult = QueryOptionsSchema.safeParse(options); if (!validationResult.success) { const errors = validationResult.error.errors @@ -113,7 +94,6 @@ function validateOptions( throw new Error(`Invalid QueryOptions: ${errors}`); } - // Validate executable path early to provide clear error messages let parsedExecutable: ReturnType; try { parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable); @@ -122,7 +102,6 @@ function validateOptions( throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`); } - // Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod) if (options.mcpServers && options.sdkMcpServers) { const externalNames = Object.keys(options.mcpServers); const sdkNames = Object.keys(options.sdkMcpServers); diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 1c717f8c..62a6b2d0 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -7,11 +7,6 @@ import { parseJsonLinesStream } from '../utils/jsonLines.js'; import { prepareSpawnInfo } from '../utils/cliPath.js'; import { AbortError } from '../types/errors.js'; -type ExitListener = { - callback: (error?: Error) => void; - handler: (code: number | null, signal: NodeJS.Signals | null) => void; -}; - export class ProcessTransport implements Transport { private childProcess: ChildProcess | null = null; private childStdin: Writable | null = null; @@ -21,7 +16,6 @@ export class ProcessTransport implements Transport { private _exitError: Error | null = null; private closed = false; private abortController: AbortController; - private exitListeners: ExitListener[] = []; private processExitHandler: (() => void) | null = null; private abortHandler: (() => void) | null = null; @@ -115,15 +109,6 @@ export class ProcessTransport implements Transport { this.logForDebugging(error.message); } } - - const error = this._exitError; - for (const listener of this.exitListeners) { - try { - listener.callback(error || undefined); - } catch (err) { - this.logForDebugging(`Exit listener error: ${err}`); - } - } }); } @@ -192,11 +177,6 @@ export class ProcessTransport implements Transport { this.abortHandler = null; } - for (const { handler } of this.exitListeners) { - this.childProcess?.off('close', handler); - } - this.exitListeners = []; - if (this.childProcess && !this.childProcess.killed) { this.childProcess.kill('SIGTERM'); setTimeout(() => { @@ -343,30 +323,6 @@ export class ProcessTransport implements Transport { return this._exitError; } - onExit(callback: (error?: Error) => void): () => void { - if (!this.childProcess) { - return () => {}; - } - - const handler = (code: number | null, signal: NodeJS.Signals | null) => { - const error = this.getProcessExitError(code, signal); - callback(error); - }; - - this.childProcess.on('close', handler); - this.exitListeners.push({ callback, handler }); - - return () => { - if (this.childProcess) { - this.childProcess.off('close', handler); - } - const index = this.exitListeners.findIndex((l) => l.handler === handler); - if (index !== -1) { - this.exitListeners.splice(index, 1); - } - }; - } - endInput(): void { if (this.childStdin) { this.childStdin.end(); diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index d2b9a400..856099fc 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -1,5 +1,4 @@ import type { PermissionMode, PermissionSuggestion } from './protocol.js'; -import type { ExternalMcpServerConfig } from './queryOptionsSchema.js'; export type { PermissionMode }; @@ -23,7 +22,6 @@ export type TransportOptions = { cwd?: string; model?: string; permissionMode?: PermissionMode; - mcpServers?: Record; env?: Record; abortController?: AbortController; debug?: boolean; diff --git a/packages/sdk-typescript/src/utils/Stream.ts b/packages/sdk-typescript/src/utils/Stream.ts index 8a58c0be..70caf82e 100644 --- a/packages/sdk-typescript/src/utils/Stream.ts +++ b/packages/sdk-typescript/src/utils/Stream.ts @@ -1,7 +1,3 @@ -/** - * Async iterable queue for streaming messages between producer and consumer. - */ - export class Stream implements AsyncIterable { private returned: (() => void) | undefined; private queue: T[] = []; @@ -24,23 +20,18 @@ export class Stream implements AsyncIterable { } async next(): Promise> { - // Check queue first - if there are queued items, return immediately if (this.queue.length > 0) { return Promise.resolve({ done: false, value: this.queue.shift()!, }); } - // Check if stream is done if (this.isDone) { return Promise.resolve({ done: true, value: undefined }); } - // Check for errors that occurred before next() was called - // This ensures errors set via error() before iteration starts are properly rejected if (this.hasError) { return Promise.reject(this.hasError); } - // No queued items, not done, no error - set up promise for next value/error return new Promise>((resolve, reject) => { this.readResolve = resolve; this.readReject = reject; @@ -70,15 +61,12 @@ export class Stream implements AsyncIterable { error(error: Error): void { this.hasError = error; - // If readReject exists (next() has been called), reject immediately if (this.readReject) { const reject = this.readReject; this.readResolve = undefined; this.readReject = undefined; reject(error); } - // Otherwise, error is stored in hasError and will be rejected when next() is called - // This handles the case where error() is called before the first next() call } return(): Promise> { diff --git a/packages/sdk-typescript/src/utils/cliPath.ts b/packages/sdk-typescript/src/utils/cliPath.ts index b6101ab3..2d919413 100644 --- a/packages/sdk-typescript/src/utils/cliPath.ts +++ b/packages/sdk-typescript/src/utils/cliPath.ts @@ -154,7 +154,6 @@ export function parseExecutableSpec(executableSpec?: string): { executablePath: string; isExplicitRuntime: boolean; } { - // Handle empty string case first (before checking for undefined/null) if ( executableSpec === '' || (executableSpec && executableSpec.trim() === '') @@ -163,7 +162,6 @@ export function parseExecutableSpec(executableSpec?: string): { } if (!executableSpec) { - // Auto-detect native CLI return { executablePath: findNativeCliPath(), isExplicitRuntime: false, @@ -178,7 +176,6 @@ export function parseExecutableSpec(executableSpec?: string): { throw new Error(`Invalid runtime specification: '${executableSpec}'`); } - // Validate runtime is supported const supportedRuntimes = ['node', 'bun', 'tsx', 'deno']; if (!supportedRuntimes.includes(runtime)) { throw new Error( @@ -186,7 +183,6 @@ export function parseExecutableSpec(executableSpec?: string): { ); } - // Validate runtime availability if (!validateRuntimeAvailability(runtime)) { throw new Error( `Runtime '${runtime}' is not available on this system. Please install it first.`, @@ -195,7 +191,6 @@ export function parseExecutableSpec(executableSpec?: string): { const resolvedPath = path.resolve(filePath); - // Validate file exists if (!fs.existsSync(resolvedPath)) { throw new Error( `Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` + @@ -203,7 +198,6 @@ export function parseExecutableSpec(executableSpec?: string): { ); } - // Validate file extension matches runtime if (!validateFileExtensionForRuntime(resolvedPath, runtime)) { const ext = path.extname(resolvedPath); throw new Error( @@ -285,14 +279,6 @@ function getExpectedExtensions(runtime: string): string[] { } } -/** - * @deprecated Use parseExecutableSpec and prepareSpawnInfo instead - */ -export function resolveCliPath(explicitPath?: string): string { - const parsed = parseExecutableSpec(explicitPath); - return parsed.executablePath; -} - function detectRuntimeFromExtension(filePath: string): string | undefined { const ext = path.extname(filePath).toLowerCase(); @@ -356,10 +342,3 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo { originalInput: executableSpec || '', }; } - -/** - * @deprecated Use prepareSpawnInfo() instead - */ -export function findCliPath(): string { - return findNativeCliPath(); -} diff --git a/packages/sdk-typescript/src/utils/jsonLines.ts b/packages/sdk-typescript/src/utils/jsonLines.ts index e534bf70..6d1bd090 100644 --- a/packages/sdk-typescript/src/utils/jsonLines.ts +++ b/packages/sdk-typescript/src/utils/jsonLines.ts @@ -38,20 +38,16 @@ export async function* parseJsonLinesStream( context = 'JsonLines', ): AsyncGenerator { for await (const line of lines) { - // Skip empty lines if (line.trim().length === 0) { continue; } - // Parse with error handling const message = parseJsonLineSafe(line, context); - // Skip malformed messages if (message === null) { continue; } - // Validate message structure if (!isValidMessage(message)) { console.warn( `[${context}] Invalid message structure (missing 'type' field), skipping:`, diff --git a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts index 5e1a9d15..0854a02d 100644 --- a/packages/sdk-typescript/test/unit/ProcessTransport.test.ts +++ b/packages/sdk-typescript/test/unit/ProcessTransport.test.ts @@ -3,205 +3,1379 @@ * Tests subprocess lifecycle management and IPC */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { ProcessTransport } from '../../src/transport/ProcessTransport.js'; +import { AbortError } from '../../src/types/errors.js'; +import type { TransportOptions } from '../../src/types/types.js'; +import { Readable, Writable } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import * as childProcess from 'node:child_process'; +import * as cliPath from '../../src/utils/cliPath.js'; +import * as jsonLines from '../../src/utils/jsonLines.js'; -// Note: This is a placeholder test file -// ProcessTransport will be implemented in Phase 3 Implementation (T021) -// These tests are written first following TDD approach +// Mock modules +vi.mock('node:child_process'); +vi.mock('../../src/utils/cliPath.js'); +vi.mock('../../src/utils/jsonLines.js'); + +const mockSpawn = vi.mocked(childProcess.spawn); +const mockPrepareSpawnInfo = vi.mocked(cliPath.prepareSpawnInfo); +const mockParseJsonLinesStream = vi.mocked(jsonLines.parseJsonLinesStream); + +// Helper function to create a mock child process with optional overrides +function createMockChildProcess( + overrides: Partial = {}, +): ChildProcess & EventEmitter { + const mockStdin = new Writable({ + write: vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }), + }); + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + const mockStdout = new Readable({ read: vi.fn() }); + const mockStderr = new Readable({ read: vi.fn() }); + + const baseProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + ...overrides, + }) as unknown as ChildProcess & EventEmitter; + + return baseProcess; +} describe('ProcessTransport', () => { + let mockChildProcess: ChildProcess & EventEmitter; + let mockStdin: Writable; + let mockStdout: Readable; + let mockStderr: Readable; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockWriteFn = vi.fn((chunk, encoding, callback) => { + if (typeof callback === 'function') callback(); + return true; + }); + + mockStdin = new Writable({ + write: mockWriteFn, + }); + // Override write with a spy so we can track calls + mockStdin.write = mockWriteFn as unknown as typeof mockStdin.write; + + mockStdout = new Readable({ read: vi.fn() }); + mockStderr = new Readable({ read: vi.fn() }); + + mockChildProcess = Object.assign(new EventEmitter(), { + stdin: mockStdin, + stdout: mockStdout, + stderr: mockStderr, + pid: 12345, + killed: false, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + send: vi.fn(), + disconnect: vi.fn(), + unref: vi.fn(), + ref: vi.fn(), + connected: false, + stdio: [mockStdin, mockStdout, mockStderr, null, null], + spawnargs: [], + spawnfile: 'qwen', + channel: null, + }) as unknown as ChildProcess & EventEmitter; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('Construction and Initialization', () => { it('should create transport with required options', () => { - // Test will be implemented with actual ProcessTransport class - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport).toBeDefined(); + expect(transport.isReady).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + ]), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); }); - it('should validate pathToQwenExecutable exists', () => { - // Should throw if pathToQwenExecutable does not exist - expect(true).toBe(true); // Placeholder + it('should build CLI arguments correctly with all options', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + model: 'qwen-max', + permissionMode: 'auto-edit', + maxSessionTurns: 10, + coreTools: ['read_file', 'write_file'], + excludeTools: ['web_search'], + authType: 'api-key', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--model', + 'qwen-max', + '--approval-mode', + 'auto-edit', + '--max-session-turns', + '10', + '--core-tools', + 'read_file,write_file', + '--exclude-tools', + 'web_search', + '--auth-type', + 'api-key', + ]), + expect.any(Object), + ); }); - it('should build CLI arguments correctly', () => { - // Should include --input-format stream-json --output-format stream-json - expect(true).toBe(true); // Placeholder + it('should throw if aborted before initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const abortController = new AbortController(); + abortController.abort(); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + expect(() => new ProcessTransport(options)).toThrow(AbortError); + expect(() => new ProcessTransport(options)).toThrow( + 'Transport start aborted', + ); + }); + + it('should use provided AbortController', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: abortController.signal, + }), + ); + }); + + it('should create default AbortController if not provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); }); }); describe('Lifecycle Management', () => { - it('should spawn subprocess during construction', async () => { - // Should call child_process.spawn in constructor - expect(true).toBe(true); // Placeholder + it('should spawn subprocess during construction', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledTimes(1); }); - it('should set isReady to true after successful initialization', async () => { - // isReady should be true after construction completes - expect(true).toBe(true); // Placeholder + it('should set isReady to true after successful initialization', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); }); - it('should throw if subprocess fails to spawn', async () => { - // Should throw Error if ENOENT or spawn fails - expect(true).toBe(true); // Placeholder + it('should set isReady to false on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Spawn failed')); + + expect(transport.isReady).toBe(false); + expect(transport.exitError).toBeDefined(); }); it('should close subprocess gracefully with SIGTERM', async () => { - // Should send SIGTERM first - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should force kill with SIGKILL after timeout', async () => { - // Should send SIGKILL after 5s if process doesn\'t exit - expect(true).toBe(true); // Placeholder + vi.useFakeTimers(); + + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + vi.advanceTimersByTime(5000); + + expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGKILL'); + + vi.useRealTimers(); }); it('should be idempotent when calling close() multiple times', async () => { - // Multiple close() calls should not error - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + await transport.close(); + await transport.close(); + + expect(mockChildProcess.kill).toHaveBeenCalledTimes(3); }); it('should wait for process exit in waitForExit()', async () => { - // Should resolve when process exits - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should reject waitForExit() on non-zero exit code', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', 1, null); + + await expect(waitPromise).rejects.toThrow( + 'CLI process exited with code 1', + ); + }); + + it('should reject waitForExit() on signal termination', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + mockChildProcess.emit('close', null, 'SIGTERM'); + + await expect(waitPromise).rejects.toThrow( + 'CLI process terminated by signal SIGTERM', + ); + }); + + it('should reject waitForExit() with AbortError when aborted', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + const waitPromise = transport.waitForExit(); + + abortController.abort(); + mockChildProcess.emit('close', 0, null); + + await expect(waitPromise).rejects.toThrow(AbortError); }); }); describe('Message Reading', () => { it('should read JSON Lines from stdout', async () => { - // Should use readline to read lines and parse JSON - expect(true).toBe(true); // Placeholder - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - it('should yield parsed messages via readMessages()', async () => { - // Should yield messages as async generator - expect(true).toBe(true); // Placeholder - }); + const mockMessages = [ + { type: 'message', content: 'test1' }, + { type: 'message', content: 'test2' }, + ]; - it('should skip malformed JSON lines with warning', async () => { - // Should log warning and continue on parse error - expect(true).toBe(true); // Placeholder - }); + mockParseJsonLinesStream.mockImplementation(async function* () { + for (const msg of mockMessages) { + yield msg; + } + }); - it('should complete generator when process exits', async () => { - // readMessages() should complete when stdout closes - expect(true).toBe(true); // Placeholder - }); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; - it('should set exitError on unexpected process crash', async () => { - // exitError should be set if process crashes - expect(true).toBe(true); // Placeholder + const transport = new ProcessTransport(options); + + const messages: unknown[] = []; + const readPromise = (async () => { + for await (const message of transport.readMessages()) { + messages.push(message); + } + })(); + + // Give time for the async generator to start and yield messages + await new Promise((resolve) => setTimeout(resolve, 10)); + + mockChildProcess.emit('close', 0, null); + + await readPromise; + + expect(messages).toEqual(mockMessages); + }, 5000); // Set a reasonable timeout + + it('should throw if reading from transport without stdout', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const generator = transport.readMessages(); + + await expect(generator.next()).rejects.toThrow( + 'Cannot read messages: process not started', + ); }); }); describe('Message Writing', () => { - it('should write JSON Lines to stdin', () => { - // Should write JSON + newline to stdin - expect(true).toBe(true); // Placeholder + it('should write message to stdin', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const message = '{"type":"test","data":"hello"}\n'; + transport.write(message); + + expect(mockStdin.write).toHaveBeenCalledWith(message); }); it('should throw if writing before transport is ready', () => { - // write() should throw if isReady is false - expect(true).toBe(true); // Placeholder + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('error', new Error('Process error')); + + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); - it('should throw if writing to closed transport', () => { - // write() should throw if transport is closed - expect(true).toBe(true); // Placeholder + it('should throw if writing to closed transport', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + // After close(), isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); + }); + + it('should throw if writing when aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + + expect(() => transport.write('test')).toThrow(AbortError); + expect(() => transport.write('test')).toThrow( + 'Cannot write: operation aborted', + ); + }); + + it('should throw if writing to ended stream', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockStdin.end(); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to ended stream', + ); + }); + + it('should throw if writing to terminated process', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const terminatedProcess = createMockChildProcess({ exitCode: 1 }); + mockSpawn.mockReturnValue(terminatedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.write('test')).toThrow( + 'Cannot write to terminated process', + ); + }); + + it('should throw if process has exit error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + // After process closes with error, isReady is false, so we get "Transport not ready" error first + expect(() => transport.write('test')).toThrow( + 'Transport not ready for writing', + ); }); }); describe('Error Handling', () => { - it('should handle process spawn errors', async () => { - // Should throw descriptive error on spawn failure - expect(true).toBe(true); // Placeholder + it('should set exitError on process error', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const error = new Error('Process error'); + mockChildProcess.emit('error', error); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toContain('CLI process error'); }); - it('should handle process exit with non-zero code', async () => { - // Should set exitError when process exits with error - expect(true).toBe(true); // Placeholder + it('should set exitError on process close with non-zero code', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 1, null); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process exited with code 1', + ); }); - it('should handle write errors to closed stdin', () => { - // Should throw if stdin is closed - expect(true).toBe(true); // Placeholder + it('should set exitError on process close with signal', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', null, 'SIGKILL'); + + expect(transport.exitError).toBeDefined(); + expect(transport.exitError?.message).toBe( + 'CLI process terminated by signal SIGKILL', + ); + }); + + it('should set AbortError when process aborted', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + abortController.abort(); + mockChildProcess.emit('error', new Error('Aborted')); + + expect(transport.exitError).toBeInstanceOf(AbortError); + expect(transport.exitError?.message).toBe('CLI process aborted by user'); + }); + + it('should not set exitError on clean exit', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + mockChildProcess.emit('close', 0, null); + + expect(transport.exitError).toBeNull(); }); }); describe('Resource Cleanup', () => { it('should register cleanup on parent process exit', () => { - // Should register process.on(\'exit\') handler - expect(true).toBe(true); // Placeholder - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - it('should kill subprocess on parent exit', () => { - // Cleanup should kill child process - expect(true).toBe(true); // Placeholder + const processOnSpy = vi.spyOn(process, 'on'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOnSpy.mockRestore(); }); it('should remove event listeners on close', async () => { - // Should clean up all event listeners - expect(true).toBe(true); // Placeholder - }); - }); + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - describe('CLI Arguments', () => { - it('should include --input-format stream-json', () => { - // Args should always include input format flag - expect(true).toBe(true); // Placeholder + const processOffSpy = vi.spyOn(process, 'off'); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(processOffSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + + processOffSpy.mockRestore(); }); - it('should include --output-format stream-json', () => { - // Args should always include output format flag - expect(true).toBe(true); // Placeholder + it('should register abort listener', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const addEventListenerSpy = vi.spyOn( + abortController.signal, + 'addEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + new ProcessTransport(options); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); }); - it('should include --model if provided', () => { - // Args should include model flag if specified - expect(true).toBe(true); // Placeholder + it('should remove abort listener on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const abortController = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + abortController.signal, + 'removeEventListener', + ); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + abortController, + }; + + const transport = new ProcessTransport(options); + + await transport.close(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); }); - it('should include --permission-mode if provided', () => { - // Args should include permission mode flag if specified - expect(true).toBe(true); // Placeholder - }); + it('should end stdin on close', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); - it('should include --mcp-server for external MCP servers', () => { - // Args should include MCP server configs - expect(true).toBe(true); // Placeholder + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + await transport.close(); + + expect(endSpy).toHaveBeenCalled(); }); }); describe('Working Directory', () => { - it('should spawn process in specified cwd', async () => { - // Should use cwd option for child_process.spawn - expect(true).toBe(true); // Placeholder + it('should spawn process in specified cwd', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + cwd: '/custom/path', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: '/custom/path', + }), + ); }); - it('should default to process.cwd() if not specified', async () => { - // Should use current working directory by default - expect(true).toBe(true); // Placeholder + it('should default to process.cwd() if not specified', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + cwd: process.cwd(), + }), + ); }); }); describe('Environment Variables', () => { - it('should pass environment variables to subprocess', async () => { - // Should merge env with process.env - expect(true).toBe(true); // Placeholder + it('should pass environment variables to subprocess', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CUSTOM_VAR: 'custom_value', + }), + }), + ); }); - it('should inherit parent env by default', async () => { - // Should use process.env if no env option - expect(true).toBe(true); // Placeholder + it('should inherit parent env by default', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining(process.env), + }), + ); + }); + + it('should merge custom env with parent env', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + env: { + CUSTOM_VAR: 'custom_value', + }, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + ...process.env, + CUSTOM_VAR: 'custom_value', + }), + }), + ); }); }); - describe('Debug Mode', () => { - it('should inherit stderr when debug is true', async () => { - // Should set stderr: \'inherit\' if debug flag set - expect(true).toBe(true); // Placeholder + describe('Debug and Stderr Handling', () => { + it('should pipe stderr when debug is true', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: true, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); }); - it('should ignore stderr when debug is false', async () => { - // Should set stderr: \'ignore\' if debug flag not set - expect(true).toBe(true); // Placeholder + it('should pipe stderr when stderr callback is provided', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }), + ); + }); + + it('should ignore stderr when debug is false and no callback', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + debug: false, + }; + + new ProcessTransport(options); + + expect(mockSpawn).toHaveBeenCalledWith( + 'qwen', + expect.any(Array), + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + }); + + it('should call stderr callback when data is received', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const stderrCallback = vi.fn(); + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + stderr: stderrCallback, + }; + + new ProcessTransport(options); + + mockStderr.emit('data', Buffer.from('error message')); + + expect(stderrCallback).toHaveBeenCalledWith('error message'); + }); + }); + + describe('Stream Access', () => { + it('should provide access to stdin via getInputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBe(mockStdin); + }); + + it('should provide access to stdout via getOutputStream()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBe(mockStdout); + }); + + it('should allow ending input via endInput()', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + mockSpawn.mockReturnValue(mockChildProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + const endSpy = vi.spyOn(mockStdin, 'end'); + + transport.endInput(); + + expect(endSpy).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle process that exits immediately', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const immediateExitProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(immediateExitProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.isReady).toBe(true); + }); + + it('should handle waitForExit() when process already exited', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const exitedProcess = createMockChildProcess({ exitCode: 0 }); + mockSpawn.mockReturnValue(exitedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.waitForExit()).resolves.toBeUndefined(); + }); + + it('should handle close() when process is already killed', async () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const killedProcess = createMockChildProcess({ killed: true }); + mockSpawn.mockReturnValue(killedProcess); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + await expect(transport.close()).resolves.toBeUndefined(); + }); + + it('should handle endInput() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(() => transport.endInput()).not.toThrow(); + }); + + it('should return undefined for getInputStream() when stdin is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdin = createMockChildProcess({ stdin: null }); + mockSpawn.mockReturnValue(processWithoutStdin); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getInputStream()).toBeUndefined(); + }); + + it('should return undefined for getOutputStream() when stdout is null', () => { + mockPrepareSpawnInfo.mockReturnValue({ + command: 'qwen', + args: [], + type: 'native', + originalInput: 'qwen', + }); + + const processWithoutStdout = createMockChildProcess({ stdout: null }); + mockSpawn.mockReturnValue(processWithoutStdout); + + const options: TransportOptions = { + pathToQwenExecutable: 'qwen', + }; + + const transport = new ProcessTransport(options); + + expect(transport.getOutputStream()).toBeUndefined(); }); }); }); diff --git a/packages/sdk-typescript/test/unit/Query.test.ts b/packages/sdk-typescript/test/unit/Query.test.ts index 5ceeee4b..9b8e34c2 100644 --- a/packages/sdk-typescript/test/unit/Query.test.ts +++ b/packages/sdk-typescript/test/unit/Query.test.ts @@ -3,282 +3,1467 @@ * Tests message routing, lifecycle, and orchestration */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Query } from '../../src/query/Query.js'; +import type { Transport } from '../../src/transport/Transport.js'; +import type { + CLIMessage, + CLIUserMessage, + CLIAssistantMessage, + CLISystemMessage, + CLIResultMessage, + CLIPartialAssistantMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from '../../src/types/protocol.js'; +import { ControlRequestType } from '../../src/types/protocol.js'; +import { AbortError } from '../../src/types/errors.js'; +import { Stream } from '../../src/utils/Stream.js'; -// Note: This is a placeholder test file -// Query will be implemented in Phase 3 Implementation (T022) -// These tests are written first following TDD approach +// Mock Transport implementation +class MockTransport implements Transport { + private messageStream = new Stream(); + public writtenMessages: string[] = []; + public closed = false; + public endInputCalled = false; + public isReady = true; + public exitError: Error | null = null; + + write(data: string): void { + this.writtenMessages.push(data); + } + + async *readMessages(): AsyncGenerator { + for await (const message of this.messageStream) { + yield message; + } + } + + async close(): Promise { + this.closed = true; + this.messageStream.done(); + } + + async waitForExit(): Promise { + // Mock implementation - do nothing + } + + endInput(): void { + this.endInputCalled = true; + } + + // Test helper methods + simulateMessage(message: unknown): void { + this.messageStream.enqueue(message); + } + + simulateError(error: Error): void { + this.messageStream.error(error); + } + + simulateClose(): void { + this.messageStream.done(); + } + + getLastWrittenMessage(): unknown { + if (this.writtenMessages.length === 0) return null; + return JSON.parse(this.writtenMessages[this.writtenMessages.length - 1]); + } + + getAllWrittenMessages(): unknown[] { + return this.writtenMessages.map((msg) => JSON.parse(msg)); + } +} + +// Helper function to find control response by request_id +function findControlResponse( + messages: unknown[], + requestId: string, +): CLIControlResponse | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_response' && + 'response' in msg && + typeof msg.response === 'object' && + msg.response !== null && + 'request_id' in msg.response && + msg.response.request_id === requestId, + ) as CLIControlResponse | undefined; +} + +// Helper function to find control request by subtype +function findControlRequest( + messages: unknown[], + subtype: string, +): CLIControlRequest | undefined { + return messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'control_request' && + 'request' in msg && + typeof msg.request === 'object' && + msg.request !== null && + 'subtype' in msg.request && + msg.request.subtype === subtype, + ) as CLIControlRequest | undefined; +} + +// Helper function to create test messages +function createUserMessage( + content: string, + sessionId = 'test-session', +): CLIUserMessage { + return { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + }; +} + +function createAssistantMessage( + content: string, + sessionId = 'test-session', +): CLIAssistantMessage { + return { + type: 'assistant', + uuid: 'msg-123', + session_id: sessionId, + message: { + id: 'msg-123', + type: 'message', + role: 'assistant', + model: 'test-model', + content: [{ type: 'text', text: content }], + usage: { input_tokens: 10, output_tokens: 20 }, + }, + parent_tool_use_id: null, + }; +} + +function createSystemMessage( + subtype: string, + sessionId = 'test-session', +): CLISystemMessage { + return { + type: 'system', + subtype, + uuid: 'sys-123', + session_id: sessionId, + cwd: '/test/path', + tools: ['read_file', 'write_file'], + model: 'test-model', + }; +} + +function createResultMessage( + success: boolean, + sessionId = 'test-session', +): CLIResultMessage { + if (success) { + return { + type: 'result', + subtype: 'success', + uuid: 'result-123', + session_id: sessionId, + is_error: false, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + result: 'Success', + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + }; + } else { + return { + type: 'result', + subtype: 'error_during_execution', + uuid: 'result-123', + session_id: sessionId, + is_error: true, + duration_ms: 1000, + duration_api_ms: 800, + num_turns: 1, + usage: { input_tokens: 10, output_tokens: 20 }, + permission_denials: [], + error: { message: 'Test error' }, + }; + } +} + +function createPartialMessage( + sessionId = 'test-session', +): CLIPartialAssistantMessage { + return { + type: 'stream_event', + uuid: 'stream-123', + session_id: sessionId, + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }, + parent_tool_use_id: null, + }; +} + +function createControlRequest( + subtype: string, + requestId = 'req-123', +): CLIControlRequest { + return { + type: 'control_request', + request_id: requestId, + request: { + subtype, + tool_name: 'test_tool', + input: { arg: 'value' }, + permission_suggestions: null, + blocked_path: null, + } as CLIControlRequest['request'], + }; +} + +function createControlResponse( + requestId: string, + success: boolean, + data?: unknown, +): CLIControlResponse { + return { + type: 'control_response', + response: success + ? { + subtype: 'success', + request_id: requestId, + response: data ?? null, + } + : { + subtype: 'error', + request_id: requestId, + error: 'Test error', + }, + }; +} + +function createControlCancel(requestId: string): ControlCancelRequest { + return { + type: 'control_cancel_request', + request_id: requestId, + }; +} describe('Query', () => { + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (!transport.closed) { + await transport.close(); + } + }); + describe('Construction and Initialization', () => { - it('should create Query with transport and options', () => { - // Should accept Transport and CreateQueryOptions - expect(true).toBe(true); // Placeholder + it('should create Query with transport and options', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + expect(query).toBeDefined(); + expect(query.getSessionId()).toBeTruthy(); + expect(query.isClosed()).toBe(false); + + // Should send initialize control request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + expect(initRequest.type).toBe('control_request'); + expect(initRequest.request.subtype).toBe('initialize'); + + await query.close(); }); - it('should generate unique session ID', () => { - // Each Query should have unique session_id - expect(true).toBe(true); // Placeholder + it('should generate unique session ID', async () => { + const transport2 = new MockTransport(); + const query1 = new Query(transport, { cwd: '/test' }); + const query2 = new Query(transport2, { + cwd: '/test', + }); + + expect(query1.getSessionId()).not.toBe(query2.getSessionId()); + + await query1.close(); + await query2.close(); + await transport2.close(); }); - it('should validate MCP server name conflicts', () => { - // Should throw if mcpServers and sdkMcpServers have same keys - expect(true).toBe(true); // Placeholder + it('should validate MCP server name conflicts', async () => { + const mockServer = { + connect: vi.fn(), + }; + + await expect(async () => { + const query = new Query(transport, { + cwd: '/test', + mcpServers: { server1: { command: 'test' } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkMcpServers: { server1: mockServer as any }, + }); + await query.initialized; + }).rejects.toThrow(/name conflicts/); }); - it('should lazy initialize on first message consumption', async () => { - // Should not call initialize() until messages are read - expect(true).toBe(true); // Placeholder + it('should initialize with SDK MCP servers', async () => { + const mockServer = { + connect: vi.fn().mockResolvedValue(undefined), + }; + + const query = new Query(transport, { + cwd: '/test', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sdkMcpServers: { testServer: mockServer as any }, + }); + + // Respond to initialize request + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + expect(mockServer.connect).toHaveBeenCalled(); + + await query.close(); + }); + + it('should handle initialization errors', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + // Simulate initialization failure + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, false), + ); + + await expect(query.initialized).rejects.toThrow(); + + await query.close(); }); }); describe('Message Routing', () => { - it('should route user messages to CLI', async () => { - // Initial prompt should be sent as user message - expect(true).toBe(true); // Placeholder + it('should route user messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const userMsg = createUserMessage('Hello'); + transport.simulateMessage(userMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(userMsg); + + await query.close(); }); it('should route assistant messages to output stream', async () => { - // Assistant messages from CLI should be yielded to user - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const assistantMsg = createAssistantMessage('Response'); + transport.simulateMessage(assistantMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(assistantMsg); + + await query.close(); }); - it('should route tool_use messages to output stream', async () => { - // Tool use messages should be yielded to user - expect(true).toBe(true); // Placeholder - }); + it('should route system messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); - it('should route tool_result messages to output stream', async () => { - // Tool result messages should be yielded to user - expect(true).toBe(true); // Placeholder + const systemMsg = createSystemMessage('session_start'); + transport.simulateMessage(systemMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(systemMsg); + + await query.close(); }); it('should route result messages to output stream', async () => { - // Result messages should be yielded to user - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(resultMsg); + + await query.close(); }); - it('should filter keep_alive messages from output', async () => { - // Keep alive messages should not be yielded to user - expect(true).toBe(true); // Placeholder + it('should route partial assistant messages to output stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const partialMsg = createPartialMessage(); + transport.simulateMessage(partialMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(partialMsg); + + await query.close(); + }); + + it('should handle unknown message types', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const unknownMsg = { type: 'unknown', data: 'test' }; + transport.simulateMessage(unknownMsg); + + const result = await query.next(); + expect(result.done).toBe(false); + expect(result.value).toEqual(unknownMsg); + + await query.close(); + }); + + it('should yield messages in order', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const msg1 = createUserMessage('First'); + const msg2 = createAssistantMessage('Second'); + const msg3 = createResultMessage(true); + + transport.simulateMessage(msg1); + transport.simulateMessage(msg2); + transport.simulateMessage(msg3); + + const result1 = await query.next(); + expect(result1.value).toEqual(msg1); + + const result2 = await query.next(); + expect(result2.value).toEqual(msg2); + + const result3 = await query.next(); + expect(result3.value).toEqual(msg3); + + await query.close(); }); }); describe('Control Plane - Permission Control', () => { it('should handle can_use_tool control requests', async () => { - // Should invoke canUseTool callback - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockResolvedValue(true); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalledWith( + 'test_tool', + { arg: 'value' }, + expect.objectContaining({ + signal: expect.any(AbortSignal), + suggestions: null, + }), + ); + }); + + await query.close(); }); - it('should send control response with permission result', async () => { - // Should send response with allowed: true/false - expect(true).toBe(true); // Placeholder + it('should send control response with permission result - allow', async () => { + const canUseTool = vi.fn().mockResolvedValue(true); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-1'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-1'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + }); + } + }); + + await query.close(); }); - it('should default to allowing tools if no callback', async () => { - // If canUseTool not provided, should allow all - expect(true).toBe(true); // Placeholder + it('should send control response with permission result - deny', async () => { + const canUseTool = vi.fn().mockResolvedValue(false); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-2'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-2'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should default to denying tools if no callback', async () => { + const query = new Query(transport, { + cwd: '/test', + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-3'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-3'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); }); it('should handle permission callback timeout', async () => { - // Should deny permission if callback exceeds 30s - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(true), 35000); // Exceeds 30s timeout + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-4'); + transport.simulateMessage(controlReq); + + await vi.waitFor( + () => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-4'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }, + { timeout: 35000 }, + ); + + await query.close(); }); it('should handle permission callback errors', async () => { - // Should deny permission if callback throws - expect(true).toBe(true); // Placeholder + const canUseTool = vi.fn().mockRejectedValue(new Error('Callback error')); + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-5'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-5'); + + expect(response).toBeDefined(); + expect(response?.response.subtype).toBe('success'); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + }); + } + }); + + await query.close(); + }); + + it('should handle PermissionResult format with updatedInput', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-6'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-6'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'allow', + updatedInput: { arg: 'modified' }, + }); + } + }); + + await query.close(); + }); + + it('should handle permission denial with interrupt flag', async () => { + const canUseTool = vi.fn().mockResolvedValue({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'perm-req-7'); + transport.simulateMessage(controlReq); + + await vi.waitFor(() => { + const responses = transport.getAllWrittenMessages(); + const response = findControlResponse(responses, 'perm-req-7'); + + expect(response).toBeDefined(); + if (response?.response.subtype === 'success') { + expect(response.response.response).toMatchObject({ + behavior: 'deny', + message: 'Denied by user', + interrupt: true, + }); + } + }); + + await query.close(); }); }); - describe('Control Plane - MCP Messages', () => { - it('should route MCP messages to SDK-embedded servers', async () => { - // Should find SdkControlServerTransport by server name - expect(true).toBe(true); // Placeholder + describe('Control Plane - Control Cancel', () => { + it('should handle control cancel requests', async () => { + const canUseTool = vi.fn().mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new AbortError())); + setTimeout(() => resolve(true), 5000); + }), + ); + + const query = new Query(transport, { + cwd: '/test', + canUseTool, + }); + + const controlReq = createControlRequest('can_use_tool', 'cancel-req-1'); + transport.simulateMessage(controlReq); + + // Wait a bit then send cancel + await new Promise((resolve) => setTimeout(resolve, 100)); + transport.simulateMessage(createControlCancel('cancel-req-1')); + + await vi.waitFor(() => { + expect(canUseTool).toHaveBeenCalled(); + }); + + await query.close(); }); - it('should handle MCP message responses', async () => { - // Should send response back to CLI - expect(true).toBe(true); // Placeholder - }); + it('should ignore cancel for unknown request_id', async () => { + const query = new Query(transport, { + cwd: '/test', + }); - it('should handle MCP message timeout', async () => { - // Should return error if MCP server doesn\'t respond in 30s - expect(true).toBe(true); // Placeholder - }); + // Send cancel for non-existent request + transport.simulateMessage(createControlCancel('unknown-req')); - it('should handle unknown MCP server names', async () => { - // Should return error if server name not found - expect(true).toBe(true); // Placeholder - }); - }); + // Should not throw or cause issues + await new Promise((resolve) => setTimeout(resolve, 100)); - describe('Control Plane - Other Requests', () => { - it('should handle initialize control request', async () => { - // Should register SDK MCP servers with CLI - expect(true).toBe(true); // Placeholder - }); - - it('should handle interrupt control request', async () => { - // Should send interrupt message to CLI - expect(true).toBe(true); // Placeholder - }); - - it('should handle set_permission_mode control request', async () => { - // Should send permission mode update to CLI - expect(true).toBe(true); // Placeholder - }); - - it('should handle supported_commands control request', async () => { - // Should query CLI capabilities - expect(true).toBe(true); // Placeholder - }); - - it('should handle mcp_server_status control request', async () => { - // Should check MCP server health - expect(true).toBe(true); // Placeholder + await query.close(); }); }); describe('Multi-Turn Conversation', () => { it('should support streamInput() for follow-up messages', async () => { - // Should accept async iterable of messages - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Follow-up 1'); + yield createUserMessage('Follow-up 2'); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + expect(userMessages.length).toBeGreaterThanOrEqual(2); + + await query.close(); }); it('should maintain session context across turns', async () => { - // All messages should have same session_id - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Turn 1', sessionId); + yield createUserMessage('Turn 2', sessionId); + } + + await query.streamInput(messageGenerator()); + + const messages = transport.getAllWrittenMessages(); + const userMessages = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ) as CLIUserMessage[]; + + userMessages.forEach((msg) => { + expect(msg.session_id).toBe(sessionId); + }); + + await query.close(); }); it('should throw if streamInput() called on closed query', async () => { - // Should throw Error if query is closed - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + async function* messageGenerator() { + yield createUserMessage('Test'); + } + + await expect(query.streamInput(messageGenerator())).rejects.toThrow( + 'Query is closed', + ); + }); + + it('should handle abort during streamInput', async () => { + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + async function* messageGenerator() { + yield createUserMessage('Message 1'); + abortController.abort(); + yield createUserMessage('Message 2'); // Should not be sent + } + + await query.streamInput(messageGenerator()); + + await query.close(); }); }); describe('Lifecycle Management', () => { it('should close transport on close()', async () => { - // Should call transport.close() - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(transport.closed).toBe(true); }); it('should mark query as closed', async () => { - // closed flag should be true after close() - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + expect(query.isClosed()).toBe(false); + + await query.close(); + expect(query.isClosed()).toBe(true); }); it('should complete output stream on close()', async () => { - // inputStream should be marked done - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: CLIMessage[] = []; + for await (const msg of query) { + messages.push(msg); + } + return messages; + })(); + + await query.close(); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(Array.isArray(messages)).toBe(true); }); it('should be idempotent when closing multiple times', async () => { - // Multiple close() calls should not error - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); - it('should cleanup MCP transports on close()', async () => { - // Should close all SdkControlServerTransport instances - expect(true).toBe(true); // Placeholder + await query.close(); + await query.close(); + await query.close(); + + expect(query.isClosed()).toBe(true); }); it('should handle abort signal cancellation', async () => { - // Should abort on AbortSignal - expect(true).toBe(true); // Placeholder + const abortController = new AbortController(); + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + abortController.abort(); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); + }); + + it('should handle pre-aborted signal', async () => { + const abortController = new AbortController(); + abortController.abort(); + + const query = new Query(transport, { + cwd: '/test', + abortController, + }); + + await vi.waitFor(() => { + expect(query.isClosed()).toBe(true); + }); }); }); describe('Async Iteration', () => { it('should support for await loop', async () => { - // Should implement AsyncIterator protocol - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); - it('should yield messages in order', async () => { - // Messages should be yielded in received order - expect(true).toBe(true); // Placeholder + const messages: CLIMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + if (messages.length >= 2) break; + } + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createAssistantMessage('Second')); + + await iterationPromise; + + expect(messages).toHaveLength(2); + expect((messages[0] as CLIUserMessage).message.content).toBe('First'); + + await query.close(); }); it('should complete iteration when query closes', async () => { - // for await loop should exit when query closes - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const messages: CLIMessage[] = []; + const iterationPromise = (async () => { + for await (const msg of query) { + messages.push(msg); + } + })(); + + transport.simulateMessage(createUserMessage('Test')); + + // Give time for message to be processed + await new Promise((resolve) => setTimeout(resolve, 10)); + + await query.close(); + transport.simulateClose(); + + await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(1); }); it('should propagate transport errors', async () => { - // Should throw if transport encounters error - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + for await (const msg of query) { + void msg; + } + })(); + + transport.simulateError(new Error('Transport error')); + + await expect(iterationPromise).rejects.toThrow('Transport error'); + + await query.close(); }); }); describe('Public API Methods', () => { it('should provide interrupt() method', async () => { - // Should send interrupt control request - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Respond to interrupt + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + transport.simulateMessage( + createControlResponse(interruptMsg.request_id, true, {}), + ); + + await interruptPromise; + await query.close(); }); it('should provide setPermissionMode() method', async () => { - // Should send set_permission_mode control request - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModePromise = query.setPermissionMode('yolo'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + ); + expect(setModeMsg).toBeDefined(); + }); + + // Respond to set permission mode + const messages = transport.getAllWrittenMessages(); + const setModeMsg = findControlRequest( + messages, + ControlRequestType.SET_PERMISSION_MODE, + )!; + transport.simulateMessage( + createControlResponse(setModeMsg.request_id, true, {}), + ); + + await setModePromise; + await query.close(); + }); + + it('should provide setModel() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const setModelPromise = query.setModel('new-model'); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + ); + expect(setModelMsg).toBeDefined(); + }); + + // Respond to set model + const messages = transport.getAllWrittenMessages(); + const setModelMsg = findControlRequest( + messages, + ControlRequestType.SET_MODEL, + )!; + transport.simulateMessage( + createControlResponse(setModelMsg.request_id, true, {}), + ); + + await setModelPromise; + await query.close(); }); it('should provide supportedCommands() method', async () => { - // Should query CLI capabilities - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const commandsPromise = query.supportedCommands(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + ); + expect(commandsMsg).toBeDefined(); + }); + + // Respond with commands + const messages = transport.getAllWrittenMessages(); + const commandsMsg = findControlRequest( + messages, + ControlRequestType.SUPPORTED_COMMANDS, + )!; + transport.simulateMessage( + createControlResponse(commandsMsg.request_id, true, { + commands: ['interrupt', 'set_model'], + }), + ); + + const result = await commandsPromise; + expect(result).toMatchObject({ commands: ['interrupt', 'set_model'] }); + + await query.close(); }); it('should provide mcpServerStatus() method', async () => { - // Should check MCP server health - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const statusPromise = query.mcpServerStatus(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + ); + expect(statusMsg).toBeDefined(); + }); + + // Respond with status + const messages = transport.getAllWrittenMessages(); + const statusMsg = findControlRequest( + messages, + ControlRequestType.MCP_SERVER_STATUS, + )!; + transport.simulateMessage( + createControlResponse(statusMsg.request_id, true, { + servers: [{ name: 'test', status: 'connected' }], + }), + ); + + const result = await statusPromise; + expect(result).toMatchObject({ + servers: [{ name: 'test', status: 'connected' }], + }); + + await query.close(); }); it('should throw if methods called on closed query', async () => { - // Public methods should throw if query is closed - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + await expect(query.interrupt()).rejects.toThrow('Query is closed'); + await expect(query.setPermissionMode('yolo')).rejects.toThrow( + 'Query is closed', + ); + await expect(query.setModel('model')).rejects.toThrow('Query is closed'); + await expect(query.supportedCommands()).rejects.toThrow( + 'Query is closed', + ); + await expect(query.mcpServerStatus()).rejects.toThrow('Query is closed'); }); }); describe('Error Handling', () => { it('should propagate transport errors to stream', async () => { - // Transport errors should be surfaced in for await loop - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + const error = new Error('Transport failure'); + transport.simulateError(error); + + await expect(query.next()).rejects.toThrow('Transport failure'); + + await query.close(); }); it('should handle control request timeout', async () => { - // Should return error if control request doesn\'t respond - expect(true).toBe(true); // Placeholder - }); + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + // Call interrupt but don't respond - should timeout + const interruptPromise = query.interrupt(); + + await expect(interruptPromise).rejects.toThrow(/timeout/i); + + await query.close(); + }, 35000); it('should handle malformed control responses', async () => { - // Should handle invalid response structures - expect(true).toBe(true); // Placeholder + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + const interruptPromise = query.interrupt(); + + await vi.waitFor(() => { + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + ); + expect(interruptMsg).toBeDefined(); + }); + + // Send malformed response + const messages = transport.getAllWrittenMessages(); + const interruptMsg = findControlRequest( + messages, + ControlRequestType.INTERRUPT, + )!; + + transport.simulateMessage({ + type: 'control_response', + response: { + subtype: 'error', + request_id: interruptMsg.request_id, + error: { message: 'Malformed error' }, + }, + }); + + await expect(interruptPromise).rejects.toThrow('Malformed error'); + + await query.close(); }); - it('should handle CLI sending error message', async () => { - // Should yield error message to user - expect(true).toBe(true); // Placeholder + it('should handle CLI sending error result message', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const errorResult = createResultMessage(false); + transport.simulateMessage(errorResult); + + const result = await query.next(); + expect(result.done).toBe(false); + expect((result.value as CLIResultMessage).is_error).toBe(true); + + await query.close(); + }); + }); + + describe('Single-Turn Mode', () => { + it('should auto-close input after result in single-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + true, // singleTurn = true + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(true); + + await query.close(); + }); + + it('should not auto-close input in multi-turn mode', async () => { + const query = new Query( + transport, + { cwd: '/test' }, + false, // singleTurn = false + ); + + const resultMsg = createResultMessage(true); + transport.simulateMessage(resultMsg); + + await query.next(); + + expect(transport.endInputCalled).toBe(false); + + await query.close(); }); }); describe('State Management', () => { - it('should track pending control requests', () => { - // Should maintain map of request_id -> Promise - expect(true).toBe(true); // Placeholder + it('should track session ID', () => { + const query = new Query(transport, { cwd: '/test' }); + const sessionId = query.getSessionId(); + + expect(sessionId).toBeTruthy(); + expect(typeof sessionId).toBe('string'); + expect(sessionId.length).toBeGreaterThan(0); }); - it('should track SDK MCP transports', () => { - // Should maintain map of server_name -> SdkControlServerTransport - expect(true).toBe(true); // Placeholder + it('should track closed state', async () => { + const query = new Query(transport, { cwd: '/test' }); + + expect(query.isClosed()).toBe(false); + await query.close(); + expect(query.isClosed()).toBe(true); }); - it('should track initialization state', () => { - // Should have initialized Promise - expect(true).toBe(true); // Placeholder + it('should provide endInput() method', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Respond to initialize + await vi.waitFor(() => { + expect(transport.writtenMessages.length).toBeGreaterThan(0); + }); + const initRequest = + transport.getLastWrittenMessage() as CLIControlRequest; + transport.simulateMessage( + createControlResponse(initRequest.request_id, true, {}), + ); + + await query.initialized; + + query.endInput(); + expect(transport.endInputCalled).toBe(true); + + await query.close(); }); - it('should track closed state', () => { - // Should have closed boolean flag - expect(true).toBe(true); // Placeholder + it('should throw if endInput() called on closed query', async () => { + const query = new Query(transport, { cwd: '/test' }); + await query.close(); + + expect(() => query.endInput()).toThrow('Query is closed'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty message stream', async () => { + const query = new Query(transport, { cwd: '/test' }); + + transport.simulateClose(); + + const result = await query.next(); + expect(result.done).toBe(true); + + await query.close(); + }); + + it('should handle rapid message flow', async () => { + const query = new Query(transport, { cwd: '/test' }); + + // Simulate rapid messages + for (let i = 0; i < 100; i++) { + transport.simulateMessage(createUserMessage(`Message ${i}`)); + } + + const messages: CLIMessage[] = []; + for (let i = 0; i < 100; i++) { + const result = await query.next(); + if (!result.done) { + messages.push(result.value); + } + } + + expect(messages.length).toBe(100); + + await query.close(); + }); + + it('should handle close during message iteration', async () => { + const query = new Query(transport, { cwd: '/test' }); + + const iterationPromise = (async () => { + const messages: CLIMessage[] = []; + for await (const msg of query) { + messages.push(msg); + if (messages.length === 2) { + await query.close(); + } + } + return messages; + })(); + + transport.simulateMessage(createUserMessage('First')); + transport.simulateMessage(createUserMessage('Second')); + transport.simulateMessage(createUserMessage('Third')); + transport.simulateClose(); + + const messages = await iterationPromise; + expect(messages.length).toBeGreaterThanOrEqual(2); }); }); }); diff --git a/packages/sdk-typescript/test/unit/cliPath.test.ts b/packages/sdk-typescript/test/unit/cliPath.test.ts index 55a87b92..0e40e23a 100644 --- a/packages/sdk-typescript/test/unit/cliPath.test.ts +++ b/packages/sdk-typescript/test/unit/cliPath.test.ts @@ -11,7 +11,6 @@ import { parseExecutableSpec, prepareSpawnInfo, findNativeCliPath, - resolveCliPath, } from '../../src/utils/cliPath.js'; // Mock fs module @@ -421,28 +420,6 @@ describe('CLI Path Utilities', () => { }); }); - describe('resolveCliPath (backward compatibility)', () => { - it('should resolve CLI path for backward compatibility', () => { - mockFs.existsSync.mockReturnValue(true); - - const result = resolveCliPath('/path/to/qwen'); - - expect(result).toBe('/path/to/qwen'); - }); - - it('should auto-detect when no path provided', () => { - const originalEnv = process.env['QWEN_CODE_CLI_PATH']; - process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen'; - mockFs.existsSync.mockReturnValue(true); - - const result = resolveCliPath(); - - expect(result).toBe('/usr/local/bin/qwen'); - - process.env['QWEN_CODE_CLI_PATH'] = originalEnv; - }); - }); - describe('real-world use cases', () => { beforeEach(() => { mockFs.existsSync.mockReturnValue(true);