UNPKG

citty-test-utils

Version:

Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.

530 lines (463 loc) 14.1 kB
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs' import { join, dirname, basename, extname } from 'node:path' import { createHash } from 'node:crypto' /** * Snapshot testing utilities for CLI output validation * Provides deterministic testing by comparing current output with stored snapshots */ // Default snapshot directory const DEFAULT_SNAPSHOT_DIR = '__snapshots__' /** * Configuration for snapshot testing */ export class SnapshotConfig { constructor(options = {}) { this.snapshotDir = options.snapshotDir || DEFAULT_SNAPSHOT_DIR this.updateSnapshots = options.updateSnapshots || false this.ciMode = options.ciMode || process.env.CI === 'true' this.ignoreWhitespace = options.ignoreWhitespace !== false // default true this.ignoreTimestamps = options.ignoreTimestamps !== false // default true this.maxDiffSize = options.maxDiffSize || 1000 this.customMatchers = options.customMatchers || [] } } /** * Snapshot manager for handling snapshot operations */ export class SnapshotManager { constructor(config = new SnapshotConfig()) { this.config = config this.snapshots = new Map() this.createdSnapshots = new Set() } /** * Generate a snapshot key from test context */ generateKey(testName, snapshotName, options = {}) { const { args, env, cwd } = options const context = { testName, snapshotName, args: args ? args.join(' ') : '', env: env ? Object.keys(env) .sort() .map((k) => `${k}=${env[k]}`) .join(',') : '', cwd: cwd || '', } const contextStr = JSON.stringify(context, Object.keys(context).sort()) return createHash('sha256').update(contextStr).digest('hex').substring(0, 16) } /** * Get snapshot file path */ getSnapshotPath(testFile, snapshotName) { const testDir = dirname(testFile) const testBaseName = basename(testFile, extname(testFile)) const snapshotDir = join(testDir, this.config.snapshotDir) // Ensure snapshot directory exists if (!existsSync(snapshotDir)) { mkdirSync(snapshotDir, { recursive: true }) } return join(snapshotDir, `${testBaseName}.${snapshotName}.snap`) } /** * Load existing snapshot */ loadSnapshot(snapshotPath) { if (!existsSync(snapshotPath)) { return null } try { const content = readFileSync(snapshotPath, 'utf8') return JSON.parse(content) } catch (error) { console.warn(`⚠️ Failed to load snapshot ${snapshotPath}: ${error.message}`) return null } } /** * Save snapshot to file */ saveSnapshot(snapshotPath, snapshotData) { try { const content = JSON.stringify(snapshotData, null, 2) writeFileSync(snapshotPath, content, 'utf8') this.createdSnapshots.add(snapshotPath) return true } catch (error) { console.error(`❌ Failed to save snapshot ${snapshotPath}: ${error.message}`) return false } } /** * Normalize data for comparison (remove timestamps, normalize whitespace, etc.) */ normalizeData(data, options = {}) { if (typeof data === 'string') { let normalized = data if (this.config.ignoreWhitespace) { // Normalize whitespace but preserve structure normalized = normalized .replace(/\r\n/g, '\n') // Normalize line endings .replace(/[ \t]+/g, ' ') // Collapse multiple spaces/tabs .replace(/\n\s+/g, '\n') // Remove leading whitespace from lines .replace(/\s+\n/g, '\n') // Remove trailing whitespace from lines .trim() } if (this.config.ignoreTimestamps) { // Remove common timestamp patterns normalized = normalized .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, '[TIMESTAMP]') .replace(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/g, '[TIMESTAMP]') .replace(/at \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'at [TIMESTAMP]') } return normalized } if (typeof data === 'object' && data !== null) { const normalized = { ...data } // Always exclude cwd to avoid temporary directory path mismatches if (normalized.cwd) { delete normalized.cwd } if (this.config.ignoreTimestamps) { // Remove timestamp fields const timestampFields = ['timestamp', 'createdAt', 'updatedAt', 'date', 'time'] timestampFields.forEach((field) => { if (normalized[field]) { normalized[field] = '[TIMESTAMP]' } }) } return normalized } return data } /** * Compare two data structures */ compareData(current, expected, path = '') { const currentNormalized = this.normalizeData(current) const expectedNormalized = this.normalizeData(expected) if (currentNormalized === expectedNormalized) { return { match: true } } if (typeof currentNormalized !== typeof expectedNormalized) { return { match: false, error: `Type mismatch at ${path}: expected ${typeof expectedNormalized}, got ${typeof currentNormalized}`, diff: this.generateDiff(currentNormalized, expectedNormalized), } } if (typeof currentNormalized === 'string') { return this.compareStrings(currentNormalized, expectedNormalized, path) } if (typeof currentNormalized === 'object') { return this.compareObjects(currentNormalized, expectedNormalized, path) } return { match: false, error: `Value mismatch at ${path}: expected ${expectedNormalized}, got ${currentNormalized}`, diff: this.generateDiff(currentNormalized, expectedNormalized), } } /** * Compare strings with detailed diff */ compareStrings(current, expected, path) { if (current === expected) { return { match: true } } const diff = this.generateStringDiff(current, expected) return { match: false, error: `String mismatch at ${path}`, diff, current: current.length > this.config.maxDiffSize ? current.substring(0, this.config.maxDiffSize) + '...' : current, expected: expected.length > this.config.maxDiffSize ? expected.substring(0, this.config.maxDiffSize) + '...' : expected, } } /** * Compare objects recursively */ compareObjects(current, expected, path) { const currentKeys = Object.keys(current || {}) const expectedKeys = Object.keys(expected || {}) const allKeys = new Set([...currentKeys, ...expectedKeys]) const differences = [] for (const key of allKeys) { const currentPath = path ? `${path}.${key}` : key const currentValue = current[key] const expectedValue = expected[key] if (!(key in current)) { differences.push({ path: currentPath, type: 'missing', expected: expectedValue, }) } else if (!(key in expected)) { differences.push({ path: currentPath, type: 'extra', current: currentValue, }) } else { const comparison = this.compareData(currentValue, expectedValue, currentPath) if (!comparison.match) { differences.push({ path: currentPath, type: 'mismatch', ...comparison, }) } } } if (differences.length === 0) { return { match: true } } return { match: false, error: `Object mismatch at ${path}`, differences, diff: this.generateDiff(current, expected), } } /** * Generate string diff */ generateStringDiff(current, expected) { const currentLines = current.split('\n') const expectedLines = expected.split('\n') const maxLines = Math.max(currentLines.length, expectedLines.length) const diff = [] for (let i = 0; i < maxLines; i++) { const currentLine = currentLines[i] || '' const expectedLine = expectedLines[i] || '' if (currentLine !== expectedLine) { diff.push({ line: i + 1, current: currentLine, expected: expectedLine, }) } } return diff.slice(0, 10) // Limit diff size } /** * Generate general diff */ generateDiff(current, expected) { return { current: typeof current === 'string' && current.length > this.config.maxDiffSize ? current.substring(0, this.config.maxDiffSize) + '...' : current, expected: typeof expected === 'string' && expected.length > this.config.maxDiffSize ? expected.substring(0, this.config.maxDiffSize) + '...' : expected, } } /** * Match snapshot - main comparison function */ matchSnapshot(currentData, testFile, snapshotName, options = {}) { const snapshotPath = this.getSnapshotPath(testFile, snapshotName) const existingSnapshot = this.loadSnapshot(snapshotPath) // If updating snapshots or no existing snapshot, save current data if (this.config.updateSnapshots || !existingSnapshot) { const snapshotData = { data: currentData, metadata: { created: new Date().toISOString(), testFile, snapshotName, options, version: '1.0.0', }, } this.saveSnapshot(snapshotPath, snapshotData) if (!existingSnapshot) { return { match: true, created: true, message: `✅ Created new snapshot: ${snapshotName}`, } } else { return { match: true, updated: true, message: `✅ Updated snapshot: ${snapshotName}`, } } } // Compare with existing snapshot const comparison = this.compareData(currentData, existingSnapshot.data) if (comparison.match) { return { match: true, message: `✅ Snapshot matches: ${snapshotName}`, } } // Generate detailed error message const errorMessage = this.generateErrorMessage(comparison, snapshotName, snapshotPath) return { match: false, error: errorMessage, snapshotPath, comparison, } } /** * Generate detailed error message for snapshot mismatch */ generateErrorMessage(comparison, snapshotName, snapshotPath) { let message = `❌ Snapshot mismatch: ${snapshotName}\n` message += `📁 Snapshot file: ${snapshotPath}\n` if (comparison.error) { message += `🔍 Error: ${comparison.error}\n` } if (comparison.diff) { message += `📊 Diff:\n` if (comparison.diff.current !== undefined) { message += `Current: ${JSON.stringify(comparison.diff.current)}\n` } if (comparison.diff.expected !== undefined) { message += `Expected: ${JSON.stringify(comparison.diff.expected)}\n` } } if (comparison.differences) { message += `🔍 Differences:\n` comparison.differences.forEach((diff) => { message += ` ${diff.path}: ${diff.type}\n` }) } message += `\n💡 To update snapshots, run with --update-snapshots flag` return message } /** * Clean up created snapshots (for testing) */ cleanup() { this.createdSnapshots.forEach((snapshotPath) => { try { if (existsSync(snapshotPath)) { const { unlinkSync } = require('node:fs') unlinkSync(snapshotPath) } } catch (error) { console.warn(`⚠️ Failed to cleanup snapshot ${snapshotPath}: ${error.message}`) } }) this.createdSnapshots.clear() } /** * Get snapshot statistics */ getStats() { return { totalSnapshots: this.snapshots.size, createdSnapshots: this.createdSnapshots.size, config: this.config, } } } // Global snapshot manager instance let globalSnapshotManager = null /** * Get or create global snapshot manager */ export function getSnapshotManager(config) { if (!globalSnapshotManager || config) { globalSnapshotManager = new SnapshotManager(config) } return globalSnapshotManager } /** * Reset global snapshot manager */ export function resetSnapshotManager() { globalSnapshotManager = null } /** * Convenience function for snapshot matching */ export function matchSnapshot(currentData, testFile, snapshotName, options = {}) { const manager = getSnapshotManager() return manager.matchSnapshot(currentData, testFile, snapshotName, options) } /** * Snapshot testing utilities */ export const snapshotUtils = { /** * Create a snapshot from CLI result */ createSnapshotFromResult(result, type = 'stdout') { switch (type) { case 'stdout': return result.stdout case 'stderr': return result.stderr case 'json': return result.json || result.stdout case 'full': return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, args: result.args, // Exclude cwd to avoid temporary directory path mismatches // cwd: result.cwd, json: result.json, } case 'output': return { stdout: result.stdout, stderr: result.stderr, } default: return result[type] || result.stdout } }, /** * Create snapshot from custom data */ createSnapshot(data, metadata = {}) { return { data, metadata: { created: new Date().toISOString(), ...metadata, }, } }, /** * Validate snapshot data */ validateSnapshot(snapshotData) { if (!snapshotData || typeof snapshotData !== 'object') { return { valid: false, error: 'Snapshot data must be an object' } } if (!snapshotData.data) { return { valid: false, error: 'Snapshot data must have a data property' } } return { valid: true } }, } export default { SnapshotConfig, SnapshotManager, getSnapshotManager, resetSnapshotManager, matchSnapshot, snapshotUtils, }