@unblessed/vrt
Version:
Visual Regression Testing tools for @unblessed terminal UI applications
420 lines (416 loc) • 12.3 kB
JavaScript
// 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