import { exec } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { ComparisonResult } from "./ImageComparison.ts"; export const autoOpenValues = ["always", "failures", "never"] as const; export type AutoOpen = (typeof autoOpenValues)[number]; const thisDir = path.dirname(fileURLToPath(import.meta.url)); const templatesDir = path.join(thisDir, "templates"); /** Failed image snapshot with comparison results and file paths. */ export interface ImageSnapshotFailure { testName: string; snapshotName: string; comparison: ComparisonResult; paths: { reference: string; actual: string; diff: string; }; } /** Configuration for HTML diff report generation. */ export interface DiffReportConfig { /** Auto-open report in browser. Default: "failures" or "never" in CI */ autoOpen: AutoOpen; /** Directory path for generated HTML report (absolute or relative). */ reportDir: string; /** Vitest config root for calculating relative paths when copying images */ configRoot: string; /** Enable live reload script in HTML. Default: false */ liveReload?: boolean; } /** Clear the diff report directory if it exists. */ export async function clearDiffReport(reportDir: string): Promise { if (fs.existsSync(reportDir)) { await fs.promises.rm(reportDir, { recursive: true }); } } /** Generate HTML diff report for image snapshot results. */ export async function generateDiffReport( failures: ImageSnapshotFailure[], config: DiffReportConfig, ): Promise { const { autoOpen, reportDir, configRoot, liveReload = false } = config; // Clear old report before generating new one to remove stale images // even if autoOpen won't open a browser to avoid confusion over which report is current await clearDiffReport(reportDir); await fs.promises.mkdir(reportDir, { recursive: true }); const cssSource = path.join(templatesDir, "report.css"); await fs.promises.copyFile(cssSource, path.join(reportDir, "report.css")); if (liveReload) { const jsSource = path.join(templatesDir, "live-reload.js"); await fs.promises.copyFile( jsSource, path.join(reportDir, "live-reload.js"), ); } const copied = failures.length > 0 ? await copyImagesToReport(failures, reportDir, configRoot) : []; const html = createReportHTML(copied, liveReload); const outputPath = path.join(reportDir, "index.html"); await fs.promises.writeFile(outputPath, html, "utf-8"); // Only log file path if not using live reload server (which logs its own URL) if (failures.length > 0 && !liveReload) { console.log(`\n Image diff report: ${outputPath}`); } const shouldOpen = autoOpen === "always" || (autoOpen === "failures" && failures.length > 0); if (!shouldOpen) return; try { const commands: Record = { darwin: "open", win32: "start" }; const cmd = commands[process.platform] ?? "xdg-open"; exec(`${cmd} "${outputPath}"`); } catch (error) { console.warn("Failed to open image diff report in browser:", error); } } /** Copy images to report dir, preserving directory structure relative to configRoot */ async function copyImagesToReport( failures: ImageSnapshotFailure[], reportDir: string, configRoot: string, ): Promise { return Promise.all( failures.map(async failure => { const { paths } = failure; const copiedPaths = { reference: await copyImage(paths.reference, reportDir, configRoot), actual: await copyImage(paths.actual, reportDir, configRoot), diff: await copyImage(paths.diff, reportDir, configRoot), }; return { ...failure, paths: copiedPaths }; }), ); } /** Copy single image to report dir, preserving directory structure relative to configRoot */ async function copyImage( sourcePath: string, reportDir: string, configRoot: string, ): Promise { if (!fs.existsSync(sourcePath)) return ""; const relativePath = path.relative(configRoot, sourcePath); const destPath = path.join(reportDir, relativePath); const destDir = path.dirname(destPath); await fs.promises.mkdir(destDir, { recursive: true }); await fs.promises.copyFile(sourcePath, destPath); return relativePath; } function loadTemplate(name: string): string { return fs.readFileSync(path.join(templatesDir, name), "utf-8"); } /** Replace all {{key}} placeholders in template with values from data object. */ function renderTemplate( template: string, data: Record, ): string { let result = template; for (const [key, value] of Object.entries(data)) { result = result.replaceAll(`{{${key}}}`, value); } const unreplaced = result.match(/\{\{(\w+)\}\}/g); if (unreplaced) { const keys = unreplaced.map(m => m.slice(2, -2)).join(", "); throw new Error(`Template has unreplaced placeholders: ${keys}`); } return result; } function createReportHTML( failures: ImageSnapshotFailure[], liveReload: boolean, ): string { const timestamp = new Date().toLocaleString(); const totalFailures = failures.length; const script = liveReload ? `` : ""; if (totalFailures === 0) { return renderTemplate(loadTemplate("report-success.hbs"), { timestamp: escapeHtml(timestamp), script, }); } const rows = failures.map(createRowHTML).join("\n"); return renderTemplate(loadTemplate("report-failure.hbs"), { totalFailures: String(totalFailures), testPlural: totalFailures === 1 ? "test" : "tests", timestamp: escapeHtml(timestamp), rows, script, }); } function createRowHTML(failure: ImageSnapshotFailure): string { const { testName, snapshotName, comparison, paths } = failure; const { mismatchedPixels, mismatchedPixelRatio } = comparison; return ` ${escapeHtml(testName)}
${escapeHtml(snapshotName)} Expected
Expected
Actual
Actual
${diffCellHTML(paths.diff)}
${mismatchedPixels} pixels
${(mismatchedPixelRatio * 100).toFixed(2)}%
`; } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function diffCellHTML(diffPath: string): string { if (!diffPath) { return `
No diff image
(dimension mismatch)
`; } return ` Diff
Diff
`; }