diff --git a/scripts/verify.mjs b/scripts/verify.mjs
index 0de2c9a..aabc1e5 100755
--- a/scripts/verify.mjs
+++ b/scripts/verify.mjs
@@ -48,7 +48,9 @@ function withoutCanonicalTemplateVocabulary(text) {
return text
.replaceAll(/driving-parity/giu, "")
.replaceAll(/driving parity/giu, "")
- .replaceAll(/parity progress/giu, "");
+ .replaceAll(/parity progress/giu, "")
+ .replaceAll(/visual-parity/giu, "")
+ .replaceAll(/visual parity/giu, "");
}
const leakPatterns = [
@@ -266,6 +268,7 @@ const jsFiles = [
...walkFiles(resolve(repoRoot, "skills/burnlist/scripts"), (path) => path.endsWith(".mjs")),
resolve(repoRoot, "skills/burnlist/dashboard/differential-testing-progress-chart.js"),
resolve(repoRoot, "skills/burnlist/dashboard/differential-testing-renderer.js"),
+ resolve(repoRoot, "skills/burnlist/dashboard/visual-parity-renderer.js"),
].sort();
for (const file of jsFiles) {
@@ -406,10 +409,11 @@ assertSourceExcludes("skills/burnlist/scripts/burnlist-dashboard-server.mjs", '"
assertSourceExcludes("skills/burnlist/dashboard/src/app.tsx", '"/targets"', "React dashboard still exposes the removed Targets route.");
assertSourceExcludes("skills/burnlist/scripts/oven-contract.mjs", '"target"', "Oven contract still accepts the removed Target widget.");
assertSkillSet(["burnlist"]);
-assertBuiltInOvenSet(["checklist", "differential-testing", "streaming-diff"]);
+assertBuiltInOvenSet(["checklist", "differential-testing", "streaming-diff", "visual-parity"]);
assertBuiltInOven("checklist", "Checklist");
assertBuiltInOven("differential-testing", "Differential Testing");
assertBuiltInOven("streaming-diff", "Streaming Diff");
+assertBuiltInOven("visual-parity", "Visual Parity");
assertDifferentialTestingContractAssets();
assertPublishablePackage();
@@ -426,6 +430,7 @@ run(process.execPath, [
"skills/burnlist/scripts/repo-state.test.mjs",
"skills/burnlist/scripts/streaming-diff-contract.test.mjs",
"skills/burnlist/scripts/streaming-diff-server.test.mjs",
+ "skills/burnlist/scripts/visual-parity.test.mjs",
]);
run(process.execPath, ["scripts/register-skills.mjs", "--force-global", "--dry-run"], {
diff --git a/skills/burnlist/dashboard/differential-testing-progress-chart.js b/skills/burnlist/dashboard/differential-testing-progress-chart.js
index efa7d33..f1c586f 100644
--- a/skills/burnlist/dashboard/differential-testing-progress-chart.js
+++ b/skills/burnlist/dashboard/differential-testing-progress-chart.js
@@ -362,20 +362,20 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f
return segments.filter((candidate, index) =>
index === segments.length - 1 || candidate.some((point) => failedFieldValueForPoint(point) > 0));
};
- const chartSegmentPath = (series, valueAccessor = valueForPoint, yAccessor = plotY) => {
+ const chartSegmentPath = (series, valueAccessor = valueForPoint, yAccessor = plotY, xAccessor = x) => {
const [first, ...rest] = series;
- const commands = ["M " + x(first.time).toFixed(1) + " " + yAccessor(valueAccessor(first)).toFixed(1)];
+ const commands = ["M " + xAccessor(first.time).toFixed(1) + " " + yAccessor(valueAccessor(first)).toFixed(1)];
let previous = first;
for (const point of rest) {
- const pointX = x(point.time).toFixed(1);
+ const pointX = xAccessor(point.time).toFixed(1);
commands.push("L " + pointX + " " + yAccessor(valueAccessor(previous)).toFixed(1));
commands.push("L " + pointX + " " + yAccessor(valueAccessor(point)).toFixed(1));
previous = point;
}
return commands.join(" ");
};
- const chartPath = (series, valueAccessor = valueForPoint, yAccessor = plotY) =>
- chartSegments(series).map((segment) => chartSegmentPath(segment, valueAccessor, yAccessor)).join(" ");
+ const chartPath = (series, valueAccessor = valueForPoint, yAccessor = plotY, xAccessor = x) =>
+ chartSegments(series).map((segment) => chartSegmentPath(segment, valueAccessor, yAccessor, xAccessor)).join(" ");
const chartAreaPath = (series, valueAccessor = valueForPoint) => {
const baselineY = plotY(valueMin).toFixed(1);
return chartSegments(series).map((segment) => {
@@ -400,7 +400,8 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f
"Z",
].join(" ");
}).join(" ");
- const path = isFailedChart ? "" : chartPath(fallback, progressValueForPoint);
+ const progressLineX = (time) => Math.min(width - seriesPad.right - 1, x(time));
+ const path = isFailedChart ? "" : chartPath(fallback, progressValueForPoint, plotY, progressLineX);
const areaPath = isFailedChart ? "" : chartAreaPath(fallback);
const last = fallback.at(-1);
const failedFieldTitleForPoint = (point) => {
@@ -460,10 +461,11 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f
svg.classList.toggle("delta-chart", isDeltaChart);
svg.classList.toggle(
"goal-reached",
- isFailedChart &&
+ (isFailedChart &&
!isDeltaChart &&
failedFieldSeries.length > 0 &&
- failedFieldValueForPoint(failedFieldSeries.at(-1)) <= 0,
+ failedFieldValueForPoint(failedFieldSeries.at(-1)) <= 0) ||
+ (!isFailedChart && progressValueForPoint(last) <= 0),
);
svg.dataset.plotLeft = String(seriesPad.left);
svg.dataset.plotRight = String(width - seriesPad.right);
@@ -474,13 +476,14 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f
svg.dataset.timeScale = compactTimeline ? "compact" : "all";
svg.replaceChildren();
if (!isFailedChart && isDrivingParityPage) {
- const remainingArea = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ const remainingArea = document.createElementNS("http://www.w3.org/2000/svg", "path");
remainingArea.setAttribute("class", "progress-remaining-area");
- remainingArea.setAttribute("x", String(seriesPad.left));
- remainingArea.setAttribute("y", String(pad.top));
- remainingArea.setAttribute("width", String(innerWidth));
- remainingArea.setAttribute("height", String(innerHeight));
+ remainingArea.setAttribute("d", areaPath);
svg.append(remainingArea);
+ const completedArea = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ completedArea.setAttribute("class", "progress-area");
+ completedArea.setAttribute("d", chartCeilingAreaPath(fallback, progressValueForPoint));
+ svg.append(completedArea);
}
const yTicks = isDeltaChart
? [0, 1, 2, 3, 4].map((index) => valueMin + ((valueMax - valueMin) * index) / 4)
@@ -529,10 +532,12 @@ export function renderDifferentialTestingProgressChart(svg, history, { mode = "f
axis.setAttribute("y2", String(height - pad.bottom));
if (!isDrivingParityPage) svg.append(axis);
if (!isFailedChart) {
- const area = document.createElementNS("http://www.w3.org/2000/svg", "path");
- area.setAttribute("class", "progress-area");
- area.setAttribute("d", isDrivingParityPage ? chartCeilingAreaPath(fallback, progressValueForPoint) : areaPath);
- svg.append(area);
+ if (!isDrivingParityPage) {
+ const area = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ area.setAttribute("class", "progress-area");
+ area.setAttribute("d", areaPath);
+ svg.append(area);
+ }
const line = document.createElementNS("http://www.w3.org/2000/svg", "path");
line.setAttribute("class", "progress-line");
line.setAttribute("d", path);
@@ -794,7 +799,15 @@ export function renderDifferentialTestingFrameDeltaChart(svg, metrics) {
const measuredHeight = Math.round(svg.getBoundingClientRect().height || svg.clientHeight || parseFloat(getComputedStyle(svg).height) || 200);
const height = Math.max(160, measuredHeight);
svg.setAttribute("viewBox", "0 0 " + width + " " + height);
- svg.setAttribute("aria-label", "Largest signed candidate minus reference residual by frame; display capped at the 98th percentile with clipped values marked");
+ const valueLabel = typeof metrics?.valueLabel === "string" && metrics.valueLabel.trim()
+ ? metrics.valueLabel.trim()
+ : "signed residual";
+ svg.setAttribute(
+ "aria-label",
+ typeof metrics?.ariaLabel === "string" && metrics.ariaLabel.trim()
+ ? metrics.ariaLabel.trim()
+ : "Largest signed candidate minus reference residual by frame; display capped at the 98th percentile with clipped values marked",
+ );
svg.classList.remove("range-zoomable", "failed-chart", "goal-reached");
svg.classList.add("delta-chart");
delete svg.dataset.domainMin;
@@ -956,7 +969,7 @@ export function renderDifferentialTestingFrameDeltaChart(svg, metrics) {
: `M${(markerX - 3).toFixed(1)},${(markerY - 5).toFixed(1)}L${markerX.toFixed(1)},${markerY.toFixed(1)}L${(markerX + 3).toFixed(1)},${(markerY - 5).toFixed(1)}Z`,
});
const title = svgElement("title");
- title.textContent = `${value > 0 ? "+" : ""}${formatRatio(value)} signed residual`;
+ title.textContent = `${value > 0 ? "+" : ""}${formatRatio(value)} ${valueLabel}`;
marker.append(title);
svg.append(marker);
});
diff --git a/skills/burnlist/dashboard/differential-testing-renderer.js b/skills/burnlist/dashboard/differential-testing-renderer.js
index 73f86c6..bd3ba73 100644
--- a/skills/burnlist/dashboard/differential-testing-renderer.js
+++ b/skills/burnlist/dashboard/differential-testing-renderer.js
@@ -12,7 +12,7 @@ function escapeHtml(value) {
.replaceAll("'", "'");
}
-export function differentialTestingLoadingMarkup() {
+export function differentialTestingLoadingMarkup({ ovenName = "Differential Testing" } = {}) {
const title = `
Scenario
@@ -28,7 +28,7 @@ export function differentialTestingLoadingMarkup() {
.replace('
', '
Fields List
')
.replace('
', '
');
return `
-
Loading Differential Testing
+
Loading ${escapeHtml(ovenName)}
${template}
`;
}
@@ -160,6 +160,9 @@ export function mountDifferentialTestingDashboard(root, oven, payload, {
onFieldViewChange = () => {},
fieldPage = null,
frameDeltaMetrics = null,
+ detailCellId = "field-details",
+ detailRenderer = null,
+ initialProgressChart = "delta",
templateOnly = false,
} = {}) {
if (templateOnly) return templateHtml();
@@ -170,7 +173,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, {
const telemetryAvailability = differentialTelemetryAvailability(payload);
const state = {
chart: "current",
- progressChart: "delta",
+ progressChart: initialProgressChart,
sort: fieldPage?.sort ?? (telemetryAvailability.status === "comparable" ? "changed" : "default"),
filter: fieldPage?.filter ?? "all",
search: fieldPage?.search ?? "",
@@ -1593,7 +1596,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, {
existingHeaderStatus?.remove();
if (!state.oven || !state.payload) return;
const cells = new Map(state.oven.detail.cells.map((cell) => [cell.id, cell]));
- const title = cells.get("title"), burns = cells.get("burns"), fields = cells.get("fields"), frames = cells.get("frames"), progressCell = cells.get("progress"), logCell = cells.get("log"), details = cells.get("field-details");
+ const title = cells.get("title"), burns = cells.get("burns"), fields = cells.get("fields"), frames = cells.get("frames"), progressCell = cells.get("progress"), logCell = cells.get("log"), details = cells.get(detailCellId);
if (![title, burns, fields, frames, progressCell, logCell, details].every(Boolean)) { root.innerHTML = '
Differential Testing Oven layout is incomplete.
'; return; }
const titleText = String(title.title || state.oven.name || "Differential Testing");
if (state.payload.scenarioCatalog?.selectedScenarioId === null && state.payload.scenarioCatalog?.scenarios?.length === 0) {
@@ -1652,6 +1655,11 @@ export function mountDifferentialTestingDashboard(root, oven, payload, {
.replace('
', `
${fieldRows(page)}
`)
.replace(/
- ) : section !== "differential-testing" && HEADER_LINKS.map((link, index) => (
+ ) : !["differential-testing", "visual-parity"].includes(section) && HEADER_LINKS.map((link, index) => (
{index > 0 && ·}
setStreamingDisconnectRequest((request) => request + 1)} section={section} streamingConnection={streamingConnection} streamingTimestamp={streamingTimestamp} />
{section === "differential-testing" ? (
) : section === "streaming-diff" ? (
+ ) : section === "visual-parity" ? (
+
) : section === "new-oven" ? (
) : section === "run-burn" ? (
diff --git a/skills/burnlist/dashboard/src/visual-parity.tsx b/skills/burnlist/dashboard/src/visual-parity.tsx
new file mode 100644
index 0000000..0a5957e
--- /dev/null
+++ b/skills/burnlist/dashboard/src/visual-parity.tsx
@@ -0,0 +1,45 @@
+import { useEffect, useRef } from "react";
+import "../visual-parity.css";
+// @ts-expect-error The canonical renderer is plain ESM so both rich Ovens share one implementation.
+import {
+ mountDifferentialTestingDashboard,
+ startDifferentialTestingLiveUpdates,
+} from "../differential-testing-renderer.js";
+// @ts-expect-error The screenshot row is plain ESM so contract tests can inspect it directly.
+import { renderVisualParityComparison } from "../visual-parity-renderer.js";
+
+export function VisualParityPage() {
+ const root = useRef(null);
+
+ useEffect(() => {
+ document.body.classList.add("driving-parity-view", "visual-parity-view");
+ return () => document.body.classList.remove("driving-parity-view", "visual-parity-view");
+ }, []);
+
+ useEffect(() => {
+ if (!root.current) return;
+ const controller = startDifferentialTestingLiveUpdates(root.current, {
+ ovenId: "visual-parity",
+ ovenName: "Visual Parity",
+ scenarioParam: "view",
+ payloadTransform: (response: { payload: { differentialTesting: object } }) => ({
+ ...response.payload.differentialTesting,
+ visualParity: response.payload,
+ }),
+ mount: (target: HTMLElement, oven: object, payload: object, options: object) => mountDifferentialTestingDashboard(
+ target,
+ oven,
+ payload,
+ {
+ ...options,
+ detailCellId: "screenshot-comparison",
+ detailRenderer: renderVisualParityComparison,
+ initialProgressChart: "delta",
+ },
+ ),
+ });
+ return () => controller.stop();
+ }, []);
+
+ return ;
+}
diff --git a/skills/burnlist/dashboard/visual-parity-renderer.js b/skills/burnlist/dashboard/visual-parity-renderer.js
new file mode 100644
index 0000000..b1cafb8
--- /dev/null
+++ b/skills/burnlist/dashboard/visual-parity-renderer.js
@@ -0,0 +1,44 @@
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function screenshot(image, role) {
+ return `
+
+ `;
+}
+
+function frameCard(comparison) {
+ const difference = comparison.difference;
+ const changed = Number(difference.changedPixels).toLocaleString("en-US");
+ const percent = (Number(difference.ratio) * 100).toFixed(2).replace(/\.00$/u, "");
+ const mean = Number(difference.meanAbsoluteDelta).toFixed(2).replace(/\.00$/u, "");
+ const maximum = Number(difference.maximumAbsoluteDelta).toFixed(2).replace(/\.00$/u, "");
+ const status = comparison.status === "pass" ? "pass" : "fail";
+ return `
+ Frame ${comparison.frame}${status === "pass" ? "Pass" : "Fail"}
+ ${changed}${percent}%${mean} mean${maximum} max
+
+ ${screenshot(comparison.reference, "Reference")}
+ ${screenshot(comparison.candidate, "Candidate")}
+ ${screenshot(comparison.diff, "Diff")}
+
+ `;
+}
+
+export function renderVisualParityComparison({ payload }) {
+ const comparisons = payload?.visualParity?.comparisons;
+ if (!Array.isArray(comparisons)) return 'Visual Parity frame comparisons are unavailable.
';
+ const captured = comparisons.filter((comparison) => (
+ comparison?.reference?.src
+ && comparison.candidate?.src
+ && comparison.diff?.src
+ ));
+ if (!captured.length) return 'No sampled frames to display.
';
+ return `${captured.map(frameCard).join("")}
`;
+}
diff --git a/skills/burnlist/dashboard/visual-parity.css b/skills/burnlist/dashboard/visual-parity.css
new file mode 100644
index 0000000..7629115
--- /dev/null
+++ b/skills/burnlist/dashboard/visual-parity.css
@@ -0,0 +1,56 @@
+.visual-parity-frame-card.hybrid-row.expanded {
+ height: auto;
+ contain-intrinsic-size: 175px;
+}
+
+.visual-parity-frame-card .hybrid-chart {
+ aspect-ratio: 24 / 5;
+ box-sizing: border-box;
+ height: auto;
+}
+
+.visual-parity-pair {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ height: 100%;
+ gap: 0;
+}
+
+.visual-parity-shot {
+ display: block;
+ position: relative;
+ min-width: 0;
+ margin: 0;
+ overflow: hidden;
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+
+.visual-parity-shot::before {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 1px;
+ background: var(--line);
+ content: "";
+ pointer-events: none;
+}
+
+.visual-parity-image-frame {
+ display: grid;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ background: #101010;
+ place-items: center;
+}
+
+.visual-parity-image-frame img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
diff --git a/skills/burnlist/ovens/visual-parity/detail.json b/skills/burnlist/ovens/visual-parity/detail.json
new file mode 100644
index 0000000..d8bc877
--- /dev/null
+++ b/skills/burnlist/ovens/visual-parity/detail.json
@@ -0,0 +1,85 @@
+{
+ "version": 1,
+ "columns": 12,
+ "rows": 14,
+ "rowHeight": 64,
+ "cells": [
+ {
+ "id": "title",
+ "title": "Visual Parity",
+ "widget": "status",
+ "source": "/",
+ "format": "plain",
+ "column": 1,
+ "row": 1,
+ "columnSpan": 4,
+ "rowSpan": 1
+ },
+ {
+ "id": "burns",
+ "title": "Results",
+ "widget": "pie-chart",
+ "source": "/differentialTesting/log",
+ "format": "plain",
+ "column": 5,
+ "row": 1,
+ "columnSpan": 2,
+ "rowSpan": 1
+ },
+ {
+ "id": "fields",
+ "title": "Fields",
+ "widget": "metric",
+ "source": "/differentialTesting/summary/fields",
+ "format": "plain",
+ "column": 7,
+ "row": 1,
+ "columnSpan": 3,
+ "rowSpan": 1
+ },
+ {
+ "id": "frames",
+ "title": "Frames",
+ "widget": "metric",
+ "source": "/differentialTesting/summary/frames",
+ "format": "plain",
+ "column": 10,
+ "row": 1,
+ "columnSpan": 3,
+ "rowSpan": 1
+ },
+ {
+ "id": "progress",
+ "title": "Parity Progress",
+ "widget": "line-chart",
+ "source": "/differentialTesting/progress",
+ "format": "number",
+ "column": 5,
+ "row": 2,
+ "columnSpan": 8,
+ "rowSpan": 5
+ },
+ {
+ "id": "log",
+ "title": "Parity Progress",
+ "widget": "log",
+ "source": "/differentialTesting/log",
+ "format": "plain",
+ "column": 1,
+ "row": 2,
+ "columnSpan": 4,
+ "rowSpan": 5
+ },
+ {
+ "id": "screenshot-comparison",
+ "title": "Frame Comparisons",
+ "widget": "comparison",
+ "source": "/comparisons",
+ "format": "plain",
+ "column": 1,
+ "row": 7,
+ "columnSpan": 12,
+ "rowSpan": 7
+ }
+ ]
+}
diff --git a/skills/burnlist/ovens/visual-parity/instructions.md b/skills/burnlist/ovens/visual-parity/instructions.md
new file mode 100644
index 0000000..e8fb2d5
--- /dev/null
+++ b/skills/burnlist/ovens/visual-parity/instructions.md
@@ -0,0 +1,29 @@
+# Visual Parity
+
+Visual Parity compares trusted reference frames with candidate frames while preserving the Differential Testing dashboard structure. The final row presents shared field-list cards sampled every 100 frames, each with reference, candidate, and magnitude-heatmap screenshots. The summary, progress, and log rows remain evidence summaries rather than visual approval signals.
+
+## State Contract
+
+The project adapter publishes `burnlist-visual-parity-data@1` through `--oven-data visual-parity=`. The document contains:
+
+- one valid `burnlist-differential-testing-data@1` document for the shared summary, progress, and log rows
+- one ordered comparison record for every captured frame, with stable identity, frame number, labels, dimensions, and status
+- exact changed-pixel totals, ratio, mean absolute delta, and maximum absolute delta for every frame
+- complete reference, candidate, and diff image triplets for frames 0, 100, 200, through 900 in a 1,000-frame scenario
+
+The adapter owns screenshot capture, alignment, normalization, pixel comparison, freshness, and atomic publication. The Oven only validates and renders the normalized document.
+
+## Evidence Rules
+
+Reference and candidate frames must come from the same scenario, viewport, camera, frame, resolution, color pipeline, and capture boundary. Comparable frames must have identical dimensions. A missing, stale, misaligned, partially written, or differently configured frame blocks publication.
+
+Do not resize, crop, stretch, recolor, blur, or otherwise normalize one side differently to improve the result. The diff may visualize exact channel-delta magnitude over dim grayscale reference context, but it must not change the recorded comparison. Do not infer a pass from visual inspection when the pixel comparison reports a failure.
+
+The frame comparison status is independent of the retained Differential Testing state summary. Both views share scenario identity and capture inputs, but either one may pass while the other fails.
+
+## Result Semantics
+
+- `pass`: both frames are comparable and no pixels differ
+- `fail`: both frames are comparable and at least one pixel differs
+
+Keep screenshot files, captures, and project-specific evidence outside the Oven package. Publish only local adapter data and image references through ignored project state.
diff --git a/skills/burnlist/scripts/burnlist-dashboard-server.mjs b/skills/burnlist/scripts/burnlist-dashboard-server.mjs
index 0a44d8c..160c42d 100644
--- a/skills/burnlist/scripts/burnlist-dashboard-server.mjs
+++ b/skills/burnlist/scripts/burnlist-dashboard-server.mjs
@@ -36,6 +36,7 @@ import {
} from "./differential-testing-transport.mjs";
import { buildRepoMapAsync } from "./repo-map.mjs";
import { assertStreamingDiffData } from "./streaming-diff-contract.mjs";
+import { assertVisualParityData, visualParityDeltaChartMetrics } from "./visual-parity-contract.mjs";
const args = new Map();
const allowedArgs = new Set([
@@ -325,6 +326,14 @@ function warmDifferentialTestingData() {
}
}
+function readVisualParityData(path) {
+ const readPath = resolve(realpathSync(dirname(path)), basename(path));
+ const stat = safeStat(readPath);
+ if (!stat?.isFile()) throw new Error("configured Visual Parity data is missing");
+ const source = readTextFileWithLimit(readPath, maxOvenDataBytes, "Oven visual-parity data");
+ return { readPath, payload: assertVisualParityData(JSON.parse(source)) };
+}
+
function positiveInteger(value, name) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
@@ -872,8 +881,41 @@ function streamingDiffDashboardEntries() {
}];
}
+function visualParityDashboardEntries() {
+ const path = ovenDataBindings.get("visual-parity");
+ if (!path) return [];
+ const { readPath, payload } = readVisualParityData(path);
+ const data = payload.differentialTesting;
+ const comparisons = payload.comparisons;
+ const failed = comparisons.filter((comparison) => comparison.status === "fail").length;
+ const latest = data.progress.at(-1);
+ const total = Number(latest?.frames ?? data.summary.frames.total) || 0;
+ const done = Number(latest?.frame ?? data.summary.frames.passed) || 0;
+ const repo = discoveredRepos()
+ .filter((entry) => readPath === entry.root || readPath.startsWith(`${entry.root}/`))
+ .sort((left, right) => right.root.length - left.root.length)[0]?.name ?? "visual-parity";
+ return [{
+ id: `${data.scenarioCatalog.selectedScenarioId}-visual-parity`,
+ repo,
+ title: `${comparisons.length} frame deltas`,
+ status: "active",
+ statusLabel: failed === 0 ? "Pass" : "Fail",
+ total,
+ done,
+ remaining: Math.max(0, total - done),
+ percent: total ? Math.round(done / total * 100) : 0,
+ errors: failed,
+ warnings: 0,
+ updatedAt: data.publishedAt,
+ ovenId: "visual-parity",
+ ovenName: "Visual Parity",
+ href: `/ovens/visual-parity/view?view=${encodeURIComponent(data.scenarioCatalog.selectedScenarioId)}`,
+ progressLabel: `${comparisons.length} deltas · ${failed} failed`,
+ }];
+}
+
function dashboardEntries() {
- return [...checklistDashboardEntries(), ...differentialTestingDashboardEntries(), ...streamingDiffDashboardEntries()]
+ return [...checklistDashboardEntries(), ...differentialTestingDashboardEntries(), ...visualParityDashboardEntries(), ...streamingDiffDashboardEntries()]
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
}
@@ -1527,6 +1569,26 @@ const server = createServer(async (req, res) => {
sendDifferentialTestingData(req, res, selected);
return;
}
+ if (id === "visual-parity") {
+ const data = readVisualParityData(path);
+ const requestedScenarioIds = url.searchParams.getAll("view");
+ if (requestedScenarioIds.length > 1) return json(res, 400, { error: "view must be supplied at most once" });
+ const requestedScenarioId = requestedScenarioIds[0] ?? "";
+ if (requestedScenarioId && !/^[a-f0-9]{16}$/u.test(requestedScenarioId)) {
+ return json(res, 400, { error: "view must be a lowercase 16-character hexadecimal scenario id" });
+ }
+ const selectedScenarioId = data.payload.differentialTesting.scenarioCatalog.selectedScenarioId;
+ if (requestedScenarioId && requestedScenarioId !== selectedScenarioId) {
+ return json(res, 404, { error: `visual parity scenario ${requestedScenarioId} is not currently published` });
+ }
+ json(res, 200, {
+ ovenId: id,
+ path: data.readPath,
+ payload: data.payload,
+ frameDeltaMetrics: visualParityDeltaChartMetrics(data.payload.comparisons),
+ });
+ return;
+ }
const readPath = path;
if (!safeStat(readPath)?.isFile()) return json(res, 404, { error: `configured data for Oven ${id} is missing` });
const indexPayload = JSON.parse(readTextFileWithLimit(readPath, maxOvenDataBytes, `Oven ${id} data`));
@@ -1570,7 +1632,7 @@ const server = createServer(async (req, res) => {
json(res, 200, { run });
return;
}
- if (["/", "/index.html", "/ovens/new", "/ovens/differential-testing/view", "/ovens/streaming-diff/view", "/runs/new"].includes(url.pathname) || routeSelection(url)) {
+ if (["/", "/index.html", "/ovens/new", "/ovens/differential-testing/view", "/ovens/streaming-diff/view", "/ovens/visual-parity/view", "/runs/new"].includes(url.pathname) || routeSelection(url)) {
if (method !== "GET") return json(res, 405, { error: "method not allowed" });
serveDashboardShell(res);
return;
diff --git a/skills/burnlist/scripts/visual-parity-contract.mjs b/skills/burnlist/scripts/visual-parity-contract.mjs
new file mode 100644
index 0000000..85fb124
--- /dev/null
+++ b/skills/burnlist/scripts/visual-parity-contract.mjs
@@ -0,0 +1,145 @@
+import { assertDifferentialTestingData } from "./differential-testing-data-contract.mjs";
+
+export const VISUAL_PARITY_SCHEMA = "burnlist-visual-parity-data@1";
+
+const comparisonStatuses = new Set(["pass", "fail"]);
+const MAX_COMPARISONS = 10_000;
+const MAX_SCREENSHOT_COMPARISONS = 10;
+const SCREENSHOT_SAMPLE_INTERVAL = 100;
+
+function object(value, label) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
+ return value;
+}
+
+function onlyKeys(value, allowed, label) {
+ for (const key of Object.keys(value)) {
+ if (!allowed.has(key)) throw new Error(`${label} contains unsupported field "${key}".`);
+ }
+}
+
+function text(value, label, maximum = 160) {
+ if (typeof value !== "string" || !value.trim() || value.length > maximum) throw new Error(`${label} is invalid.`);
+ return value;
+}
+
+function dimension(value, label) {
+ if (!Number.isSafeInteger(value) || value < 1 || value > 16_384) throw new Error(`${label} must be an integer from 1 to 16384.`);
+ return value;
+}
+
+function imageSource(value, label) {
+ if (value === null) return null;
+ text(value, label, 16_777_216);
+ if (!value.startsWith("data:image/") && !value.startsWith("/") && !/^https?:\/\//u.test(value)) {
+ throw new Error(`${label} must be an image data URL, an absolute dashboard path, or an HTTP URL.`);
+ }
+ return value;
+}
+
+function image(value, label) {
+ const item = object(value, label);
+ onlyKeys(item, new Set(["label", "src", "width", "height"]), label);
+ text(item.label, `${label} label`);
+ imageSource(item.src, `${label} src`);
+ dimension(item.width, `${label} width`);
+ dimension(item.height, `${label} height`);
+ return item;
+}
+
+function difference(value, status) {
+ const item = object(value, "Visual Parity difference");
+ onlyKeys(item, new Set(["changedPixels", "totalPixels", "ratio", "meanAbsoluteDelta", "maximumAbsoluteDelta"]), "Visual Parity difference");
+ if (!Number.isSafeInteger(item.totalPixels) || item.totalPixels < 1) throw new Error("Visual Parity totalPixels must be a positive integer.");
+ if (!Number.isSafeInteger(item.changedPixels) || item.changedPixels < 0 || item.changedPixels > item.totalPixels) {
+ throw new Error("Visual Parity changedPixels must be between zero and totalPixels.");
+ }
+ if (typeof item.ratio !== "number" || !Number.isFinite(item.ratio) || item.ratio < 0 || item.ratio > 1) {
+ throw new Error("Visual Parity ratio must be from zero to one.");
+ }
+ if (Math.abs(item.ratio - item.changedPixels / item.totalPixels) > 1e-12) {
+ throw new Error("Visual Parity ratio must equal changedPixels divided by totalPixels.");
+ }
+ for (const [key, label] of [["meanAbsoluteDelta", "meanAbsoluteDelta"], ["maximumAbsoluteDelta", "maximumAbsoluteDelta"]]) {
+ if (typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 255) {
+ throw new Error(`Visual Parity ${label} must be from zero to 255.`);
+ }
+ }
+ if (item.maximumAbsoluteDelta < item.meanAbsoluteDelta) {
+ throw new Error("Visual Parity maximumAbsoluteDelta cannot be lower than meanAbsoluteDelta.");
+ }
+ if (status === "pass" && item.changedPixels !== 0) throw new Error("A passing Visual Parity comparison cannot contain changed pixels.");
+ if (status === "fail" && item.changedPixels === 0) throw new Error("A failing Visual Parity comparison must contain changed pixels.");
+ return item;
+}
+
+export function assertVisualParityData(value) {
+ const payload = object(value, "Visual Parity payload");
+ onlyKeys(payload, new Set(["schema", "differentialTesting", "comparisons"]), "Visual Parity payload");
+ if (payload.schema !== VISUAL_PARITY_SCHEMA) throw new Error(`Visual Parity schema must be ${VISUAL_PARITY_SCHEMA}.`);
+ assertDifferentialTestingData(payload.differentialTesting);
+ if (payload.differentialTesting.scenarioCatalog.selectedScenarioId === null) {
+ throw new Error("Visual Parity requires one selected screenshot scenario.");
+ }
+
+ if (!Array.isArray(payload.comparisons) || payload.comparisons.length < 1 || payload.comparisons.length > MAX_COMPARISONS) {
+ throw new Error(`Visual Parity comparisons must contain from 1 to ${MAX_COMPARISONS} frames.`);
+ }
+ const ids = new Set();
+ let previousFrame = -1;
+ const screenshotFrames = [];
+ for (const [index, value] of payload.comparisons.entries()) {
+ const label = `Visual Parity comparison ${index}`;
+ const comparison = object(value, label);
+ onlyKeys(comparison, new Set(["id", "label", "frame", "status", "reference", "candidate", "diff", "difference"]), label);
+ text(comparison.id, `${label} id`);
+ text(comparison.label, `${label} label`);
+ if (ids.has(comparison.id)) throw new Error("Visual Parity comparison ids must be unique.");
+ ids.add(comparison.id);
+ if (!Number.isSafeInteger(comparison.frame) || comparison.frame < 0 || comparison.frame <= previousFrame) {
+ throw new Error("Visual Parity comparison frames must be unique non-negative integers in ascending order.");
+ }
+ previousFrame = comparison.frame;
+ if (!comparisonStatuses.has(comparison.status)) throw new Error("Visual Parity status must be pass or fail.");
+ const reference = image(comparison.reference, `${label} reference`);
+ const candidate = image(comparison.candidate, `${label} candidate`);
+ const diff = image(comparison.diff, `${label} diff`);
+ const comparisonDifference = difference(comparison.difference, comparison.status);
+ if (
+ reference.width !== candidate.width
+ || reference.height !== candidate.height
+ || reference.width !== diff.width
+ || reference.height !== diff.height
+ ) {
+ throw new Error("Comparable Visual Parity screenshots and diff must have identical dimensions.");
+ }
+ if (comparisonDifference.totalPixels !== reference.width * reference.height) {
+ throw new Error("Visual Parity totalPixels must equal screenshot width multiplied by height.");
+ }
+ const sourceCount = [reference.src, candidate.src, diff.src].filter(Boolean).length;
+ if (sourceCount !== 0 && sourceCount !== 3) throw new Error("A captured Visual Parity frame requires its complete screenshot triplet.");
+ if (sourceCount === 3) screenshotFrames.push(comparison.frame);
+ }
+ const expectedScreenshotFrames = payload.comparisons
+ .filter((_comparison, index) => index % SCREENSHOT_SAMPLE_INTERVAL === 0)
+ .slice(0, MAX_SCREENSHOT_COMPARISONS)
+ .map((comparison) => comparison.frame);
+ if (
+ screenshotFrames.length !== expectedScreenshotFrames.length
+ || screenshotFrames.some((frame, index) => frame !== expectedScreenshotFrames[index])
+ ) throw new Error("Visual Parity screenshot cards must sample one frame every 100 frames, up to 10 cards.");
+ return payload;
+}
+
+export function visualParityDeltaChartMetrics(comparisons) {
+ if (!Array.isArray(comparisons) || comparisons.length === 0) {
+ throw new Error("Visual Parity Delta chart requires frame comparisons.");
+ }
+ return {
+ frameDeviationRatios: comparisons.map((comparison) => comparison.difference.ratio),
+ frameSignedResiduals: comparisons.map((comparison) => comparison.difference.meanAbsoluteDelta),
+ firstFailingFrame: comparisons.findIndex((comparison) => comparison.status === "fail"),
+ ariaLabel: "Mean absolute RGB channel delta by frame; display capped at the 98th percentile with clipped values marked",
+ valueLabel: "mean absolute RGB channel delta",
+ };
+}
diff --git a/skills/burnlist/scripts/visual-parity.test.mjs b/skills/burnlist/scripts/visual-parity.test.mjs
new file mode 100644
index 0000000..934bb96
--- /dev/null
+++ b/skills/burnlist/scripts/visual-parity.test.mjs
@@ -0,0 +1,277 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { createServer } from "node:http";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+import {
+ differentialExactPrefixFrameDeltaMetrics,
+ startDifferentialTestingLiveUpdates,
+} from "../dashboard/differential-testing-renderer.js";
+import { renderVisualParityComparison } from "../dashboard/visual-parity-renderer.js";
+import { buildPayload } from "../examples/differential-testing/adapter.mjs";
+import {
+ assertVisualParityData,
+ visualParityDeltaChartMetrics,
+ VISUAL_PARITY_SCHEMA,
+} from "./visual-parity-contract.mjs";
+
+const scriptDirectory = dirname(fileURLToPath(import.meta.url));
+const serverPath = resolve(scriptDirectory, "burnlist-dashboard-server.mjs");
+const differentialTestingRendererPath = resolve(scriptDirectory, "../dashboard/differential-testing-renderer.js");
+const visualParityPagePath = resolve(scriptDirectory, "../dashboard/src/visual-parity.tsx");
+const visualParityCssPath = resolve(scriptDirectory, "../dashboard/visual-parity.css");
+const pixel = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
+
+function fixture({ status = "fail" } = {}) {
+ const frameCount = status === "pass" ? 1 : 1_000;
+ const reference = {
+ captureId: "visual-reference",
+ generatedAt: "2026-07-14T12:00:00.000Z",
+ fields: [{
+ id: "pixel-difference",
+ label: "Pixel difference",
+ sourceOwner: "visual-comparator",
+ meaning: "Changed-pixel count for the aligned screenshot pair",
+ unit: "pixels",
+ tolerance: 0,
+ }],
+ samples: Array.from({ length: frameCount }, (_, tick) => ({ tick, values: { "pixel-difference": 0 } })),
+ };
+ const candidate = {
+ captureId: "visual-candidate",
+ generatedAt: reference.generatedAt,
+ samples: Array.from({ length: frameCount }, (_, tick) => ({ tick, values: { "pixel-difference": status === "pass" ? 0 : 1 } })),
+ };
+ const differentialTesting = buildPayload(reference, candidate);
+ differentialTesting.title = "Visual Parity";
+ for (const row of [...differentialTesting.progress, ...differentialTesting.log]) {
+ row.frame = status === "pass" ? frameCount : 0;
+ row.frameDelta = null;
+ }
+ return {
+ schema: VISUAL_PARITY_SCHEMA,
+ differentialTesting,
+ comparisons: Array.from({ length: frameCount }, (_, frame) => {
+ const captured = frame % 100 === 0 && frame < 1_000;
+ return {
+ id: `frame-${frame}`,
+ label: `Frame ${frame}`,
+ frame,
+ status,
+ reference: { label: "Native", src: captured ? pixel : null, width: 1, height: 1 },
+ candidate: { label: "Browser", src: captured ? pixel : null, width: 1, height: 1 },
+ diff: { label: "Changed pixels", src: captured ? pixel : null, width: 1, height: 1 },
+ difference: status === "pass"
+ ? { changedPixels: 0, totalPixels: 1, ratio: 0, meanAbsoluteDelta: 0, maximumAbsoluteDelta: 0 }
+ : { changedPixels: 1, totalPixels: 1, ratio: 1, meanAbsoluteDelta: 1, maximumAbsoluteDelta: 1 },
+ };
+ }),
+ };
+}
+
+test("Visual Parity validates all frame deltas and one screenshot triplet every 100 frames", () => {
+ const payload = fixture();
+ assert.equal(assertVisualParityData(payload), payload);
+ assert.equal(payload.schema, "burnlist-visual-parity-data@1");
+
+ const mismatched = structuredClone(payload);
+ mismatched.comparisons[0].diff.width = 2;
+ assert.throws(() => assertVisualParityData(mismatched), /identical dimensions/u);
+
+ const inconsistent = structuredClone(payload);
+ inconsistent.comparisons[0].difference.ratio = 0.5;
+ assert.throws(() => assertVisualParityData(inconsistent), /must equal changedPixels/u);
+
+ const skipped = structuredClone(payload);
+ skipped.comparisons[0].reference.src = null;
+ skipped.comparisons[0].candidate.src = null;
+ skipped.comparisons[0].diff.src = null;
+ assert.throws(() => assertVisualParityData(skipped), /every 100 frames/u);
+
+ const independent = fixture({ status: "pass" });
+ assert.equal(assertVisualParityData(independent), independent);
+});
+
+test("Visual Parity maps its recorded frame deltas into the shared Delta chart contract", () => {
+ const payload = fixture();
+ const metrics = visualParityDeltaChartMetrics(payload.comparisons);
+ assert.equal(metrics.frameDeviationRatios.length, 1_000);
+ assert.equal(metrics.frameSignedResiduals.length, 1_000);
+ assert.deepEqual(metrics.frameDeviationRatios.slice(0, 2), [1, 1]);
+ assert.deepEqual(metrics.frameSignedResiduals.slice(0, 2), [1, 1]);
+ assert.equal(metrics.firstFailingFrame, 0);
+ assert.match(metrics.ariaLabel, /Mean absolute RGB channel delta/u);
+ assert.equal(differentialExactPrefixFrameDeltaMetrics(payload.differentialTesting, metrics)?.frameSignedResiduals.length, 1_000);
+});
+
+test("Visual Parity renders 10 scenario-wide samples as shared field-list cards", () => {
+ const payload = fixture();
+ const html = renderVisualParityComparison({ payload: { visualParity: payload } });
+ assert.match(html, /hybrid-list visual-parity-frame-list/u);
+ assert.equal((html.match(/hybrid-row fail expanded visual-parity-frame-card/gu) ?? []).length, 10);
+ assert.doesNotMatch(html, //u);
+ assert.equal((html.match(/
1 mean<\/span>1 max<\/span>/u);
+ assert.doesNotMatch(html, /mean ·/u);
+ assert.doesNotMatch(html, /data-frame="1"/u);
+});
+
+test("Visual Parity carries the shared expanded field-list card layout after detail injection", async () => {
+ const css = await readFile(visualParityCssPath, "utf8");
+ const page = await readFile(visualParityPagePath, "utf8");
+ const renderer = await readFile(differentialTestingRendererPath, "utf8");
+ assert.match(page, /initialProgressChart:\s*"delta"/u);
+ assert.match(renderer, /grid-template-columns:\s*20% 10% minmax\(0, 70%\);/u);
+ assert.match(renderer, /\.hybrid-row\.expanded\s*\{[\s\S]*?height:\s*220px;/u);
+ assert.doesNotMatch(css, /\.visual-parity-frame-card \.hybrid-metric/u);
+ assert.doesNotMatch(css, /\.visual-parity-frame-card \.hybrid-count/u);
+ assert.match(css, /\.visual-parity-frame-card\.hybrid-row\.expanded\s*\{[\s\S]*?height:\s*auto;/u);
+ assert.doesNotMatch(css, /\.visual-parity-frame-card\.hybrid-row\.expanded\s*\{[^}]*grid-template-columns/u);
+ assert.match(css, /\.visual-parity-shot\s*\{[\s\S]*?border:\s*0;/u);
+ assert.doesNotMatch(css, /\.visual-parity-shot figcaption/u);
+ assert.match(css, /\.visual-parity-frame-card \.hybrid-chart\s*\{[\s\S]*?aspect-ratio:\s*24 \/ 5;/u);
+ assert.match(css, /\.visual-parity-shot::before\s*\{[\s\S]*?left:\s*0;[\s\S]*?width:\s*1px;[\s\S]*?background:\s*var\(--line\);/u);
+ assert.match(renderer, /const detailRows = root\.querySelector\("#hybrid-rows"\);/u);
+ assert.match(renderer, /detailRows\.innerHTML = detailRenderer/u);
+});
+
+test("Visual Parity binds its selected scenario through the view query", async () => {
+ const payload = fixture();
+ const scenarioId = payload.differentialTesting.scenarioCatalog.selectedScenarioId;
+ const requests = [];
+ const replacements = [];
+ let mountOptions = null;
+ const frameDeltaMetrics = visualParityDeltaChartMetrics(payload.comparisons);
+ const controller = startDifferentialTestingLiveUpdates({ innerHTML: "" }, {
+ ovenId: "visual-parity",
+ ovenName: "Visual Parity",
+ scenarioParam: "view",
+ locationImpl: {
+ search: `?view=${scenarioId}`,
+ href: `http://localhost/ovens/visual-parity/view?view=${scenarioId}`,
+ },
+ historyImpl: { replaceState: (_state, _title, href) => replacements.push(href) },
+ fetchImpl: async (url) => {
+ requests.push(url);
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => null },
+ async json() {
+ return url === "/api/ovens/visual-parity"
+ ? { oven: { detail: { cells: [] } } }
+ : { payload, frameDeltaMetrics };
+ },
+ };
+ },
+ setIntervalImpl: () => 17,
+ clearIntervalImpl() {},
+ payloadTransform: (response) => ({
+ ...response.payload.differentialTesting,
+ visualParity: response.payload,
+ }),
+ mount: (_root, _oven, _payload, options) => {
+ mountOptions = options;
+ return { update() {}, setClientRefreshStatus() {} };
+ },
+ });
+
+ await controller.ready;
+ assert.ok(requests.includes(`/api/oven-data/visual-parity?view=${scenarioId}`));
+ assert.deepEqual(mountOptions.frameDeltaMetrics, frameDeltaMetrics);
+ await controller.selectScenario(scenarioId);
+ assert.equal(replacements.at(-1), `/ovens/visual-parity/view?view=${scenarioId}`);
+ controller.stop();
+});
+
+test("Visual Parity is exposed as a validated read-only Oven route", { timeout: 20_000 }, async () => {
+ const root = await mkdtemp(join(tmpdir(), "burnlist-visual-parity-"));
+ const repo = join(root, "fixture-repo");
+ const payloadPath = join(repo, ".local", "visual-parity", "current.json");
+ let child;
+ try {
+ await mkdir(dirname(payloadPath), { recursive: true });
+ await writeFile(payloadPath, `${JSON.stringify(fixture())}\n`);
+ const port = await availablePort();
+ child = spawn(process.execPath, [
+ serverPath,
+ "--port", String(port),
+ "--scan-root", repo,
+ "--state-dir", join(root, "state"),
+ "--oven-data", `visual-parity=${payloadPath}`,
+ ], { cwd: repo, stdio: ["ignore", "pipe", "pipe"] });
+ const baseUrl = await waitForServer(child);
+
+ assert.equal((await fetch(`${baseUrl}ovens/visual-parity/view`)).status, 200);
+ const dataResponse = await fetch(`${baseUrl}api/oven-data/visual-parity`);
+ assert.equal(dataResponse.status, 200);
+ const data = await dataResponse.json();
+ assert.equal(data.payload.schema, VISUAL_PARITY_SCHEMA);
+ assert.equal(data.payload.comparisons.length, 1_000);
+ assert.equal(data.payload.comparisons[0].id, "frame-0");
+ assert.equal(data.frameDeltaMetrics.frameSignedResiduals.length, 1_000);
+ assert.equal(data.frameDeltaMetrics.firstFailingFrame, 0);
+ const scenarioId = data.payload.differentialTesting.scenarioCatalog.selectedScenarioId;
+ assert.equal((await fetch(`${baseUrl}api/oven-data/visual-parity?view=${scenarioId}`)).status, 200);
+ assert.equal((await fetch(`${baseUrl}api/oven-data/visual-parity?view=../../etc/passwd`)).status, 400);
+ assert.equal((await fetch(`${baseUrl}api/oven-data/visual-parity?view=aaaaaaaaaaaaaaaa`)).status, 404);
+
+ const index = await (await fetch(`${baseUrl}api/burnlists`)).json();
+ const row = index.burnlists.find((entry) => entry.ovenId === "visual-parity");
+ assert.equal(row?.href, `/ovens/visual-parity/view?view=${scenarioId}`);
+ assert.equal(row?.statusLabel, "Fail");
+ assert.equal(row?.errors, 1_000);
+ assert.equal(row?.title, "1000 frame deltas");
+
+ await writeFile(payloadPath, `${JSON.stringify({ schema: "wrong" })}\n`);
+ const invalid = await fetch(`${baseUrl}api/oven-data/visual-parity`);
+ assert.equal(invalid.status, 422);
+ assert.match((await invalid.json()).error, /schema/u);
+ } finally {
+ await stop(child);
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+function availablePort() {
+ return new Promise((resolvePort, reject) => {
+ const probe = createServer();
+ probe.once("error", reject);
+ probe.listen(0, "127.0.0.1", () => {
+ const address = probe.address();
+ const port = typeof address === "object" && address ? address.port : null;
+ probe.close((error) => error ? reject(error) : resolvePort(port));
+ });
+ });
+}
+
+function waitForServer(child) {
+ return new Promise((resolveReady, reject) => {
+ let output = "";
+ const timer = setTimeout(() => reject(new Error(`Server did not start: ${output}`)), 8_000);
+ child.stdout.on("data", (chunk) => {
+ output += chunk.toString();
+ const match = output.match(/http:\/\/127\.0\.0\.1:\d+\//u);
+ if (!match) return;
+ clearTimeout(timer);
+ resolveReady(match[0]);
+ });
+ child.stderr.on("data", (chunk) => { output += chunk.toString(); });
+ child.once("exit", (code) => {
+ clearTimeout(timer);
+ reject(new Error(`Server exited with ${code}: ${output}`));
+ });
+ });
+}
+
+async function stop(child) {
+ if (!child || child.exitCode !== null) return;
+ child.kill("SIGTERM");
+ await new Promise((resolveStop) => child.once("exit", resolveStop));
+}