import { exec } from 'node:child_process' import { appendFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { platform } from 'node:os' import { resolve, sep } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import type { DecodedPng } from 'fast-png' const templatesDir = resolve(fileURLToPath(import.meta.url), '..') const REPORT_ENTRY = readFileSync(resolve(templatesDir, 'report-entry.html'), 'utf-8') const REPORT_PATH = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-path') const REPORT_LOCK = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-lock') // create report file on first call // protected from parallel execution issues export function ensureReportCreated(targetDir = 'test/e2e'): void { withConcurrentLock(() => { let reportPath: string if (!existsSync(targetDir)) { throw new Error(`Target directory "${targetDir}" does not exist.`) } if (existsSync(REPORT_PATH)) { // report file was already created reportPath = readFileSync(REPORT_PATH, 'utf-8').trim() // this shouldn't happen... if (!existsSync(reportPath)) { throw new Error(`Invalid data in "${REPORT_PATH}": Report file "${reportPath}" does not exist.`) } } else { // for the first time, prepare and store new time-stamped file path const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date()) reportPath = resolve(targetDir, '__current__', `report-${fileTimestamp}.html`) writeFileSync(REPORT_PATH, reportPath) } // store the path for `appendToReport` process.env.SCREENSHOT_REPORT_PATH = reportPath // report file was already created if (existsSync(reportPath)) return // create new report file from template const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true) const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8') const report = template.replace('{{TIMESTAMP}}', titleTimestamp) writeFileSync(reportPath, report) }) } // append a side-by-side baseline/actual comparison // to the HTML report if the screenshots don't match // protected from parallel execution issues export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void { const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}` const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}` const entry = REPORT_ENTRY .replace('{{FILE_NAME}}', escapeHtml(fileName)) .replace('{{MESSAGE}}', escapeHtml(message)) .replace('{{BASELINE_URI}}', baselineUri) .replace('{{ACTUAL_URI}}', actualUri) withConcurrentLock(() => { const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH) ? readFileSync(REPORT_PATH, 'utf-8').trim() : '') if (!reportPath || !existsSync(reportPath)) return appendFileSync(reportPath, entry) }) } // Vitest globalSetup entry point // - computes stable timestamp values and exposes them via env variables // - the report file itself is created lazily on first compareScreenshot call // - provides a callback to close the HTML report once tests are finished (if it was created) export function screenshotSetup() { const NOW = new Date() const fileTimestamp = reportTimestamp(NOW) const titleTimestamp = reportTimestamp(NOW, true) process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp // callback that is executed upon Vitest teardown phase return () => { if (!existsSync(REPORT_PATH)) return const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim() unlinkSync(REPORT_PATH) if (!reportPath) return if (!existsSync(reportPath)) return // add "success" / "error" message as a conclusion const reportBody = readFileSync(reportPath, 'utf-8') const hasFailure = reportBody.includes('
All tests have passed
\n') } // wrap the report up with a footer let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8') footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString()) appendFileSync(reportPath, footer) console.log(`\n(nuxt-spec) Visual regression report available at:\n${pathToFileURL(reportPath).href}`) if (!process.env.CI) { console.log('(nuxt-spec) Opening report in default browser...') const openCmd = platform() === 'darwin' ? `open "${reportPath}"` : platform() === 'win32' ? `start "" "${reportPath}"` : `xdg-open "${reportPath}"` exec(openCmd, (err) => { if (err) { console.log('(nuxt-spec) Failed to automatically open report') } }) } console.log('\n') } } // helper to keep user-provided targetDir inside the current project root export function resolveWithin(base: string, segment: string): string { const target = resolve(base, segment) if (target !== base && !target.startsWith(base + sep)) { throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`) } return target } // helper for bridging difference between Vitest PNG saving and fast-png encoding export function toRGBA(img: DecodedPng): Uint8Array { const { width, height, data, channels = 4 } = img if (channels === 4) return data as Uint8Array const pixels = width * height const rgba = new Uint8Array(pixels * 4) for (let i = 0; i < pixels; i++) { const src = i * 3 rgba[i * 4 + 0] = data[src + 0] ?? 0 rgba[i * 4 + 1] = data[src + 1] ?? 0 rgba[i * 4 + 2] = data[src + 2] ?? 0 rgba[i * 4 + 3] = 255 } return rgba } // protection from parallel execution issues function withConcurrentLock