Files
2025-11-30 09:04:14 +08:00

363 lines
9.2 KiB
TypeScript

/**
* Node.js Test Helper Functions
*
* Utility functions for CLI testing with Jest
*/
import { execSync, spawn, SpawnOptions } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
/**
* CLI execution result interface
*/
export interface CLIResult {
stdout: string;
stderr: string;
code: number;
success: boolean;
}
/**
* Execute CLI command synchronously
* @param cliPath - Path to CLI executable
* @param args - Command arguments
* @param options - Execution options
* @returns CLI execution result
*/
export function runCLI(
cliPath: string,
args: string,
options: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
} = {}
): CLIResult {
try {
const stdout = execSync(`${cliPath} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
cwd: options.cwd,
env: { ...process.env, ...options.env },
timeout: options.timeout,
});
return {
stdout,
stderr: '',
code: 0,
success: true,
};
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
success: false,
};
}
}
/**
* Execute CLI command asynchronously
* @param cliPath - Path to CLI executable
* @param args - Command arguments array
* @param options - Spawn options
* @returns Promise of CLI execution result
*/
export function runCLIAsync(
cliPath: string,
args: string[],
options: SpawnOptions = {}
): Promise<CLIResult> {
return new Promise((resolve) => {
const child = spawn(cliPath, args, {
...options,
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({
stdout,
stderr,
code: code || 0,
success: code === 0,
});
});
child.on('error', (error) => {
resolve({
stdout,
stderr: stderr + error.message,
code: 1,
success: false,
});
});
});
}
/**
* Create temporary test directory
* @returns Path to temporary directory
*/
export function createTempDir(): string {
const tempDir = path.join(os.tmpdir(), `cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tempDir, { recursive: true });
return tempDir;
}
/**
* Clean up temporary directory
* @param dirPath - Directory to remove
*/
export function cleanupTempDir(dirPath: string): void {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
}
/**
* Create temporary file with content
* @param content - File content
* @param extension - File extension
* @returns Path to created file
*/
export function createTempFile(content: string, extension: string = 'txt'): string {
const tempFile = path.join(os.tmpdir(), `test-${Date.now()}.${extension}`);
fs.writeFileSync(tempFile, content);
return tempFile;
}
/**
* Assert CLI command succeeds
* @param result - CLI execution result
* @param expectedOutput - Optional expected output substring
*/
export function assertSuccess(result: CLIResult, expectedOutput?: string): void {
if (!result.success) {
throw new Error(`CLI command failed with exit code ${result.code}\nStderr: ${result.stderr}`);
}
if (expectedOutput && !result.stdout.includes(expectedOutput)) {
throw new Error(`Expected output to contain "${expectedOutput}"\nActual: ${result.stdout}`);
}
}
/**
* Assert CLI command fails
* @param result - CLI execution result
* @param expectedError - Optional expected error substring
*/
export function assertFailure(result: CLIResult, expectedError?: string): void {
if (result.success) {
throw new Error(`CLI command should have failed but succeeded\nStdout: ${result.stdout}`);
}
if (expectedError && !result.stderr.includes(expectedError) && !result.stdout.includes(expectedError)) {
throw new Error(`Expected error to contain "${expectedError}"\nActual stderr: ${result.stderr}\nActual stdout: ${result.stdout}`);
}
}
/**
* Assert exit code matches expected value
* @param result - CLI execution result
* @param expectedCode - Expected exit code
*/
export function assertExitCode(result: CLIResult, expectedCode: number): void {
if (result.code !== expectedCode) {
throw new Error(`Expected exit code ${expectedCode} but got ${result.code}\nStderr: ${result.stderr}`);
}
}
/**
* Parse JSON output from CLI
* @param result - CLI execution result
* @returns Parsed JSON object
*/
export function parseJSONOutput<T = any>(result: CLIResult): T {
try {
return JSON.parse(result.stdout);
} catch (error) {
throw new Error(`Failed to parse JSON output: ${error}\nStdout: ${result.stdout}`);
}
}
/**
* Mock environment variables for test
* @param vars - Environment variables to set
* @returns Function to restore original environment
*/
export function mockEnv(vars: Record<string, string>): () => void {
const original = { ...process.env };
Object.entries(vars).forEach(([key, value]) => {
process.env[key] = value;
});
return () => {
Object.keys(process.env).forEach((key) => {
if (!(key in original)) {
delete process.env[key];
}
});
Object.entries(original).forEach(([key, value]) => {
process.env[key] = value;
});
};
}
/**
* Wait for file to exist
* @param filePath - Path to file
* @param timeout - Timeout in milliseconds
* @returns Promise that resolves when file exists
*/
export async function waitForFile(filePath: string, timeout: number = 5000): Promise<void> {
const startTime = Date.now();
while (!fs.existsSync(filePath)) {
if (Date.now() - startTime > timeout) {
throw new Error(`Timeout waiting for file: ${filePath}`);
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Create CLI test fixture with setup and teardown
* @param setup - Setup function
* @param teardown - Teardown function
* @returns Test fixture object
*/
export function createFixture<T>(
setup: () => T | Promise<T>,
teardown: (fixture: T) => void | Promise<void>
): {
beforeEach: () => Promise<T>;
afterEach: (fixture: T) => Promise<void>;
} {
return {
beforeEach: async () => setup(),
afterEach: async (fixture: T) => teardown(fixture),
};
}
/**
* Capture stdout/stderr during function execution
* @param fn - Function to execute
* @returns Captured output
*/
export function captureOutput(fn: () => void): { stdout: string; stderr: string } {
const originalStdout = process.stdout.write;
const originalStderr = process.stderr.write;
let stdout = '';
let stderr = '';
process.stdout.write = ((chunk: any) => {
stdout += chunk.toString();
return true;
}) as any;
process.stderr.write = ((chunk: any) => {
stderr += chunk.toString();
return true;
}) as any;
try {
fn();
} finally {
process.stdout.write = originalStdout;
process.stderr.write = originalStderr;
}
return { stdout, stderr };
}
/**
* Test helper for testing CLI with different input combinations
*/
export class CLITestHarness {
constructor(private cliPath: string) {}
/**
* Run command with arguments
*/
run(args: string, options?: { cwd?: string; env?: Record<string, string> }): CLIResult {
return runCLI(this.cliPath, args, options);
}
/**
* Run command and assert success
*/
assertSuccess(args: string, expectedOutput?: string): CLIResult {
const result = this.run(args);
assertSuccess(result, expectedOutput);
return result;
}
/**
* Run command and assert failure
*/
assertFailure(args: string, expectedError?: string): CLIResult {
const result = this.run(args);
assertFailure(result, expectedError);
return result;
}
/**
* Run command and parse JSON output
*/
runJSON<T = any>(args: string): T {
const result = this.run(args);
assertSuccess(result);
return parseJSONOutput<T>(result);
}
}
/**
* Validate JSON schema in CLI output
* @param result - CLI execution result
* @param schema - Expected schema object
*/
export function validateJSONSchema(result: CLIResult, schema: Record<string, string>): void {
const output = parseJSONOutput(result);
Object.entries(schema).forEach(([key, expectedType]) => {
if (!(key in output)) {
throw new Error(`Missing expected key in JSON output: ${key}`);
}
const actualType = typeof output[key];
if (actualType !== expectedType) {
throw new Error(`Expected type ${expectedType} for key ${key}, but got ${actualType}`);
}
});
}
/**
* Compare CLI output with snapshot
* @param result - CLI execution result
* @param snapshotPath - Path to snapshot file
* @param update - Whether to update snapshot
*/
export function compareSnapshot(result: CLIResult, snapshotPath: string, update: boolean = false): void {
if (update || !fs.existsSync(snapshotPath)) {
fs.writeFileSync(snapshotPath, result.stdout);
return;
}
const snapshot = fs.readFileSync(snapshotPath, 'utf8');
if (result.stdout !== snapshot) {
throw new Error(`Output does not match snapshot\nExpected:\n${snapshot}\n\nActual:\n${result.stdout}`);
}
}