UNPKG

@unblessed/vrt

Version:

Visual Regression Testing tools for @unblessed terminal UI applications

420 lines (416 loc) 12.3 kB
// src/comparator.ts import { readFileSync } from "fs"; var VRTComparator = class { /** * Compare two VRT recordings * @param expected - Path to expected recording or VRTRecording object * @param actual - Path to actual recording or VRTRecording object * @param options - Comparison options * @returns Comparison result */ static compare(expected, actual, options = {}) { const expectedRecording = typeof expected === "string" ? JSON.parse(readFileSync(expected, "utf8")) : expected; const actualRecording = typeof actual === "string" ? JSON.parse(readFileSync(actual, "utf8")) : actual; return this.compareRecordings(expectedRecording, actualRecording, options); } /** * Compare two VRTRecording objects * @param expected - Expected recording * @param actual - Actual recording * @param options - Comparison options * @returns Comparison result */ static compareRecordings(expected, actual, options = {}) { const { threshold = 0, ignoreColors = false, ignoreWhitespace = false } = options; if (expected.dimensions.cols !== actual.dimensions.cols || expected.dimensions.rows !== actual.dimensions.rows) { return { match: false, totalFrames: Math.max(expected.frames.length, actual.frames.length), matchedFrames: 0, differentFrames: Math.max(expected.frames.length, actual.frames.length), differentFrameIndices: [], differences: [ { frameIndex: -1, expected: `${expected.dimensions.cols}x${expected.dimensions.rows}`, actual: `${actual.dimensions.cols}x${actual.dimensions.rows}`, diffCount: -1, diff: "Dimension mismatch" } ] }; } if (expected.frames.length !== actual.frames.length) { return { match: false, totalFrames: Math.max(expected.frames.length, actual.frames.length), matchedFrames: 0, differentFrames: Math.max(expected.frames.length, actual.frames.length), differentFrameIndices: [], differences: [ { frameIndex: -1, expected: `${expected.frames.length} frames`, actual: `${actual.frames.length} frames`, diffCount: -1, diff: "Frame count mismatch" } ] }; } const differences = []; const differentFrameIndices = []; let matchedFrames = 0; for (let i = 0; i < expected.frames.length; i++) { const diff = this.compareFrames(expected.frames[i], actual.frames[i], { threshold, ignoreColors, ignoreWhitespace }); if (diff.diffCount === 0) { matchedFrames++; } else { differentFrameIndices.push(i); differences.push({ frameIndex: i, expected: expected.frames[i].screenshot, actual: actual.frames[i].screenshot, diffCount: diff.diffCount, diff: diff.diff }); } } return { match: differences.length === 0, totalFrames: expected.frames.length, matchedFrames, differentFrames: differences.length, differentFrameIndices, differences: differences.length > 0 ? differences : void 0 }; } /** * Compare two frames * @param expected - Expected frame * @param actual - Actual frame * @param options - Comparison options * @returns Difference information */ static compareFrames(expected, actual, options) { let expectedStr = expected.screenshot; let actualStr = actual.screenshot; if (options.ignoreColors) { expectedStr = this.stripAnsiColors(expectedStr); actualStr = this.stripAnsiColors(actualStr); } if (options.ignoreWhitespace) { expectedStr = expectedStr.replace(/\s+/g, " ").trim(); actualStr = actualStr.replace(/\s+/g, " ").trim(); } if (expectedStr === actualStr) { return { diffCount: 0 }; } const diffCount = this.countDifferences(expectedStr, actualStr); if (options.threshold !== void 0 && diffCount <= options.threshold) { return { diffCount: 0 }; } const diff = this.generateSimpleDiff(expectedStr, actualStr); return { diffCount, diff }; } /** * Strip ANSI color codes from string * @param str - String with ANSI codes * @returns String without ANSI codes */ static stripAnsiColors(str) { return str.replace(/\x1b\[[0-9;]*m/g, ""); } /** * Count character differences between two strings * @param str1 - First string * @param str2 - Second string * @returns Number of different characters */ static countDifferences(str1, str2) { const maxLen = Math.max(str1.length, str2.length); let diffCount = 0; for (let i = 0; i < maxLen; i++) { if (str1[i] !== str2[i]) { diffCount++; } } return diffCount; } /** * Generate a simple text diff (first 200 chars of each) * @param expected - Expected string * @param actual - Actual string * @returns Diff string */ static generateSimpleDiff(expected, actual) { const maxPreview = 200; const expPreview = expected.substring(0, maxPreview); const actPreview = actual.substring(0, maxPreview); return `Expected (${expected.length} chars): ${expPreview} Actual (${actual.length} chars): ${actPreview}`; } }; // src/golden.ts import { existsSync, mkdirSync, writeFileSync } from "fs"; import { dirname } from "path"; function saveGoldenSnapshot(goldenPath, recording) { const dir = dirname(goldenPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const json = JSON.stringify(recording, null, 2); writeFileSync(goldenPath, json, "utf8"); } function compareWithGolden(fixturePath, recording, testName) { const updateGolden = process.env.UPDATE_SNAPSHOTS === "1"; if (updateGolden) { saveGoldenSnapshot(fixturePath, recording); console.log(` \u2705 Updated golden snapshot: ${fixturePath}`); return { pass: true, action: "updated" }; } if (!existsSync(fixturePath)) { saveGoldenSnapshot(fixturePath, recording); console.log(` \u26A0\uFE0F Created new golden snapshot: ${fixturePath}`); return { pass: true, action: "created" }; } const comparisonResult = VRTComparator.compare(fixturePath, recording); if (comparisonResult.match) { return { pass: true, action: "matched" }; } const errorMsg = [ `VRT snapshot mismatch for: ${testName}`, ` Total frames: ${comparisonResult.totalFrames}`, ` Matched: ${comparisonResult.matchedFrames}`, ` Different: ${comparisonResult.differentFrames}`, ` Different frame indices: ${comparisonResult.differentFrameIndices.join(", ")}` ]; if (comparisonResult.differences && comparisonResult.differences.length > 0) { const firstDiff = comparisonResult.differences[0]; errorMsg.push(` First difference (frame ${firstDiff.frameIndex}):`); errorMsg.push( ` Expected ${firstDiff.expected.length} chars, got ${firstDiff.actual.length} chars` ); errorMsg.push(` ${firstDiff.diffCount} characters differ`); if (firstDiff.diff) { errorMsg.push(` Diff preview:`); errorMsg.push(firstDiff.diff); } } errorMsg.push(` To update snapshot: UPDATE_SNAPSHOTS=1 pnpm test`); return { pass: false, action: "failed", comparisonResult, errorMessage: errorMsg.join("\n") }; } // src/player.ts import { readFileSync as readFileSync2 } from "fs"; var VRTPlayer = class { /** * Create a player from a recording file or object * @param source - Path to VRT recording file or VRTRecording object */ constructor(source) { if (typeof source === "string") { this.recording = this.load(source); } else { this.recording = source; } } /** * Load a recording from file * @param filePath - Path to the VRT recording file * @returns Parsed VRT recording */ load(filePath) { const content = readFileSync2(filePath, "utf8"); return JSON.parse(content); } /** * Play the recording * @param options - Playback options * @returns Promise that resolves when playback is complete */ async play(options = {}) { const { speed = 1, onFrame, writeToStdout = false } = options; if (this.recording.frames.length === 0) { return; } for (let i = 0; i < this.recording.frames.length; i++) { const frame = this.recording.frames[i]; if (onFrame) { onFrame(frame, i); } if (writeToStdout) { process.stdout.write(frame.screenshot); } if (i < this.recording.frames.length - 1) { const nextFrame = this.recording.frames[i + 1]; const delay = (nextFrame.timestamp - frame.timestamp) / speed; if (delay > 0) { await this.sleep(delay); } } } } /** * Get all frames from the recording * @returns Array of frames */ getFrames() { return this.recording.frames; } /** * Get recording metadata * @returns Recording metadata */ getMetadata() { return this.recording.metadata; } /** * Get recording dimensions * @returns Terminal dimensions */ getDimensions() { return this.recording.dimensions; } /** * Get a specific frame by index * @param index - Frame index * @returns The frame at the given index */ getFrame(index) { return this.recording.frames[index]; } /** * Sleep for specified milliseconds * @param ms - Milliseconds to sleep */ sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }; // src/recorder.ts import { writeFileSync as writeFileSync2 } from "fs"; var VRTRecorder = class { constructor(screen, options = {}) { this.frames = []; this.startTime = null; this.timer = null; this.recording = false; this.screen = screen; this.options = { interval: options.interval ?? 100, outputPath: options.outputPath, // No default - only save if explicitly provided description: options.description ?? "VRT Recording", metadata: options.metadata ?? {} }; } /** * Start recording screenshots */ start() { if (this.recording) { throw new Error("Recording already in progress"); } this.recording = true; this.frames = []; this.startTime = Date.now(); this.timer = setInterval(() => { this.captureFrame(); }, this.options.interval); } /** * Capture a single frame */ captureFrame() { if (!this.recording || !this.startTime) return; const screenshot = this.screen.screenshot(); const timestamp = Date.now() - this.startTime; this.frames.push({ screenshot, timestamp }); } /** * Stop recording and save to file * @returns The complete VRT recording */ stop() { if (!this.recording) { throw new Error("No recording in progress"); } if (this.timer) { clearInterval(this.timer); this.timer = null; } this.captureFrame(); this.recording = false; const duration = this.startTime ? Date.now() - this.startTime : 0; this.startTime = null; const recording = { version: "1.0.0", dimensions: { cols: this.screen.cols, rows: this.screen.rows }, metadata: { createdAt: (/* @__PURE__ */ new Date()).toISOString(), duration, frameCount: this.frames.length, description: this.options.description, ...this.options.metadata }, frames: this.frames }; if (this.options.outputPath) { this.save(recording, this.options.outputPath); } return recording; } /** * Save recording to file * @param recording - The recording to save * @param outputPath - Path to save the recording */ save(recording, outputPath) { const json = JSON.stringify(recording, null, 2); writeFileSync2(outputPath, json, "utf8"); } /** * Check if recording is in progress */ isRecording() { return this.recording; } /** * Get current frame count */ getFrameCount() { return this.frames.length; } }; export { VRTComparator, VRTPlayer, VRTRecorder, compareWithGolden, saveGoldenSnapshot }; //# sourceMappingURL=index.js.map