refactor: update test structure and clean up unused code in cli and sdk

This commit is contained in:
mingholy.lmh
2025-11-25 11:45:34 +08:00
parent ad9ba914e1
commit ac6aecb622
14 changed files with 2620 additions and 401 deletions

View File

@@ -1,5 +1,5 @@
export { query } from './query/createQuery.js';
export { AbortError, isAbortError } from './types/errors.js';
export { Query } from './query/Query.js';
export type { ExternalMcpServerConfig } from './types/queryOptionsSchema.js';

View File

@@ -21,28 +21,20 @@ export function query({
prompt: string | AsyncIterable<CLIUserMessage>;
options?: QueryOptions;
}): Query {
// Validate options and obtain normalized executable metadata
const parsedExecutable = validateOptions(options);
// Determine if this is a single-turn or multi-turn query
// Single-turn: string prompt (simple Q&A)
// Multi-turn: AsyncIterable prompt (streaming conversation)
const isSingleTurn = typeof prompt === 'string';
// Resolve CLI specification while preserving explicit runtime directives
const pathToQwenExecutable =
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
// Use provided abortController or create a new one
const abortController = options.abortController ?? new AbortController();
// Create transport with abortController
const transport = new ProcessTransport({
pathToQwenExecutable,
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
mcpServers: options.mcpServers,
env: options.env,
abortController,
debug: options.debug,
@@ -53,18 +45,14 @@ export function query({
authType: options.authType,
});
// Build query options with abortController
const queryOptions: QueryOptions = {
...options,
abortController,
};
// Create Query
const queryInstance = new Query(transport, queryOptions, isSingleTurn);
// Handle prompt based on type
if (isSingleTurn) {
// For single-turn queries, send the prompt directly via transport
const stringPrompt = prompt as string;
const message: CLIUserMessage = {
type: 'user',
@@ -95,16 +83,9 @@ export function query({
return queryInstance;
}
/**
* Backward compatibility alias
* @deprecated Use query() instead
*/
export const createQuery = query;
function validateOptions(
options: QueryOptions,
): ReturnType<typeof parseExecutableSpec> {
// Validate options using Zod schema
const validationResult = QueryOptionsSchema.safeParse(options);
if (!validationResult.success) {
const errors = validationResult.error.errors
@@ -113,7 +94,6 @@ function validateOptions(
throw new Error(`Invalid QueryOptions: ${errors}`);
}
// Validate executable path early to provide clear error messages
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
try {
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
@@ -122,7 +102,6 @@ function validateOptions(
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
}
// Validate no MCP server name conflicts (cross-field validation not easily expressible in Zod)
if (options.mcpServers && options.sdkMcpServers) {
const externalNames = Object.keys(options.mcpServers);
const sdkNames = Object.keys(options.sdkMcpServers);

View File

@@ -7,11 +7,6 @@ import { parseJsonLinesStream } from '../utils/jsonLines.js';
import { prepareSpawnInfo } from '../utils/cliPath.js';
import { AbortError } from '../types/errors.js';
type ExitListener = {
callback: (error?: Error) => void;
handler: (code: number | null, signal: NodeJS.Signals | null) => void;
};
export class ProcessTransport implements Transport {
private childProcess: ChildProcess | null = null;
private childStdin: Writable | null = null;
@@ -21,7 +16,6 @@ export class ProcessTransport implements Transport {
private _exitError: Error | null = null;
private closed = false;
private abortController: AbortController;
private exitListeners: ExitListener[] = [];
private processExitHandler: (() => void) | null = null;
private abortHandler: (() => void) | null = null;
@@ -115,15 +109,6 @@ export class ProcessTransport implements Transport {
this.logForDebugging(error.message);
}
}
const error = this._exitError;
for (const listener of this.exitListeners) {
try {
listener.callback(error || undefined);
} catch (err) {
this.logForDebugging(`Exit listener error: ${err}`);
}
}
});
}
@@ -192,11 +177,6 @@ export class ProcessTransport implements Transport {
this.abortHandler = null;
}
for (const { handler } of this.exitListeners) {
this.childProcess?.off('close', handler);
}
this.exitListeners = [];
if (this.childProcess && !this.childProcess.killed) {
this.childProcess.kill('SIGTERM');
setTimeout(() => {
@@ -343,30 +323,6 @@ export class ProcessTransport implements Transport {
return this._exitError;
}
onExit(callback: (error?: Error) => void): () => void {
if (!this.childProcess) {
return () => {};
}
const handler = (code: number | null, signal: NodeJS.Signals | null) => {
const error = this.getProcessExitError(code, signal);
callback(error);
};
this.childProcess.on('close', handler);
this.exitListeners.push({ callback, handler });
return () => {
if (this.childProcess) {
this.childProcess.off('close', handler);
}
const index = this.exitListeners.findIndex((l) => l.handler === handler);
if (index !== -1) {
this.exitListeners.splice(index, 1);
}
};
}
endInput(): void {
if (this.childStdin) {
this.childStdin.end();

View File

@@ -1,5 +1,4 @@
import type { PermissionMode, PermissionSuggestion } from './protocol.js';
import type { ExternalMcpServerConfig } from './queryOptionsSchema.js';
export type { PermissionMode };
@@ -23,7 +22,6 @@ export type TransportOptions = {
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
mcpServers?: Record<string, ExternalMcpServerConfig>;
env?: Record<string, string>;
abortController?: AbortController;
debug?: boolean;

View File

@@ -1,7 +1,3 @@
/**
* Async iterable queue for streaming messages between producer and consumer.
*/
export class Stream<T> implements AsyncIterable<T> {
private returned: (() => void) | undefined;
private queue: T[] = [];
@@ -24,23 +20,18 @@ export class Stream<T> implements AsyncIterable<T> {
}
async next(): Promise<IteratorResult<T>> {
// Check queue first - if there are queued items, return immediately
if (this.queue.length > 0) {
return Promise.resolve({
done: false,
value: this.queue.shift()!,
});
}
// Check if stream is done
if (this.isDone) {
return Promise.resolve({ done: true, value: undefined });
}
// Check for errors that occurred before next() was called
// This ensures errors set via error() before iteration starts are properly rejected
if (this.hasError) {
return Promise.reject(this.hasError);
}
// No queued items, not done, no error - set up promise for next value/error
return new Promise<IteratorResult<T>>((resolve, reject) => {
this.readResolve = resolve;
this.readReject = reject;
@@ -70,15 +61,12 @@ export class Stream<T> implements AsyncIterable<T> {
error(error: Error): void {
this.hasError = error;
// If readReject exists (next() has been called), reject immediately
if (this.readReject) {
const reject = this.readReject;
this.readResolve = undefined;
this.readReject = undefined;
reject(error);
}
// Otherwise, error is stored in hasError and will be rejected when next() is called
// This handles the case where error() is called before the first next() call
}
return(): Promise<IteratorResult<T>> {

View File

@@ -154,7 +154,6 @@ export function parseExecutableSpec(executableSpec?: string): {
executablePath: string;
isExplicitRuntime: boolean;
} {
// Handle empty string case first (before checking for undefined/null)
if (
executableSpec === '' ||
(executableSpec && executableSpec.trim() === '')
@@ -163,7 +162,6 @@ export function parseExecutableSpec(executableSpec?: string): {
}
if (!executableSpec) {
// Auto-detect native CLI
return {
executablePath: findNativeCliPath(),
isExplicitRuntime: false,
@@ -178,7 +176,6 @@ export function parseExecutableSpec(executableSpec?: string): {
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
}
// Validate runtime is supported
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
if (!supportedRuntimes.includes(runtime)) {
throw new Error(
@@ -186,7 +183,6 @@ export function parseExecutableSpec(executableSpec?: string): {
);
}
// Validate runtime availability
if (!validateRuntimeAvailability(runtime)) {
throw new Error(
`Runtime '${runtime}' is not available on this system. Please install it first.`,
@@ -195,7 +191,6 @@ export function parseExecutableSpec(executableSpec?: string): {
const resolvedPath = path.resolve(filePath);
// Validate file exists
if (!fs.existsSync(resolvedPath)) {
throw new Error(
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
@@ -203,7 +198,6 @@ export function parseExecutableSpec(executableSpec?: string): {
);
}
// Validate file extension matches runtime
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
const ext = path.extname(resolvedPath);
throw new Error(
@@ -285,14 +279,6 @@ function getExpectedExtensions(runtime: string): string[] {
}
}
/**
* @deprecated Use parseExecutableSpec and prepareSpawnInfo instead
*/
export function resolveCliPath(explicitPath?: string): string {
const parsed = parseExecutableSpec(explicitPath);
return parsed.executablePath;
}
function detectRuntimeFromExtension(filePath: string): string | undefined {
const ext = path.extname(filePath).toLowerCase();
@@ -356,10 +342,3 @@ export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
originalInput: executableSpec || '',
};
}
/**
* @deprecated Use prepareSpawnInfo() instead
*/
export function findCliPath(): string {
return findNativeCliPath();
}

View File

@@ -38,20 +38,16 @@ export async function* parseJsonLinesStream(
context = 'JsonLines',
): AsyncGenerator<unknown, void, unknown> {
for await (const line of lines) {
// Skip empty lines
if (line.trim().length === 0) {
continue;
}
// Parse with error handling
const message = parseJsonLineSafe(line, context);
// Skip malformed messages
if (message === null) {
continue;
}
// Validate message structure
if (!isValidMessage(message)) {
console.warn(
`[${context}] Invalid message structure (missing 'type' field), skipping:`,