chore: update ESLint configuration and improve TypeScript type handling

- Added additional test file patterns to ESLint configuration for better coverage.
- Introduced new rule to ignore unused TypeScript variables starting with an underscore.
- Updated `PermissionController` to set approval mode correctly.
- Refactored timeout constants in `Query` class for better maintainability.
- Enhanced E2E tests for permission control and multi-turn conversations with clearer prompts and expectations.
- Removed outdated simple query test file and added new single-turn test suite for better organization.
This commit is contained in:
mingholy.lmh
2025-11-21 17:49:19 +08:00
parent f635cd3070
commit 56d24a6e99
10 changed files with 540 additions and 1279 deletions

View File

@@ -150,7 +150,7 @@ export default tseslint.config(
},
},
{
files: ['packages/*/src/**/*.test.{ts,tsx}'],
files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'],
plugins: {
vitest,
},
@@ -158,6 +158,14 @@ export default tseslint.config(
...vitest.configs.recommended.rules,
'vitest/expect-expect': 'off',
'vitest/no-commented-out-tests': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
// extra settings for scripts that we run directly with node

View File

@@ -19,6 +19,7 @@ import type {
WaitingToolCall,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
ApprovalMode,
} from '@qwen-code/qwen-code-core';
import {
InputFormat,
@@ -208,6 +209,7 @@ export class PermissionController extends BaseController {
}
this.context.permissionMode = mode;
this.context.config.setApprovalMode(mode as ApprovalMode);
if (this.context.debugMode) {
console.error(

View File

@@ -293,27 +293,6 @@ export interface ConfigParameters {
inputFormat?: InputFormat;
outputFormat?: OutputFormat;
skipStartupContext?: boolean;
inputFormat?: InputFormat;
outputFormat?: OutputFormat;
}
function normalizeConfigOutputFormat(
format: OutputFormat | undefined,
): OutputFormat | undefined {
if (!format) {
return undefined;
}
switch (format) {
case 'stream-json':
return OutputFormat.STREAM_JSON;
case 'json':
case OutputFormat.JSON:
return OutputFormat.JSON;
case 'text':
case OutputFormat.TEXT:
default:
return OutputFormat.TEXT;
}
}
function normalizeConfigOutputFormat(

View File

@@ -5,6 +5,12 @@
* Implements AsyncIterator protocol for message consumption.
*/
// Timeout constants (in milliseconds)
const PERMISSION_CALLBACK_TIMEOUT = 30000; // 30 seconds
const MCP_REQUEST_TIMEOUT = 30000; // 30 seconds
const CONTROL_REQUEST_TIMEOUT = 30000; // 30 seconds
const STREAM_CLOSE_TIMEOUT = 10000; // 10 seconds
import { randomUUID } from 'node:crypto';
import type {
CLIMessage,
@@ -373,11 +379,10 @@ export class Query implements AsyncIterable<CLIMessage> {
}
try {
const timeoutMs = 30000;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error('Permission callback timeout')),
timeoutMs,
PERMISSION_CALLBACK_TIMEOUT,
);
});
@@ -484,7 +489,7 @@ export class Query implements AsyncIterable<CLIMessage> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('MCP request timeout'));
}, 30000);
}, MCP_REQUEST_TIMEOUT);
const messageId = 'id' in message ? message.id : null;
@@ -603,7 +608,7 @@ export class Query implements AsyncIterable<CLIMessage> {
const timeout = setTimeout(() => {
this.pendingControlRequests.delete(requestId);
reject(new Error(`Control request timeout: ${subtype}`));
}, 300000);
}, CONTROL_REQUEST_TIMEOUT);
this.pendingControlRequests.set(requestId, {
resolve,
@@ -771,8 +776,6 @@ export class Query implements AsyncIterable<CLIMessage> {
this.sdkMcpTransports.size > 0 &&
this.firstResultReceivedPromise
) {
const STREAM_CLOSE_TIMEOUT = 10000;
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {

View File

@@ -236,29 +236,28 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
});
let receivedResponse = false;
let endInputCalled = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
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('')
.slice(0, 100);
const text = textBlocks.map((b: TextBlock) => b.text).join('');
expect(text.length).toBeGreaterThan(0);
receivedResponse = true;
// End input after receiving first response
q.endInput();
break;
endInputCalled = true;
}
}
expect(receivedResponse).toBe(true);
expect(endInputCalled).toBe(true);
} finally {
await q.close();
}
@@ -389,9 +388,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
break;
}
// Consume all messages
}
} finally {
await q.close();

View File

@@ -179,8 +179,8 @@ describe('Control Request/Response (E2E)', () => {
'should set permission mode via control request during streaming input',
async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'List files in the current directory',
'Now read the package.json file',
'What is 1 + 1?',
'What is 2 + 2?',
);
const q = query({

View File

@@ -83,21 +83,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
session_id: sessionId,
message: {
role: 'user',
content:
'What is the name of this project? Check the package.json file.',
},
parent_tool_use_id: null,
} as CLIUserMessage;
// Wait a bit to simulate user thinking time
await new Promise((resolve) => setTimeout(resolve, 100));
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'What version is it currently on?',
content: 'What is 1 + 1?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
@@ -109,7 +95,19 @@ describe('Multi-Turn Conversations (E2E)', () => {
session_id: sessionId,
message: {
role: 'user',
content: 'What are the main dependencies?',
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;
@@ -120,14 +118,13 @@ describe('Multi-Turn Conversations (E2E)', () => {
prompt: createMultiTurnConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
const assistantMessages: CLIAssistantMessage[] = [];
let turnCount = 0;
const assistantTexts: string[] = [];
try {
for await (const message of q) {
@@ -135,13 +132,18 @@ describe('Multi-Turn Conversations (E2E)', () => {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
turnCount++;
const text = extractText(message.message.content);
assistantTexts.push(text);
}
}
expect(messages.length).toBeGreaterThan(0);
expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions
expect(turnCount).toBeGreaterThanOrEqual(3);
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();
}
@@ -160,7 +162,8 @@ describe('Multi-Turn Conversations (E2E)', () => {
session_id: sessionId,
message: {
role: 'user',
content: 'My name is Alice. Hello!',
content:
'Suppose we have 3 rabbits and 4 carrots. How many animals are there?',
},
parent_tool_use_id: null,
} as CLIUserMessage;
@@ -172,7 +175,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
session_id: sessionId,
message: {
role: 'user',
content: 'What is my name?',
content: 'How many animals are there? Only output the number',
},
parent_tool_use_id: null,
} as CLIUserMessage;
@@ -197,11 +200,11 @@ describe('Multi-Turn Conversations (E2E)', () => {
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
// The second response should reference the name Alice
// The second response should reference the color blue
const secondResponse = extractText(
assistantMessages[1].message.content,
);
expect(secondResponse.toLowerCase()).toContain('alice');
expect(secondResponse.toLowerCase()).toContain('3');
} finally {
await q.close();
}
@@ -211,7 +214,9 @@ describe('Multi-Turn Conversations (E2E)', () => {
});
describe('Tool Usage in Multi-Turn', () => {
it('should handle tool usage across multiple turns', async () => {
it(
'should handle tool usage across multiple turns',
async () => {
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
const sessionId = crypto.randomUUID();
@@ -220,7 +225,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
session_id: sessionId,
message: {
role: 'user',
content: 'List the files in the current directory',
content: 'Create a file named test.txt with content "hello"',
},
parent_tool_use_id: null,
} as CLIUserMessage;
@@ -232,7 +237,7 @@ describe('Multi-Turn Conversations (E2E)', () => {
session_id: sessionId,
message: {
role: 'user',
content: 'Now tell me about the package.json file specifically',
content: 'Now read the test.txt file',
},
parent_tool_use_id: null,
} as CLIUserMessage;
@@ -242,20 +247,21 @@ describe('Multi-Turn Conversations (E2E)', () => {
prompt: createToolConversation(),
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
cwd: '/tmp',
debug: false,
},
});
const messages: CLIMessage[] = [];
let toolUseCount = 0;
let assistantCount = 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',
@@ -264,19 +270,23 @@ describe('Multi-Turn Conversations (E2E)', () => {
toolUseCount++;
}
}
if (isCLIAssistantMessage(message)) {
assistantCount++;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(toolUseCount).toBeGreaterThan(0); // Should use tools
expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions
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();
}
}, 60000); //TEST_TIMEOUT,
},
TEST_TIMEOUT,
);
});
describe('Message Flow and Sequencing', () => {
@@ -435,10 +445,6 @@ describe('Multi-Turn Conversations (E2E)', () => {
try {
for await (const message of q) {
messages.push(message);
if (isCLIResultMessage(message)) {
break;
}
}
// Should handle empty conversation without crashing

View File

@@ -17,7 +17,7 @@ import {
const TEST_CLI_PATH =
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
const TEST_TIMEOUT = 1600000;
const TEST_TIMEOUT = 60000;
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
@@ -156,10 +156,11 @@ describe('Permission Control (E2E)', () => {
let callbackInvoked = false;
const q = query({
prompt: 'List files in the current directory',
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 {
@@ -183,9 +184,6 @@ describe('Permission Control (E2E)', () => {
hasToolResult = true;
}
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(callbackInvoked).toBe(true);
@@ -203,7 +201,7 @@ describe('Permission Control (E2E)', () => {
let callbackInvoked = false;
const q = query({
prompt: 'List files in the current directory',
prompt: 'Create a file named test.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
@@ -218,10 +216,8 @@ describe('Permission Control (E2E)', () => {
});
try {
for await (const message of q) {
if (isCLIResultMessage(message)) {
break;
}
for await (const _message of q) {
// Consume all messages
}
expect(callbackInvoked).toBe(true);
@@ -240,12 +236,13 @@ describe('Permission Control (E2E)', () => {
let receivedSuggestions: unknown = null;
const q = query({
prompt: 'List files in the current directory',
prompt: 'Create a file named data.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input, options) => {
receivedSuggestions = options.suggestions;
receivedSuggestions = options?.suggestions;
return {
behavior: 'allow',
updatedInput: input,
@@ -255,10 +252,8 @@ describe('Permission Control (E2E)', () => {
});
try {
for await (const message of q) {
if (isCLIResultMessage(message)) {
break;
}
for await (const _message of q) {
// Consume all messages
}
// Suggestions may be null or an array, depending on CLI implementation
@@ -276,12 +271,13 @@ describe('Permission Control (E2E)', () => {
let receivedSignal: AbortSignal | undefined = undefined;
const q = query({
prompt: 'List files in the current directory',
prompt: 'Create a file named signal.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input, options) => {
receivedSignal = options.signal;
receivedSignal = options?.signal;
return {
behavior: 'allow',
updatedInput: input,
@@ -291,10 +287,8 @@ describe('Permission Control (E2E)', () => {
});
try {
for await (const message of q) {
if (isCLIResultMessage(message)) {
break;
}
for await (const _message of q) {
// Consume all messages
}
expect(receivedSignal).toBeDefined();
@@ -313,10 +307,11 @@ describe('Permission Control (E2E)', () => {
const updatedInputs: Record<string, unknown>[] = [];
const q = query({
prompt: 'List files in the current directory',
prompt: 'Create a file named modified.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input) => {
originalInputs.push({ ...input });
const updatedInput = {
@@ -334,10 +329,8 @@ describe('Permission Control (E2E)', () => {
});
try {
for await (const message of q) {
if (isCLIResultMessage(message)) {
break;
}
for await (const _message of q) {
// Consume all messages
}
expect(originalInputs.length).toBeGreaterThan(0);
@@ -355,10 +348,11 @@ describe('Permission Control (E2E)', () => {
'should default to deny when canUseTool is not provided',
async () => {
const q = query({
prompt: 'List files in the current directory',
prompt: 'Create a file named default.txt',
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
// canUseTool not provided
},
});
@@ -366,10 +360,8 @@ describe('Permission Control (E2E)', () => {
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) {
if (isCLIResultMessage(message)) {
break;
}
for await (const _message of q) {
// Consume all messages
}
// Test passes if no errors occur
expect(true).toBe(true);
@@ -386,8 +378,8 @@ describe('Permission Control (E2E)', () => {
'should change permission mode from default to yolo',
async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'List files in the current directory',
'Now read the package.json file',
'What is 1 + 1?',
'What is 2 + 2?',
);
const q = query({
@@ -468,8 +460,8 @@ describe('Permission Control (E2E)', () => {
'should change permission mode from yolo to plan',
async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'List files in the current directory',
'Now read the package.json file',
'What is 3 + 3?',
'What is 4 + 4?',
);
const q = query({
@@ -550,8 +542,8 @@ describe('Permission Control (E2E)', () => {
'should change permission mode to auto-edit',
async () => {
const { generator, resume } = createStreamingInputWithControlPoint(
'List files in the current directory',
'Now read the package.json file',
'What is 5 + 5?',
'What is 6 + 6?',
);
const q = query({
@@ -650,17 +642,15 @@ describe('Permission Control (E2E)', () => {
});
describe('canUseTool and setPermissionMode integration', () => {
it(
'should work together - canUseTool callback with dynamic permission mode change',
async () => {
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(
'List files in the current directory',
'Now read the package.json file',
'Create a file named first.txt',
'Create a file named second.txt',
);
const q = query({
@@ -668,7 +658,9 @@ describe('Permission Control (E2E)', () => {
options: {
...SHARED_TEST_OPTIONS,
permissionMode: 'default',
cwd: '/tmp',
canUseTool: async (toolName, input) => {
console.log('canUseTool', toolName, input);
toolCalls.push({ toolName, input });
return {
behavior: 'allow',
@@ -695,10 +687,7 @@ describe('Permission Control (E2E)', () => {
(async () => {
for await (const message of q) {
if (
isCLIAssistantMessage(message) ||
isCLIResultMessage(message)
) {
if (isCLIResultMessage(message)) {
if (!firstResponseReceived) {
firstResponseReceived = true;
resolvers.first?.();
@@ -741,8 +730,6 @@ describe('Permission Control (E2E)', () => {
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
}, 60000); // TEST_TIMEOUT,
});
});

View File

@@ -1,747 +0,0 @@
/**
* End-to-End tests for simple query execution with real CLI
* Tests the complete SDK workflow with actual CLI subprocess
*/
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, it, expect } from 'vitest';
import {
query,
AbortError,
isAbortError,
isCLIAssistantMessage,
isCLIUserMessage,
isCLIResultMessage,
type TextBlock,
type ToolUseBlock,
type ToolResultBlock,
type ContentBlock,
type CLIMessage,
type CLIAssistantMessage,
} from '../../src/index.js';
// Test configuration
const TEST_CLI_PATH =
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
const TEST_TIMEOUT = 30000;
// Shared test options with permissionMode to allow all tools
const SHARED_TEST_OPTIONS = {
pathToQwenExecutable: TEST_CLI_PATH,
permissionMode: 'yolo' as const,
};
describe('Simple Query Execution (E2E)', () => {
describe('Basic Query Flow', () => {
it(
'should execute simple text query',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
env: {
DEBUG: '1',
},
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
// Should have at least one assistant message
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
// Should end with result message
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should receive assistant response',
async () => {
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let hasAssistantMessage = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasAssistantMessage = true;
const textBlocks = message.message.content.filter(
(block): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text.length).toBeGreaterThan(0);
break;
}
}
expect(hasAssistantMessage).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should receive result message at end',
async () => {
const q = query({
prompt: 'Simple test',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
expect(messages.length).toBeGreaterThan(0);
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
if (isCLIResultMessage(lastMessage)) {
expect(lastMessage.subtype).toBe('success');
}
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should complete iteration after result',
async () => {
const q = query({
prompt: 'Hello, who are you?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let messageCount = 0;
let completedNaturally = false;
try {
for await (const message of q) {
messageCount++;
if (isCLIResultMessage(message)) {
// Should be the last message
completedNaturally = true;
}
}
expect(messageCount).toBeGreaterThan(0);
expect(completedNaturally).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Query with Tool Usage', () => {
it(
'should handle query requiring tool execution',
async () => {
const q = query({
prompt:
'What files are in the current directory? List only the top-level files and folders.',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
let hasToolUse = false;
let hasAssistantResponse = false;
try {
for await (const message of q) {
messages.push(message);
if (isCLIAssistantMessage(message)) {
hasAssistantResponse = true;
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock) => block.type === 'tool_use',
);
if (hasToolUseBlock) {
hasToolUse = true;
}
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(hasToolUse).toBe(true);
expect(hasAssistantResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should yield tool_use messages',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let toolUseMessage: ToolUseBlock | null = null;
try {
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) {
toolUseMessage = toolUseBlock;
expect(toolUseBlock.name).toBeDefined();
expect(toolUseBlock.id).toBeDefined();
expect(toolUseBlock.input).toBeDefined();
break;
}
}
}
expect(toolUseMessage).not.toBeNull();
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should yield tool_result messages',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let toolResultMessage: ToolResultBlock | null = null;
try {
for await (const message of q) {
if (isCLIUserMessage(message)) {
// Tool results are sent as user messages with ToolResultBlock[] content
if (Array.isArray(message.message.content)) {
const toolResultBlock = message.message.content.find(
(block: ContentBlock): block is ToolResultBlock =>
block.type === 'tool_result',
);
if (toolResultBlock) {
toolResultMessage = toolResultBlock;
expect(toolResultBlock.tool_use_id).toBeDefined();
expect(toolResultBlock.content).toBeDefined();
// Content should not be a simple string but structured data
expect(typeof toolResultBlock.content).not.toBe('undefined');
break;
}
}
}
}
expect(toolResultMessage).not.toBeNull();
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should yield final assistant response',
async () => {
const q = query({
prompt: 'List files in current directory and tell me what you found',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const assistantMessages: CLIAssistantMessage[] = [];
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessages.push(message);
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(assistantMessages.length).toBeGreaterThan(0);
// Final assistant message should contain summary
const finalAssistant =
assistantMessages[assistantMessages.length - 1];
const textBlocks = finalAssistant.message.content.filter(
(block: ContentBlock): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text.length).toBeGreaterThan(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Configuration Options', () => {
it(
'should respect cwd option',
async () => {
const testDir = '/tmp';
const q = query({
prompt: 'What is the current working directory?',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
// Should execute in specified directory
break;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should use explicit CLI path when provided',
async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
break;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
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
},
TEST_TIMEOUT,
);
});
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,
},
});
// Should not reach here - query() should throw immediately
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',
);
}
},
TEST_TIMEOUT,
);
});
describe('Timeout and Cancellation', () => {
it(
'should support AbortSignal cancellation',
async () => {
const controller = new AbortController();
// Abort after 2 seconds
setTimeout(() => {
controller.abort();
}, 2000);
const q = query({
prompt: 'Write a very long story about TypeScript',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
try {
for await (const _message of q) {
// Should be interrupted by abort
}
// Should not reach here
expect(false).toBe(true);
} catch (error) {
expect(isAbortError(error)).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should cleanup on cancellation',
async () => {
const controller = new AbortController();
const q = query({
prompt: 'Write a very long essay',
options: {
...SHARED_TEST_OPTIONS,
abortController: controller,
debug: false,
},
});
// Abort immediately
setTimeout(() => controller.abort(), 100);
try {
for await (const _message of q) {
// Should be interrupted
}
} catch (error) {
expect(error instanceof AbortError).toBe(true);
} finally {
// Should cleanup successfully even after abort
await q.close();
expect(true).toBe(true); // Cleanup completed
}
},
TEST_TIMEOUT,
);
});
describe('Message Collection Patterns', () => {
it(
'should collect all messages in array',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
expect(messages.length).toBeGreaterThan(0);
// Should have various message types
const messageTypes = messages.map((m) => m.type);
expect(messageTypes).toContain('assistant');
expect(messageTypes).toContain('result');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should extract final answer',
async () => {
const q = query({
prompt: 'What is the capital of France?',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Get last assistant message content
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
const lastAssistant = assistantMessages[assistantMessages.length - 1];
const textBlocks = lastAssistant.message.content.filter(
(block: ContentBlock): block is TextBlock => block.type === 'text',
);
expect(textBlocks.length).toBeGreaterThan(0);
expect(textBlocks[0].text).toContain('Paris');
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should track tool usage',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Count tool_use blocks in assistant messages and tool_result blocks in user messages
let toolUseCount = 0;
let toolResultCount = 0;
messages.forEach((message) => {
if (isCLIAssistantMessage(message)) {
message.message.content.forEach((block: ContentBlock) => {
if (block.type === 'tool_use') {
toolUseCount++;
}
});
} else if (isCLIUserMessage(message)) {
// Tool results are in user messages
if (Array.isArray(message.message.content)) {
message.message.content.forEach((block: ContentBlock) => {
if (block.type === 'tool_result') {
toolResultCount++;
}
});
}
}
});
expect(toolUseCount).toBeGreaterThan(0);
expect(toolResultCount).toBeGreaterThan(0);
// Each tool_use should have a corresponding tool_result
expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Real-World Scenarios', () => {
it(
'should handle code analysis query',
async () => {
const q = query({
prompt:
'What is the main export of the package.json file in this directory?',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let hasAnalysis = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
hasAnalysis = true;
break;
}
}
}
expect(hasAnalysis).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should handle multi-step query',
async () => {
const q = query({
prompt:
'List the files in this directory and tell me what type of project this is',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let hasToolUse = false;
let hasAnalysis = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
const hasToolUseBlock = message.message.content.some(
(block: ContentBlock) => block.type === 'tool_use',
);
if (hasToolUseBlock) {
hasToolUse = true;
}
}
if (isCLIAssistantMessage(message)) {
const textBlocks = message.message.content.filter(
(block: ContentBlock): block is TextBlock =>
block.type === 'text',
);
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
hasAnalysis = true;
}
}
if (isCLIResultMessage(message)) {
break;
}
}
expect(hasToolUse).toBe(true);
expect(hasAnalysis).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
});

View File

@@ -1,28 +1,19 @@
/**
* E2E tests based on basic-usage.ts example
* Tests message type recognition and basic query patterns
* 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 {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
type TextBlock,
type ContentBlock,
type CLIMessage,
type ControlMessage,
type CLISystemMessage,
type CLIUserMessage,
type CLIAssistantMessage,
type ToolUseBlock,
type ToolResultBlock,
} from '../../src/types/protocol.js';
// Test configuration
@@ -37,143 +28,48 @@ const SHARED_TEST_OPTIONS = {
};
/**
* Determine the message type using protocol type guards
* Helper to extract text from ContentBlock array
*/
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';
}
function extractText(content: ContentBlock[]): string {
return content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
}
describe('Basic Usage (E2E)', () => {
describe('Message Type Recognition', () => {
it('should correctly identify message types using type guards', async () => {
describe('Single-Turn Query (E2E)', () => {
describe('Simple Text Queries', () => {
it(
'should answer basic arithmetic question',
async () => {
const q = query({
prompt:
'What files are in the current directory? List only the top-level files and folders.',
prompt: 'What is 2 + 2? Just give me the number.',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: true,
debug: false,
},
});
const messages: CLIMessage[] = [];
const messageTypes: string[] = [];
let assistantText = '';
try {
for await (const message of q) {
messages.push(message);
const messageType = getMessageType(message);
messageTypes.push(messageType);
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(messageTypes.length).toBe(messages.length);
// Should have at least assistant and result messages
expect(messageTypes.some((type) => type.includes('ASSISTANT'))).toBe(
true,
);
expect(messageTypes.some((type) => type.includes('RESULT'))).toBe(true);
// Verify type guards work correctly
const assistantMessages = messages.filter(isCLIAssistantMessage);
const resultMessages = messages.filter(isCLIResultMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(resultMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
});
it(
'should handle message content extraction',
async () => {
const q = query({
prompt: 'Say hello and explain what you are',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
},
});
let assistantMessage: CLIAssistantMessage | null = null;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
assistantMessage = message;
break;
assistantText += extractText(message.message.content);
}
}
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);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('Basic Query Patterns', () => {
it(
'should handle simple question-answer pattern',
async () => {
const q = query({
prompt: 'What is 2 + 2?',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
},
});
const messages: CLIMessage[] = [];
try {
for await (const message of q) {
messages.push(message);
}
// Validate we got messages
expect(messages.length).toBeGreaterThan(0);
// Should have assistant response
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
// Validate assistant response content
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText).toMatch(/4/);
// Should end with result
// Validate message flow ends with success
const lastMessage = messages[messages.length - 1];
expect(isCLIResultMessage(lastMessage)).toBe(true);
if (isCLIResultMessage(lastMessage)) {
@@ -187,63 +83,70 @@ describe('Basic Usage (E2E)', () => {
);
it(
'should handle file system query pattern',
'should answer simple factual question',
async () => {
const q = query({
prompt:
'What files are in the current directory? List only the top-level files and folders.',
prompt: 'What is the capital of France? One word answer.',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: true,
debug: false,
},
});
const messages: CLIMessage[] = [];
let hasToolUse = false;
let hasToolResult = false;
let assistantText = '';
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',
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();
}
},
TEST_TIMEOUT,
);
if (toolUseBlock) {
hasToolUse = true;
expect(toolUseBlock.name).toBeDefined();
expect(toolUseBlock.id).toBeDefined();
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);
}
}
if (isCLIUserMessage(message)) {
// Tool results are sent as user messages with ToolResultBlock[] content
if (Array.isArray(message.message.content)) {
const toolResultBlock = message.message.content.find(
(block: ToolResultBlock): block is ToolResultBlock =>
block.type === 'tool_result',
);
if (toolResultBlock) {
hasToolResult = true;
expect(toolResultBlock.tool_use_id).toBeDefined();
expect(toolResultBlock.content).toBeDefined();
}
}
}
// Validate content contains greeting
expect(assistantText.length).toBeGreaterThan(0);
expect(assistantText.toLowerCase()).toMatch(/hello|hi|greetings/);
if (isCLIResultMessage(message)) {
break;
}
}
expect(messages.length).toBeGreaterThan(0);
expect(hasToolUse).toBe(true);
expect(hasToolResult).toBe(true);
// Should have assistant response after tool execution
// Validate message types
const assistantMessages = messages.filter(isCLIAssistantMessage);
expect(assistantMessages.length).toBeGreaterThan(0);
} finally {
@@ -254,73 +157,9 @@ describe('Basic Usage (E2E)', () => {
);
});
describe('Configuration and Options', () => {
describe('System Initialization', () => {
it(
'should respect debug option',
async () => {
const stderrMessages: string[] = [];
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
stderr: (message: string) => {
stderrMessages.push(message);
},
},
});
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
break;
}
}
// Debug mode should produce stderr output
expect(stderrMessages.length).toBeGreaterThan(0);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
it(
'should respect cwd option',
async () => {
const q = query({
prompt: 'List files in current directory',
options: {
...SHARED_TEST_OPTIONS,
cwd: process.cwd(),
debug: false,
},
});
let hasResponse = false;
try {
for await (const message of q) {
if (isCLIAssistantMessage(message)) {
hasResponse = true;
break;
}
}
expect(hasResponse).toBe(true);
} finally {
await q.close();
}
},
TEST_TIMEOUT,
);
});
describe('SDK-CLI Handshaking Process', () => {
it(
'should receive system message after initialization',
'should receive system message with initialization info',
async () => {
const q = query({
prompt: 'Hello',
@@ -337,24 +176,15 @@ describe('Basic Usage (E2E)', () => {
for await (const message of q) {
messages.push(message);
// Capture system message
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
break; // Exit early once we get the system message
}
// Stop after getting assistant response to avoid long execution
if (isCLIAssistantMessage(message)) {
break;
}
}
// Verify system message was received after initialization
// Validate system message exists and has required fields
expect(systemMessage).not.toBeNull();
expect(systemMessage!.type).toBe('system');
expect(systemMessage!.subtype).toBe('init');
// Validate system message structure matches sendSystemMessage()
expect(systemMessage!.uuid).toBeDefined();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.cwd).toBeDefined();
@@ -364,22 +194,14 @@ describe('Basic Usage (E2E)', () => {
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
expect(systemMessage!.model).toBeDefined();
expect(systemMessage!.permissionMode).toBeDefined();
expect(systemMessage!.slash_commands).toBeDefined();
expect(Array.isArray(systemMessage!.slash_commands)).toBe(true);
// expect(systemMessage!.apiKeySource).toBeDefined();
expect(systemMessage!.qwen_code_version).toBeDefined();
// expect(systemMessage!.output_style).toBeDefined();
expect(systemMessage!.agents).toBeDefined();
expect(Array.isArray(systemMessage!.agents)).toBe(true);
// expect(systemMessage!.skills).toBeDefined();
// expect(Array.isArray(systemMessage!.skills)).toBe(true);
// Verify system message appears early in the message sequence
// 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); // Should be one of the first few messages
expect(systemMessageIndex).toBeLessThan(3);
} finally {
await q.close();
}
@@ -388,7 +210,7 @@ describe('Basic Usage (E2E)', () => {
);
it(
'should handle initialization with session ID consistency',
'should maintain session ID consistency',
async () => {
const q = query({
prompt: 'Hello',
@@ -399,41 +221,21 @@ describe('Basic Usage (E2E)', () => {
});
let systemMessage: CLISystemMessage | null = null;
let userMessage: CLIUserMessage | null = null;
const sessionId = q.getSessionId();
try {
for await (const message of q) {
// Capture system message
if (isCLISystemMessage(message) && message.subtype === 'init') {
systemMessage = message;
}
// Capture user message
if (isCLIUserMessage(message)) {
userMessage = message;
}
// Stop after getting assistant response to avoid long execution
if (isCLIAssistantMessage(message)) {
break;
}
}
// Verify session IDs are consistent within the system
// Validate session IDs are consistent
expect(sessionId).toBeDefined();
expect(systemMessage).not.toBeNull();
expect(systemMessage!.session_id).toBeDefined();
expect(systemMessage!.uuid).toBeDefined();
// System message should have consistent session_id and uuid
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
if (userMessage) {
expect(userMessage.session_id).toBeDefined();
// User message should have the same session_id as system message
expect(userMessage.session_id).toBe(systemMessage!.session_id);
}
} finally {
await q.close();
}
@@ -442,36 +244,29 @@ describe('Basic Usage (E2E)', () => {
);
});
describe('Message Flow Validation', () => {
describe('Message Flow', () => {
it(
'should follow expected message sequence',
async () => {
const q = query({
prompt: 'What is the current time?',
prompt: 'Say hi',
options: {
...SHARED_TEST_OPTIONS,
debug: false,
},
});
const messageSequence: string[] = [];
const messageTypes: string[] = [];
try {
for await (const message of q) {
messageSequence.push(message.type);
if (isCLIResultMessage(message)) {
break;
}
messageTypes.push(message.type);
}
expect(messageSequence.length).toBeGreaterThan(0);
// Should end with result
expect(messageSequence[messageSequence.length - 1]).toBe('result');
// Should have at least one assistant message
expect(messageSequence).toContain('assistant');
// Validate message sequence
expect(messageTypes.length).toBeGreaterThan(0);
expect(messageTypes).toContain('assistant');
expect(messageTypes[messageTypes.length - 1]).toBe('result');
} finally {
await q.close();
}
@@ -480,13 +275,13 @@ describe('Basic Usage (E2E)', () => {
);
it(
'should handle graceful completion',
'should complete iteration naturally',
async () => {
const q = query({
prompt: 'Say goodbye',
options: {
...SHARED_TEST_OPTIONS,
debug: true,
debug: false,
},
});
@@ -512,4 +307,235 @@ describe('Basic Usage (E2E)', () => {
TEST_TIMEOUT,
);
});
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: (message: string) => {
stderrMessages.push(message);
},
},
});
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();
}
},
TEST_TIMEOUT,
);
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();
}
},
TEST_TIMEOUT,
);
});
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();
}
},
TEST_TIMEOUT,
);
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();
}
},
TEST_TIMEOUT,
);
});
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',
);
}
},
TEST_TIMEOUT,
);
});
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
},
TEST_TIMEOUT,
);
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);
},
TEST_TIMEOUT,
);
});
});