feat: create draft framework for cli & sdk

This commit is contained in:
mingholy.lmh
2025-11-23 19:36:31 +08:00
parent 6729980b47
commit e1ffaec499
58 changed files with 8982 additions and 668 deletions

View File

@@ -0,0 +1,466 @@
/**
* E2E tests based on abort-and-lifecycle.ts example
* Tests AbortController integration and process lifecycle management
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect } from 'vitest';
import {
query,
AbortError,
isAbortError,
isCLIAssistantMessage,
type TextBlock,
type ContentBlock,
} from '../../src/index.js';
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
};
describe('AbortController and Process Lifecycle (E2E)', () => {
describe('Basic AbortController Usage', () => {
/* TODO: Currently query does not throw AbortError when aborted */
it('should support AbortController cancellation', async () => {
const controller = new AbortController();
// Abort after 5 seconds
setTimeout(() => {
controller.abort();
}, 5000);
const q = query({
prompt: 'Write a very long story about TypeScript programming',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 100);
// Should receive some content before abort
expect(text.length).toBeGreaterThan(0);
}
}
// Should not reach here - query should be aborted
expect(false).toBe(true);
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
});
it('should handle abort during query execution', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
let receivedFirstMessage = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
if (!receivedFirstMessage) {
// Abort immediately after receiving first assistant message
receivedFirstMessage = true;
controller.abort();
}
}
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
// Should have received at least one message before abort
expect(receivedFirstMessage).toBe(true);
} finally {
await q.close();
}
});
it('should handle abort immediately after query starts', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort immediately after query initialization
setTimeout(() => {
controller.abort();
}, 200);
try {
for await (const _message of q) {
// May or may not receive messages before abort
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
} finally {
await q.close();
}
});
});
describe('Process Lifecycle Monitoring', () => {
it('should handle normal process completion', async () => {
const q = query({
prompt: 'Why do we choose to go to the moon?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let completedSuccessfully = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 100);
expect(text.length).toBeGreaterThan(0);
}
}
completedSuccessfully = true;
} catch (error) {
// Should not throw for normal completion
expect(false).toBe(true);
} finally {
await q.close();
expect(completedSuccessfully).toBe(true);
}
});
it('should handle process cleanup after error', async () => {
const q = query({
prompt: 'Hello world',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 50);
expect(text.length).toBeGreaterThan(0);
}
}
} catch (error) {
// Expected to potentially have errors
} finally {
// Should cleanup successfully even after error
await q.close();
expect(true).toBe(true); // Cleanup completed
}
});
});
describe('Input Stream Control', () => {
it('should support endInput() method', async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let receivedResponse = false;
let endInputCalled = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message) && !endInputCalled) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
const text = textBlocks.map((b: TextBlock) => b.text).join('');
expect(text.length).toBeGreaterThan(0);
receivedResponse = true;
// End input after receiving first response
q.endInput();
endInputCalled = true;
}
}
expect(receivedResponse).toBe(true);
expect(endInputCalled).toBe(true);
} finally {
await q.close();
}
});
});
describe('Error Handling and Recovery', () => {
it('should handle invalid executable path', async () => {
try {
const q = query({
prompt: 'Hello world',
options: {
pathToQwenExecutable: '/nonexistent/path/to/cli',
debug: false,
},
});
// Should not reach here - query() should throw immediately
for await (const _message of q) {
// Should not reach here
}
// Should not reach here
expect(false).toBe(true);
} catch (error) {
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toBeDefined();
expect((error as Error).message).toContain(
'Invalid pathToQwenExecutable',
);
}
});
it('should throw AbortError with correct properties', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Explain the concept of async programming',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort after allowing query to start
setTimeout(() => controller.abort(), 1000);
try {
for await (const _message of q) {
// May receive some messages before abort
}
} catch (error) {
// Verify error type and helper functions
expect(isAbortError(error)).toBe(true);
expect(error instanceof AbortError).toBe(true);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBeDefined();
} finally {
await q.close();
}
});
});
describe('Debugging with stderr callback', () => {
it('should capture stderr messages when debug is enabled', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Why do we choose to go to the moon?',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
const text = textBlocks
.map((b) => b.text)
.join('')
.slice(0, 50);
expect(text.length).toBeGreaterThan(0);
}
}
} finally {
await q.close();
expect(stderrMessages.length).toBeGreaterThan(0);
}
});
it('should not capture stderr when debug is disabled', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
} finally {
await q.close();
// Should have minimal or no stderr output when debug is false
expect(stderrMessages.length).toBeLessThan(10);
}
});
});
describe('Abort with Cleanup', () => {
it('should cleanup properly after abort', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay about programming',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort immediately
setTimeout(() => controller.abort(), 100);
try {
for await (const _message of q) {
// May receive some messages before abort
}
} catch (error) {
if (error instanceof AbortError) {
expect(true).toBe(true); // Expected abort error
} else {
throw error; // Unexpected error
}
} finally {
await q.close();
expect(true).toBe(true); // Cleanup completed after abort
}
});
it('should handle multiple abort calls gracefully', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Count to 100',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Multiple abort calls
setTimeout(() => controller.abort(), 100);
setTimeout(() => controller.abort(), 200);
setTimeout(() => controller.abort(), 300);
try {
for await (const _message of q) {
// Should be interrupted
}
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
});
});
describe('Resource Management Edge Cases', () => {
it('should handle close() called multiple times', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
// Start the query
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Close multiple times
await q.close();
await q.close();
await q.close();
// Should not throw
expect(true).toBe(true);
});
it('should handle abort after close', async () => {
const controller = new AbortController();
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Start and close immediately
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
await q.close();
// Abort after close
controller.abort();
// Should not throw
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,254 @@
import { describe, it, expect } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIAssistantMessage,
isCLIResultMessage,
isCLISystemMessage,
type CLIUserMessage,
} from '../../src/types/protocol.js';
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
};
/**
* Factory function that creates a streaming input with a control point.
* After the first message is yielded, the generator waits for a resume signal,
* allowing the test code to call query instance methods like setModel or setPermissionMode.
*
* @param firstMessage - The first user message to send
* @param secondMessage - The second user message to send after control operations
* @returns Object containing the async generator and a resume function
*/
function createStreamingInputWithControlPoint(
firstMessage: string,
secondMessage: string,
): {
generator: AsyncIterable<CLIUserMessage>;
resume: () => void;
} {
let resumeResolve: (() => void) | null = null;
const resumePromise = new Promise<void>((resolve) => {
resumeResolve = resolve;
});
const generator = (async function* () {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: firstMessage,
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: secondMessage,
},
parent_tool_use_id: null,
} as CLIUserMessage;
})();
const resume = () => {
if (resumeResolve) {
resumeResolve();
}
};
return { generator, resume };
}
describe('Control Request/Response (E2E)', () => {
describe('System Controller Scope', () => {
it('should set model via control request during streaming input', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'Tell me the model name.',
'Tell me the model name now again.',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
model: 'qwen3-max',
debug: false,
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
const systemMessages: Array<{ model?: string }> = [];
// Consume messages in a single loop
(async () => {
for await (const message of q) {
if (isCLISystemMessage(message)) {
systemMessages.push({ model: message.model });
}
if (isCLIAssistantMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
// Wait for first response
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
10000,
),
),
]);
expect(firstResponseReceived).toBe(true);
// Perform control operation: set model
await q.setModel('qwen3-vl-plus');
// Resume the input stream
resume();
// Wait for second response
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
10000,
),
),
]);
expect(secondResponseReceived).toBe(true);
// Verify system messages - model should change from qwen3-max to qwen3-vl-plus
expect(systemMessages.length).toBeGreaterThanOrEqual(2);
expect(systemMessages[0].model).toBeOneOf(['qwen3-max', 'coder-model']);
expect(systemMessages[1].model).toBe('qwen3-vl-plus');
} finally {
await q.close();
}
});
});
describe('Permission Controller Scope', () => {
it('should set permission mode via control request during streaming input', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 1 + 1?',
'What is 2 + 2?',
);
const q = query({
prompt: generator,
options: {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'default',
debug: false,
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let permissionModeChanged = false;
let secondResponseReceived = false;
// Consume messages in a single loop
(async () => {
for await (const message of q) {
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
// Wait for first response
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
10000,
),
),
]);
expect(firstResponseReceived).toBe(true);
// Perform control operation: set permission mode
await q.setPermissionMode('yolo');
permissionModeChanged = true;
// Resume the input stream
resume();
// Wait for second response
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
10000,
),
),
]);
expect(permissionModeChanged).toBe(true);
expect(secondResponseReceived).toBe(true);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '../..');
const e2eTestsDir = join(rootDir, '.integration-tests');
let runDir = '';
export async function setup() {
runDir = join(e2eTestsDir, `${Date.now()}`);
await mkdir(runDir, { recursive: true });
// Clean up old test runs, but keep the latest few for debugging
try {
const testRuns = await readdir(e2eTestsDir);
if (testRuns.length > 5) {
const oldRuns = testRuns.sort().slice(0, testRuns.length - 5);
await Promise.all(
oldRuns.map((oldRun) =>
rm(join(e2eTestsDir, oldRun), {
recursive: true,
force: true,
}),
),
);
}
} catch (e) {
console.error('Error cleaning up old test runs:', e);
}
process.env['E2E_TEST_FILE_DIR'] = runDir;
process.env['QWEN_CLI_E2E_TEST'] = 'true';
process.env['TEST_CLI_PATH'] = join(rootDir, '../../dist/cli.js');
if (process.env['KEEP_OUTPUT']) {
console.log(`Keeping output for test run in: ${runDir}`);
}
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
console.log(`\nE2E test output directory: ${runDir}`);
console.log(`CLI path: ${process.env['TEST_CLI_PATH']}`);
}
export async function teardown() {
// Cleanup the test run directory unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
await rm(runDir, { recursive: true, force: true });
}
}

View File

@@ -0,0 +1,610 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* E2E tests for MCP (Model Context Protocol) server integration via SDK
* Tests that the SDK can properly interact with MCP servers configured in qwen-code
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIAssistantMessage,
isCLIResultMessage,
isCLISystemMessage,
isCLIUserMessage,
type TextBlock,
type ContentBlock,
type CLIMessage,
type ToolUseBlock,
type CLISystemMessage,
} from '../../src/types/protocol.js';
import { writeFileSync, mkdirSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
const E2E_TEST_FILE_DIR = process.env['E2E_TEST_FILE_DIR']!;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'yolo' as const,
};
/**
* Helper to extract text from ContentBlock array
*/
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
/**
* Minimal MCP server implementation that doesn't require external dependencies
* This implements the MCP protocol directly using Node.js built-ins
*/
const MCP_SERVER_SCRIPT = `#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
const readline = require('readline');
const fs = require('fs');
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
const debugEnabled = process.env['MCP_DEBUG'] === 'true' || process.env['VERBOSE'] === 'true';
function debug(msg) {
if (debugEnabled) {
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
}
}
debug('MCP server starting...');
// Simple JSON-RPC implementation for MCP
class SimpleJSONRPC {
constructor() {
this.handlers = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.rl.on('line', (line) => {
debug(\`Received line: \${line}\`);
try {
const message = JSON.parse(line);
debug(\`Parsed message: \${JSON.stringify(message)}\`);
this.handleMessage(message);
} catch (e) {
debug(\`Parse error: \${e.message}\`);
}
});
}
send(message) {
const msgStr = JSON.stringify(message);
debug(\`Sending message: \${msgStr}\`);
process.stdout.write(msgStr + '\\n');
}
async handleMessage(message) {
if (message.method && this.handlers.has(message.method)) {
try {
const result = await this.handlers.get(message.method)(message.params || {});
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
result
});
}
} catch (error) {
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error.message
}
});
}
}
} else if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found'
}
});
}
}
on(method, handler) {
this.handlers.set(method, handler);
}
}
// Create MCP server
const rpc = new SimpleJSONRPC();
// Handle initialize
rpc.on('initialize', async (params) => {
debug('Handling initialize request');
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'test-math-server',
version: '1.0.0'
}
};
});
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [
{
name: 'add',
description: 'Add two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
},
{
name: 'multiply',
description: 'Multiply two numbers together',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
}
]
};
});
// Handle tools/call
rpc.on('tools/call', async (params) => {
debug(\`Handling tools/call request for tool: \${params.name}\`);
if (params.name === 'add') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a + b)
}]
};
}
if (params.name === 'multiply') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a * b)
}]
};
}
throw new Error('Unknown tool: ' + params.name);
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
describe('MCP Server Integration (E2E)', () => {
let testDir: string;
let serverScriptPath: string;
beforeAll(() => {
// Use the centralized E2E test directory from globalSetup
testDir = join(E2E_TEST_FILE_DIR, 'mcp-server-test');
mkdirSync(testDir, { recursive: true });
// Write MCP server script
serverScriptPath = join(testDir, 'mcp-server.cjs');
writeFileSync(serverScriptPath, MCP_SERVER_SCRIPT);
// Make script executable on Unix-like systems
if (process.platform !== 'win32') {
chmodSync(serverScriptPath, 0o755);
}
});
describe('Basic MCP Tool Usage', () => {
it('should use MCP add tool to add two numbers', async () => {
const q = query({
prompt:
'Use the add tool to calculate 5 + 10. Just give me the result.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (toolUseBlock && toolUseBlock.name === 'add') {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains expected answer
expect(assistantText).toMatch(/15/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
if (isCLIResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
});
it('should use MCP multiply tool to multiply two numbers', async () => {
const q = query({
prompt:
'Use the multiply tool to calculate 6 * 7. Just give me the result.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
let foundToolUse = false;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (toolUseBlock && toolUseBlock.name === 'multiply') {
foundToolUse = true;
}
assistantText += extractText(message.message.content);
}
}
// Validate tool was called
expect(foundToolUse).toBe(true);
// Validate result contains expected answer
expect(assistantText).toMatch(/42/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('MCP Server Discovery', () => {
it('should list MCP servers in system init message', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
let systemMessage: CLISystemMessage | null = null;
try {
for await (const message of q) {
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
break;
}
}
// Validate MCP server is listed
expect(systemMessage).not.toBeNull();
expect(systemMessage!.mcp_servers).toBeDefined();
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
// Find our test server
const testServer = systemMessage!.mcp_servers?.find(
(server) => server.name === 'test-math-server',
);
expect(testServer).toBeDefined();
// Note: tools are not exposed in the mcp_servers array in system message
// They are available through the MCP protocol but not in the init message
} finally {
await q.close();
}
});
});
describe('Complex MCP Operations', () => {
it('should chain multiple MCP tool calls', async () => {
const q = query({
prompt:
'First use add to calculate 10 + 5, then multiply the result by 2. Give me the final answer.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
const toolCalls: string[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
const toolUseBlocks = message.message.content.filter(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
toolUseBlocks.forEach((block) => {
toolCalls.push(block.name);
});
assistantText += extractText(message.message.content);
}
}
// Validate both tools were called
expect(toolCalls).toContain('add');
expect(toolCalls).toContain('multiply');
// Validate result: (10 + 5) * 2 = 30
expect(assistantText).toMatch(/30/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
it('should handle multiple calls to the same MCP tool', async () => {
const q = query({
prompt:
'Use the add tool twice: first add 1 + 2, then add 3 + 4. Tell me both results.',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
const addToolCalls: ToolUseBlock[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
const toolUseBlocks = message.message.content.filter(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
toolUseBlocks.forEach((block) => {
if (block.name === 'add') {
addToolCalls.push(block);
}
});
assistantText += extractText(message.message.content);
}
}
// Validate add tool was called at least twice
expect(addToolCalls.length).toBeGreaterThanOrEqual(2);
// Validate results contain expected answers: 3 and 7
expect(assistantText).toMatch(/3/);
expect(assistantText).toMatch(/7/);
// Validate successful completion
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
});
describe('MCP Tool Message Flow', () => {
it('should receive proper message sequence for MCP tool usage', async () => {
const q = query({
prompt: 'Use add to calculate 2 + 3',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messageTypes: string[] = [];
let foundToolUse = false;
let foundToolResult = false;
try {
for await (const message of q) {
messageTypes.push(message.type);
if (isCLIAssistantMessage(message)) {
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (toolUseBlock) {
foundToolUse = true;
expect(toolUseBlock.name).toBe('add');
expect(toolUseBlock.input).toBeDefined();
}
}
if (isCLIUserMessage(message)) {
const content = message.message.content;
const contentArray = Array.isArray(content)
? content
: [{ type: 'text', text: content }];
const toolResultBlock = contentArray.find(
(block) => block.type === 'tool_result',
);
if (toolResultBlock) {
foundToolResult = true;
}
}
}
// Validate message flow
expect(foundToolUse).toBe(true);
expect(foundToolResult).toBe(true);
expect(messageTypes).toContain('system');
expect(messageTypes).toContain('assistant');
expect(messageTypes).toContain('user');
expect(messageTypes).toContain('result');
// Result should be last message
expect(messageTypes[messageTypes.length - 1]).toBe('result');
} finally {
await q.close();
}
});
});
describe('Error Handling', () => {
it('should handle gracefully when MCP tool is not available', async () => {
const q = query({
prompt: 'Use the subtract tool to calculate 10 - 5',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
mcpServers: {
'test-math-server': {
command: 'node',
args: [serverScriptPath],
},
},
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Should complete without crashing
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
// Assistant should indicate tool is not available or provide alternative
expect(assistantText.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,479 @@
/**
* E2E tests based on multi-turn.ts example
* Tests multi-turn conversation functionality with real CLI
*/
import { describe, it, expect } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
type CLIUserMessage,
type CLIAssistantMessage,
type TextBlock,
type ContentBlock,
type CLIMessage,
type ControlMessage,
type ToolUseBlock,
} from '../../src/types/protocol.js';
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
};
/**
* Determine the message type using protocol type guards
*/
function getMessageType(message: CLIMessage | ControlMessage): string {
if (isCLIUserMessage(message)) {
return '🧑 USER';
} else if (isCLIAssistantMessage(message)) {
return '🤖 ASSISTANT';
} else if (isCLISystemMessage(message)) {
return `🖥️ SYSTEM(${message.subtype})`;
} else if (isCLIResultMessage(message)) {
return `✅ RESULT(${message.subtype})`;
} else if (isCLIPartialAssistantMessage(message)) {
return '⏳ STREAM_EVENT';
} else if (isControlRequest(message)) {
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
} else if (isControlResponse(message)) {
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
} else if (isControlCancel(message)) {
return '🛑 CONTROL_CANCEL';
} else {
return '❓ UNKNOWN';
}
}
/**
* Helper to extract text from ContentBlock array
*/
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
describe('Multi-Turn Conversations (E2E)', () => {
describe('AsyncIterable Prompt Support', () => {
it('should handle multi-turn conversation using AsyncIterable prompt', async () => {
// Create multi-turn conversation generator
async function* createMultiTurnConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 1 + 1?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 2 + 2?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What is 3 + 3?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
// Create multi-turn query using AsyncIterable prompt
const q = query({
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
const assistantMessages: CLIAssistantMessage[] = [];
const assistantTexts: string[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
const text = extractText(message.message.content);
assistantTexts.push(text);
}
}
expect(messages.length).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
// Validate content of responses
expect(assistantTexts[0]).toMatch(/2/);
expect(assistantTexts[1]).toMatch(/4/);
expect(assistantTexts[2]).toMatch(/6/);
} finally {
await q.close();
}
});
it('should maintain session context across turns', async () => {
async function* createContextualConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content:
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'How many animals are there? Only output the number',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createContextualConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// The second response should reference the color blue
const secondResponse = extractText(
assistantMessages[1].message.content,
);
expect(secondResponse.toLowerCase()).toContain('3');
} finally {
await q.close();
}
});
});
describe('Tool Usage in Multi-Turn', () => {
it('should handle tool usage across multiple turns', async () => {
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Create a file named test.txt with content "hello"',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Now read the test.txt file',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createToolConversation(),
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'yolo',
cwd: '/tmp',
debug: false,
},
});
const messages: CLIMessage[] = [];
let toolUseCount = 0;
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (hasToolUseBlock) {
toolUseCount++;
}
}
}
expect(messages.length).toBeGreaterThan(0);
expect(toolUseCount).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// Validate second response mentions the file content
const secondResponse = extractText(
assistantMessages[assistantMessages.length - 1].message.content,
);
expect(secondResponse.toLowerCase()).toMatch(/hello|test\.txt/);
} finally {
await q.close();
}
});
});
describe('Message Flow and Sequencing', () => {
it('should process messages in correct sequence', async () => {
async function* createSequentialConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First question: What is 1 + 1?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second question: What is 2 + 2?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createSequentialConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messageSequence: string[] = [];
const assistantResponses: string[] = [];
try {
for await (const message of q) {
const messageType = getMessageType(message);
messageSequence.push(messageType);
if (isCLIAssistantMessage(message)) {
const text = extractText(message.message.content);
assistantResponses.push(text);
}
}
expect(messageSequence.length).toBeGreaterThan(0);
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
// Should end with result
expect(messageSequence[messageSequence.length - 1]).toContain('RESULT');
// Should have assistant responses
expect(messageSequence.some((type) => type.includes('ASSISTANT'))).toBe(
true,
);
} finally {
await q.close();
}
});
it('should handle conversation completion correctly', async () => {
async function* createSimpleConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Hello',
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Goodbye',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createSimpleConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isCLIResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
});
});
describe('Error Handling in Multi-Turn', () => {
it('should handle empty conversation gracefully', async () => {
async function* createEmptyConversation(): AsyncIterable<CLIUserMessage> {
// Generator that yields nothing
/* eslint-disable no-constant-condition */
if (false) {
yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript
}
}
const q = query({
prompt: createEmptyConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Should handle empty conversation without crashing
expect(true).toBe(true);
} finally {
await q.close();
}
});
it('should handle conversation with delays', async () => {
async function* createDelayedConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'First message',
},
parent_tool_use_id: null,
} as CLIUserMessage;
// Longer delay to test patience
await new Promise((resolve) => setTimeout(resolve, 500));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Second message after delay',
},
parent_tool_use_id: null,
} as CLIUserMessage;
}
const q = query({
prompt: createDelayedConversation(),
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
}
}
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,676 @@
/**
* E2E tests for permission control features:
* - canUseTool callback parameter
* - setPermissionMode API
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIAssistantMessage,
isCLIResultMessage,
isCLIUserMessage,
type CLIUserMessage,
type ToolUseBlock,
type ContentBlock,
} from '../../src/types/protocol.js';
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
const TEST_TIMEOUT = 30000;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
debug: false,
env: {},
};
/**
* Factory function that creates a streaming input with a control point.
* After the first message is yielded, the generator waits for a resume signal,
* allowing the test code to call query instance methods like setPermissionMode.
*/
function createStreamingInputWithControlPoint(
firstMessage: string,
secondMessage: string,
): {
generator: AsyncIterable<CLIUserMessage>;
resume: () => void;
} {
let resumeResolve: (() => void) | null = null;
const resumePromise = new Promise<void>((resolve) => {
resumeResolve = resolve;
});
const generator = (async function* () {
const sessionId = crypto.randomUUID();
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: firstMessage,
},
parent_tool_use_id: null,
} as CLIUserMessage;
await new Promise((resolve) => setTimeout(resolve, 200));
await resumePromise;
await new Promise((resolve) => setTimeout(resolve, 200));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: secondMessage,
},
parent_tool_use_id: null,
} as CLIUserMessage;
})();
const resume = () => {
if (resumeResolve) {
resumeResolve();
}
};
return { generator, resume };
}
describe('Permission Control (E2E)', () => {
beforeAll(() => {
//process.env['DEBUG'] = '1';
});
afterAll(() => {
delete process.env['DEBUG'];
});
describe('canUseTool callback parameter', () => {
it('should invoke canUseTool callback when tool is requested', async () => {
const toolCalls: Array<{
toolName: string;
input: Record<string, unknown>;
}> = [];
const q = query({
prompt: 'Write a js hello world to file.',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
canUseTool: async (toolName, input) => {
toolCalls.push({ toolName, input });
/*
{
behavior: 'allow',
updatedInput: input,
};
*/
return {
behavior: 'deny',
message: 'Tool execution denied by user.',
};
},
},
});
try {
let hasToolUse = false;
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const toolUseBlock = message.message.content.find(
(block: ContentBlock): block is ToolUseBlock =>
block.type === 'tool_use',
);
if (toolUseBlock) {
hasToolUse = true;
}
}
}
expect(hasToolUse).toBe(true);
expect(toolCalls.length).toBeGreaterThan(0);
expect(toolCalls[0].toolName).toBeDefined();
expect(toolCalls[0].input).toBeDefined();
} finally {
await q.close();
}
});
it('should allow tool execution when canUseTool returns allow', async () => {
let callbackInvoked = false;
const q = query({
prompt: 'Create a file named hello.txt with content "world"',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input) => {
callbackInvoked = true;
return {
behavior: 'allow',
updatedInput: input,
};
},
},
});
try {
let hasToolResult = false;
for await (const message of q) {
if (isCLIUserMessage(message)) {
if (
Array.isArray(message.message.content) &&
message.message.content.some(
(block) => block.type === 'tool_result',
)
) {
hasToolResult = true;
}
}
}
expect(callbackInvoked).toBe(true);
expect(hasToolResult).toBe(true);
} finally {
await q.close();
}
});
it('should deny tool execution when canUseTool returns deny', async () => {
let callbackInvoked = false;
const q = query({
prompt: 'Create a file named test.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
canUseTool: async () => {
callbackInvoked = true;
return {
behavior: 'deny',
message: 'Tool execution denied by test',
};
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
expect(callbackInvoked).toBe(true);
// Tool use might still appear, but execution should be denied
// The exact behavior depends on CLI implementation
} finally {
await q.close();
}
});
it('should pass suggestions to canUseTool callback', async () => {
let receivedSuggestions: unknown = null;
const q = query({
prompt: 'Create a file named data.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input, options) => {
receivedSuggestions = options?.suggestions;
return {
behavior: 'allow',
updatedInput: input,
};
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
// Suggestions may be null or an array, depending on CLI implementation
expect(receivedSuggestions !== undefined).toBe(true);
} finally {
await q.close();
}
});
it('should pass abort signal to canUseTool callback', async () => {
let receivedSignal: AbortSignal | undefined = undefined;
const q = query({
prompt: 'Create a file named signal.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input, options) => {
receivedSignal = options?.signal;
return {
behavior: 'allow',
updatedInput: input,
};
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
expect(receivedSignal).toBeDefined();
expect(receivedSignal).toBeInstanceOf(AbortSignal);
} finally {
await q.close();
}
});
it('should allow updatedInput modification in canUseTool callback', async () => {
const originalInputs: Record<string, unknown>[] = [];
const updatedInputs: Record<string, unknown>[] = [];
const q = query({
prompt: 'Create a file named modified.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input) => {
originalInputs.push({ ...input });
const updatedInput = {
...input,
modified: true,
testKey: 'testValue',
};
updatedInputs.push(updatedInput);
return {
behavior: 'allow',
updatedInput,
};
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
expect(originalInputs.length).toBeGreaterThan(0);
expect(updatedInputs.length).toBeGreaterThan(0);
expect(updatedInputs[0]?.['modified']).toBe(true);
expect(updatedInputs[0]?.['testKey']).toBe('testValue');
} finally {
await q.close();
}
});
it('should default to deny when canUseTool is not provided', async () => {
const q = query({
prompt: 'Create a file named default.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
// canUseTool not provided
},
});
try {
// When canUseTool is not provided, tools should be denied by default
// The exact behavior depends on CLI implementation
for await (const _message of q) {
// Consume all messages
}
// Test passes if no errors occur
expect(true).toBe(true);
} finally {
await q.close();
}
});
});
describe('setPermissionMode API', () => {
it('should change permission mode from default to yolo', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 1 + 1?',
'What is 2 + 2?',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
debug: true,
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
(async () => {
for await (const message of q) {
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
40000,
),
),
]);
expect(firstResponseReceived).toBe(true);
await q.setPermissionMode('yolo');
resume();
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
40000,
),
),
]);
expect(secondResponseReceived).toBe(true);
} finally {
await q.close();
}
});
it('should change permission mode from yolo to plan', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 3 + 3?',
'What is 4 + 4?',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'yolo',
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
(async () => {
for await (const message of q) {
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
10000,
),
),
]);
expect(firstResponseReceived).toBe(true);
await q.setPermissionMode('plan');
resume();
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
10000,
),
),
]);
expect(secondResponseReceived).toBe(true);
} finally {
await q.close();
}
});
it('should change permission mode to auto-edit', async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'What is 5 + 5?',
'What is 6 + 6?',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
(async () => {
for await (const message of q) {
if (isCLIAssistantMessage(message) || isCLIResultMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
10000,
),
),
]);
expect(firstResponseReceived).toBe(true);
await q.setPermissionMode('auto-edit');
resume();
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
10000,
),
),
]);
expect(secondResponseReceived).toBe(true);
} finally {
await q.close();
}
});
it('should throw error when setPermissionMode is called on closed query', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
},
});
await q.close();
await expect(q.setPermissionMode('yolo')).rejects.toThrow(
'Query is closed',
);
});
});
describe('canUseTool and setPermissionMode integration', () => {
it('should work together - canUseTool callback with dynamic permission mode change', async () => {
const toolCalls: Array<{
toolName: string;
input: Record<string, unknown>;
}> = [];
const { generator, resume } = createStreamingInputWithControlPoint(
'Create a file named first.txt',
'Create a file named second.txt',
);
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input) => {
toolCalls.push({ toolName, input });
return {
behavior: 'allow',
updatedInput: input,
};
},
},
});
try {
const resolvers: {
first?: () => void;
second?: () => void;
} = {};
const firstResponsePromise = new Promise<void>((resolve) => {
resolvers.first = resolve;
});
const secondResponsePromise = new Promise<void>((resolve) => {
resolvers.second = resolve;
});
let firstResponseReceived = false;
let secondResponseReceived = false;
(async () => {
for await (const message of q) {
if (isCLIResultMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
} else if (!secondResponseReceived) {
secondResponseReceived = true;
resolvers.second?.();
}
}
}
})();
await Promise.race([
firstResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for first response')),
TEST_TIMEOUT,
),
),
]);
expect(firstResponseReceived).toBe(true);
expect(toolCalls.length).toBeGreaterThan(0);
await q.setPermissionMode('yolo');
resume();
await Promise.race([
secondResponsePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for second response')),
TEST_TIMEOUT,
),
),
]);
expect(secondResponseReceived).toBe(true);
} finally {
await q.close();
}
});
});
});

View File

@@ -0,0 +1,479 @@
/**
* E2E tests for single-turn query execution
* Tests basic query patterns with simple prompts and clear output expectations
*/
import { describe, it, expect } from 'vitest';
import { query } from '../../src/index.js';
import {
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
type TextBlock,
type ContentBlock,
type CLIMessage,
type CLISystemMessage,
type CLIAssistantMessage,
} from '../../src/types/protocol.js';
const TEST_CLI_PATH = process.env['TEST_CLI_PATH']!;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
};
/**
* Helper to extract text from ContentBlock array
*/
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
describe('Single-Turn Query (E2E)', () => {
describe('Simple Text Queries', () => {
it('should answer basic arithmetic question', async () => {
const q = query({
prompt: 'What is 2 + 2? Just give me the number.',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Validate we got messages
expect(messages.length).toBeGreaterThan(0);
// Validate assistant response content
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText).toMatch(/4/);
// Validate message flow ends with success
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
if (isCLIResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
});
it('should answer simple factual question', async () => {
const q = query({
prompt: 'What is the capital of France? One word answer.',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Validate content
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText.toLowerCase()).toContain('paris');
// Validate completion
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
});
it('should handle greeting and self-description', async () => {
const q = query({
prompt: 'Say hello and tell me your name in one sentence.',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
assistantText += extractText(message.message.content);
}
}
// Validate content contains greeting
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/);
// Validate message types
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
});
describe('System Initialization', () => {
it('should receive system message with initialization info', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
let systemMessage: CLISystemMessage | null = null;
try {
for await (const message of q) {
messages.push(message);
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
}
}
// Validate system message exists and has required fields
expect(systemMessage).not.toBeNull();
expect(systemMessage!.type).toBe('system');
expect(systemMessage!.subtype).toBe('init');
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.cwd).toBeDefined();
expect(systemMessage!.tools).toBeDefined();
expect(Array.isArray(systemMessage!.tools)).toBe(true);
expect(systemMessage!.mcp_servers).toBeDefined();
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
expect(systemMessage!.model).toBeDefined();
expect(systemMessage!.permissionMode).toBeDefined();
expect(systemMessage!.qwen_code_version).toBeDefined();
// Validate system message appears early in sequence
const systemMessageIndex = messages.findIndex(
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
);
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
expect(systemMessageIndex).toBeLessThan(3);
} finally {
await q.close();
}
});
it('should maintain session ID consistency', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let systemMessage: CLISystemMessage | null = null;
const sessionId = q.getSessionId();
try {
for await (const message of q) {
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
}
}
// Validate session IDs are consistent
expect(sessionId).toBeDefined();
expect(systemMessage).not.toBeNull();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
} finally {
await q.close();
}
});
});
describe('Message Flow', () => {
it('should follow expected message sequence', async () => {
const q = query({
prompt: 'Say hi',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messageTypes: string[] = [];
try {
for await (const message of q) {
messageTypes.push(message.type);
}
// Validate message sequence
expect(messageTypes.length).toBeGreaterThan(0);
expect(messageTypes).toContain('assistant');
expect(messageTypes[messageTypes.length - 1]).toBe('result');
} finally {
await q.close();
}
});
it('should complete iteration naturally', async () => {
const q = query({
prompt: 'Say goodbye',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let completedNaturally = false;
let messageCount = 0;
try {
for await (const message of q) {
messageCount++;
if (isCLIResultMessage(message)) {
completedNaturally = true;
expect(message.subtype).toBe('success');
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
});
});
describe('Configuration Options', () => {
it('should respect debug option and capture stderr', async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
stderr: (msg: string) => {
stderrMessages.push(msg);
},
},
});
try {
for await (const _message of q) {
// Consume all messages
}
// Debug mode should produce stderr output
expect(stderrMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
it('should respect cwd option', async () => {
const testDir = process.cwd();
const q = query({
prompt: 'What is 1 + 1?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
});
});
describe('Message Type Recognition', () => {
it('should correctly identify all message types', async () => {
const q = query({
prompt: 'What is 5 + 5?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate type guards work correctly
const assistantMessages = messages.filter(isCLIAssistantMessage);
const resultMessages = messages.filter(isCLIResultMessage);
const systemMessages = messages.filter(isCLISystemMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(resultMessages.length).toBeGreaterThan(0);
expect(systemMessages.length).toBeGreaterThan(0);
// Validate assistant message structure
const firstAssistant = assistantMessages[0];
expect(firstAssistant.message.content).toBeDefined();
expect(Array.isArray(firstAssistant.message.content)).toBe(true);
// Validate result message structure
const resultMessage = resultMessages[0];
expect(resultMessage.subtype).toBe('success');
} finally {
await q.close();
}
});
it('should extract text content from assistant messages', async () => {
const q = query({
prompt: 'Count from 1 to 3',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let assistantMessage: CLIAssistantMessage | null = null;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessage = message;
}
}
expect(assistantMessage).not.toBeNull();
expect(assistantMessage!.message.content).toBeDefined();
// Extract text blocks
const textBlocks = assistantMessage!.message.content.filter(
(block: ContentBlock): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text).toBeDefined();
expect(textBlocks[0].text.length).toBeGreaterThan(0);
// Validate content contains expected numbers
const text = extractText(assistantMessage!.message.content);
expect(text).toMatch(/1/);
expect(text).toMatch(/2/);
expect(text).toMatch(/3/);
} finally {
await q.close();
}
});
});
describe('Error Handling', () => {
it('should throw if CLI not found', async () => {
try {
const q = query({
prompt: 'Hello',
options: {
pathToQwenExecutable: '/nonexistent/path/to/cli',
debug: false,
},
});
for await (const _message of q) {
// Should not reach here
}
expect(false).toBe(true); // Should have thrown
} catch (error) {
expect(error).toBeDefined();
expect(error instanceof Error).toBe(true);
expect((error as Error).message).toContain(
'Invalid pathToQwenExecutable',
);
}
});
});
describe('Resource Management', () => {
it('should cleanup subprocess on close()', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
// Start and immediately close
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Should close without error
await q.close();
expect(true).toBe(true); // Cleanup completed
});
it('should handle close() called multiple times', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
// Start the query
const iterator = q[Symbol.asyncIterator]();
await iterator.next();
// Close multiple times
await q.close();
await q.close();
await q.close();
// Should not throw
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,207 @@
/**
* Unit tests for ProcessTransport
* Tests subprocess lifecycle management and IPC
*/
import { describe, expect, it } from 'vitest';
// Note: This is a placeholder test file
// ProcessTransport will be implemented in Phase 3 Implementation (T021)
// These tests are written first following TDD approach
describe('ProcessTransport', () => {
describe('Construction and Initialization', () => {
it('should create transport with required options', () => {
// Test will be implemented with actual ProcessTransport class
expect(true).toBe(true); // Placeholder
});
it('should validate pathToQwenExecutable exists', () => {
// Should throw if pathToQwenExecutable does not exist
expect(true).toBe(true); // Placeholder
});
it('should build CLI arguments correctly', () => {
// Should include --input-format stream-json --output-format stream-json
expect(true).toBe(true); // Placeholder
});
});
describe('Lifecycle Management', () => {
it('should spawn subprocess during construction', async () => {
// Should call child_process.spawn in constructor
expect(true).toBe(true); // Placeholder
});
it('should set isReady to true after successful initialization', async () => {
// isReady should be true after construction completes
expect(true).toBe(true); // Placeholder
});
it('should throw if subprocess fails to spawn', async () => {
// Should throw Error if ENOENT or spawn fails
expect(true).toBe(true); // Placeholder
});
it('should close subprocess gracefully with SIGTERM', async () => {
// Should send SIGTERM first
expect(true).toBe(true); // Placeholder
});
it('should force kill with SIGKILL after timeout', async () => {
// Should send SIGKILL after 5s if process doesn\'t exit
expect(true).toBe(true); // Placeholder
});
it('should be idempotent when calling close() multiple times', async () => {
// Multiple close() calls should not error
expect(true).toBe(true); // Placeholder
});
it('should wait for process exit in waitForExit()', async () => {
// Should resolve when process exits
expect(true).toBe(true); // Placeholder
});
});
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
});
it('should yield parsed messages via readMessages()', async () => {
// Should yield messages as async generator
expect(true).toBe(true); // Placeholder
});
it('should skip malformed JSON lines with warning', async () => {
// Should log warning and continue on parse error
expect(true).toBe(true); // Placeholder
});
it('should complete generator when process exits', async () => {
// readMessages() should complete when stdout closes
expect(true).toBe(true); // Placeholder
});
it('should set exitError on unexpected process crash', async () => {
// exitError should be set if process crashes
expect(true).toBe(true); // Placeholder
});
});
describe('Message Writing', () => {
it('should write JSON Lines to stdin', () => {
// Should write JSON + newline to stdin
expect(true).toBe(true); // Placeholder
});
it('should throw if writing before transport is ready', () => {
// write() should throw if isReady is false
expect(true).toBe(true); // Placeholder
});
it('should throw if writing to closed transport', () => {
// write() should throw if transport is closed
expect(true).toBe(true); // Placeholder
});
});
describe('Error Handling', () => {
it('should handle process spawn errors', async () => {
// Should throw descriptive error on spawn failure
expect(true).toBe(true); // Placeholder
});
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 handle write errors to closed stdin', () => {
// Should throw if stdin is closed
expect(true).toBe(true); // Placeholder
});
});
describe('Resource Cleanup', () => {
it('should register cleanup on parent process exit', () => {
// Should register process.on(\'exit\') handler
expect(true).toBe(true); // Placeholder
});
it('should kill subprocess on parent exit', () => {
// Cleanup should kill child process
expect(true).toBe(true); // Placeholder
});
it('should remove event listeners on close', async () => {
// Should clean up all event listeners
expect(true).toBe(true); // Placeholder
});
});
describe('CLI Arguments', () => {
it('should include --input-format stream-json', () => {
// Args should always include input format flag
expect(true).toBe(true); // Placeholder
});
it('should include --output-format stream-json', () => {
// Args should always include output format flag
expect(true).toBe(true); // Placeholder
});
it('should include --model if provided', () => {
// Args should include model flag if specified
expect(true).toBe(true); // Placeholder
});
it('should include --permission-mode if provided', () => {
// Args should include permission mode flag if specified
expect(true).toBe(true); // Placeholder
});
it('should include --mcp-server for external MCP servers', () => {
// Args should include MCP server configs
expect(true).toBe(true); // Placeholder
});
});
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 default to process.cwd() if not specified', async () => {
// Should use current working directory by default
expect(true).toBe(true); // Placeholder
});
});
describe('Environment Variables', () => {
it('should pass environment variables to subprocess', async () => {
// Should merge env with process.env
expect(true).toBe(true); // Placeholder
});
it('should inherit parent env by default', async () => {
// Should use process.env if no env option
expect(true).toBe(true); // Placeholder
});
});
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
});
it('should ignore stderr when debug is false', async () => {
// Should set stderr: \'ignore\' if debug flag not set
expect(true).toBe(true); // Placeholder
});
});
});

View File

@@ -0,0 +1,284 @@
/**
* Unit tests for Query class
* Tests message routing, lifecycle, and orchestration
*/
import { describe, expect, it } from 'vitest';
// Note: This is a placeholder test file
// Query will be implemented in Phase 3 Implementation (T022)
// These tests are written first following TDD approach
describe('Query', () => {
describe('Construction and Initialization', () => {
it('should create Query with transport and options', () => {
// Should accept Transport and CreateQueryOptions
expect(true).toBe(true); // Placeholder
});
it('should generate unique session ID', () => {
// Each Query should have unique session_id
expect(true).toBe(true); // Placeholder
});
it('should validate MCP server name conflicts', () => {
// Should throw if mcpServers and sdkMcpServers have same keys
expect(true).toBe(true); // Placeholder
});
it('should lazy initialize on first message consumption', async () => {
// Should not call initialize() until messages are read
expect(true).toBe(true); // Placeholder
});
});
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 assistant messages to output stream', async () => {
// Assistant messages from CLI should be yielded to user
expect(true).toBe(true); // Placeholder
});
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 tool_result messages to output stream', async () => {
// Tool result messages should be yielded to user
expect(true).toBe(true); // Placeholder
});
it('should route result messages to output stream', async () => {
// Result messages should be yielded to user
expect(true).toBe(true); // Placeholder
});
it('should filter keep_alive messages from output', async () => {
// Keep alive messages should not be yielded to user
expect(true).toBe(true); // Placeholder
});
});
describe('Control Plane - Permission Control', () => {
it('should handle can_use_tool control requests', async () => {
// Should invoke canUseTool callback
expect(true).toBe(true); // Placeholder
});
it('should send control response with permission result', async () => {
// Should send response with allowed: true/false
expect(true).toBe(true); // Placeholder
});
it('should default to allowing tools if no callback', async () => {
// If canUseTool not provided, should allow all
expect(true).toBe(true); // Placeholder
});
it('should handle permission callback timeout', async () => {
// Should deny permission if callback exceeds 30s
expect(true).toBe(true); // Placeholder
});
it('should handle permission callback errors', async () => {
// Should deny permission if callback throws
expect(true).toBe(true); // Placeholder
});
});
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
});
it('should handle MCP message responses', async () => {
// Should send response back to CLI
expect(true).toBe(true); // Placeholder
});
it('should handle MCP message timeout', async () => {
// Should return error if MCP server doesn\'t respond in 30s
expect(true).toBe(true); // Placeholder
});
it('should handle unknown MCP server names', async () => {
// Should return error if server name not found
expect(true).toBe(true); // Placeholder
});
});
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
});
});
describe('Multi-Turn Conversation', () => {
it('should support streamInput() for follow-up messages', async () => {
// Should accept async iterable of messages
expect(true).toBe(true); // Placeholder
});
it('should maintain session context across turns', async () => {
// All messages should have same session_id
expect(true).toBe(true); // Placeholder
});
it('should throw if streamInput() called on closed query', async () => {
// Should throw Error if query is closed
expect(true).toBe(true); // Placeholder
});
});
describe('Lifecycle Management', () => {
it('should close transport on close()', async () => {
// Should call transport.close()
expect(true).toBe(true); // Placeholder
});
it('should mark query as closed', async () => {
// closed flag should be true after close()
expect(true).toBe(true); // Placeholder
});
it('should complete output stream on close()', async () => {
// inputStream should be marked done
expect(true).toBe(true); // Placeholder
});
it('should be idempotent when closing multiple times', async () => {
// Multiple close() calls should not error
expect(true).toBe(true); // Placeholder
});
it('should cleanup MCP transports on close()', async () => {
// Should close all SdkControlServerTransport instances
expect(true).toBe(true); // Placeholder
});
it('should handle abort signal cancellation', async () => {
// Should abort on AbortSignal
expect(true).toBe(true); // Placeholder
});
});
describe('Async Iteration', () => {
it('should support for await loop', async () => {
// Should implement AsyncIterator protocol
expect(true).toBe(true); // Placeholder
});
it('should yield messages in order', async () => {
// Messages should be yielded in received order
expect(true).toBe(true); // Placeholder
});
it('should complete iteration when query closes', async () => {
// for await loop should exit when query closes
expect(true).toBe(true); // Placeholder
});
it('should propagate transport errors', async () => {
// Should throw if transport encounters error
expect(true).toBe(true); // Placeholder
});
});
describe('Public API Methods', () => {
it('should provide interrupt() method', async () => {
// Should send interrupt control request
expect(true).toBe(true); // Placeholder
});
it('should provide setPermissionMode() method', async () => {
// Should send set_permission_mode control request
expect(true).toBe(true); // Placeholder
});
it('should provide supportedCommands() method', async () => {
// Should query CLI capabilities
expect(true).toBe(true); // Placeholder
});
it('should provide mcpServerStatus() method', async () => {
// Should check MCP server health
expect(true).toBe(true); // Placeholder
});
it('should throw if methods called on closed query', async () => {
// Public methods should throw if query is closed
expect(true).toBe(true); // Placeholder
});
});
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
});
it('should handle control request timeout', async () => {
// Should return error if control request doesn\'t respond
expect(true).toBe(true); // Placeholder
});
it('should handle malformed control responses', async () => {
// Should handle invalid response structures
expect(true).toBe(true); // Placeholder
});
it('should handle CLI sending error message', async () => {
// Should yield error message to user
expect(true).toBe(true); // Placeholder
});
});
describe('State Management', () => {
it('should track pending control requests', () => {
// Should maintain map of request_id -> Promise
expect(true).toBe(true); // Placeholder
});
it('should track SDK MCP transports', () => {
// Should maintain map of server_name -> SdkControlServerTransport
expect(true).toBe(true); // Placeholder
});
it('should track initialization state', () => {
// Should have initialized Promise
expect(true).toBe(true); // Placeholder
});
it('should track closed state', () => {
// Should have closed boolean flag
expect(true).toBe(true); // Placeholder
});
});
});

View File

@@ -0,0 +1,259 @@
/**
* Unit tests for SdkControlServerTransport
*
* Tests MCP message proxying between MCP Server and Query's control plane.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js';
describe('SdkControlServerTransport', () => {
let sendToQuery: ReturnType<typeof vi.fn>;
let transport: SdkControlServerTransport;
beforeEach(() => {
sendToQuery = vi.fn().mockResolvedValue({ result: 'success' });
transport = new SdkControlServerTransport({
serverName: 'test-server',
sendToQuery,
});
});
describe('Lifecycle', () => {
it('should start successfully', async () => {
await transport.start();
expect(transport.isStarted()).toBe(true);
});
it('should close successfully', async () => {
await transport.start();
await transport.close();
expect(transport.isStarted()).toBe(false);
});
it('should handle close callback', async () => {
const onclose = vi.fn();
transport.onclose = onclose;
await transport.start();
await transport.close();
expect(onclose).toHaveBeenCalled();
});
});
describe('Message Sending', () => {
it('should send message to Query', async () => {
await transport.start();
const message = {
jsonrpc: '2.0' as const,
id: 1,
method: 'tools/list',
params: {},
};
await transport.send(message);
expect(sendToQuery).toHaveBeenCalledWith(message);
});
it('should throw error when sending before start', async () => {
const message = {
jsonrpc: '2.0' as const,
id: 1,
method: 'tools/list',
};
await expect(transport.send(message)).rejects.toThrow('not started');
});
it('should handle send errors', async () => {
const error = new Error('Network error');
sendToQuery.mockRejectedValue(error);
const onerror = vi.fn();
transport.onerror = onerror;
await transport.start();
const message = {
jsonrpc: '2.0' as const,
id: 1,
method: 'tools/list',
};
await expect(transport.send(message)).rejects.toThrow('Network error');
expect(onerror).toHaveBeenCalledWith(error);
});
});
describe('Message Receiving', () => {
it('should deliver message to MCP Server via onmessage', async () => {
const onmessage = vi.fn();
transport.onmessage = onmessage;
await transport.start();
const message = {
jsonrpc: '2.0' as const,
id: 1,
result: { tools: [] },
};
transport.handleMessage(message);
expect(onmessage).toHaveBeenCalledWith(message);
});
it('should warn when receiving message without onmessage handler', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
await transport.start();
const message = {
jsonrpc: '2.0' as const,
id: 1,
result: {},
};
transport.handleMessage(message);
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it('should warn when receiving message for closed transport', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {});
const onmessage = vi.fn();
transport.onmessage = onmessage;
await transport.start();
await transport.close();
const message = {
jsonrpc: '2.0' as const,
id: 1,
result: {},
};
transport.handleMessage(message);
expect(consoleWarnSpy).toHaveBeenCalled();
expect(onmessage).not.toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
});
describe('Error Handling', () => {
it('should deliver error to MCP Server via onerror', async () => {
const onerror = vi.fn();
transport.onerror = onerror;
await transport.start();
const error = new Error('Test error');
transport.handleError(error);
expect(onerror).toHaveBeenCalledWith(error);
});
it('should log error when no onerror handler set', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await transport.start();
const error = new Error('Test error');
transport.handleError(error);
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('Server Name', () => {
it('should return server name', () => {
expect(transport.getServerName()).toBe('test-server');
});
});
describe('Bidirectional Communication', () => {
it('should support full message round-trip', async () => {
const onmessage = vi.fn();
transport.onmessage = onmessage;
await transport.start();
// Send request from MCP Server to CLI
const request = {
jsonrpc: '2.0' as const,
id: 1,
method: 'tools/list',
params: {},
};
await transport.send(request);
expect(sendToQuery).toHaveBeenCalledWith(request);
// Receive response from CLI to MCP Server
const response = {
jsonrpc: '2.0' as const,
id: 1,
result: {
tools: [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: { type: 'object' },
},
],
},
};
transport.handleMessage(response);
expect(onmessage).toHaveBeenCalledWith(response);
});
it('should handle multiple messages in sequence', async () => {
const onmessage = vi.fn();
transport.onmessage = onmessage;
await transport.start();
// Send multiple requests
for (let i = 0; i < 5; i++) {
const message = {
jsonrpc: '2.0' as const,
id: i,
method: 'test',
};
await transport.send(message);
}
expect(sendToQuery).toHaveBeenCalledTimes(5);
// Receive multiple responses
for (let i = 0; i < 5; i++) {
const message = {
jsonrpc: '2.0' as const,
id: i,
result: {},
};
transport.handleMessage(message);
}
expect(onmessage).toHaveBeenCalledTimes(5);
});
});
});

View File

@@ -0,0 +1,255 @@
/**
* Unit tests for Stream class
* Tests producer-consumer patterns and async iteration
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Stream } from '../../src/utils/Stream.js';
describe('Stream', () => {
let stream: Stream<string>;
beforeEach(() => {
stream = new Stream<string>();
});
describe('Producer-Consumer Patterns', () => {
it('should deliver enqueued value immediately to waiting consumer', async () => {
// Start consumer (waits for value)
const consumerPromise = stream.next();
// Producer enqueues value
stream.enqueue('hello');
// Consumer should receive value immediately
const result = await consumerPromise;
expect(result).toEqual({ value: 'hello', done: false });
});
it('should buffer values when consumer is slow', async () => {
// Producer enqueues multiple values
stream.enqueue('first');
stream.enqueue('second');
stream.enqueue('third');
// Consumer reads buffered values
expect(await stream.next()).toEqual({ value: 'first', done: false });
expect(await stream.next()).toEqual({ value: 'second', done: false });
expect(await stream.next()).toEqual({ value: 'third', done: false });
});
it('should handle fast producer and fast consumer', async () => {
const values: string[] = [];
// Produce and consume simultaneously
const consumerPromise = (async () => {
for (let i = 0; i < 3; i++) {
const result = await stream.next();
if (!result.done) {
values.push(result.value);
}
}
})();
stream.enqueue('a');
stream.enqueue('b');
stream.enqueue('c');
await consumerPromise;
expect(values).toEqual(['a', 'b', 'c']);
});
it('should handle async iteration with for await loop', async () => {
const values: string[] = [];
// Start consumer
const consumerPromise = (async () => {
for await (const value of stream) {
values.push(value);
}
})();
// Producer enqueues and completes
stream.enqueue('x');
stream.enqueue('y');
stream.enqueue('z');
stream.done();
await consumerPromise;
expect(values).toEqual(['x', 'y', 'z']);
});
});
describe('Stream Completion', () => {
it('should signal completion when done() is called', async () => {
stream.done();
const result = await stream.next();
expect(result).toEqual({ done: true, value: undefined });
});
it('should complete waiting consumer immediately', async () => {
const consumerPromise = stream.next();
stream.done();
const result = await consumerPromise;
expect(result).toEqual({ done: true, value: undefined });
});
it('should allow done() to be called multiple times', async () => {
stream.done();
stream.done();
stream.done();
const result = await stream.next();
expect(result).toEqual({ done: true, value: undefined });
});
it('should allow enqueuing to completed stream (no check in reference)', async () => {
stream.done();
// Reference version doesn't check for done in enqueue
stream.enqueue('value');
// Verify value was enqueued by reading it
expect(await stream.next()).toEqual({ value: 'value', done: false });
});
it('should deliver buffered values before completion', async () => {
stream.enqueue('first');
stream.enqueue('second');
stream.done();
expect(await stream.next()).toEqual({ value: 'first', done: false });
expect(await stream.next()).toEqual({ value: 'second', done: false });
expect(await stream.next()).toEqual({ done: true, value: undefined });
});
});
describe('Error Handling', () => {
it('should propagate error to waiting consumer', async () => {
const consumerPromise = stream.next();
const error = new Error('Stream error');
stream.error(error);
await expect(consumerPromise).rejects.toThrow('Stream error');
});
it('should throw error on next read after error is set', async () => {
const error = new Error('Test error');
stream.error(error);
await expect(stream.next()).rejects.toThrow('Test error');
});
it('should allow enqueuing to stream with error (no check in reference)', async () => {
stream.error(new Error('Error'));
// Reference version doesn't check for error in enqueue
stream.enqueue('value');
// Verify value was enqueued by reading it
expect(await stream.next()).toEqual({ value: 'value', done: false });
});
it('should store last error (reference overwrites)', async () => {
const firstError = new Error('First');
const secondError = new Error('Second');
stream.error(firstError);
stream.error(secondError);
await expect(stream.next()).rejects.toThrow('Second');
});
it('should deliver buffered values before throwing error', async () => {
stream.enqueue('buffered');
stream.error(new Error('Stream error'));
expect(await stream.next()).toEqual({ value: 'buffered', done: false });
await expect(stream.next()).rejects.toThrow('Stream error');
});
});
describe('State Properties', () => {
it('should track error state', () => {
expect(stream.hasError).toBeUndefined();
stream.error(new Error('Test'));
expect(stream.hasError).toBeInstanceOf(Error);
expect(stream.hasError?.message).toBe('Test');
});
});
describe('Edge Cases', () => {
it('should handle empty stream', async () => {
stream.done();
const result = await stream.next();
expect(result.done).toBe(true);
});
it('should handle single value', async () => {
stream.enqueue('only');
stream.done();
expect(await stream.next()).toEqual({ value: 'only', done: false });
expect(await stream.next()).toEqual({ done: true, value: undefined });
});
it('should handle rapid enqueue-dequeue cycles', async () => {
const numberStream = new Stream<number>();
const iterations = 100;
const values: number[] = [];
const producer = async (): Promise<void> => {
for (let i = 0; i < iterations; i++) {
numberStream.enqueue(i);
await new Promise((resolve) => setImmediate(resolve));
}
numberStream.done();
};
const consumer = async (): Promise<void> => {
for await (const value of numberStream) {
values.push(value);
}
};
await Promise.all([producer(), consumer()]);
expect(values).toHaveLength(iterations);
expect(values[0]).toBe(0);
expect(values[iterations - 1]).toBe(iterations - 1);
});
});
describe('TypeScript Types', () => {
it('should handle different value types', async () => {
const numberStream = new Stream<number>();
numberStream.enqueue(42);
numberStream.done();
const result = await numberStream.next();
expect(result.value).toBe(42);
const objectStream = new Stream<{ id: number; name: string }>();
objectStream.enqueue({ id: 1, name: 'test' });
objectStream.done();
const objectResult = await objectStream.next();
expect(objectResult.value).toEqual({ id: 1, name: 'test' });
});
});
describe('Iteration Restrictions', () => {
it('should only allow iteration once', async () => {
const stream = new Stream<string>();
stream.enqueue('test');
stream.done();
// First iteration should work
const iterator1 = stream[Symbol.asyncIterator]();
expect(await iterator1.next()).toEqual({
value: 'test',
done: false,
});
// Second iteration should throw
expect(() => stream[Symbol.asyncIterator]()).toThrow(
'Stream can only be iterated once',
);
});
});
});

View File

@@ -0,0 +1,668 @@
/**
* Unit tests for CLI path utilities
* Tests executable detection, parsing, and spawn info preparation
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execSync } from 'node:child_process';
import {
parseExecutableSpec,
prepareSpawnInfo,
findNativeCliPath,
resolveCliPath,
} from '../../src/utils/cliPath.js';
// Mock fs module
vi.mock('node:fs');
const mockFs = vi.mocked(fs);
// Mock child_process module
vi.mock('node:child_process');
const mockExecSync = vi.mocked(execSync);
// Mock process.versions for bun detection
const originalVersions = process.versions;
describe('CLI Path Utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset process.versions
Object.defineProperty(process, 'versions', {
value: { ...originalVersions },
writable: true,
});
// Default: tsx is available (can be overridden in specific tests)
mockExecSync.mockReturnValue(Buffer.from(''));
// Default: mock statSync to return a proper file stat object
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
});
afterEach(() => {
// Restore original process.versions
Object.defineProperty(process, 'versions', {
value: originalVersions,
writable: true,
});
});
describe('parseExecutableSpec', () => {
describe('auto-detection (no spec provided)', () => {
it('should auto-detect native CLI when no spec provided', () => {
// Mock environment variable
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec();
expect(result).toEqual({
executablePath: '/usr/local/bin/qwen',
isExplicitRuntime: false,
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should throw when auto-detection fails', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec()).toThrow(
'qwen CLI not found. Please:',
);
});
});
describe('runtime prefix parsing', () => {
it('should parse node runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('node:/path/to/cli.js');
expect(result).toEqual({
runtime: 'node',
executablePath: path.resolve('/path/to/cli.js'),
isExplicitRuntime: true,
});
});
it('should parse bun runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('bun:/path/to/cli.js');
expect(result).toEqual({
runtime: 'bun',
executablePath: path.resolve('/path/to/cli.js'),
isExplicitRuntime: true,
});
});
it('should parse tsx runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('tsx:/path/to/index.ts');
expect(result).toEqual({
runtime: 'tsx',
executablePath: path.resolve('/path/to/index.ts'),
isExplicitRuntime: true,
});
});
it('should parse deno runtime prefix', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('deno:/path/to/cli.ts');
expect(result).toEqual({
runtime: 'deno',
executablePath: path.resolve('/path/to/cli.ts'),
isExplicitRuntime: true,
});
});
it('should throw for invalid runtime prefix format', () => {
expect(() => parseExecutableSpec('invalid:format')).toThrow(
'Unsupported runtime',
);
});
it('should throw when runtime-prefixed file does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow(
'Executable file not found at',
);
});
});
describe('command name detection', () => {
it('should detect command names without path separators', () => {
const result = parseExecutableSpec('qwen');
expect(result).toEqual({
executablePath: 'qwen',
isExplicitRuntime: false,
});
});
it('should detect command names on Windows', () => {
const result = parseExecutableSpec('qwen.exe');
expect(result).toEqual({
executablePath: 'qwen.exe',
isExplicitRuntime: false,
});
});
});
describe('file path resolution', () => {
it('should resolve absolute file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('/absolute/path/to/qwen');
expect(result).toEqual({
executablePath: '/absolute/path/to/qwen',
isExplicitRuntime: false,
});
});
it('should resolve relative file paths', () => {
mockFs.existsSync.mockReturnValue(true);
const result = parseExecutableSpec('./relative/path/to/qwen');
expect(result).toEqual({
executablePath: path.resolve('./relative/path/to/qwen'),
isExplicitRuntime: false,
});
});
it('should throw when file path does not exist', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
});
});
describe('prepareSpawnInfo', () => {
beforeEach(() => {
mockFs.existsSync.mockReturnValue(true);
});
describe('native executables', () => {
it('should prepare spawn info for native binary command', () => {
const result = prepareSpawnInfo('qwen');
expect(result).toEqual({
command: 'qwen',
args: [],
type: 'native',
originalInput: 'qwen',
});
});
it('should prepare spawn info for native binary path', () => {
const result = prepareSpawnInfo('/usr/local/bin/qwen');
expect(result).toEqual({
command: '/usr/local/bin/qwen',
args: [],
type: 'native',
originalInput: '/usr/local/bin/qwen',
});
});
});
describe('JavaScript files', () => {
it('should use node for .js files', () => {
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: '/path/to/cli.js',
});
});
it('should default to node for .js files (not auto-detect bun)', () => {
// Even when running under bun, default to node for .js files
Object.defineProperty(process, 'versions', {
value: { ...originalVersions, bun: '1.0.0' },
writable: true,
});
const result = prepareSpawnInfo('/path/to/cli.js');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: '/path/to/cli.js',
});
});
it('should handle .mjs files', () => {
const result = prepareSpawnInfo('/path/to/cli.mjs');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.mjs')],
type: 'node',
originalInput: '/path/to/cli.mjs',
});
});
it('should handle .cjs files', () => {
const result = prepareSpawnInfo('/path/to/cli.cjs');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.cjs')],
type: 'node',
originalInput: '/path/to/cli.cjs',
});
});
});
describe('TypeScript files', () => {
it('should use tsx for .ts files when tsx is available', () => {
// tsx is available by default in beforeEach
const result = prepareSpawnInfo('/path/to/index.ts');
expect(result).toEqual({
command: 'tsx',
args: [path.resolve('/path/to/index.ts')],
type: 'tsx',
originalInput: '/path/to/index.ts',
});
});
it('should use tsx for .tsx files when tsx is available', () => {
const result = prepareSpawnInfo('/path/to/cli.tsx');
expect(result).toEqual({
command: 'tsx',
args: [path.resolve('/path/to/cli.tsx')],
type: 'tsx',
originalInput: '/path/to/cli.tsx',
});
});
it('should throw helpful error when tsx is not available', () => {
// Mock tsx not being available
mockExecSync.mockImplementation(() => {
throw new Error('Command not found');
});
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
"TypeScript file '/path/to/index.ts' requires 'tsx' runtime, but it's not available",
);
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
'Please install tsx: npm install -g tsx',
);
});
});
describe('explicit runtime specifications', () => {
it('should use explicit node runtime', () => {
const result = prepareSpawnInfo('node:/path/to/cli.js');
expect(result).toEqual({
command: process.execPath,
args: [path.resolve('/path/to/cli.js')],
type: 'node',
originalInput: 'node:/path/to/cli.js',
});
});
it('should use explicit bun runtime', () => {
const result = prepareSpawnInfo('bun:/path/to/cli.js');
expect(result).toEqual({
command: 'bun',
args: [path.resolve('/path/to/cli.js')],
type: 'bun',
originalInput: 'bun:/path/to/cli.js',
});
});
it('should use explicit tsx runtime', () => {
const result = prepareSpawnInfo('tsx:/path/to/index.ts');
expect(result).toEqual({
command: 'tsx',
args: [path.resolve('/path/to/index.ts')],
type: 'tsx',
originalInput: 'tsx:/path/to/index.ts',
});
});
it('should use explicit deno runtime', () => {
const result = prepareSpawnInfo('deno:/path/to/cli.ts');
expect(result).toEqual({
command: 'deno',
args: [path.resolve('/path/to/cli.ts')],
type: 'deno',
originalInput: 'deno:/path/to/cli.ts',
});
});
});
describe('auto-detection fallback', () => {
it('should auto-detect when no spec provided', () => {
// Mock environment variable
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
const result = prepareSpawnInfo();
expect(result).toEqual({
command: '/usr/local/bin/qwen',
args: [],
type: 'native',
originalInput: '',
});
// Restore env
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
});
});
describe('findNativeCliPath', () => {
it('should find CLI from environment variable', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
mockFs.existsSync.mockReturnValue(true);
const result = findNativeCliPath();
expect(result).toBe('/custom/path/to/qwen');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should search common installation locations', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
// Mock fs.existsSync to return true for volta bin
mockFs.existsSync.mockImplementation((path) => {
return path.toString().includes('.volta/bin/qwen');
});
const result = findNativeCliPath();
expect(result).toContain('.volta/bin/qwen');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
it('should throw descriptive error when CLI not found', () => {
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
delete process.env['QWEN_CODE_CLI_PATH'];
mockFs.existsSync.mockReturnValue(false);
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
});
});
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);
});
it('should handle development with TypeScript source', () => {
const devPath = '/Users/dev/qwen-code/packages/cli/index.ts';
const result = prepareSpawnInfo(devPath);
expect(result).toEqual({
command: 'tsx',
args: [path.resolve(devPath)],
type: 'tsx',
originalInput: devPath,
});
});
it('should handle production bundle validation', () => {
const bundlePath = '/path/to/bundled/cli.js';
const result = prepareSpawnInfo(bundlePath);
expect(result).toEqual({
command: process.execPath,
args: [path.resolve(bundlePath)],
type: 'node',
originalInput: bundlePath,
});
});
it('should handle production native binary', () => {
const result = prepareSpawnInfo('qwen');
expect(result).toEqual({
command: 'qwen',
args: [],
type: 'native',
originalInput: 'qwen',
});
});
it('should handle bun runtime with bundle', () => {
const bundlePath = '/path/to/cli.js';
const result = prepareSpawnInfo(`bun:${bundlePath}`);
expect(result).toEqual({
command: 'bun',
args: [path.resolve(bundlePath)],
type: 'bun',
originalInput: `bun:${bundlePath}`,
});
});
});
describe('error cases', () => {
it('should provide helpful error for missing TypeScript file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow(
'Executable file not found at',
);
});
it('should provide helpful error for missing JavaScript file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow(
'Executable file not found at',
);
});
it('should provide helpful error for invalid runtime specification', () => {
expect(() => prepareSpawnInfo('invalid:spec')).toThrow(
'Unsupported runtime',
);
});
});
describe('comprehensive validation', () => {
describe('runtime validation', () => {
it('should reject unsupported runtimes', () => {
expect(() =>
parseExecutableSpec('unsupported:/path/to/file.js'),
).toThrow(
"Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno",
);
});
it('should validate runtime availability for explicit runtime specs', () => {
mockFs.existsSync.mockReturnValue(true);
// Mock bun not being available
mockExecSync.mockImplementation((command) => {
if (command.includes('bun')) {
throw new Error('Command not found');
}
return Buffer.from('');
});
expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow(
"Runtime 'bun' is not available on this system. Please install it first.",
);
});
it('should allow node runtime (always available)', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow();
});
it('should validate file extension matches runtime', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow(
"File extension '.js' is not compatible with runtime 'tsx'",
);
});
it('should validate node runtime with JavaScript files', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow(
"File extension '.ts' is not compatible with runtime 'node'",
);
});
it('should accept valid runtime-file combinations', () => {
mockFs.existsSync.mockReturnValue(true);
expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow();
expect(() =>
parseExecutableSpec('node:/path/to/file.js'),
).not.toThrow();
expect(() =>
parseExecutableSpec('bun:/path/to/file.mjs'),
).not.toThrow();
});
});
describe('command name validation', () => {
it('should reject empty command names', () => {
expect(() => parseExecutableSpec('')).toThrow(
'Command name cannot be empty',
);
expect(() => parseExecutableSpec(' ')).toThrow(
'Command name cannot be empty',
);
});
it('should reject invalid command name characters', () => {
expect(() => parseExecutableSpec('qwen@invalid')).toThrow(
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
);
expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path
});
it('should accept valid command names', () => {
expect(() => parseExecutableSpec('qwen')).not.toThrow();
expect(() => parseExecutableSpec('qwen-code')).not.toThrow();
expect(() => parseExecutableSpec('qwen_code')).not.toThrow();
expect(() => parseExecutableSpec('qwen.exe')).not.toThrow();
expect(() => parseExecutableSpec('qwen123')).not.toThrow();
});
});
describe('file path validation', () => {
it('should validate file exists', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
'Executable file not found at',
);
});
it('should validate path points to a file, not directory', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => false,
} as ReturnType<typeof import('fs').statSync>);
expect(() => parseExecutableSpec('/path/to/directory')).toThrow(
'exists but is not a file',
);
});
it('should accept valid file paths', () => {
mockFs.existsSync.mockReturnValue(true);
mockFs.statSync.mockReturnValue({
isFile: () => true,
} as ReturnType<typeof import('fs').statSync>);
expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow();
expect(() => parseExecutableSpec('./relative/path')).not.toThrow();
});
});
describe('error message quality', () => {
it('should provide helpful error for missing runtime-prefixed file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
'Executable file not found at',
);
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
'Please check the file path and ensure the file exists',
);
});
it('should provide helpful error for missing regular file', () => {
mockFs.existsSync.mockReturnValue(false);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Set QWEN_CODE_CLI_PATH environment variable',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Install qwen globally: npm install -g qwen',
);
expect(() => parseExecutableSpec('/missing/file')).toThrow(
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
);
});
});
});
});

View File

@@ -0,0 +1,350 @@
/**
* Unit tests for createSdkMcpServer
*
* Tests MCP server creation and tool registration.
*/
import { describe, expect, it, vi } from 'vitest';
import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js';
import { tool } from '../../src/mcp/tool.js';
import type { ToolDefinition } from '../../src/types/config.js';
describe('createSdkMcpServer', () => {
describe('Server Creation', () => {
it('should create server with name and version', () => {
const server = createSdkMcpServer('test-server', '1.0.0', []);
expect(server).toBeDefined();
});
it('should throw error with invalid name', () => {
expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow(
'name must be a non-empty string',
);
});
it('should throw error with invalid version', () => {
expect(() => createSdkMcpServer('test', '', [])).toThrow(
'version must be a non-empty string',
);
});
it('should throw error with non-array tools', () => {
expect(() =>
createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]),
).toThrow('Tools must be an array');
});
});
describe('Tool Registration', () => {
it('should register single tool', () => {
const testTool = tool({
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
input: { type: 'string' },
},
},
handler: async () => 'result',
});
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
expect(server).toBeDefined();
});
it('should register multiple tools', () => {
const tool1 = tool({
name: 'tool1',
description: 'Tool 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool2 = tool({
name: 'tool2',
description: 'Tool 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const server = createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]);
expect(server).toBeDefined();
});
it('should throw error for duplicate tool names', () => {
const tool1 = tool({
name: 'duplicate',
description: 'Tool 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool2 = tool({
name: 'duplicate',
description: 'Tool 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
expect(() =>
createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]),
).toThrow("Duplicate tool name 'duplicate'");
});
it('should validate tool names', () => {
const invalidTool = {
name: '123invalid', // Starts with number
description: 'Invalid tool',
inputSchema: { type: 'object' },
handler: async () => 'result',
};
expect(() =>
createSdkMcpServer('test-server', '1.0.0', [
invalidTool as unknown as ToolDefinition,
]),
).toThrow('Tool name');
});
});
describe('Tool Handler Invocation', () => {
it('should invoke tool handler with correct input', async () => {
const handler = vi.fn().mockResolvedValue({ result: 'success' });
const testTool = tool({
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string' },
},
required: ['value'],
},
handler,
});
createSdkMcpServer('test-server', '1.0.0', [testTool]);
// Note: Actual invocation testing requires MCP SDK integration
// This test verifies the handler was properly registered
expect(handler).toBeDefined();
});
it('should handle async tool handlers', async () => {
const handler = vi
.fn()
.mockImplementation(async (input: { value: string }) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return { processed: input.value };
});
const testTool = tool({
name: 'async_tool',
description: 'An async tool',
inputSchema: { type: 'object' },
handler,
});
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
expect(server).toBeDefined();
});
});
describe('Type Safety', () => {
it('should preserve input type in handler', async () => {
type ToolInput = {
name: string;
age: number;
};
type ToolOutput = {
greeting: string;
};
const handler = vi
.fn()
.mockImplementation(async (input: ToolInput): Promise<ToolOutput> => {
return {
greeting: `Hello ${input.name}, age ${input.age}`,
};
});
const typedTool = tool<ToolInput, ToolOutput>({
name: 'typed_tool',
description: 'A typed tool',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name', 'age'],
},
handler,
});
const server = createSdkMcpServer('test-server', '1.0.0', [
typedTool as ToolDefinition,
]);
expect(server).toBeDefined();
});
});
describe('Error Handling in Tools', () => {
it('should handle tool handler errors gracefully', async () => {
const handler = vi.fn().mockRejectedValue(new Error('Tool failed'));
const errorTool = tool({
name: 'error_tool',
description: 'A tool that errors',
inputSchema: { type: 'object' },
handler,
});
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
expect(server).toBeDefined();
// Error handling occurs during tool invocation
});
it('should handle synchronous tool handler errors', async () => {
const handler = vi.fn().mockImplementation(() => {
throw new Error('Sync error');
});
const errorTool = tool({
name: 'sync_error_tool',
description: 'A tool that errors synchronously',
inputSchema: { type: 'object' },
handler,
});
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
expect(server).toBeDefined();
});
});
describe('Complex Tool Scenarios', () => {
it('should support tool with complex input schema', () => {
const complexTool = tool({
name: 'complex_tool',
description: 'A tool with complex schema',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
filters: {
type: 'object',
properties: {
category: { type: 'string' },
minPrice: { type: 'number' },
},
},
options: {
type: 'array',
items: { type: 'string' },
},
},
required: ['query'],
},
handler: async (input: { filters?: unknown[] }) => {
return {
results: [],
filters: input.filters,
};
},
});
const server = createSdkMcpServer('test-server', '1.0.0', [
complexTool as ToolDefinition,
]);
expect(server).toBeDefined();
});
it('should support tool returning complex output', () => {
const complexOutputTool = tool({
name: 'complex_output_tool',
description: 'Returns complex data',
inputSchema: { type: 'object' },
handler: async () => {
return {
data: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
],
metadata: {
total: 2,
page: 1,
},
nested: {
deep: {
value: 'test',
},
},
};
},
});
const server = createSdkMcpServer('test-server', '1.0.0', [
complexOutputTool,
]);
expect(server).toBeDefined();
});
});
describe('Multiple Servers', () => {
it('should create multiple independent servers', () => {
const tool1 = tool({
name: 'tool1',
description: 'Tool in server 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool2 = tool({
name: 'tool2',
description: 'Tool in server 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
expect(server1).toBeDefined();
expect(server2).toBeDefined();
});
it('should allow same tool name in different servers', () => {
const tool1 = tool({
name: 'shared_name',
description: 'Tool in server 1',
inputSchema: { type: 'object' },
handler: async () => 'result1',
});
const tool2 = tool({
name: 'shared_name',
description: 'Tool in server 2',
inputSchema: { type: 'object' },
handler: async () => 'result2',
});
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
expect(server1).toBeDefined();
expect(server2).toBeDefined();
});
});
});