363 lines
9.2 KiB
TypeScript
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}`);
|
|
}
|
|
}
|