Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Clock3, ListChecks } from "lucide-react";
import { AppHeader, ChecklistDashboard, DashboardError, DifferentialTestingPage, EmptyState, FILTERS, Filters, NewOvenPage, ProjectGroup, RunBurnPage, StreamingDiff } from "@components";
import { AppHeader, ChecklistDashboard, DashboardError, DifferentialTestingPage, EmptyState, FILTERS, Filters, NewOvenPage, ProjectGroup, RunBurnPage, StreamingDiff, VisualParityPage } from "@components";
import { useDashboardData } from "@hooks";
import { currentSection, filterFromUrl, listHref, selectedBurnlist } from "@lib";
import type { Filter } from "@lib";
Expand All @@ -24,8 +24,8 @@ export function App() {
return (
<div className="dashboard-app">
<AppHeader section={section} />
<main className="dashboard-main" data-layout={section === "differential-testing" || section === "performance-tracing" || section === "streaming-diff" || selected ? "full" : "index"} data-section={section}>
{section === "differential-testing" ? <DifferentialTestingPage /> : section === "performance-tracing" ? <DifferentialTestingPage ovenId="performance-tracing" /> : section === "streaming-diff" ? <StreamingDiff projects={projects} projectsLoading={loading} /> : section === "new-oven" ? <NewOvenPage /> : section === "run-burn" ? <RunBurnPage /> : selected ? (
<main className="dashboard-main" data-layout={section === "differential-testing" || section === "performance-tracing" || section === "streaming-diff" || section === "visual-parity" || selected ? "full" : "index"} data-section={section}>
{section === "differential-testing" ? <DifferentialTestingPage /> : section === "performance-tracing" ? <DifferentialTestingPage ovenId="performance-tracing" /> : section === "streaming-diff" ? <StreamingDiff projects={projects} projectsLoading={loading} /> : section === "visual-parity" ? <VisualParityPage /> : section === "new-oven" ? <NewOvenPage /> : section === "run-burn" ? <RunBurnPage /> : selected ? (
error ? <DashboardError message={error} /> : loading && !progress ? <EmptyState title="Loading progress" detail="Reading the selected Burnlist." /> : progress ? (
<ChecklistDashboard backHref={listHref(filter)} data={progress} />
) : <EmptyState title="Choose a Burnlist" detail="Select an item from the list to inspect its progress." icon={ListChecks} />
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/components/AppHeader/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ export function AppHeader({ section }: { section: string }) {
{section === "differential-testing" && <div className="dashboard-oven-title">Differential Testing</div>}
{section === "performance-tracing" && <div className="dashboard-oven-title">Performance Tracing</div>}
{section === "streaming-diff" && <div className="dashboard-oven-title">Streaming Diff</div>}
{section === "visual-parity" && <div className="dashboard-oven-title">Visual Parity</div>}
<nav aria-label="Primary navigation" className="dashboard-primary-nav">
{section !== "differential-testing" && section !== "performance-tracing" && section !== "streaming-diff" && HEADER_LINKS.map((link, index) => (
{section !== "differential-testing" && section !== "performance-tracing" && section !== "streaming-diff" && section !== "visual-parity" && HEADER_LINKS.map((link, index) => (
<span className="dashboard-primary-nav-item" key={link.href}>
{index > 0 && <span aria-hidden="true" className="dashboard-primary-nav-separator">·</span>}
<a aria-label={link.label} aria-current={section === link.section ? "page" : undefined} className="dashboard-primary-nav-link" href={link.href} title={link.label}>
Expand Down
110 changes: 110 additions & 0 deletions dashboard/src/components/VisualParity/VisualParity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useVisualParityData } from "@hooks";
import { visualParityDomainSummary } from "@lib";

function percent(value: number) {
return `${(value * 100).toFixed(value < 0.01 ? 3 : 2)}%`;
}

function delta(value: number) {
return value.toFixed(4).replace(/0+$/u, "").replace(/\.$/u, "");
}

export function VisualParityPage() {
const { payload, error, loading } = useVisualParityData();
const initialDomain = payload?.domains.find((domain) => domain.qualification === "target")?.id
?? payload?.domains[0]?.id ?? "";
const [selectedDomainId, setSelectedDomainId] = useState(initialDomain);

useEffect(() => {
if (!payload?.domains.length) return;
if (!payload.domains.some((domain) => domain.id === selectedDomainId)) {
setSelectedDomainId(payload.domains.find((domain) => domain.qualification === "target")?.id
?? payload.domains[0].id);
}
}, [payload, selectedDomainId]);

const domain = payload?.domains.find((entry) => entry.id === selectedDomainId)
?? payload?.domains.find((entry) => entry.qualification === "target")
?? payload?.domains[0];
const summary = useMemo(() => payload && domain
? visualParityDomainSummary(payload, domain.id) : null, [payload, domain]);
const targetPass = payload?.comparisons.every((comparison) => comparison.status === "pass") ?? false;
const visibleComparisons = payload && domain ? payload.comparisons.filter((comparison) => {
const entry = comparison.domains[domain.id];
return entry.reference.src && entry.candidate.src && entry.diff.src;
}) : [];

if (loading && !payload) return <div className="visual-parity-state">Loading Visual Parity…</div>;
if (error && !payload) return <div className="visual-parity-state is-error">{error}</div>;
if (!payload || !domain || !summary) return <div className="visual-parity-state">Visual Parity has no retained domains.</div>;

return (
<section className="visual-parity-page">
<header className="visual-parity-heading">
<a className="visual-parity-back" href="/"><ArrowLeft aria-hidden="true" />Burnlists</a>
<div>
<div className={`visual-parity-verdict ${targetPass ? "pass" : "fail"}`}>
{targetPass ? "Target qualified" : "Target open"}
</div>
<p>{payload.comparisons.length} settled frames · isolated render passes · live refresh</p>
</div>
{error && <span className="visual-parity-refresh-error">{error}</span>}
</header>

<nav aria-label="Visual parity domains" className="visual-parity-domains">
{payload.domains.map((entry) => {
const current = entry.id === domain.id;
const domainSummary = visualParityDomainSummary(payload, entry.id);
return (
<button
aria-pressed={current}
className={current ? "is-active" : ""}
key={entry.id}
onClick={() => setSelectedDomainId(entry.id)}
type="button"
>
<span>{entry.label}</span>
<small>{entry.qualification} · {domainSummary.failed ? `${domainSummary.failed} fail` : "pass"}</small>
</button>
);
})}
</nav>

<div className="visual-parity-metrics">
<article><span>Frames</span><strong>{summary.passed}/{payload.comparisons.length}</strong></article>
<article><span>Changed pixels</span><strong>{percent(summary.ratio)}</strong></article>
<article><span>Mean RGB delta</span><strong>{delta(summary.meanAbsoluteDelta)}</strong></article>
<article><span>Maximum delta</span><strong>{summary.maximumAbsoluteDelta}</strong></article>
</div>

<div className="visual-parity-domain-note">
<strong>{domain.qualification === "target" ? "Qualifying target" : "Diagnostic context"}</strong>
<span>{domain.tolerance?.rationale ?? "Exact zero tolerance."}</span>
</div>

<div className="visual-parity-frames">
{visibleComparisons.map((comparison) => {
const entry = comparison.domains[domain.id];
return (
<article className={`visual-parity-frame ${entry.status}`} key={comparison.id}>
<header>
<strong>Frame {comparison.frame}</strong>
<span>{entry.status} · {percent(entry.difference.ratio)} · mean {delta(entry.difference.meanAbsoluteDelta)} · max {entry.difference.maximumAbsoluteDelta}</span>
</header>
<div className="visual-parity-shots">
{[entry.reference, entry.candidate, entry.diff].map((image) => (
<figure key={image.label}>
<figcaption>{image.label}</figcaption>
<img alt={`${entry.label} ${image.label.toLowerCase()} frame ${comparison.frame}`} height={image.height} src={image.src ?? undefined} width={image.width} />
</figure>
))}
</div>
</article>
);
})}
</div>
</section>
);
}
1 change: 1 addition & 0 deletions dashboard/src/components/VisualParity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { VisualParityPage } from "./VisualParity";
1 change: 1 addition & 0 deletions dashboard/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export { EmptyState } from "./EmptyState";
export { FILTERS, Filters } from "./Filters";
export { ProjectGroup } from "./ProjectGroup";
export { StreamingDiff } from "./StreamingDiff";
export { VisualParityPage } from "./VisualParity";
1 change: 1 addition & 0 deletions dashboard/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { useDashboardData } from "./useDashboardData";
export { useStreamingDiffCards, useStreamingDiffFeeds } from "./useStreamingDiff";
export { useVisualParityData } from "./useVisualParityData";
42 changes: 42 additions & 0 deletions dashboard/src/hooks/useVisualParityData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from "react";
import { ovenRepoKey, type VisualParityPayload } from "@lib";

export function useVisualParityData() {
const [payload, setPayload] = useState<VisualParityPayload | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const inFlight = useRef(false);

useEffect(() => {
let cancelled = false;
const refresh = async () => {
if (inFlight.current) return;
inFlight.current = true;
try {
const repoKey = ovenRepoKey();
const query = repoKey ? `?repoKey=${encodeURIComponent(repoKey)}` : "";
const response = await fetch(`/api/oven-data/visual-parity${query}`, { cache: "no-store" });
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? "Could not load Visual Parity.");
if (data.validated !== true) throw new Error("Visual Parity data was not validated by the Oven.");
if (!cancelled) {
setPayload(data.payload);
setError("");
}
} catch (cause) {
if (!cancelled) setError(cause instanceof Error ? cause.message : "Could not load Visual Parity.");
} finally {
inFlight.current = false;
if (!cancelled) setLoading(false);
}
};
void refresh();
const timer = window.setInterval(refresh, 2_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);

return { payload, error, loading };
}
1 change: 1 addition & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "../../ovens/differential-testing/renderer/differential-testing.css";
@import "../../ovens/visual-parity/renderer/visual-parity.css";

:root {
color-scheme: dark;
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/lib/hrefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ export function currentSection() {
if (window.location.pathname === "/ovens/differential-testing/view") return "differential-testing";
if (window.location.pathname === "/ovens/performance-tracing/view") return "performance-tracing";
if (window.location.pathname === "/ovens/streaming-diff/view") return "streaming-diff";
if (window.location.pathname === "/ovens/visual-parity/view") return "visual-parity";
if (window.location.pathname === "/runs/new") return "run-burn";
return "burnlists";
}

export function ovenRepoKey() {
return ["differential-testing", "performance-tracing", "streaming-diff"].includes(currentSection())
return ["differential-testing", "performance-tracing", "streaming-diff", "visual-parity"].includes(currentSection())
? new URLSearchParams(window.location.search).get("repoKey")
: null;
}
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export { formatTime } from "./format";
export { burnlistHref, currentSection, filterFromUrl, listHref, ovenRepoKey, selectedBurnlist, streamingDiffSelection } from "./hrefs";
export { adaptPerformanceTracingReport } from "./performance-tracing.mjs";
export { applyStreamingDiffUpdate, fileKindChip, groupStreamingDiffCard, isTextFileKind, mapStreamingDiffFeeds, mapStreamingDiffLandingFeeds, parseStreamingDiffCard, streamingDiffAutoOpenHref, streamingDiffFeedHref, streamingDiffFeedKey, streamingDiffRepositories } from "./streaming-diff.mjs";
export { visualParityDomainSummary } from "./visual-parity";
export type { VisualParityDifference, VisualParityDomain, VisualParityDomainComparison, VisualParityImage, VisualParityPayload } from "./visual-parity";
export type { Burnlist, ChecklistProgressData, ChecklistItem, CompletedItem, Filter, HistoryPoint, ProgressData, Project, SelectedBurnlist, StreamingDiffCard, StreamingDiffFeed, StreamingDiffFile, StreamingDiffFileKind, StreamingDiffIdentity, Warning } from "./types";
export { cn, joinClasses } from "./utils";
export type { ClassValue } from "./utils";
67 changes: 67 additions & 0 deletions dashboard/src/lib/visual-parity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export type VisualParityDifference = {
changedPixels: number;
totalPixels: number;
ratio: number;
meanAbsoluteDelta: number;
maximumAbsoluteDelta: number;
};

export type VisualParityImage = {
label: string;
src: string | null;
width: number;
height: number;
};

export type VisualParityDomain = {
id: string;
label: string;
isolation: "render-pass";
qualification: "target" | "context";
tolerance?: {
channelDelta: number;
meanAbsoluteDelta: number;
changedPixelRatio: number;
rationale: string;
};
};

export type VisualParityDomainComparison = {
label: string;
status: "pass" | "fail";
reference: VisualParityImage;
candidate: VisualParityImage;
diff: VisualParityImage;
difference: VisualParityDifference;
};

export type VisualParityPayload = {
schema: "burnlist-visual-parity-data@1";
domains: VisualParityDomain[];
differentialTesting: Record<string, unknown>;
comparisons: Array<{
id: string;
label: string;
frame: number;
status: "pass" | "fail";
domains: Record<string, VisualParityDomainComparison>;
}>;
};

export function visualParityDomainSummary(payload: VisualParityPayload, domainId: string) {
const entries = payload.comparisons.map((comparison) => comparison.domains[domainId]);
const changedPixels = entries.reduce((sum, entry) => sum + entry.difference.changedPixels, 0);
const totalPixels = entries.reduce((sum, entry) => sum + entry.difference.totalPixels, 0);
const absoluteDelta = entries.reduce((sum, entry) => (
sum + entry.difference.meanAbsoluteDelta * entry.difference.totalPixels * 3
), 0);
return {
passed: entries.filter((entry) => entry.status === "pass").length,
failed: entries.filter((entry) => entry.status === "fail").length,
ratio: totalPixels ? changedPixels / totalPixels : 0,
meanAbsoluteDelta: totalPixels ? absoluteDelta / (totalPixels * 3) : 0,
maximumAbsoluteDelta: entries.reduce((maximum, entry) => (
Math.max(maximum, entry.difference.maximumAbsoluteDelta)
), 0),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,26 @@ export function validateDifferentialTestingData(payload, { maxIssues = 50 } = {}
issue(path, "must be a scenario catalog entry");
return;
}
onlyKeys(scenario, path, new Set(["id", "label", "frameCount", "replaySha256", "profileSha256", "contractSha256", "updatedAt"]), "scenario-catalog contract");
onlyKeys(scenario, path, new Set(["id", "label", "engine", "frameCount", "replaySha256", "profileSha256", "contractSha256", "updatedAt"]), "scenario-catalog contract");
if (typeof scenario.id !== "string" || !scenarioIdPattern.test(scenario.id)) issue(`${path}.id`, "must be a lowercase 16-character hexadecimal scenario id");
else if (scenarioIds.has(scenario.id)) issue(`${path}.id`, `duplicates scenario id ${scenario.id}`);
else scenarioIds.add(scenario.id);
text(scenario.label, `${path}.label`, { max: 160 });
if (scenario.engine !== undefined) {
if (!plainObject(scenario.engine)) {
issue(`${path}.engine`, "must identify a runtime engine");
} else {
onlyKeys(scenario.engine, `${path}.engine`, new Set(["id", "runtimeRoot"]), "scenario-engine contract");
text(scenario.engine.id, `${path}.engine.id`, { max: 80 });
if (typeof scenario.engine.id === "string" && !/^[A-Za-z][A-Za-z0-9_-]*$/u.test(scenario.engine.id)) {
issue(`${path}.engine.id`, "must be a runtime engine id");
}
text(scenario.engine.runtimeRoot, `${path}.engine.runtimeRoot`, { max: 400 });
if (typeof scenario.engine.runtimeRoot === "string" && !containedRuntimeRoot(scenario.engine.runtimeRoot)) {
issue(`${path}.engine.runtimeRoot`, "must be a contained relative path");
}
}
}
count(scenario.frameCount, `${path}.frameCount`);
if (scenario.frameCount === 0) issue(`${path}.frameCount`, "must be greater than zero");
sha256(scenario.replaySha256, `${path}.replaySha256`);
Expand Down Expand Up @@ -1135,6 +1150,13 @@ export function validateDifferentialTestingData(payload, { maxIssues = 50 } = {}
return { ok: issues.length === 0, issues };
}

function containedRuntimeRoot(value) {
if (value === ".") return true;
const parts = value.split("/");
return !value.startsWith("/") && !value.includes("\\")
&& parts.every((part) => part && part !== "." && part !== "..");
}

export function assertDifferentialTestingData(payload, options) {
const result = validateDifferentialTestingData(payload, options);
if (!result.ok) throw new DifferentialTestingDataValidationError(result.issues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,28 @@ test("accepts the empty shipped example without inventing a run", () => {
assert.doesNotThrow(() => assertDifferentialTestingData(payload));
});

test("scenario catalogs retain a contained runtime engine identity", () => {
const payload = buildPayload(...populatedCaptures());
payload.scenarioCatalog.scenarios[0].engine = {
id: "fixtureEngine",
runtimeRoot: "src/runtime",
};
assert.doesNotThrow(() => assertDifferentialTestingData(payload));

payload.scenarioCatalog.scenarios[0].engine.runtimeRoot = ".";
assert.doesNotThrow(() => assertDifferentialTestingData(payload));

for (const runtimeRoot of ["/tmp/runtime", "../runtime", "src/../runtime", "src\\runtime", "src//runtime"]) {
const invalid = structuredClone(payload);
invalid.scenarioCatalog.scenarios[0].engine.runtimeRoot = runtimeRoot;
assert.match(
validateDifferentialTestingData(invalid).issues.map((entry) => entry.message).join("\n"),
/contained relative path/u,
runtimeRoot,
);
}
});

test("empty scenario state rejects fake selection and retained data", async (t) => {
await t.test("selectedScenarioId must stay null", () => {
const payload = buildPayload(...emptyCaptures());
Expand Down
Loading
Loading