From 51559321b30a84a16864d551fa85a2a49d114b2f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 17:40:29 +0200 Subject: [PATCH 01/85] feat: extract shared chart-core compact-time-scale math and adopt in Checklist chart --- dashboard/src/lib/checklist-progress-chart.js | 53 +--- .../src/oven/chart-core/compact-time-scale.js | 61 ++++ .../chart-core/compact-time-scale.test.mjs | 273 ++++++++++++++++++ scripts/verify-test-files.mjs | 1 + 4 files changed, 337 insertions(+), 51 deletions(-) create mode 100644 dashboard/src/oven/chart-core/compact-time-scale.js create mode 100644 dashboard/src/oven/chart-core/compact-time-scale.test.mjs diff --git a/dashboard/src/lib/checklist-progress-chart.js b/dashboard/src/lib/checklist-progress-chart.js index 8e20981..6017bac 100644 --- a/dashboard/src/lib/checklist-progress-chart.js +++ b/dashboard/src/lib/checklist-progress-chart.js @@ -1,3 +1,5 @@ +import { compactTimeScale, niceCeiling, stepPath } from "../oven/chart-core/compact-time-scale.js"; + function finiteNumber(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) ? number : fallback; @@ -7,14 +9,6 @@ function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); } -function niceCeiling(value) { - if (!(value > 0)) return 1; - const magnitude = 10 ** Math.floor(Math.log10(value)); - const scaled = value / magnitude; - const factor = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10; - return factor * magnitude; -} - function burnAxis(rawMaximum) { const maximum = Math.max(1, Math.ceil(rawMaximum)); if (maximum <= 5) return { maximum, ticks: Array.from({ length: maximum + 1 }, (_, index) => index) }; @@ -43,49 +37,6 @@ function normalizedHistory(history) { return collapsed; } -function compactTimeScale(points, minimumTime, maximumTime) { - const idleThreshold = 30 * 60_000; - const compactIdleGap = 8 * 60_000; - const anchors = [...new Set([minimumTime, ...points.map((point) => point.time).filter((time) => time > minimumTime && time < maximumTime), maximumTime])].sort((left, right) => left - right); - const segments = []; - let displayEnd = 0; - for (let index = 1; index < anchors.length; index += 1) { - const start = anchors[index - 1]; - const end = anchors[index]; - const elapsed = Math.max(0, end - start); - const displayElapsed = elapsed > idleThreshold ? compactIdleGap : elapsed; - segments.push({ start, end, displayStart: displayEnd, displayEnd: displayEnd + displayElapsed }); - displayEnd += displayElapsed; - } - const project = (time) => { - const clamped = clamp(Number(time), minimumTime, maximumTime); - const segment = segments.find((candidate) => clamped <= candidate.end) ?? segments.at(-1); - if (!segment || segment.end <= segment.start) return 0; - const ratio = (clamped - segment.start) / (segment.end - segment.start); - return segment.displayStart + (segment.displayEnd - segment.displayStart) * ratio; - }; - const unproject = (displayTime) => { - const clamped = clamp(Number(displayTime), 0, displayEnd); - const segment = segments.find((candidate) => clamped <= candidate.displayEnd) ?? segments.at(-1); - if (!segment || segment.displayEnd <= segment.displayStart) return minimumTime; - const ratio = (clamped - segment.displayStart) / (segment.displayEnd - segment.displayStart); - return segment.start + (segment.end - segment.start) * ratio; - }; - return { span: Math.max(1, displayEnd), project, unproject }; -} - -function stepPath(points, x, y, valueForPoint) { - if (!points.length) return ""; - const commands = [`M ${x(points[0]).toFixed(1)} ${y(valueForPoint(points[0])).toFixed(1)}`]; - for (let index = 1; index < points.length; index += 1) { - const point = points[index]; - const previous = points[index - 1]; - commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(previous)).toFixed(1)}`); - commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(point)).toFixed(1)}`); - } - return commands.join(" "); -} - export function buildChecklistProgressChart(history, mode = "done", { width = 640, height = 180 } = {}) { const safeWidth = Math.max(360, Math.round(width)); const safeHeight = Math.max(160, Math.round(height)); diff --git a/dashboard/src/oven/chart-core/compact-time-scale.js b/dashboard/src/oven/chart-core/compact-time-scale.js new file mode 100644 index 0000000..d744bd9 --- /dev/null +++ b/dashboard/src/oven/chart-core/compact-time-scale.js @@ -0,0 +1,61 @@ +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); +} + +export function niceCeiling(value) { + if (!(value > 0)) return 1; + const magnitude = 10 ** Math.floor(Math.log10(value)); + const scaled = value / magnitude; + const factor = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10; + return factor * magnitude; +} + +export function compactTimeScale(points, minimumTime, maximumTime) { + const idleThreshold = 30 * 60_000; + const compactIdleGap = 8 * 60_000; + const anchors = [...new Set([minimumTime, ...points.map((point) => Number(point.time)).filter((time) => Number.isFinite(time) && time > minimumTime && time < maximumTime), maximumTime])].sort((left, right) => left - right); + const segments = []; + let displayEnd = 0; + for (let index = 1; index < anchors.length; index += 1) { + const start = anchors[index - 1]; + const end = anchors[index]; + const elapsed = Math.max(0, end - start); + const displayElapsed = elapsed > idleThreshold ? compactIdleGap : elapsed; + segments.push({ start, end, displayStart: displayEnd, displayEnd: displayEnd + displayElapsed }); + displayEnd += displayElapsed; + } + const project = (time) => { + const clamped = clamp(Number(time), minimumTime, maximumTime); + const segment = segments.find((candidate) => clamped <= candidate.end) ?? segments.at(-1); + if (!segment || segment.end <= segment.start) return 0; + const ratio = (clamped - segment.start) / (segment.end - segment.start); + return segment.displayStart + (segment.displayEnd - segment.displayStart) * ratio; + }; + const unproject = (displayTime) => { + const clamped = clamp(Number(displayTime), 0, displayEnd); + const segment = segments.find((candidate) => clamped <= candidate.displayEnd) ?? segments.at(-1); + if (!segment || segment.displayEnd <= segment.displayStart) return minimumTime; + const ratio = (clamped - segment.displayStart) / (segment.displayEnd - segment.displayStart); + return segment.start + (segment.end - segment.start) * ratio; + }; + const ticks = (count) => { + if (count <= 1) return [minimumTime]; + return Array.from( + { length: count }, + (_, index) => unproject((displayEnd * index) / (count - 1)), + ); + }; + return { span: Math.max(1, displayEnd), project, ticks, unproject }; +} + +export function stepPath(points, x, y, valueForPoint) { + if (!points.length) return ""; + const commands = [`M ${x(points[0]).toFixed(1)} ${y(valueForPoint(points[0])).toFixed(1)}`]; + for (let index = 1; index < points.length; index += 1) { + const point = points[index]; + const previous = points[index - 1]; + commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(previous)).toFixed(1)}`); + commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(point)).toFixed(1)}`); + } + return commands.join(" "); +} diff --git a/dashboard/src/oven/chart-core/compact-time-scale.test.mjs b/dashboard/src/oven/chart-core/compact-time-scale.test.mjs new file mode 100644 index 0000000..602cbe3 --- /dev/null +++ b/dashboard/src/oven/chart-core/compact-time-scale.test.mjs @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compactTimeScale, niceCeiling, stepPath } from "./compact-time-scale.js"; + +// FROZEN SNAPSHOT — original Checklist compactTimeScale (pre-extraction) +const originalChecklistCompactTimeScale = (() => { + function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + function compactTimeScale(points, minimumTime, maximumTime) { + const idleThreshold = 30 * 60_000; + const compactIdleGap = 8 * 60_000; + const anchors = [...new Set([minimumTime, ...points.map((point) => point.time).filter((time) => time > minimumTime && time < maximumTime), maximumTime])].sort((left, right) => left - right); + const segments = []; + let displayEnd = 0; + for (let index = 1; index < anchors.length; index += 1) { + const start = anchors[index - 1]; + const end = anchors[index]; + const elapsed = Math.max(0, end - start); + const displayElapsed = elapsed > idleThreshold ? compactIdleGap : elapsed; + segments.push({ start, end, displayStart: displayEnd, displayEnd: displayEnd + displayElapsed }); + displayEnd += displayElapsed; + } + const project = (time) => { + const clamped = clamp(Number(time), minimumTime, maximumTime); + const segment = segments.find((candidate) => clamped <= candidate.end) ?? segments.at(-1); + if (!segment || segment.end <= segment.start) return 0; + const ratio = (clamped - segment.start) / (segment.end - segment.start); + return segment.displayStart + (segment.displayEnd - segment.displayStart) * ratio; + }; + const unproject = (displayTime) => { + const clamped = clamp(Number(displayTime), 0, displayEnd); + const segment = segments.find((candidate) => clamped <= candidate.displayEnd) ?? segments.at(-1); + if (!segment || segment.displayEnd <= segment.displayStart) return minimumTime; + const ratio = (clamped - segment.displayStart) / (segment.displayEnd - segment.displayStart); + return segment.start + (segment.end - segment.start) * ratio; + }; + return { span: Math.max(1, displayEnd), project, unproject }; + } + + return compactTimeScale; +})(); + +// FROZEN SNAPSHOT — DT createCompactTimeScale (reference) +function createCompactTimeScale(points, minTime, maxTime) { + const idleThresholdMs = 30 * 60_000; + const compactIdleGapMs = 8 * 60_000; + const anchors = [...new Set([ + minTime, + ...points.map((point) => Number(point.time)).filter((time) => Number.isFinite(time) && time > minTime && time < maxTime), + maxTime, + ])].sort((left, right) => left - right); + const segments = []; + let displayEnd = 0; + for (let index = 1; index < anchors.length; index += 1) { + const start = anchors[index - 1]; + const end = anchors[index]; + const elapsed = Math.max(0, end - start); + const displayElapsed = elapsed > idleThresholdMs ? compactIdleGapMs : elapsed; + segments.push({ start, end, displayStart: displayEnd, displayEnd: displayEnd + displayElapsed }); + displayEnd += displayElapsed; + } + const project = (time) => { + const clamped = Math.max(minTime, Math.min(maxTime, Number(time))); + const segment = segments.find((candidate) => clamped <= candidate.end) ?? segments.at(-1); + if (!segment || segment.end <= segment.start) return 0; + const ratio = (clamped - segment.start) / (segment.end - segment.start); + return segment.displayStart + (segment.displayEnd - segment.displayStart) * ratio; + }; + const unproject = (displayTime) => { + const clamped = Math.max(0, Math.min(displayEnd, Number(displayTime))); + const segment = segments.find((candidate) => clamped <= candidate.displayEnd) ?? segments.at(-1); + if (!segment || segment.displayEnd <= segment.displayStart) return minTime; + const ratio = (clamped - segment.displayStart) / (segment.displayEnd - segment.displayStart); + return segment.start + (segment.end - segment.start) * ratio; + }; + const ticks = (count) => { + if (count <= 1) return [minTime]; + return Array.from( + { length: count }, + (_, index) => unproject((displayEnd * index) / (count - 1)), + ); + }; + return { span: Math.max(1, displayEnd), project, ticks, unproject }; +} + +// FROZEN SNAPSHOT — original Checklist niceCeiling (pre-extraction) +function originalChecklistNiceCeiling(value) { + if (!(value > 0)) return 1; + const magnitude = 10 ** Math.floor(Math.log10(value)); + const scaled = value / magnitude; + const factor = scaled <= 1 ? 1 : scaled <= 2 ? 2 : scaled <= 5 ? 5 : 10; + return factor * magnitude; +} + +// FROZEN SNAPSHOT — original Checklist stepPath (pre-extraction) +function originalChecklistStepPath(points, x, y, valueForPoint) { + if (!points.length) return ""; + const commands = [`M ${x(points[0]).toFixed(1)} ${y(valueForPoint(points[0])).toFixed(1)}`]; + for (let index = 1; index < points.length; index += 1) { + const point = points[index]; + const previous = points[index - 1]; + commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(previous)).toFixed(1)}`); + commands.push(`L ${x(point).toFixed(1)} ${y(valueForPoint(point)).toFixed(1)}`); + } + return commands.join(" "); +} + +function mulberry32(seed) { + return () => { + let value = seed += 0x6D2B79F5; + value = Math.imul(value ^ value >>> 15, value | 1); + value ^= value + Math.imul(value ^ value >>> 7, value | 61); + return ((value ^ value >>> 14) >>> 0) / 4_294_967_296; + }; +} + +function randomCase(random, index) { + const minimumTime = 1_700_000_000_000 + Math.floor(random() * 1_000_000_000); + const pointCount = 2 + Math.floor(random() * 7); + const points = [{ time: minimumTime }]; + let currentTime = minimumTime; + for (let pointIndex = 1; pointIndex < pointCount; pointIndex += 1) { + const isIdleGap = (index + pointIndex) % 2 === 0; + const gap = isIdleGap + ? 30 * 60_000 + 1 + Math.floor(random() * 20 * 60_000) + : 1_000 + Math.floor(random() * 20 * 60_000); + currentTime += gap; + points.push({ time: currentTime }); + } + return { points, minimumTime, maximumTime: currentTime }; +} + +function assertScaleMatchesSnapshots(points, minimumTime, maximumTime, label) { + const shared = compactTimeScale(points, minimumTime, maximumTime); + const differentialTesting = createCompactTimeScale(points, minimumTime, maximumTime); + const checklist = originalChecklistCompactTimeScale(points, minimumTime, maximumTime); + + assert.equal(shared.span, differentialTesting.span, `span DT ${label}`); + assert.equal(shared.span, checklist.span, `span Checklist ${label}`); + const times = [ + minimumTime, + maximumTime, + ...points.map((point) => point.time), + (minimumTime + maximumTime) / 2, + ]; + for (const time of times) { + assert.equal(shared.project(time), differentialTesting.project(time), `project DT ${label}, time ${time}`); + assert.equal(shared.project(time), checklist.project(time), `project Checklist ${label}, time ${time}`); + } + for (const displayTime of [0, shared.span / 2, shared.span]) { + assert.equal(shared.unproject(displayTime), differentialTesting.unproject(displayTime), `unproject DT ${label}, display ${displayTime}`); + assert.equal(shared.unproject(displayTime), checklist.unproject(displayTime), `unproject Checklist ${label}, display ${displayTime}`); + } + for (const count of [0, 1, 2, 3, 7]) { + assert.deepEqual(shared.ticks(count), differentialTesting.ticks(count), `ticks DT ${label}, count ${count}`); + } +} + +test("shared compact time scale is exactly equivalent to Checklist and DT snapshots", () => { + const random = mulberry32(0xC0FFEE); + for (let caseIndex = 0; caseIndex < 240; caseIndex += 1) { + const { points, minimumTime, maximumTime } = randomCase(random, caseIndex); + const shared = compactTimeScale(points, minimumTime, maximumTime); + const checklist = originalChecklistCompactTimeScale(points, minimumTime, maximumTime); + const differentialTesting = createCompactTimeScale(points, minimumTime, maximumTime); + assert.equal(shared.span, checklist.span, `span Checklist case ${caseIndex}`); + assert.equal(shared.span, differentialTesting.span, `span DT case ${caseIndex}`); + for (let index = 0; index <= 20; index += 1) { + const time = minimumTime + (maximumTime - minimumTime) * index / 20; + assert.equal(shared.project(time), checklist.project(time), `project Checklist case ${caseIndex}, sample ${index}`); + assert.equal(shared.project(time), differentialTesting.project(time), `project DT case ${caseIndex}, sample ${index}`); + } + for (let index = 0; index <= 20; index += 1) { + const displayTime = shared.span * index / 20; + assert.equal(shared.unproject(displayTime), checklist.unproject(displayTime), `unproject Checklist case ${caseIndex}, sample ${index}`); + assert.equal(shared.unproject(displayTime), differentialTesting.unproject(displayTime), `unproject DT case ${caseIndex}, sample ${index}`); + } + for (const count of [0, 1, 2, 4, 7, 11]) { + assert.deepEqual(shared.ticks(count), differentialTesting.ticks(count), `ticks case ${caseIndex}, count ${count}`); + } + } +}); + +test("shared compact time scale preserves DT coercion and edge-case equivalence", () => { + const minimumTime = 1_700_000_000_000; + const numericStringTime = minimumTime + 30 * 60_000; + const maximumTime = minimumTime + 2 * 30 * 60_000; + assertScaleMatchesSnapshots( + [ + { time: minimumTime }, + { time: String(numericStringTime) }, + { time: numericStringTime }, + { time: maximumTime }, + ], + minimumTime, + maximumTime, + "numeric-string anchor", + ); + + const exactIdleGap = compactTimeScale( + [{ time: 0 }, { time: 30 * 60_000 }], + 0, + 30 * 60_000, + ); + assert.equal(exactIdleGap.span, 30 * 60_000, "exact threshold is not compacted"); + assertScaleMatchesSnapshots([{ time: 0 }, { time: 30 * 60_000 }], 0, 30 * 60_000, "exact idle threshold"); + + const justOverIdleGap = compactTimeScale( + [{ time: 0 }, { time: 30 * 60_000 + 1 }], + 0, + 30 * 60_000 + 1, + ); + assert.equal(justOverIdleGap.span, 8 * 60_000, "threshold plus one compacts to eight minutes"); + assertScaleMatchesSnapshots([{ time: 0 }, { time: 30 * 60_000 + 1 }], 0, 30 * 60_000 + 1, "just over idle threshold"); + + const unsortedMinimumTime = 1_700_000_000_000; + const unsortedMaximumTime = unsortedMinimumTime + 2 * 30 * 60_000 + 1_000; + assertScaleMatchesSnapshots( + [ + { time: unsortedMaximumTime + 1 }, + { time: unsortedMinimumTime + 30 * 60_000 + 1 }, + { time: unsortedMinimumTime + 30 * 60_000 + 1 }, + { time: unsortedMinimumTime - 1 }, + { time: unsortedMaximumTime }, + { time: unsortedMinimumTime }, + { time: unsortedMinimumTime + 1 }, + ], + unsortedMinimumTime, + unsortedMaximumTime, + "duplicate unsorted out-of-range anchors", + ); + + assertScaleMatchesSnapshots( + [{ time: 5 }, { time: 5 }, { time: 4 }, { time: 6 }], + 5, + 5, + "degenerate range", + ); +}); + +test("shared compact time scale has explicit hand-computed outputs", () => { + const scale = compactTimeScale([{ time: 1_000 }, { time: 3_000 }, { time: 5_000 }], 1_000, 5_000); + assert.equal(scale.span, 4_000); + assert.equal(scale.project(2_000), 1_000); + assert.equal(scale.unproject(2_000), 3_000); + assert.deepEqual(scale.ticks(3), [1_000, 3_000, 5_000]); +}); + +test("shared niceCeiling and stepPath preserve Checklist behavior", () => { + for (const value of [-100, -1, 0, 0.0001, 0.2, 1, 1.1, 2, 2.1, 5, 5.1, 9.99, 10, 100, 999.9, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.equal(niceCeiling(value), originalChecklistNiceCeiling(value), `niceCeiling(${value})`); + } + + const points = [ + { time: 0, value: 1 }, + { time: 1.234, value: 2.5 }, + { time: 4.5, value: 1 }, + ]; + const x = (point) => point.time * 12.3 + 0.04; + const y = (value) => 100 - value * 7.1; + const valueForPoint = (point) => point.value; + assert.equal(stepPath([], x, y, valueForPoint), ""); + assert.equal( + stepPath([{ time: 2, value: 3 }], (point) => point.time, (value) => value, valueForPoint), + "M 2.0 3.0", + ); + assert.equal( + stepPath(points, x, y, valueForPoint), + originalChecklistStepPath(points, x, y, valueForPoint), + ); +}); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index e46f6c0..958da23 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -56,6 +56,7 @@ export const verificationTestFiles = [ "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", + "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", "dashboard/src/lib/streaming-diff.test.mjs", "dashboard/src/lib/project-open.test.mjs", "dashboard/src/lib/oven-identity.test.mjs", From 56bd32d02ae7337c26b60c2dc958443f02a9cb83 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 17:58:10 +0200 Subject: [PATCH 02/85] feat: extract shared ProgressDonut and SectionHeader oven components and adopt in Checklist --- .../ChecklistDashboard/ChecklistDashboard.tsx | 10 ++-- .../oven/ProgressDonut/ProgressDonut.test.mjs | 33 ++++++++++++ .../src/oven/ProgressDonut/ProgressDonut.tsx | 13 +++++ dashboard/src/oven/ProgressDonut/index.ts | 1 + .../oven/SectionHeader/SectionHeader.test.mjs | 51 +++++++++++++++++++ .../src/oven/SectionHeader/SectionHeader.tsx | 10 ++++ dashboard/src/oven/SectionHeader/index.ts | 1 + scripts/verify-test-files.mjs | 2 + 8 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs create mode 100644 dashboard/src/oven/ProgressDonut/ProgressDonut.tsx create mode 100644 dashboard/src/oven/ProgressDonut/index.ts create mode 100644 dashboard/src/oven/SectionHeader/SectionHeader.test.mjs create mode 100644 dashboard/src/oven/SectionHeader/SectionHeader.tsx create mode 100644 dashboard/src/oven/SectionHeader/index.ts diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 68d3f7b..71d3a22 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -4,6 +4,8 @@ import type { ChecklistProgressData, CompletedItem, HistoryPoint } from "@lib"; import "./ChecklistDashboard.css"; // @ts-expect-error The chart model is plain ESM so the dashboard and Node tests share it. import { buildChecklistProgressChart } from "../../lib/checklist-progress-chart.js"; +import { ProgressDonut } from "../../oven/ProgressDonut"; +import { SectionHeader } from "../../oven/SectionHeader"; function formatDuration(milliseconds: number) { if (!Number.isFinite(milliseconds) || milliseconds < 0) return "--"; @@ -40,12 +42,6 @@ function timing(data: ChecklistProgressData) { return { elapsed: end - start, pace, timeLeft }; } -function ProgressDonut({ percent }: { percent: number }) { - const donePercent = Math.max(0, Math.min(100, percent)); - const remainingPercent = Math.max(0, 100 - donePercent); - return ; -} - function ChecklistKpis({ data }: { data: ChecklistProgressData }) { const durations = timing(data); const current = data.active[0]; @@ -158,7 +154,7 @@ function EventDetail({ detail }: { detail: string }) { function EventCardList({ data }: { data: ChecklistProgressData }) { const rows = eventRows(data); const [expandedKey, setExpandedKey] = useState(null); - return

Events ({rows.length})

{rows.map((item) => { + return
{rows.map((item) => { const key = `${item.id}/${item.completedAt}`; const fields = item.detail ? checklistEventDetailFields(item.detail) : []; const outcome = fields.find((field) => field.label === "Outcome") ?? fields.find((field) => field.label === "Detail"); diff --git a/dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs b/dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs new file mode 100644 index 0000000..b1ce8ee --- /dev/null +++ b/dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./ProgressDonut.tsx", import.meta.url).pathname; + +test("ProgressDonut keeps its static markup for representative values", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".progress-donut-test-")); + try { + const outputPath = join(outputDir, "ProgressDonut.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + jsx: "automatic", packages: "external", target: "node18", + }); + const { ProgressDonut } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); + for (const percent of [0, 37.5, 50, 99.999, 100, -5, 150]) { + const donePercent = Math.max(0, Math.min(100, percent)); + const remainingPercent = Math.max(0, 100 - donePercent); + const expected = ``; + assert.equal(renderToStaticMarkup(createElement(ProgressDonut, { percent })), expected); + } + assert.equal( + renderToStaticMarkup(createElement(ProgressDonut, { percent: 50, className: "custom-donut" })), + ``, + ); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } +}); diff --git a/dashboard/src/oven/ProgressDonut/ProgressDonut.tsx b/dashboard/src/oven/ProgressDonut/ProgressDonut.tsx new file mode 100644 index 0000000..b9f6d72 --- /dev/null +++ b/dashboard/src/oven/ProgressDonut/ProgressDonut.tsx @@ -0,0 +1,13 @@ +type ProgressDonutProps = { + percent: number; + className?: string; +}; + +export function ProgressDonut({ + percent, + className = "driving-parity-kpi-gauge driving-parity-kpi-progress-donut", +}: ProgressDonutProps) { + const donePercent = Math.max(0, Math.min(100, percent)); + const remainingPercent = Math.max(0, 100 - donePercent); + return ; +} diff --git a/dashboard/src/oven/ProgressDonut/index.ts b/dashboard/src/oven/ProgressDonut/index.ts new file mode 100644 index 0000000..c4e1620 --- /dev/null +++ b/dashboard/src/oven/ProgressDonut/index.ts @@ -0,0 +1 @@ +export { ProgressDonut } from "./ProgressDonut"; diff --git a/dashboard/src/oven/SectionHeader/SectionHeader.test.mjs b/dashboard/src/oven/SectionHeader/SectionHeader.test.mjs new file mode 100644 index 0000000..c90cbf9 --- /dev/null +++ b/dashboard/src/oven/SectionHeader/SectionHeader.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup, renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./SectionHeader.tsx", import.meta.url).pathname; + +test("SectionHeader keeps count and child markup stable", async () => { + const outputDir = await mkdtemp(join(process.cwd(), ".section-header-test-")); + try { + const outputPath = join(outputDir, "SectionHeader.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + jsx: "automatic", packages: "external", target: "node18", + }); + const { SectionHeader } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); + + assert.equal( + renderToStaticMarkup(createElement(SectionHeader, { title: "Events", count: 3 })), + `

Events (3)

`, + ); + assert.equal( + renderToStaticMarkup(createElement(SectionHeader, { title: "Fields List", count: 12 })), + `

Fields List (12)

`, + ); + assert.equal( + renderToStaticMarkup(createElement(SectionHeader, { + title: "Events", count: 3, children: createElement("span", { className: "custom-count" }, "(custom)"), + })), + `

Events (custom)

`, + ); + assert.equal( + renderToStaticMarkup(createElement(SectionHeader, { title: "Events", count: 3, className: "events-heading" })), + `

Events (3)

`, + ); + + function ReferenceSectionHeader({ title, count }) { + return createElement("h2", null, `${title} `, createElement("span", { className: "field-list-count" }, "(", count, ")")); + } + + const sectionHeaderOutput = renderToString(createElement(SectionHeader, { title: "Events", count: 3 })); + const referenceOutput = renderToString(createElement(ReferenceSectionHeader, { title: "Events", count: 3 })); + assert.equal(sectionHeaderOutput, referenceOutput); + assert.match(sectionHeaderOutput, /^

Events /u); + } finally { + await rm(outputDir, { force: true, recursive: true }); + } +}); diff --git a/dashboard/src/oven/SectionHeader/SectionHeader.tsx b/dashboard/src/oven/SectionHeader/SectionHeader.tsx new file mode 100644 index 0000000..3e775e4 --- /dev/null +++ b/dashboard/src/oven/SectionHeader/SectionHeader.tsx @@ -0,0 +1,10 @@ +type SectionHeaderProps = { + title: string; + count?: number; + className?: string; + children?: import("react").ReactNode; +}; + +export function SectionHeader({ title, count, className, children }: SectionHeaderProps) { + return

{`${title} `}{children ?? ({count})}

; +} diff --git a/dashboard/src/oven/SectionHeader/index.ts b/dashboard/src/oven/SectionHeader/index.ts new file mode 100644 index 0000000..689e6f6 --- /dev/null +++ b/dashboard/src/oven/SectionHeader/index.ts @@ -0,0 +1 @@ +export { SectionHeader } from "./SectionHeader"; diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 958da23..fc918c0 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -55,6 +55,8 @@ export const verificationTestFiles = [ "src/server/repo-state.test.mjs", "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs", + "dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs", + "dashboard/src/oven/SectionHeader/SectionHeader.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", "dashboard/src/lib/streaming-diff.test.mjs", From 44c4ae2ab91d864bec17784d860a92475e8d21f4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 18:11:20 +0200 Subject: [PATCH 03/85] feat: extract shared LogTable oven component (DT-superset markup) and adopt in Checklist ledger --- .../ChecklistDashboard/ChecklistDashboard.tsx | 17 +- dashboard/src/oven/LogTable/LogTable.test.mjs | 153 ++++++++++++++++++ dashboard/src/oven/LogTable/LogTable.tsx | 18 +++ dashboard/src/oven/LogTable/index.ts | 1 + scripts/verify-test-files.mjs | 1 + 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/oven/LogTable/LogTable.test.mjs create mode 100644 dashboard/src/oven/LogTable/LogTable.tsx create mode 100644 dashboard/src/oven/LogTable/index.ts diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 71d3a22..8770dc5 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -5,6 +5,7 @@ import "./ChecklistDashboard.css"; // @ts-expect-error The chart model is plain ESM so the dashboard and Node tests share it. import { buildChecklistProgressChart } from "../../lib/checklist-progress-chart.js"; import { ProgressDonut } from "../../oven/ProgressDonut"; +import { LogTable } from "../../oven/LogTable"; import { SectionHeader } from "../../oven/SectionHeader"; function formatDuration(milliseconds: number) { @@ -139,7 +140,21 @@ function progressHistory(data: ChecklistProgressData): HistoryPoint[] { function ProgressLedger({ data }: { data: ChecklistProgressData }) { const rows = eventRows(data).slice(0, 8); - return
Progress
AgeEventResultDeltaDone
{rows.map((item) =>
{compactAge(item.completedAt, data.generatedAt)}{item.id}Done+1{item.percent}%
)}{!rows.length &&
No completed events
}
; + return
Progress
({ + key: `${item.id}/${item.completedAt}`, + className: "log-row log-table-row", + cells: [ + { className: "log-table-cell age", content: compactAge(item.completedAt, data.generatedAt) }, + { className: "log-table-cell event", content: item.id }, + { className: "log-table-cell result improved", content: "Done" }, + { className: "log-table-cell delta improved", content: "+1" }, + { className: "log-table-cell done", content: <>{item.percent}% }, + ], + }))} + emptyState={
No completed events
} + />
; } function EventDetail({ detail }: { detail: string }) { diff --git a/dashboard/src/oven/LogTable/LogTable.test.mjs b/dashboard/src/oven/LogTable/LogTable.test.mjs new file mode 100644 index 0000000..2667f5b --- /dev/null +++ b/dashboard/src/oven/LogTable/LogTable.test.mjs @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { Fragment, createElement } from "react"; +import { renderToStaticMarkup, renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./LogTable.tsx", import.meta.url).pathname; +let outputDir; +let LogTable; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".log-table-test-")); + const outputPath = join(outputDir, "LogTable.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + jsx: "automatic", packages: "external", target: "node18", + }); + ({ LogTable } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function FrozenChecklistLog({ rows, emptyState }) { + return createElement( + "div", + { className: "checklist-log-list" }, + createElement( + "div", + { className: "checklist-log-table-header" }, + createElement("span", null, "Age"), + createElement("span", null, "Event"), + createElement("span", null, "Result"), + createElement("span", null, "Delta"), + createElement("span", null, "Done"), + ), + rows.map((item) => createElement( + "article", + { className: "log-row log-table-row", key: `${item.id}/${item.completedAt}` }, + createElement("span", { className: "log-table-cell age" }, item.age), + createElement("span", { className: "log-table-cell event" }, item.id), + createElement("span", { className: "log-table-cell result improved" }, "Done"), + createElement("span", { className: "log-table-cell delta improved" }, "+1"), + createElement("span", { className: "log-table-cell done" }, item.percent, "%"), + )), + !rows.length && emptyState, + ); +} + +function checklistProps(rows, emptyState) { + return { + columns: ["Age", "Event", "Result", "Delta", "Done"], + rows: rows.map((item) => ({ + className: "log-row log-table-row", + cells: [ + { className: "log-table-cell age", content: item.age }, + { className: "log-table-cell event", content: item.id }, + { className: "log-table-cell result improved", content: "Done" }, + { className: "log-table-cell delta improved", content: "+1" }, + { className: "log-table-cell done", content: createElement(Fragment, null, item.percent, "%") }, + ], + })), + emptyState, + }; +} + +test("LogTable preserves the populated Checklist snapshot", () => { + const rows = [ + { id: "BL-2", completedAt: "2026-07-18T11:40:00Z", age: "20m", percent: 100 }, + { id: "BL-1", completedAt: "2026-07-18T11:20:00Z", age: "40m", percent: 50 }, + ]; + const props = checklistProps(rows); + const componentOutput = renderToString(createElement(LogTable, props)); + const frozenOutput = renderToString(createElement(FrozenChecklistLog, { rows })); + const expected = "
AgeEventResultDeltaDone
20mBL-2Done+1100%
40mBL-1Done+150%
"; + + assert.equal(componentOutput, frozenOutput); + assert.equal(renderToStaticMarkup(createElement(LogTable, props)), expected); +}); + +test("LogTable preserves the empty Checklist snapshot", () => { + const emptyState = createElement("div", { className: "event-ledger-empty" }, "No completed events"); + const rows = []; + const props = checklistProps(rows, emptyState); + const componentOutput = renderToString(createElement(LogTable, props)); + const frozenOutput = renderToString(createElement(FrozenChecklistLog, { rows, emptyState })); + const expected = "
AgeEventResultDeltaDone
No completed events
"; + + assert.equal(componentOutput, frozenOutput); + assert.equal(renderToStaticMarkup(createElement(LogTable, props)), expected); + assert.match(componentOutput, /
[\s\S]*
No completed events<\/div>/u); +}); + +test("LogTable reproduces the Differential Testing log superset snapshot", () => { + const nestedResult = (indicator, value) => createElement( + "span", + { className: "log-delta-content" }, + createElement("span", { className: "log-delta-indicator" }, indicator), + createElement("span", null, value), + ); + const props = { + columns: ["Age", "Frame", "Result", "Delta", "Done"], + rows: [ + { + className: "log-row improved no-detail log-table-row", + cells: [ + { className: "log-table-cell age", content: "2m" }, + { className: "log-table-cell failed improved", content: "5" }, + { className: "log-table-cell result improved", content: nestedResult("▲", "2") }, + { className: "log-table-cell delta improved", content: "20%" }, + { className: "log-table-cell done", content: "50%" }, + ], + }, + { + className: "log-row worsened no-detail log-table-row", + cells: [ + { className: "log-table-cell age", content: "65m" }, + { className: "log-table-cell failed worsened", content: "9" }, + { className: "log-table-cell result worsened", content: nestedResult("▼", "3") }, + { className: "log-table-cell delta worsened", content: "25%" }, + { className: "log-table-cell done", content: "75%" }, + ], + }, + { + className: "log-row unchanged no-detail log-table-row", + cells: [ + { className: "log-table-cell age", content: "90m" }, + { className: "log-table-cell failed unchanged", content: "—" }, + { className: "log-table-cell result unchanged", content: "—" }, + { className: "log-table-cell delta unchanged", content: "—" }, + { className: "log-table-cell done", content: "—" }, + ], + }, + { + className: "log-row unchanged no-detail log-table-row", + cells: [ + { className: "log-table-cell age", content: "120m" }, + { className: "log-table-cell failed unchanged", content: "42" }, + { className: "log-table-cell result unchanged", content: "0" }, + { className: "log-table-cell delta unchanged", content: "0%" }, + { className: "log-table-cell done", content: "50%" }, + ], + }, + ], + placeholderCount: 4, + }; + const expected = "
AgeFrameResultDeltaDone
2m5220%50%
65m9325%75%
90m
120m4200%50%
.....
.....
.....
.....
"; + + assert.equal(renderToStaticMarkup(createElement(LogTable, props)), expected); +}); diff --git a/dashboard/src/oven/LogTable/LogTable.tsx b/dashboard/src/oven/LogTable/LogTable.tsx new file mode 100644 index 0000000..faec48b --- /dev/null +++ b/dashboard/src/oven/LogTable/LogTable.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +type LogTableProps = { + columns: string[]; + rows: { key?: string; className: string; cells: { className: string; content: ReactNode }[] }[]; + placeholderCount?: number; + emptyState?: ReactNode; + className?: string; +}; + +export function LogTable({ columns, rows, placeholderCount = 0, emptyState, className }: LogTableProps) { + return
+
{columns.map((column, index) => {column})}
+ {rows.map((row, index) =>
{row.cells.map((cell, cellIndex) => {cell.content})}
)} + {Array.from({ length: Math.max(0, placeholderCount ?? 0) }).map((_, index) => )} + {rows.length === 0 && emptyState} +
; +} diff --git a/dashboard/src/oven/LogTable/index.ts b/dashboard/src/oven/LogTable/index.ts new file mode 100644 index 0000000..b0db52c --- /dev/null +++ b/dashboard/src/oven/LogTable/index.ts @@ -0,0 +1 @@ +export { LogTable } from "./LogTable"; diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index fc918c0..400fc12 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -56,6 +56,7 @@ export const verificationTestFiles = [ "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs", "dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs", + "dashboard/src/oven/LogTable/LogTable.test.mjs", "dashboard/src/oven/SectionHeader/SectionHeader.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", From 425f507052f0f78590b12c170355e5e29367a2f7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 18:24:10 +0200 Subject: [PATCH 04/85] feat: extract shared KpiItem and KpiStrip oven components and adopt in Checklist KPI strip --- .../ChecklistDashboard.test.mjs | 7 ++ .../ChecklistDashboard/ChecklistDashboard.tsx | 12 +- dashboard/src/oven/KpiItem/KpiItem.test.mjs | 80 ++++++++++++ dashboard/src/oven/KpiItem/KpiItem.tsx | 13 ++ dashboard/src/oven/KpiItem/index.ts | 1 + dashboard/src/oven/KpiStrip/KpiStrip.test.mjs | 43 +++++++ dashboard/src/oven/KpiStrip/KpiStrip.tsx | 11 ++ .../oven/KpiStrip/checklist-strip.test.mjs | 116 ++++++++++++++++++ dashboard/src/oven/KpiStrip/index.ts | 1 + scripts/verify-test-files.mjs | 3 + 10 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/oven/KpiItem/KpiItem.test.mjs create mode 100644 dashboard/src/oven/KpiItem/KpiItem.tsx create mode 100644 dashboard/src/oven/KpiItem/index.ts create mode 100644 dashboard/src/oven/KpiStrip/KpiStrip.test.mjs create mode 100644 dashboard/src/oven/KpiStrip/KpiStrip.tsx create mode 100644 dashboard/src/oven/KpiStrip/checklist-strip.test.mjs create mode 100644 dashboard/src/oven/KpiStrip/index.ts diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index ecfebb8..87598ed 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -33,6 +33,13 @@ test("checklist detail renders the split progress surface and event card list", const markup = renderToStaticMarkup(createElement(ChecklistDashboard, { data })); assert.match(markup, /aria-label="Burnlist progress KPIs"/u); + assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current"/u); + assert.match(markup, /
Current<\/div>
Complete<\/div>/u); + assert.match(markup, /class="driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress"/u); + assert.match(markup, /
2<\/span>·<\/span>2<\/span> \(100%\)<\/span><\/div>/u); + assert.match(markup, /
Elapsed<\/div>/u); + assert.match(markup, /
Avg pace<\/div>/u); + assert.match(markup, /
Time left<\/div>/u); assert.match(markup, /class="driving-parity-kpi-gauge driving-parity-kpi-progress-donut" viewBox="0 0 58 58"/u); assert.match(markup, /class="driving-parity-kpi-progress-donut-segment"[^>]+stroke-dasharray="100\.000 0\.000"/u); assert.match(markup, /aria-label="Remaining items over time"/u); diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 8770dc5..e93d2d2 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -7,6 +7,8 @@ import { buildChecklistProgressChart } from "../../lib/checklist-progress-chart. import { ProgressDonut } from "../../oven/ProgressDonut"; import { LogTable } from "../../oven/LogTable"; import { SectionHeader } from "../../oven/SectionHeader"; +import { KpiItem } from "../../oven/KpiItem"; +import { KpiStrip } from "../../oven/KpiStrip"; function formatDuration(milliseconds: number) { if (!Number.isFinite(milliseconds) || milliseconds < 0) return "--"; @@ -51,11 +53,11 @@ function ChecklistKpis({ data }: { data: ChecklistProgressData }) { { icon: Gauge, heading: "Avg pace", value: formatDuration(durations.pace) }, { icon: TimerReset, heading: "Time left", value: formatDuration(durations.timeLeft) }, ]; - return
-
-
Progress
{data.done}·{data.total} ({data.percent}%)
- {metrics.map(({ icon: Icon, heading, value }) =>
)} -
; + return + ; } function useElementSize() { diff --git a/dashboard/src/oven/KpiItem/KpiItem.test.mjs b/dashboard/src/oven/KpiItem/KpiItem.test.mjs new file mode 100644 index 0000000..34426d6 --- /dev/null +++ b/dashboard/src/oven/KpiItem/KpiItem.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { Fragment, createElement } from "react"; +import { renderToStaticMarkup, renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./KpiItem.tsx", import.meta.url).pathname; +let outputDir; +let KpiItem; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".kpi-item-test-")); + const outputPath = join(outputDir, "KpiItem.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + jsx: "automatic", packages: "external", target: "node18", + }); + ({ KpiItem } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +test("KpiItem preserves the exact Checklist item markup", () => { + const visual = createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" }); + const item = createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current", + title: "No active task", + visual, + heading: "Current", + value: "Complete", + }); + const expected = "
Current
Complete
"; + + assert.equal(renderToStaticMarkup(item), expected); + assert.equal(renderToString(item), expected); +}); + +test("KpiItem omits an undefined title attribute", () => { + const markup = renderToStaticMarkup(createElement(KpiItem, { heading: "Elapsed", value: "2m" })); + + assert.doesNotMatch(markup, / title=/u); +}); + +test("KpiItem preserves the value fragment topology", () => { + const value = createElement( + Fragment, + null, + createElement("span", { className: "pass" }, 2), + createElement("span", { className: "separator" }, "·"), + createElement("span", { className: "total" }, 2), + " ", + createElement("span", { className: "pass" }, "(", 100, "%)"), + ); + const kpiItemElement = createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress", + visual: createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-progress-donut", viewBox: "0 0 58 58" }), + heading: "Progress", + value, + }); + function FrozenProgressItem() { + return createElement("div", { className: "driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress" }, + createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-progress-donut", viewBox: "0 0 58 58" }), + createElement("div", { className: "driving-parity-kpi-text" }, + createElement("div", { className: "driving-parity-kpi-heading" }, "Progress"), + createElement("div", { className: "driving-parity-kpi-ratio" }, + createElement("span", { className: "pass" }, 2), + createElement("span", { className: "separator" }, "·"), + createElement("span", { className: "total" }, 2), + " ", + createElement("span", { className: "pass" }, "(", 100, "%)")))); + } + + assert.equal(renderToString(kpiItemElement), renderToString(createElement(FrozenProgressItem))); + const item = createElement(KpiItem, { heading: "Progress", value }); + assert.equal(renderToStaticMarkup(item), "
Progress
2·2 (100%)
"); +}); diff --git a/dashboard/src/oven/KpiItem/KpiItem.tsx b/dashboard/src/oven/KpiItem/KpiItem.tsx new file mode 100644 index 0000000..0c02469 --- /dev/null +++ b/dashboard/src/oven/KpiItem/KpiItem.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +type KpiItemProps = { + className?: string; + title?: string; + visual?: ReactNode; + heading: ReactNode; + value: ReactNode; +}; + +export function KpiItem({ className, title, visual, heading, value }: KpiItemProps) { + return
{visual}
{heading}
{value}
; +} diff --git a/dashboard/src/oven/KpiItem/index.ts b/dashboard/src/oven/KpiItem/index.ts new file mode 100644 index 0000000..0256dbe --- /dev/null +++ b/dashboard/src/oven/KpiItem/index.ts @@ -0,0 +1 @@ +export { KpiItem } from "./KpiItem"; diff --git a/dashboard/src/oven/KpiStrip/KpiStrip.test.mjs b/dashboard/src/oven/KpiStrip/KpiStrip.test.mjs new file mode 100644 index 0000000..287c8bc --- /dev/null +++ b/dashboard/src/oven/KpiStrip/KpiStrip.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup, renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./KpiStrip.tsx", import.meta.url).pathname; +let outputDir; +let KpiStrip; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".kpi-strip-test-")); + const outputPath = join(outputDir, "KpiStrip.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + jsx: "automatic", packages: "external", target: "node18", + }); + ({ KpiStrip } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +test("KpiStrip preserves exact attributes and child output", () => { + const strip = createElement(KpiStrip, { + ariaLabel: "Burnlist progress KPIs", + className: "driving-parity-kpi-strip has-burns checklist-kpi-strip", + children: "CHILD", + }); + const expected = "
CHILD
"; + + assert.equal(renderToStaticMarkup(strip), expected); + assert.equal(renderToString(strip), expected); +}); + +test("KpiStrip omits an undefined aria-label attribute", () => { + const markup = renderToStaticMarkup(createElement(KpiStrip, { className: "checklist-kpi-strip" })); + + assert.doesNotMatch(markup, / aria-label=/u); +}); diff --git a/dashboard/src/oven/KpiStrip/KpiStrip.tsx b/dashboard/src/oven/KpiStrip/KpiStrip.tsx new file mode 100644 index 0000000..0a35a9f --- /dev/null +++ b/dashboard/src/oven/KpiStrip/KpiStrip.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +type KpiStripProps = { + className?: string; + ariaLabel?: string; + children?: ReactNode; +}; + +export function KpiStrip({ className, ariaLabel, children }: KpiStripProps) { + return
{children}
; +} diff --git a/dashboard/src/oven/KpiStrip/checklist-strip.test.mjs b/dashboard/src/oven/KpiStrip/checklist-strip.test.mjs new file mode 100644 index 0000000..1a0917c --- /dev/null +++ b/dashboard/src/oven/KpiStrip/checklist-strip.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement, Fragment } from "react"; +import { renderToStaticMarkup, renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const itemPath = new URL("../KpiItem/KpiItem.tsx", import.meta.url).pathname; +const stripPath = new URL("./KpiStrip.tsx", import.meta.url).pathname; +let outputDir; +let KpiItem; +let KpiStrip; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".checklist-strip-test-")); + const itemOutputPath = join(outputDir, "KpiItem.mjs"); + const stripOutputPath = join(outputDir, "KpiStrip.mjs"); + const buildOptions = { + bundle: true, format: "esm", platform: "node", + jsx: "automatic", packages: "external", target: "node18", + }; + await build({ ...buildOptions, entryPoints: [itemPath], outfile: itemOutputPath }); + await build({ ...buildOptions, entryPoints: [stripPath], outfile: stripOutputPath }); + ({ KpiItem } = await import(`${new URL(`file://${itemOutputPath}`).href}?test=${Date.now()}`)); + ({ KpiStrip } = await import(`${new URL(`file://${stripOutputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +test("Checklist KPI strip preserves its complete static structure", () => { + const scenarioIcon = createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" }); + const donut = createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-progress-donut", viewBox: "0 0 58 58" }); + const progressValue = createElement( + Fragment, + null, + createElement("span", { className: "pass" }, 2), + createElement("span", { className: "separator" }, "·"), + createElement("span", { className: "total" }, 2), + " ", + createElement("span", { className: "pass" }, "(", 100, "%)"), + ); + const strip = createElement( + KpiStrip, + { ariaLabel: "Burnlist progress KPIs", className: "driving-parity-kpi-strip has-burns checklist-kpi-strip" }, + createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current", + title: "No active task", + visual: scenarioIcon, + heading: "Current", + value: "Complete", + }), + createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress", + title: "2 of 2 tasks complete", + visual: donut, + heading: "Progress", + value: progressValue, + }), + createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section", + heading: "Elapsed", + value: "20m", + visual: scenarioIcon, + }), + createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section", + heading: "Avg pace", + value: "10m", + visual: scenarioIcon, + }), + createElement(KpiItem, { + className: "driving-parity-kpi-item driving-parity-kpi-section", + heading: "Time left", + value: "--", + visual: scenarioIcon, + }), + ); + const expected = "
Current
Complete
Progress
2·2 (100%)
Elapsed
20m
Avg pace
10m
Time left
--
"; + const frozenReferenceStrip = createElement("div", { "aria-label": "Burnlist progress KPIs", className: "driving-parity-kpi-strip has-burns checklist-kpi-strip" }, + createElement("div", { className: "driving-parity-kpi-item driving-parity-kpi-section checklist-kpi-current", title: "No active task" }, + scenarioIcon, + createElement("div", { className: "driving-parity-kpi-text" }, + createElement("div", { className: "driving-parity-kpi-heading" }, "Current"), + createElement("div", { className: "driving-parity-kpi-ratio" }, "Complete"))), + createElement("div", { className: "driving-parity-kpi-item driving-parity-kpi-section driving-parity-kpi-progress", title: "2 of 2 tasks complete" }, + donut, + createElement("div", { className: "driving-parity-kpi-text" }, + createElement("div", { className: "driving-parity-kpi-heading" }, "Progress"), + createElement("div", { className: "driving-parity-kpi-ratio" }, + createElement("span", { className: "pass" }, 2), + createElement("span", { className: "separator" }, "·"), + createElement("span", { className: "total" }, 2), + " ", + createElement("span", { className: "pass" }, "(", 100, "%)")))), + createElement("div", { className: "driving-parity-kpi-item driving-parity-kpi-section" }, + scenarioIcon, + createElement("div", { className: "driving-parity-kpi-text" }, + createElement("div", { className: "driving-parity-kpi-heading" }, "Elapsed"), + createElement("div", { className: "driving-parity-kpi-ratio" }, "20m"))), + createElement("div", { className: "driving-parity-kpi-item driving-parity-kpi-section" }, + scenarioIcon, + createElement("div", { className: "driving-parity-kpi-text" }, + createElement("div", { className: "driving-parity-kpi-heading" }, "Avg pace"), + createElement("div", { className: "driving-parity-kpi-ratio" }, "10m"))), + createElement("div", { className: "driving-parity-kpi-item driving-parity-kpi-section" }, + scenarioIcon, + createElement("div", { className: "driving-parity-kpi-text" }, + createElement("div", { className: "driving-parity-kpi-heading" }, "Time left"), + createElement("div", { className: "driving-parity-kpi-ratio" }, "--")))); + + assert.equal(renderToStaticMarkup(strip), expected); + assert.equal(renderToString(strip), renderToString(frozenReferenceStrip)); +}); diff --git a/dashboard/src/oven/KpiStrip/index.ts b/dashboard/src/oven/KpiStrip/index.ts new file mode 100644 index 0000000..c38b305 --- /dev/null +++ b/dashboard/src/oven/KpiStrip/index.ts @@ -0,0 +1 @@ +export { KpiStrip } from "./KpiStrip"; diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 400fc12..152dc03 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -56,6 +56,9 @@ export const verificationTestFiles = [ "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs", "dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs", + "dashboard/src/oven/KpiItem/KpiItem.test.mjs", + "dashboard/src/oven/KpiStrip/KpiStrip.test.mjs", + "dashboard/src/oven/KpiStrip/checklist-strip.test.mjs", "dashboard/src/oven/LogTable/LogTable.test.mjs", "dashboard/src/oven/SectionHeader/SectionHeader.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", From bf761df87f1a2fe25e7487af2032aa5e05a5d6c5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 18:45:12 +0200 Subject: [PATCH 05/85] feat: add shared useOvenLiveData hook (poll/sse/props) and port Visual Parity poll + Streaming Diff SSE onto it --- .../src/hooks/streaming-diff-transport.mjs | 7 + dashboard/src/hooks/useStreamingDiff.ts | 44 +-- dashboard/src/hooks/useVisualParityData.ts | 53 ++-- .../src/hooks/visual-parity-transport.mjs | 5 + .../src/oven/live-data/adapters.test.mjs | 45 ++++ dashboard/src/oven/live-data/index.ts | 2 + dashboard/src/oven/live-data/transports.js | 57 ++++ .../src/oven/live-data/transports.test.mjs | 255 ++++++++++++++++++ .../src/oven/live-data/useOvenLiveData.ts | 92 +++++++ scripts/verify-test-files.mjs | 2 + 10 files changed, 495 insertions(+), 67 deletions(-) create mode 100644 dashboard/src/hooks/streaming-diff-transport.mjs create mode 100644 dashboard/src/hooks/visual-parity-transport.mjs create mode 100644 dashboard/src/oven/live-data/adapters.test.mjs create mode 100644 dashboard/src/oven/live-data/index.ts create mode 100644 dashboard/src/oven/live-data/transports.js create mode 100644 dashboard/src/oven/live-data/transports.test.mjs create mode 100644 dashboard/src/oven/live-data/useOvenLiveData.ts diff --git a/dashboard/src/hooks/streaming-diff-transport.mjs b/dashboard/src/hooks/streaming-diff-transport.mjs new file mode 100644 index 0000000..ce6f03d --- /dev/null +++ b/dashboard/src/hooks/streaming-diff-transport.mjs @@ -0,0 +1,7 @@ +import { applyStreamingDiffUpdate, parseStreamingDiffCard } from "../lib/streaming-diff.mjs"; + +export function applyStreamingDiffCardMessage(cards, raw) { + const card = parseStreamingDiffCard(JSON.parse(raw)); + if (!card) throw new Error("invalid card"); + return applyStreamingDiffUpdate(cards, { type: "card", card }); +} diff --git a/dashboard/src/hooks/useStreamingDiff.ts b/dashboard/src/hooks/useStreamingDiff.ts index 6ab86ec..8440b56 100644 --- a/dashboard/src/hooks/useStreamingDiff.ts +++ b/dashboard/src/hooks/useStreamingDiff.ts @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; -import { applyStreamingDiffUpdate, mapStreamingDiffLandingFeeds, parseStreamingDiffCard } from "@lib"; +import { applyStreamingDiffUpdate, mapStreamingDiffLandingFeeds } from "@lib"; import type { StreamingDiffCard, StreamingDiffFeed } from "@lib"; +import { applyStreamingDiffCardMessage } from "./streaming-diff-transport.mjs"; +import { useOvenLiveData } from "../oven/live-data"; type FeedState = { feeds: StreamingDiffFeed[]; error: string; loading: boolean }; type CardState = { cards: StreamingDiffCard[]; error: string }; @@ -45,34 +47,16 @@ export function useStreamingDiffFeeds(repositories: Repository[], discoveryLoadi } export function useStreamingDiffCards(selection: Selection): CardState { - const [state, setState] = useState({ cards: [], error: "" }); + const { data, error } = useOvenLiveData({ + transport: "sse", + makeUrl: () => selection ? `/api/oven-data/streaming-diff?${new URLSearchParams(selection)}` : null, + initialData: [], + applyReset: (cards) => applyStreamingDiffUpdate(cards, { type: "reset" }), + applyMessage: applyStreamingDiffCardMessage, + invalidError: "Received an invalid Streaming Diff card.", + disconnectError: "The stream disconnected; reconnecting.", + deps: [selection?.repoKey, selection?.session, selection?.worktreeKey], + }); - useEffect(() => { - if (!selection) { - setState({ cards: [], error: "" }); - return; - } - const params = new URLSearchParams(selection); - const stream = new EventSource(`/api/oven-data/streaming-diff?${params}`); - setState({ cards: [], error: "" }); - const reset = () => setState((current) => ({ ...current, cards: applyStreamingDiffUpdate(current.cards, { type: "reset" }) })); - stream.addEventListener("reset", reset); - stream.onopen = () => setState((current) => ({ ...current, error: "" })); - stream.onmessage = (event) => { - try { - const card = parseStreamingDiffCard(JSON.parse(event.data)); - if (!card) throw new Error("invalid card"); - setState((current) => ({ ...current, cards: applyStreamingDiffUpdate(current.cards, { type: "card", card }) })); - } catch { - setState((current) => ({ ...current, error: "Received an invalid Streaming Diff card." })); - } - }; - stream.onerror = () => setState((current) => ({ ...current, error: "The stream disconnected; reconnecting." })); - return () => { - stream.removeEventListener("reset", reset); - stream.close(); - }; - }, [selection?.repoKey, selection?.session, selection?.worktreeKey]); - - return state; + return { cards: data, error }; } diff --git a/dashboard/src/hooks/useVisualParityData.ts b/dashboard/src/hooks/useVisualParityData.ts index 48a48f7..9b9f09b 100644 --- a/dashboard/src/hooks/useVisualParityData.ts +++ b/dashboard/src/hooks/useVisualParityData.ts @@ -1,42 +1,21 @@ -import { useEffect, useRef, useState } from "react"; import { ovenRepoKey, type VisualParityPayload } from "@lib"; +import { receiveVisualParity } from "./visual-parity-transport.mjs"; +import { useOvenLiveData } from "../oven/live-data"; export function useVisualParityData() { - const [payload, setPayload] = useState(null); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(true); - const inFlight = useRef(false); + const { data, error, loading } = useOvenLiveData({ + transport: "poll", + makeUrl: () => { + const repoKey = ovenRepoKey(); + const query = repoKey ? `?repoKey=${encodeURIComponent(repoKey)}` : ""; + return `/api/oven-data/visual-parity${query}`; + }, + intervalMs: 2_000, + receive: receiveVisualParity, + fallbackError: "Could not load Visual Parity.", + initialData: null, + deps: [], + }); - 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 }; + return { payload: data, error, loading }; } diff --git a/dashboard/src/hooks/visual-parity-transport.mjs b/dashboard/src/hooks/visual-parity-transport.mjs new file mode 100644 index 0000000..a8d4357 --- /dev/null +++ b/dashboard/src/hooks/visual-parity-transport.mjs @@ -0,0 +1,5 @@ +export function receiveVisualParity(response, json) { + if (!response.ok) throw new Error(json.error ?? "Could not load Visual Parity."); + if (json.validated !== true) throw new Error("Visual Parity data was not validated by the Oven."); + return json.payload; +} diff --git a/dashboard/src/oven/live-data/adapters.test.mjs b/dashboard/src/oven/live-data/adapters.test.mjs new file mode 100644 index 0000000..57bc0bd --- /dev/null +++ b/dashboard/src/oven/live-data/adapters.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyStreamingDiffCardMessage } from "../../hooks/streaming-diff-transport.mjs"; +import { receiveVisualParity } from "../../hooks/visual-parity-transport.mjs"; + +test("Visual Parity receive returns validated payloads", () => { + const payload = { score: 0.98 }; + assert.deepEqual(receiveVisualParity({ ok: true }, { validated: true, payload }), payload); +}); + +test("Visual Parity receive enforces the validated gate", () => { + for (const json of [{}, { validated: false }]) { + assert.throws( + () => receiveVisualParity({ ok: true }, json), + { message: "Visual Parity data was not validated by the Oven." }, + ); + } +}); + +test("Visual Parity receive reports response errors exactly", () => { + assert.throws(() => receiveVisualParity({ ok: false }, { error: "x" }), { message: "x" }); + assert.throws( + () => receiveVisualParity({ ok: false }, {}), + { message: "Could not load Visual Parity." }, + ); +}); + +test("Streaming Diff card messages use the real card adapter", () => { + const card = { + revId: "revision-1", + toolUseId: "tool-1", + ts: "2026-07-15T09:00:00.000Z", + status: "captured", + files: [{ path: "src/example.mjs", kind: "modified", diff: "+after" }], + }; + + const cards = applyStreamingDiffCardMessage([], JSON.stringify(card)); + assert.equal(cards.length, 1); + assert.deepEqual(cards[0], card); +}); + +test("Streaming Diff card messages reject invalid and unparseable input", () => { + assert.throws(() => applyStreamingDiffCardMessage([], "{}"), { message: "invalid card" }); + assert.throws(() => applyStreamingDiffCardMessage([], "not-json")); +}); diff --git a/dashboard/src/oven/live-data/index.ts b/dashboard/src/oven/live-data/index.ts new file mode 100644 index 0000000..61b85ea --- /dev/null +++ b/dashboard/src/oven/live-data/index.ts @@ -0,0 +1,2 @@ +export { createPollTransport, createSseTransport } from "./transports.js"; +export { useOvenLiveData } from "./useOvenLiveData"; diff --git a/dashboard/src/oven/live-data/transports.js b/dashboard/src/oven/live-data/transports.js new file mode 100644 index 0000000..657d830 --- /dev/null +++ b/dashboard/src/oven/live-data/transports.js @@ -0,0 +1,57 @@ +export function createPollTransport({ + makeUrl, + intervalMs, + receive, + fallbackError, + inFlightRef = { current: false }, + fetchImpl = fetch, + setIntervalImpl = setInterval, + clearIntervalImpl = clearInterval, +}) { + return { + start({ onData, onError, onSettled }) { + let cancelled = false; + + const refresh = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + const response = await fetchImpl(makeUrl(), { cache: "no-store" }); + const json = await response.json(); + const data = receive(response, json); + if (!cancelled) onData(data); + } catch (cause) { + if (!cancelled) onError(cause instanceof Error ? cause.message : fallbackError); + } finally { + inFlightRef.current = false; + if (!cancelled) onSettled(); + } + }; + + void refresh(); + const timer = setIntervalImpl(refresh, intervalMs); + return () => { + cancelled = true; + clearIntervalImpl(timer); + }; + }, + }; +} + +export function createSseTransport({ makeUrl, EventSourceImpl = EventSource }) { + return { + start({ onReset, onOpen, onMessage, onError }) { + const url = makeUrl(); + const stream = new EventSourceImpl(url); + const reset = () => onReset(); + stream.addEventListener("reset", reset); + stream.onopen = () => onOpen(); + stream.onmessage = (event) => onMessage(event.data); + stream.onerror = () => onError(); + return () => { + stream.removeEventListener("reset", reset); + stream.close(); + }; + }, + }; +} diff --git a/dashboard/src/oven/live-data/transports.test.mjs b/dashboard/src/oven/live-data/transports.test.mjs new file mode 100644 index 0000000..81b7a24 --- /dev/null +++ b/dashboard/src/oven/live-data/transports.test.mjs @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyStreamingDiffUpdate, parseStreamingDiffCard } from "../../lib/streaming-diff.mjs"; +import { createPollTransport, createSseTransport } from "./transports.js"; + +const waitForAsyncWork = () => new Promise((resolve) => setImmediate(resolve)); + +function visualParityReceive(response, json) { + if (!response.ok) throw new Error(json.error ?? "Could not load Visual Parity."); + if (json.validated !== true) throw new Error("Visual Parity data was not validated by the Oven."); + return json.payload; +} + +function startPoll({ fetchImpl, receive = visualParityReceive, events = [], setIntervalImpl, clearIntervalImpl } = {}) { + let intervalCallback; + const transport = createPollTransport({ + makeUrl: () => "/api/oven-data/visual-parity", + intervalMs: 2_000, + receive, + fallbackError: "Could not load Visual Parity.", + fetchImpl, + setIntervalImpl: setIntervalImpl ?? ((callback) => { + intervalCallback = callback; + return "timer"; + }), + clearIntervalImpl: clearIntervalImpl ?? (() => {}), + }); + const stop = transport.start({ + onData: (data) => events.push(["data", data]), + onError: (error) => events.push(["error", error]), + onSettled: () => events.push(["settled"]), + }); + return { stop, get intervalCallback() { return intervalCallback; } }; +} + +test("poll uses the configured URL, cache mode, and interval", async () => { + const fetchCalls = []; + let intervalArgs; + const { stop } = startPoll({ + fetchImpl: async (...args) => { + fetchCalls.push(args); + return { ok: true, json: () => ({ validated: true, payload: {} }) }; + }, + setIntervalImpl: (...args) => { + intervalArgs = args; + return "timer"; + }, + }); + + await waitForAsyncWork(); + assert.equal(typeof intervalArgs[0], "function"); + assert.equal(intervalArgs[1], 2_000); + assert.deepEqual(fetchCalls, [["/api/oven-data/visual-parity", { cache: "no-store" }]]); + stop(); +}); + +test("poll success delivers data before settled", async () => { + const events = []; + const { stop } = startPoll({ + events, + fetchImpl: async () => ({ ok: true, json: () => ({ validated: true, payload: { x: 1 } }) }), + }); + + await waitForAsyncWork(); + stop(); + assert.deepEqual(events, [["data", { x: 1 }], ["settled"]]); +}); + +test("poll skips an interval refresh while the first request is in flight", async () => { + let resolveFetch; + let fetchCalls = 0; + const { stop, intervalCallback } = startPoll({ + fetchImpl: () => { + fetchCalls += 1; + return new Promise((resolve) => { resolveFetch = resolve; }); + }, + }); + + await Promise.resolve(); + assert.equal(fetchCalls, 1); + await intervalCallback(); + assert.equal(fetchCalls, 1); + resolveFetch({ ok: true, json: () => ({ validated: true, payload: {} }) }); + await waitForAsyncWork(); + stop(); +}); + +test("poll replay shares the in-flight guard", async () => { + let resolveFetch; + let fetchCalls = 0; + const inFlightRef = { current: false }; + const fetchImpl = () => { + fetchCalls += 1; + return new Promise((resolve) => { resolveFetch = resolve; }); + }; + const config = { + makeUrl: () => "/api/oven-data/visual-parity", + intervalMs: 2_000, + receive: visualParityReceive, + fallbackError: "Could not load Visual Parity.", + inFlightRef, + fetchImpl, + setIntervalImpl: () => "timer", + clearIntervalImpl: () => {}, + }; + const events = []; + const events2 = []; + const stop = createPollTransport(config).start({ + onData: (data) => events.push(["data", data]), + onError: (error) => events.push(["error", error]), + onSettled: () => events.push(["settled"]), + }); + const stop2 = createPollTransport(config).start({ + onData: (data) => events2.push(["data", data]), + onError: (error) => events2.push(["error", error]), + onSettled: () => events2.push(["settled"]), + }); + + assert.equal(fetchCalls, 1); + resolveFetch({ ok: true, json: () => ({ validated: true, payload: { replay: "done" } }) }); + await waitForAsyncWork(); + assert.deepEqual(events, [["data", { replay: "done" }], ["settled"]]); + assert.deepEqual(events2, []); + stop2(); + stop(); +}); + +test("poll validated gate reports its exact error", async () => { + const events = []; + const { stop } = startPoll({ + events, + fetchImpl: async () => ({ ok: true, json: () => ({ validated: false, payload: {} }) }), + }); + + await waitForAsyncWork(); + stop(); + assert.deepEqual(events, [["error", "Visual Parity data was not validated by the Oven."], ["settled"]]); +}); + +test("poll not-ok response reports the server error", async () => { + const events = []; + const { stop } = startPoll({ + events, + fetchImpl: async () => ({ ok: false, json: () => ({ error: "boom" }) }), + }); + + await waitForAsyncWork(); + stop(); + assert.deepEqual(events, [["error", "boom"], ["settled"]]); +}); + +test("poll not-ok response uses the fallback when no error is supplied", async () => { + const events = []; + const { stop } = startPoll({ + events, + fetchImpl: async () => ({ ok: false, json: () => ({}) }), + }); + + await waitForAsyncWork(); + stop(); + assert.deepEqual(events, [["error", "Could not load Visual Parity."], ["settled"]]); +}); + +test("poll stop suppresses late state writes and clears the timer", async () => { + let resolveFetch; + let clearedTimer; + const events = []; + const { stop } = startPoll({ + events, + fetchImpl: () => new Promise((resolve) => { resolveFetch = resolve; }), + clearIntervalImpl: (timer) => { clearedTimer = timer; }, + }); + + stop(); + resolveFetch({ ok: true, json: () => ({ validated: true, payload: { late: true } }) }); + await waitForAsyncWork(); + assert.equal(clearedTimer, "timer"); + assert.deepEqual(events, []); +}); + +test("stream card message validation rejects invalid data and applies valid data", () => { + const applyMessage = (cards, raw) => { + const card = parseStreamingDiffCard(JSON.parse(raw)); + if (!card) throw new Error("invalid card"); + return applyStreamingDiffUpdate(cards, { type: "card", card }); + }; + const card = { + revId: "revision-1", + toolUseId: "tool-1", + ts: "2026-07-15T09:00:00.000Z", + status: "captured", + files: [{ path: "src/example.mjs", kind: "modified", diff: "+after" }], + }; + + assert.throws(() => applyMessage([], "{}"), /invalid card/u); + assert.deepEqual(applyMessage([], JSON.stringify(card)), [card]); +}); + +class FakeEventSource { + static instances = []; + + constructor(url) { + this.url = url; + this.listeners = new Map(); + this.closed = false; + FakeEventSource.instances.push(this); + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type, listener) { + this.removed = [type, listener]; + this.listeners.delete(type); + } + + close() { + this.closed = true; + } + + dispatch(type) { + this.listeners.get(type)?.(); + } + + dispatchMessage(data) { + this.onmessage?.({ data }); + } +} + +test("sse forwards events and cleanup removes reset listener and closes stream", () => { + const calls = []; + const stop = createSseTransport({ + makeUrl: () => "/stream", + EventSourceImpl: FakeEventSource, + }).start({ + onReset: () => calls.push("reset"), + onOpen: () => calls.push("open"), + onMessage: (raw) => calls.push(["message", raw]), + onError: () => calls.push("error"), + }); + const stream = FakeEventSource.instances.at(-1); + + stream.onopen(); + stream.dispatch("reset"); + stream.dispatchMessage('{"id":"card-1"}'); + stream.onerror(); + stop(); + + assert.equal(stream.url, "/stream"); + assert.deepEqual(calls, ["open", "reset", ["message", '{"id":"card-1"}'], "error"]); + assert.equal(stream.removed[0], "reset"); + assert.equal(typeof stream.removed[1], "function"); + assert.equal(stream.closed, true); +}); diff --git a/dashboard/src/oven/live-data/useOvenLiveData.ts b/dashboard/src/oven/live-data/useOvenLiveData.ts new file mode 100644 index 0000000..9e9c6b1 --- /dev/null +++ b/dashboard/src/oven/live-data/useOvenLiveData.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState, type DependencyList } from "react"; +import { createPollTransport, createSseTransport } from "./transports.js"; + +type PropsConfig = { transport: "props"; data: T }; + +type PollConfig = { + transport: "poll"; + makeUrl: () => string; + intervalMs: number; + receive: (response: Response, json: unknown) => T; + fallbackError: string; + initialData?: T | null; + deps?: DependencyList; +}; + +type SseConfig = { + transport: "sse"; + makeUrl: () => string | null; + initialData: T; + applyReset: (data: T) => T; + applyMessage: (data: T, raw: string) => T; + invalidError: string; + disconnectError: string; + deps?: DependencyList; +}; + +export type OvenLiveDataConfig = PropsConfig | PollConfig | SseConfig; + +type PropsResult = { data: T; error: ""; loading: false }; +type PollResult = { data: T | null; error: string; loading: boolean }; +type SseResult = { data: T; error: string }; + +export function useOvenLiveData(config: PropsConfig): PropsResult; +export function useOvenLiveData(config: PollConfig): PollResult; +export function useOvenLiveData(config: SseConfig): SseResult; +export function useOvenLiveData(config: OvenLiveDataConfig) { + if (config.transport === "props") { + return { data: config.data, error: "", loading: false }; + } + + if (config.transport === "poll") { + const [state, setState] = useState>({ + data: config.initialData ?? null, + error: "", + loading: true, + }); + const inFlightRef = useRef(false); + + useEffect(() => { + const stop = createPollTransport({ + makeUrl: config.makeUrl, + intervalMs: config.intervalMs, + receive: config.receive, + fallbackError: config.fallbackError, + inFlightRef, + }).start({ + onData: (data) => setState((current) => ({ ...current, data, error: "" })), + onError: (error) => setState((current) => ({ ...current, error })), + onSettled: () => setState((current) => ({ ...current, loading: false })), + }); + return stop; + }, config.deps ?? []); + + return state; + } + + const [state, setState] = useState>({ data: config.initialData, error: "" }); + + useEffect(() => { + const url = config.makeUrl(); + if (url == null) { + setState({ data: config.initialData, error: "" }); + return undefined; + } + setState({ data: config.initialData, error: "" }); + const stop = createSseTransport({ makeUrl: () => url }).start({ + onReset: () => setState((current) => ({ ...current, data: config.applyReset(current.data) })), + onOpen: () => setState((current) => ({ ...current, error: "" })), + onMessage: (raw) => setState((current) => { + try { + return { ...current, data: config.applyMessage(current.data, raw) }; + } catch { + return { ...current, error: config.invalidError }; + } + }), + onError: () => setState((current) => ({ ...current, error: config.disconnectError })), + }); + return stop; + }, config.deps ?? []); + + return state; +} diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 152dc03..61e0f44 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -63,6 +63,8 @@ export const verificationTestFiles = [ "dashboard/src/oven/SectionHeader/SectionHeader.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", + "dashboard/src/oven/live-data/transports.test.mjs", + "dashboard/src/oven/live-data/adapters.test.mjs", "dashboard/src/lib/streaming-diff.test.mjs", "dashboard/src/lib/project-open.test.mjs", "dashboard/src/lib/oven-identity.test.mjs", From e76c056e0120638737e85f3351028208b43a7e78 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 18:49:49 +0200 Subject: [PATCH 06/85] chore: add @oven alias and barrel; route oven imports through it --- .../ChecklistDashboard/ChecklistDashboard.test.mjs | 3 ++- .../components/ChecklistDashboard/ChecklistDashboard.tsx | 6 +----- dashboard/src/hooks/useStreamingDiff.ts | 2 +- dashboard/src/hooks/useVisualParityData.ts | 2 +- dashboard/src/oven/index.ts | 6 ++++++ dashboard/vite.config.mjs | 1 + 6 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 dashboard/src/oven/index.ts diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs index 87598ed..b998a7a 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs @@ -8,6 +8,7 @@ import { build } from "esbuild"; const componentPath = new URL("./ChecklistDashboard.tsx", import.meta.url).pathname; const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("../../oven", import.meta.url).pathname; test("checklist detail renders the split progress surface and event card list", async () => { const outputDir = await mkdtemp(join(process.cwd(), ".checklist-dashboard-test-")); @@ -15,7 +16,7 @@ test("checklist detail renders the split progress surface and event card list", const outputPath = join(outputDir, "ChecklistDashboard.mjs"); await build({ entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", }); const { ChecklistDashboard, checklistEventDetailFields } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); const data = { diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index e93d2d2..27ba312 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -4,11 +4,7 @@ import type { ChecklistProgressData, CompletedItem, HistoryPoint } from "@lib"; import "./ChecklistDashboard.css"; // @ts-expect-error The chart model is plain ESM so the dashboard and Node tests share it. import { buildChecklistProgressChart } from "../../lib/checklist-progress-chart.js"; -import { ProgressDonut } from "../../oven/ProgressDonut"; -import { LogTable } from "../../oven/LogTable"; -import { SectionHeader } from "../../oven/SectionHeader"; -import { KpiItem } from "../../oven/KpiItem"; -import { KpiStrip } from "../../oven/KpiStrip"; +import { KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; function formatDuration(milliseconds: number) { if (!Number.isFinite(milliseconds) || milliseconds < 0) return "--"; diff --git a/dashboard/src/hooks/useStreamingDiff.ts b/dashboard/src/hooks/useStreamingDiff.ts index 8440b56..5686989 100644 --- a/dashboard/src/hooks/useStreamingDiff.ts +++ b/dashboard/src/hooks/useStreamingDiff.ts @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import { applyStreamingDiffUpdate, mapStreamingDiffLandingFeeds } from "@lib"; import type { StreamingDiffCard, StreamingDiffFeed } from "@lib"; +import { useOvenLiveData } from "@oven"; import { applyStreamingDiffCardMessage } from "./streaming-diff-transport.mjs"; -import { useOvenLiveData } from "../oven/live-data"; type FeedState = { feeds: StreamingDiffFeed[]; error: string; loading: boolean }; type CardState = { cards: StreamingDiffCard[]; error: string }; diff --git a/dashboard/src/hooks/useVisualParityData.ts b/dashboard/src/hooks/useVisualParityData.ts index 9b9f09b..2c679c8 100644 --- a/dashboard/src/hooks/useVisualParityData.ts +++ b/dashboard/src/hooks/useVisualParityData.ts @@ -1,6 +1,6 @@ import { ovenRepoKey, type VisualParityPayload } from "@lib"; +import { useOvenLiveData } from "@oven"; import { receiveVisualParity } from "./visual-parity-transport.mjs"; -import { useOvenLiveData } from "../oven/live-data"; export function useVisualParityData() { const { data, error, loading } = useOvenLiveData({ diff --git a/dashboard/src/oven/index.ts b/dashboard/src/oven/index.ts new file mode 100644 index 0000000..d35b05e --- /dev/null +++ b/dashboard/src/oven/index.ts @@ -0,0 +1,6 @@ +export { ProgressDonut } from "./ProgressDonut"; +export { SectionHeader } from "./SectionHeader"; +export { LogTable } from "./LogTable"; +export { KpiItem } from "./KpiItem"; +export { KpiStrip } from "./KpiStrip"; +export { useOvenLiveData } from "./live-data"; diff --git a/dashboard/vite.config.mjs b/dashboard/vite.config.mjs index 6834916..676faed 100644 --- a/dashboard/vite.config.mjs +++ b/dashboard/vite.config.mjs @@ -16,6 +16,7 @@ export default defineConfig({ "@components": fileURLToPath(new URL("./src/components", import.meta.url)), "@hooks": fileURLToPath(new URL("./src/hooks", import.meta.url)), "@lib": fileURLToPath(new URL("./src/lib", import.meta.url)), + "@oven": fileURLToPath(new URL("./src/oven", import.meta.url)), }, }, build: { From 60ec221f4caf9cbfb0f7684d1e5105cb00545e2e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 18:57:51 +0200 Subject: [PATCH 07/85] feat: relocate Streaming Diff FeedList/DiffCard/FileDiff blocks under oven/ (verbatim) --- .../StreamingDiff/StreamingDiff.tsx | 66 +------------------ dashboard/src/oven/DiffCard/DiffCard.test.mjs | 57 ++++++++++++++++ dashboard/src/oven/DiffCard/DiffCard.tsx | 21 ++++++ dashboard/src/oven/DiffCard/index.ts | 1 + dashboard/src/oven/FeedList/FeedList.test.mjs | 62 +++++++++++++++++ dashboard/src/oven/FeedList/FeedList.tsx | 26 ++++++++ dashboard/src/oven/FeedList/index.ts | 1 + dashboard/src/oven/FileDiff/FileDiff.test.mjs | 51 ++++++++++++++ dashboard/src/oven/FileDiff/FileDiff.tsx | 17 +++++ dashboard/src/oven/FileDiff/index.ts | 1 + dashboard/src/oven/index.ts | 3 + dashboard/src/oven/streaming-diff-time.ts | 4 ++ scripts/verify-test-files.mjs | 3 + 13 files changed, 250 insertions(+), 63 deletions(-) create mode 100644 dashboard/src/oven/DiffCard/DiffCard.test.mjs create mode 100644 dashboard/src/oven/DiffCard/DiffCard.tsx create mode 100644 dashboard/src/oven/DiffCard/index.ts create mode 100644 dashboard/src/oven/FeedList/FeedList.test.mjs create mode 100644 dashboard/src/oven/FeedList/FeedList.tsx create mode 100644 dashboard/src/oven/FeedList/index.ts create mode 100644 dashboard/src/oven/FileDiff/FileDiff.test.mjs create mode 100644 dashboard/src/oven/FileDiff/FileDiff.tsx create mode 100644 dashboard/src/oven/FileDiff/index.ts create mode 100644 dashboard/src/oven/streaming-diff-time.ts diff --git a/dashboard/src/components/StreamingDiff/StreamingDiff.tsx b/dashboard/src/components/StreamingDiff/StreamingDiff.tsx index 5fb7f5f..92a22e5 100644 --- a/dashboard/src/components/StreamingDiff/StreamingDiff.tsx +++ b/dashboard/src/components/StreamingDiff/StreamingDiff.tsx @@ -1,70 +1,10 @@ import { useEffect, useMemo } from "react"; -import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout"; import { useStreamingDiffCards, useStreamingDiffFeeds } from "@hooks"; -import { ovenRepoKey, streamingDiffAutoOpenHref, streamingDiffFeedKey, streamingDiffRepositories, streamingDiffSelection } from "@lib"; -import { fileKindChip, isTextFileKind } from "@lib"; -import type { Project, StreamingDiffCard, StreamingDiffFeed, StreamingDiffFile } from "@lib"; +import { DiffCard, FeedList } from "@oven"; +import { ovenRepoKey, streamingDiffAutoOpenHref, streamingDiffRepositories, streamingDiffSelection } from "@lib"; +import type { Project, StreamingDiffCard } from "@lib"; import "../../../../ovens/streaming-diff/renderer/streaming-diff.css"; -function timestamp(value: string | null) { - const parsed = value ? Date.parse(value) : Number.NaN; - return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value ?? "Unknown activity time"; -} - -function FeedList({ feeds, error, loading, showRepository }: { feeds: StreamingDiffFeed[]; error: string; loading: boolean; showRepository: boolean }) { - return ( -
-
-

Streaming Diff

-

Recent feeds are ordered by published activity time, not process liveness.

-
- {error ?

{error}

: loading ?

Loading recent feeds.

: !feeds.length ?

No recent feeds.

: ( - - )} -
- ); -} - -function FileDiff({ file }: { file: StreamingDiffFile }) { - const chip = fileKindChip(file.kind, file.meta); - if (chip) { - return
-
{file.path}{chip}
- {(file.meta?.reason || file.meta?.bytes !== undefined) &&

{file.meta?.reason}{file.meta?.reason && file.meta?.bytes !== undefined ? " · " : ""}{file.meta?.bytes !== undefined ? `${file.meta.bytes} bytes` : ""}

} -
; - } - return
-
{file.path}{file.kind}
- {isTextFileKind(file.kind) && file.diff !== undefined ?
{file.diff}
:

Diff content is unavailable.

} -
; -} - -function DiffCard({ card }: { card: StreamingDiffCard }) { - const partial = card.status === "partial"; - return - -
- {card.toolUseId} - · {card.revId} -
- {card.status} -
- - {partial &&

{card.partialReason ?? "This revision is partial."}

} - {card.files.length ? card.files.map((file) => ) :

No file content was captured.

} -
-
; -} - function SelectedFeed({ cards, error, session }: { cards: StreamingDiffCard[]; error: string; session: string }) { return
diff --git a/dashboard/src/oven/DiffCard/DiffCard.test.mjs b/dashboard/src/oven/DiffCard/DiffCard.test.mjs new file mode 100644 index 0000000..6f20fa3 --- /dev/null +++ b/dashboard/src/oven/DiffCard/DiffCard.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./DiffCard.tsx", import.meta.url).pathname; +const layoutPath = new URL("../../layout", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +let outputDir; +let DiffCard; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".diff-card-test-")); + const outputPath = join(outputDir, "DiffCard.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@layout": layoutPath, "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ DiffCard } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +test("DiffCard preserves a complete card with file content", () => { + const card = { + toolUseId: "tool-123", revId: "rev-456", ts: "2026-07-18T10:20:30.000Z", status: "captured", + files: [{ path: "src/app.ts", kind: "modified", diff: "@@ -1 +1 @@\n-old\n+new" }], + }; + const timestamp = new Date(card.ts).toLocaleString(); + const markup = renderToStaticMarkup(createElement(DiffCard, { card })); + + assert.equal(markup, `
tool-123
· rev-456
captured
src/app.tsmodified
@@ -1 +1 @@\n-old\n+new
`); +}); + +test("DiffCard preserves the partial status paragraph", () => { + const card = { toolUseId: "tool-partial", revId: "rev-partial", ts: "2026-07-18T11:00:00.000Z", status: "partial", partialReason: "Capture stopped early.", files: [] }; + const timestamp = new Date(card.ts).toLocaleString(); + const markup = renderToStaticMarkup(createElement(DiffCard, { card })); + + assert.equal(markup, `
tool-partial
· rev-partial
partial

Capture stopped early.

No file content was captured.

`); +}); + +test("DiffCard reports when no file content was captured", () => { + const card = { toolUseId: "tool-empty", revId: "rev-empty", ts: "2026-07-18T12:00:00.000Z", status: "captured", files: [] }; + const markup = renderToStaticMarkup(createElement(DiffCard, { card })); + + assert.match(markup, /No file content was captured\./u); + assert.match(markup, /data-variant="default"[^>]*>captured/u); + assert.match(markup, /tool-empty/u); + assert.match(markup, /rev-empty/u); + assert.match(markup, new RegExp(new Date(card.ts).toLocaleString().replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); +}); diff --git a/dashboard/src/oven/DiffCard/DiffCard.tsx b/dashboard/src/oven/DiffCard/DiffCard.tsx new file mode 100644 index 0000000..c320f5e --- /dev/null +++ b/dashboard/src/oven/DiffCard/DiffCard.tsx @@ -0,0 +1,21 @@ +import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout"; +import { FileDiff } from "../FileDiff"; +import type { StreamingDiffCard } from "@lib"; +import { timestamp } from "../streaming-diff-time"; + +export function DiffCard({ card }: { card: StreamingDiffCard }) { + const partial = card.status === "partial"; + return + +
+ {card.toolUseId} + · {card.revId} +
+ {card.status} +
+ + {partial &&

{card.partialReason ?? "This revision is partial."}

} + {card.files.length ? card.files.map((file) => ) :

No file content was captured.

} +
+
; +} diff --git a/dashboard/src/oven/DiffCard/index.ts b/dashboard/src/oven/DiffCard/index.ts new file mode 100644 index 0000000..8a3e1e4 --- /dev/null +++ b/dashboard/src/oven/DiffCard/index.ts @@ -0,0 +1 @@ +export { DiffCard } from "./DiffCard"; diff --git a/dashboard/src/oven/FeedList/FeedList.test.mjs b/dashboard/src/oven/FeedList/FeedList.test.mjs new file mode 100644 index 0000000..43e1264 --- /dev/null +++ b/dashboard/src/oven/FeedList/FeedList.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./FeedList.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +let outputDir; +let FeedList; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".feed-list-test-")); + const outputPath = join(outputDir, "FeedList.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ FeedList } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +const heading = "

Streaming Diff

Recent feeds are ordered by published activity time, not process liveness.

"; + +test("FeedList preserves the loading state", () => { + assert.equal(renderToStaticMarkup(createElement(FeedList, { feeds: [], error: "", loading: true, showRepository: false })), `${heading}

Loading recent feeds.

`); +}); + +test("FeedList preserves the error state", () => { + assert.equal(renderToStaticMarkup(createElement(FeedList, { feeds: [], error: "Feed unavailable.", loading: false, showRepository: false })), `${heading}

Feed unavailable.

`); +}); + +test("FeedList preserves the empty state", () => { + assert.equal(renderToStaticMarkup(createElement(FeedList, { feeds: [], error: "", loading: false, showRepository: false })), `${heading}

No recent feeds.

`); +}); + +test("FeedList preserves repository details when requested", () => { + const feed = { + identity: { logicalRepoKey: "repo-key", worktreeKey: "worktree-1", session: "session-1" }, + updatedAt: "2026-07-18T13:14:15.000Z", href: "/ovens/streaming-diff/view?session=session-1", repoLabel: "Example repository", + }; + const timestamp = new Date(feed.updatedAt).toLocaleString(); + const markup = renderToStaticMarkup(createElement(FeedList, { feeds: [feed], error: "", loading: false, showRepository: true })); + + assert.equal(markup, `${heading}
`); +}); + +test("FeedList omits repository details when not requested", () => { + const feed = { + identity: { logicalRepoKey: "repo-key", worktreeKey: "worktree-1", session: "session-1" }, + updatedAt: "2026-07-18T13:14:15.000Z", href: "/ovens/streaming-diff/view?session=session-1", repoLabel: "Example repository", + }; + const markup = renderToStaticMarkup(createElement(FeedList, { feeds: [feed], error: "", loading: false, showRepository: false })); + + assert.equal(markup, `${heading}`); + assert.doesNotMatch(markup, /repository Example repository/u); +}); diff --git a/dashboard/src/oven/FeedList/FeedList.tsx b/dashboard/src/oven/FeedList/FeedList.tsx new file mode 100644 index 0000000..0b99040 --- /dev/null +++ b/dashboard/src/oven/FeedList/FeedList.tsx @@ -0,0 +1,26 @@ +import { streamingDiffFeedKey } from "@lib"; +import type { StreamingDiffFeed } from "@lib"; +import { timestamp } from "../streaming-diff-time"; + +export function FeedList({ feeds, error, loading, showRepository }: { feeds: StreamingDiffFeed[]; error: string; loading: boolean; showRepository: boolean }) { + return ( +
+
+

Streaming Diff

+

Recent feeds are ordered by published activity time, not process liveness.

+
+ {error ?

{error}

: loading ?

Loading recent feeds.

: !feeds.length ?

No recent feeds.

: ( +
+ {feeds.map((feed) => ( + + {feed.identity.session} + {showRepository && repository {feed.repoLabel}} + worktree {feed.identity.worktreeKey} + + + ))} +
+ )} +
+ ); +} diff --git a/dashboard/src/oven/FeedList/index.ts b/dashboard/src/oven/FeedList/index.ts new file mode 100644 index 0000000..e679b8d --- /dev/null +++ b/dashboard/src/oven/FeedList/index.ts @@ -0,0 +1 @@ +export { FeedList } from "./FeedList"; diff --git a/dashboard/src/oven/FileDiff/FileDiff.test.mjs b/dashboard/src/oven/FileDiff/FileDiff.test.mjs new file mode 100644 index 0000000..4271983 --- /dev/null +++ b/dashboard/src/oven/FileDiff/FileDiff.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./FileDiff.tsx", import.meta.url).pathname; +const layoutPath = new URL("../../layout", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +let outputDir; +let FileDiff; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".file-diff-test-")); + const outputPath = join(outputDir, "FileDiff.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@layout": layoutPath, "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ FileDiff } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +test("FileDiff preserves a chip-kind file and its metadata", () => { + const markup = renderToStaticMarkup(createElement(FileDiff, { + file: { path: "assets/logo.png", kind: "binary", meta: { reason: "Generated asset", bytes: 128 } }, + })); + + assert.equal(markup, "
assets/logo.pngbinary

Generated asset · 128 bytes

"); +}); + +test("FileDiff preserves unified text content", () => { + const markup = renderToStaticMarkup(createElement(FileDiff, { + file: { path: "src/app.ts", kind: "modified", diff: "@@ -1 +1 @@\n-old\n+new" }, + })); + + assert.equal(markup, "
src/app.tsmodified
@@ -1 +1 @@\n-old\n+new
"); +}); + +test("FileDiff reports unavailable content for a text file without a diff", () => { + const markup = renderToStaticMarkup(createElement(FileDiff, { + file: { path: "src/app.ts", kind: "modified" }, + })); + + assert.equal(markup, "
src/app.tsmodified

Diff content is unavailable.

"); +}); diff --git a/dashboard/src/oven/FileDiff/FileDiff.tsx b/dashboard/src/oven/FileDiff/FileDiff.tsx new file mode 100644 index 0000000..064cc83 --- /dev/null +++ b/dashboard/src/oven/FileDiff/FileDiff.tsx @@ -0,0 +1,17 @@ +import { Badge } from "@layout"; +import { fileKindChip, isTextFileKind } from "@lib"; +import type { StreamingDiffFile } from "@lib"; + +export function FileDiff({ file }: { file: StreamingDiffFile }) { + const chip = fileKindChip(file.kind, file.meta); + if (chip) { + return
+
{file.path}{chip}
+ {(file.meta?.reason || file.meta?.bytes !== undefined) &&

{file.meta?.reason}{file.meta?.reason && file.meta?.bytes !== undefined ? " · " : ""}{file.meta?.bytes !== undefined ? `${file.meta.bytes} bytes` : ""}

} +
; + } + return
+
{file.path}{file.kind}
+ {isTextFileKind(file.kind) && file.diff !== undefined ?
{file.diff}
:

Diff content is unavailable.

} +
; +} diff --git a/dashboard/src/oven/FileDiff/index.ts b/dashboard/src/oven/FileDiff/index.ts new file mode 100644 index 0000000..7c0c530 --- /dev/null +++ b/dashboard/src/oven/FileDiff/index.ts @@ -0,0 +1 @@ +export { FileDiff } from "./FileDiff"; diff --git a/dashboard/src/oven/index.ts b/dashboard/src/oven/index.ts index d35b05e..3238e13 100644 --- a/dashboard/src/oven/index.ts +++ b/dashboard/src/oven/index.ts @@ -3,4 +3,7 @@ export { SectionHeader } from "./SectionHeader"; export { LogTable } from "./LogTable"; export { KpiItem } from "./KpiItem"; export { KpiStrip } from "./KpiStrip"; +export { FeedList } from "./FeedList"; +export { DiffCard } from "./DiffCard"; +export { FileDiff } from "./FileDiff"; export { useOvenLiveData } from "./live-data"; diff --git a/dashboard/src/oven/streaming-diff-time.ts b/dashboard/src/oven/streaming-diff-time.ts new file mode 100644 index 0000000..e14136a --- /dev/null +++ b/dashboard/src/oven/streaming-diff-time.ts @@ -0,0 +1,4 @@ +export function timestamp(value: string | null) { + const parsed = value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value ?? "Unknown activity time"; +} diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 61e0f44..5a00da0 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -56,6 +56,9 @@ export const verificationTestFiles = [ "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs", "dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs", + "dashboard/src/oven/FileDiff/FileDiff.test.mjs", + "dashboard/src/oven/DiffCard/DiffCard.test.mjs", + "dashboard/src/oven/FeedList/FeedList.test.mjs", "dashboard/src/oven/KpiItem/KpiItem.test.mjs", "dashboard/src/oven/KpiStrip/KpiStrip.test.mjs", "dashboard/src/oven/KpiStrip/checklist-strip.test.mjs", From 328620658e53fc4b13c67ddb207ff0a5aaef06e6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 19:08:12 +0200 Subject: [PATCH 08/85] feat: lift Visual Parity blocks (VerdictHeader/DomainTabs/MetricTiles/DomainNote/ImageTriptych/FrameCard) into oven components --- .../components/VisualParity/VisualParity.tsx | 72 ++---------------- .../src/oven/DomainNote/DomainNote.test.mjs | 45 ++++++++++++ dashboard/src/oven/DomainNote/DomainNote.tsx | 8 ++ dashboard/src/oven/DomainNote/index.ts | 1 + .../src/oven/DomainTabs/DomainTabs.test.mjs | 55 ++++++++++++++ dashboard/src/oven/DomainTabs/DomainTabs.tsx | 16 ++++ dashboard/src/oven/DomainTabs/index.ts | 1 + .../src/oven/FrameCard/FrameCard.test.mjs | 73 +++++++++++++++++++ dashboard/src/oven/FrameCard/FrameCard.tsx | 15 ++++ dashboard/src/oven/FrameCard/index.ts | 1 + .../oven/ImageTriptych/ImageTriptych.test.mjs | 53 ++++++++++++++ .../src/oven/ImageTriptych/ImageTriptych.tsx | 11 +++ dashboard/src/oven/ImageTriptych/index.ts | 1 + .../src/oven/MetricTiles/MetricTiles.test.mjs | 51 +++++++++++++ .../src/oven/MetricTiles/MetricTiles.tsx | 13 ++++ dashboard/src/oven/MetricTiles/index.ts | 1 + .../oven/VerdictHeader/VerdictHeader.test.mjs | 53 ++++++++++++++ .../src/oven/VerdictHeader/VerdictHeader.tsx | 11 +++ dashboard/src/oven/VerdictHeader/index.ts | 1 + dashboard/src/oven/index.ts | 6 ++ dashboard/src/oven/visual-parity-format.ts | 7 ++ scripts/verify-test-files.mjs | 6 ++ 22 files changed, 435 insertions(+), 66 deletions(-) create mode 100644 dashboard/src/oven/DomainNote/DomainNote.test.mjs create mode 100644 dashboard/src/oven/DomainNote/DomainNote.tsx create mode 100644 dashboard/src/oven/DomainNote/index.ts create mode 100644 dashboard/src/oven/DomainTabs/DomainTabs.test.mjs create mode 100644 dashboard/src/oven/DomainTabs/DomainTabs.tsx create mode 100644 dashboard/src/oven/DomainTabs/index.ts create mode 100644 dashboard/src/oven/FrameCard/FrameCard.test.mjs create mode 100644 dashboard/src/oven/FrameCard/FrameCard.tsx create mode 100644 dashboard/src/oven/FrameCard/index.ts create mode 100644 dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs create mode 100644 dashboard/src/oven/ImageTriptych/ImageTriptych.tsx create mode 100644 dashboard/src/oven/ImageTriptych/index.ts create mode 100644 dashboard/src/oven/MetricTiles/MetricTiles.test.mjs create mode 100644 dashboard/src/oven/MetricTiles/MetricTiles.tsx create mode 100644 dashboard/src/oven/MetricTiles/index.ts create mode 100644 dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs create mode 100644 dashboard/src/oven/VerdictHeader/VerdictHeader.tsx create mode 100644 dashboard/src/oven/VerdictHeader/index.ts create mode 100644 dashboard/src/oven/visual-parity-format.ts diff --git a/dashboard/src/components/VisualParity/VisualParity.tsx b/dashboard/src/components/VisualParity/VisualParity.tsx index d3d0d43..aad14f0 100644 --- a/dashboard/src/components/VisualParity/VisualParity.tsx +++ b/dashboard/src/components/VisualParity/VisualParity.tsx @@ -1,15 +1,7 @@ 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, ""); -} +import { DomainNote, DomainTabs, FrameCard, MetricTiles, VerdictHeader } from "@oven"; export function VisualParityPage() { const { payload, error, loading } = useVisualParityData(); @@ -42,67 +34,15 @@ export function VisualParityPage() { return (
-
- -
-
- {targetPass ? "Target qualified" : "Target open"} -
-

{payload.comparisons.length} settled frames · isolated render passes · live refresh

-
- {error && {error}} -
- - - -
-
Frames{summary.passed}/{payload.comparisons.length}
-
Changed pixels{percent(summary.ratio)}
-
Mean RGB delta{delta(summary.meanAbsoluteDelta)}
-
Maximum delta{summary.maximumAbsoluteDelta}
-
- -
- {domain.qualification === "target" ? "Qualifying target" : "Diagnostic context"} - {domain.tolerance?.rationale ?? "Exact zero tolerance."} -
+ + ({ id: entry.id, label: entry.label, qualification: entry.qualification, failed: visualParityDomainSummary(payload, entry.id).failed }))} activeId={domain.id} onSelect={setSelectedDomainId} /> + +
{visibleComparisons.map((comparison) => { const entry = comparison.domains[domain.id]; - return ( -
-
- Frame {comparison.frame} - {entry.status} · {percent(entry.difference.ratio)} · mean {delta(entry.difference.meanAbsoluteDelta)} · max {entry.difference.maximumAbsoluteDelta} -
-
- {[entry.reference, entry.candidate, entry.diff].map((image) => ( -
-
{image.label}
- {`${entry.label} -
- ))} -
-
- ); + return ; })}
diff --git a/dashboard/src/oven/DomainNote/DomainNote.test.mjs b/dashboard/src/oven/DomainNote/DomainNote.test.mjs new file mode 100644 index 0000000..ffc1594 --- /dev/null +++ b/dashboard/src/oven/DomainNote/DomainNote.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./DomainNote.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; +let outputDir; +let DomainNote; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".domain-note-test-")); + const outputPath = join(outputDir, "DomainNote.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ DomainNote } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function FrozenDomainNote({ isTarget, rationale }) { + return createElement( + "div", + { className: "visual-parity-domain-note" }, + createElement("strong", null, isTarget ? "Qualifying target" : "Diagnostic context"), + createElement("span", null, rationale), + ); +} + +test("DomainNote matches target and diagnostic labels", () => { + for (const props of [ + { isTarget: true, rationale: "Exact zero tolerance." }, + { isTarget: false, rationale: "Used for diagnosis." }, + ]) { + assert.equal(renderToString(createElement(DomainNote, props)), renderToString(createElement(FrozenDomainNote, props))); + } +}); diff --git a/dashboard/src/oven/DomainNote/DomainNote.tsx b/dashboard/src/oven/DomainNote/DomainNote.tsx new file mode 100644 index 0000000..b78db9f --- /dev/null +++ b/dashboard/src/oven/DomainNote/DomainNote.tsx @@ -0,0 +1,8 @@ +type DomainNoteProps = { + isTarget: boolean; + rationale: string; +}; + +export function DomainNote({ isTarget, rationale }: DomainNoteProps) { + return
{isTarget ? "Qualifying target" : "Diagnostic context"}{rationale}
; +} diff --git a/dashboard/src/oven/DomainNote/index.ts b/dashboard/src/oven/DomainNote/index.ts new file mode 100644 index 0000000..013243f --- /dev/null +++ b/dashboard/src/oven/DomainNote/index.ts @@ -0,0 +1 @@ +export { DomainNote } from "./DomainNote"; diff --git a/dashboard/src/oven/DomainTabs/DomainTabs.test.mjs b/dashboard/src/oven/DomainTabs/DomainTabs.test.mjs new file mode 100644 index 0000000..2239c21 --- /dev/null +++ b/dashboard/src/oven/DomainTabs/DomainTabs.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./DomainTabs.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; +let outputDir; +let DomainTabs; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".domain-tabs-test-")); + const outputPath = join(outputDir, "DomainTabs.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ DomainTabs } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function FrozenDomainTabs({ tabs, activeId, onSelect }) { + return createElement( + "nav", + { "aria-label": "Visual parity domains", className: "visual-parity-domains" }, + tabs.map((tab) => { + const current = tab.id === activeId; + return createElement( + "button", + { "aria-pressed": current, className: current ? "is-active" : "", key: tab.id, onClick: () => onSelect(tab.id), type: "button" }, + createElement("span", null, tab.label), + createElement("small", null, tab.qualification, " · ", tab.failed ? `${tab.failed} fail` : "pass"), + ); + }), + ); +} + +test("DomainTabs matches active and inactive domain buttons", () => { + const props = { + tabs: [ + { id: "target", label: "Target", qualification: "target", failed: 0 }, + { id: "context", label: "Context", qualification: "context", failed: 2 }, + ], + activeId: "target", + onSelect: () => {}, + }; + assert.equal(renderToString(createElement(DomainTabs, props)), renderToString(createElement(FrozenDomainTabs, props))); +}); diff --git a/dashboard/src/oven/DomainTabs/DomainTabs.tsx b/dashboard/src/oven/DomainTabs/DomainTabs.tsx new file mode 100644 index 0000000..503019a --- /dev/null +++ b/dashboard/src/oven/DomainTabs/DomainTabs.tsx @@ -0,0 +1,16 @@ +type DomainTab = { + id: string; + label: string; + qualification: string; + failed: number; +}; + +type DomainTabsProps = { + tabs: DomainTab[]; + activeId: string; + onSelect: (id: string) => void; +}; + +export function DomainTabs({ tabs, activeId, onSelect }: DomainTabsProps) { + return ; +} diff --git a/dashboard/src/oven/DomainTabs/index.ts b/dashboard/src/oven/DomainTabs/index.ts new file mode 100644 index 0000000..de7f694 --- /dev/null +++ b/dashboard/src/oven/DomainTabs/index.ts @@ -0,0 +1 @@ +export { DomainTabs } from "./DomainTabs"; diff --git a/dashboard/src/oven/FrameCard/FrameCard.test.mjs b/dashboard/src/oven/FrameCard/FrameCard.test.mjs new file mode 100644 index 0000000..c32874f --- /dev/null +++ b/dashboard/src/oven/FrameCard/FrameCard.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./FrameCard.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; +let outputDir; +let FrameCard; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".frame-card-test-")); + const outputPath = join(outputDir, "FrameCard.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ FrameCard } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function referencePercent(value) { + return `${(value * 100).toFixed(value < 0.01 ? 3 : 2)}%`; +} + +function referenceDelta(value) { + return value.toFixed(4).replace(/0+$/u, "").replace(/\.$/u, ""); +} + +function FrozenFrameCard({ status, frame, difference, images, label }) { + return createElement( + "article", + { className: `visual-parity-frame ${status}` }, + createElement( + "header", + null, + createElement("strong", null, "Frame ", frame), + createElement("span", null, status, " · ", referencePercent(difference.ratio), " · mean ", referenceDelta(difference.meanAbsoluteDelta), " · max ", difference.maximumAbsoluteDelta), + ), + createElement( + "div", + { className: "visual-parity-shots" }, + images.map((image) => createElement( + "figure", + { key: image.label }, + createElement("figcaption", null, image.label), + createElement("img", { alt: `${label} ${image.label.toLowerCase()} frame ${frame}`, height: image.height, src: image.src ?? undefined, width: image.width }), + )), + ), + ); +} + +test("FrameCard matches a passing frame snapshot", () => { + const props = { + status: "pass", + frame: 8, + difference: { ratio: 0.0025, meanAbsoluteDelta: 0.0312, maximumAbsoluteDelta: 5 }, + images: [ + { label: "Reference", height: 90, src: "/reference.png", width: 160 }, + { label: "Candidate", height: 90, src: "/candidate.png", width: 160 }, + { label: "Diff", height: 90, src: "/diff.png", width: 160 }, + ], + label: "Dashboard", + }; + assert.equal(renderToString(createElement(FrameCard, props)), renderToString(createElement(FrozenFrameCard, props))); +}); diff --git a/dashboard/src/oven/FrameCard/FrameCard.tsx b/dashboard/src/oven/FrameCard/FrameCard.tsx new file mode 100644 index 0000000..b379e6d --- /dev/null +++ b/dashboard/src/oven/FrameCard/FrameCard.tsx @@ -0,0 +1,15 @@ +import type { VisualParityDifference, VisualParityImage } from "@lib"; +import { delta, percent } from "../visual-parity-format"; +import { ImageTriptych } from "../ImageTriptych"; + +type FrameCardProps = { + status: string; + frame: number; + difference: VisualParityDifference; + images: VisualParityImage[]; + label: string; +}; + +export function FrameCard({ status, frame, difference, images, label }: FrameCardProps) { + return
Frame {frame}{status} · {percent(difference.ratio)} · mean {delta(difference.meanAbsoluteDelta)} · max {difference.maximumAbsoluteDelta}
; +} diff --git a/dashboard/src/oven/FrameCard/index.ts b/dashboard/src/oven/FrameCard/index.ts new file mode 100644 index 0000000..c8da399 --- /dev/null +++ b/dashboard/src/oven/FrameCard/index.ts @@ -0,0 +1 @@ +export { FrameCard } from "./FrameCard"; diff --git a/dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs b/dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs new file mode 100644 index 0000000..1d10d2c --- /dev/null +++ b/dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./ImageTriptych.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; +let outputDir; +let ImageTriptych; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".image-triptych-test-")); + const outputPath = join(outputDir, "ImageTriptych.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ ImageTriptych } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function FrozenImageTriptych({ images, label, frame }) { + return createElement( + "div", + { className: "visual-parity-shots" }, + images.map((image) => createElement( + "figure", + { key: image.label }, + createElement("figcaption", null, image.label), + createElement("img", { alt: `${label} ${image.label.toLowerCase()} frame ${frame}`, height: image.height, src: image.src ?? undefined, width: image.width }), + )), + ); +} + +test("ImageTriptych matches the three image snapshot", () => { + const props = { + images: [ + { label: "Reference", height: 100, src: "/reference.png", width: 200 }, + { label: "Candidate", height: 100, src: null, width: 200 }, + { label: "Diff", height: 100, src: "/diff.png", width: 200 }, + ], + label: "Dashboard", + frame: 4, + }; + assert.equal(renderToString(createElement(ImageTriptych, props)), renderToString(createElement(FrozenImageTriptych, props))); +}); diff --git a/dashboard/src/oven/ImageTriptych/ImageTriptych.tsx b/dashboard/src/oven/ImageTriptych/ImageTriptych.tsx new file mode 100644 index 0000000..0037b1e --- /dev/null +++ b/dashboard/src/oven/ImageTriptych/ImageTriptych.tsx @@ -0,0 +1,11 @@ +import type { VisualParityImage } from "@lib"; + +type ImageTriptychProps = { + images: VisualParityImage[]; + label: string; + frame: number; +}; + +export function ImageTriptych({ images, label, frame }: ImageTriptychProps) { + return
{images.map((image) =>
{image.label}
{`${label}
)}
; +} diff --git a/dashboard/src/oven/ImageTriptych/index.ts b/dashboard/src/oven/ImageTriptych/index.ts new file mode 100644 index 0000000..82bd5b3 --- /dev/null +++ b/dashboard/src/oven/ImageTriptych/index.ts @@ -0,0 +1 @@ +export { ImageTriptych } from "./ImageTriptych"; diff --git a/dashboard/src/oven/MetricTiles/MetricTiles.test.mjs b/dashboard/src/oven/MetricTiles/MetricTiles.test.mjs new file mode 100644 index 0000000..78578c5 --- /dev/null +++ b/dashboard/src/oven/MetricTiles/MetricTiles.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { build } from "esbuild"; + +const componentPath = new URL("./MetricTiles.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; +let outputDir; +let MetricTiles; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".metric-tiles-test-")); + const outputPath = join(outputDir, "MetricTiles.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ MetricTiles } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function referencePercent(value) { + return `${(value * 100).toFixed(value < 0.01 ? 3 : 2)}%`; +} + +function referenceDelta(value) { + return value.toFixed(4).replace(/0+$/u, "").replace(/\.$/u, ""); +} + +function FrozenMetricTiles({ passed, total, ratio, meanAbsoluteDelta, maximumAbsoluteDelta }) { + return createElement( + "div", + { className: "visual-parity-metrics" }, + createElement("article", null, createElement("span", null, "Frames"), createElement("strong", null, passed, "/", total)), + createElement("article", null, createElement("span", null, "Changed pixels"), createElement("strong", null, referencePercent(ratio))), + createElement("article", null, createElement("span", null, "Mean RGB delta"), createElement("strong", null, referenceDelta(meanAbsoluteDelta))), + createElement("article", null, createElement("span", null, "Maximum delta"), createElement("strong", null, maximumAbsoluteDelta)), + ); +} + +test("MetricTiles matches its formatted metric snapshot", () => { + const props = { passed: 2, total: 3, ratio: 0.001234, meanAbsoluteDelta: 0.12, maximumAbsoluteDelta: 7 }; + assert.equal(renderToString(createElement(MetricTiles, props)), renderToString(createElement(FrozenMetricTiles, props))); +}); diff --git a/dashboard/src/oven/MetricTiles/MetricTiles.tsx b/dashboard/src/oven/MetricTiles/MetricTiles.tsx new file mode 100644 index 0000000..a0811a0 --- /dev/null +++ b/dashboard/src/oven/MetricTiles/MetricTiles.tsx @@ -0,0 +1,13 @@ +import { delta, percent } from "../visual-parity-format"; + +type MetricTilesProps = { + passed: number; + total: number; + ratio: number; + meanAbsoluteDelta: number; + maximumAbsoluteDelta: number; +}; + +export function MetricTiles({ passed, total, ratio, meanAbsoluteDelta, maximumAbsoluteDelta }: MetricTilesProps) { + return
Frames{passed}/{total}
Changed pixels{percent(ratio)}
Mean RGB delta{delta(meanAbsoluteDelta)}
Maximum delta{maximumAbsoluteDelta}
; +} diff --git a/dashboard/src/oven/MetricTiles/index.ts b/dashboard/src/oven/MetricTiles/index.ts new file mode 100644 index 0000000..793c6ac --- /dev/null +++ b/dashboard/src/oven/MetricTiles/index.ts @@ -0,0 +1 @@ +export { MetricTiles } from "./MetricTiles"; diff --git a/dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs b/dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs new file mode 100644 index 0000000..27bddca --- /dev/null +++ b/dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { ArrowLeft } from "lucide-react"; +import { build } from "esbuild"; + +const componentPath = new URL("./VerdictHeader.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; +let outputDir; +let VerdictHeader; + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".verdict-header-test-")); + const outputPath = join(outputDir, "VerdictHeader.mjs"); + await build({ + entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", + alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", + }); + ({ VerdictHeader } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function FrozenVerdictHeader({ targetPass, framesCount, error }) { + return createElement( + "header", + { className: "visual-parity-heading" }, + createElement("a", { className: "visual-parity-back", href: "/" }, createElement(ArrowLeft, { "aria-hidden": "true" }), "Burnlists"), + createElement( + "div", + null, + createElement("div", { className: `visual-parity-verdict ${targetPass ? "pass" : "fail"}` }, targetPass ? "Target qualified" : "Target open"), + createElement("p", null, framesCount, " settled frames · isolated render passes · live refresh"), + ), + error && createElement("span", { className: "visual-parity-refresh-error" }, error), + ); +} + +test("VerdictHeader matches the qualified state with a refresh error", () => { + const props = { targetPass: true, framesCount: 3, error: "Refresh delayed." }; + assert.equal(renderToString(createElement(VerdictHeader, props)), renderToString(createElement(FrozenVerdictHeader, props))); +}); + +test("VerdictHeader matches the open state without a refresh error", () => { + const props = { targetPass: false, framesCount: 1, error: "" }; + assert.equal(renderToString(createElement(VerdictHeader, props)), renderToString(createElement(FrozenVerdictHeader, props))); +}); diff --git a/dashboard/src/oven/VerdictHeader/VerdictHeader.tsx b/dashboard/src/oven/VerdictHeader/VerdictHeader.tsx new file mode 100644 index 0000000..7aef956 --- /dev/null +++ b/dashboard/src/oven/VerdictHeader/VerdictHeader.tsx @@ -0,0 +1,11 @@ +import { ArrowLeft } from "lucide-react"; + +type VerdictHeaderProps = { + targetPass: boolean; + framesCount: number; + error: string; +}; + +export function VerdictHeader({ targetPass, framesCount, error }: VerdictHeaderProps) { + return
{targetPass ? "Target qualified" : "Target open"}

{framesCount} settled frames · isolated render passes · live refresh

{error && {error}}
; +} diff --git a/dashboard/src/oven/VerdictHeader/index.ts b/dashboard/src/oven/VerdictHeader/index.ts new file mode 100644 index 0000000..588dd0a --- /dev/null +++ b/dashboard/src/oven/VerdictHeader/index.ts @@ -0,0 +1 @@ +export { VerdictHeader } from "./VerdictHeader"; diff --git a/dashboard/src/oven/index.ts b/dashboard/src/oven/index.ts index 3238e13..e071b12 100644 --- a/dashboard/src/oven/index.ts +++ b/dashboard/src/oven/index.ts @@ -7,3 +7,9 @@ export { FeedList } from "./FeedList"; export { DiffCard } from "./DiffCard"; export { FileDiff } from "./FileDiff"; export { useOvenLiveData } from "./live-data"; +export { VerdictHeader } from "./VerdictHeader"; +export { DomainTabs } from "./DomainTabs"; +export { MetricTiles } from "./MetricTiles"; +export { DomainNote } from "./DomainNote"; +export { ImageTriptych } from "./ImageTriptych"; +export { FrameCard } from "./FrameCard"; diff --git a/dashboard/src/oven/visual-parity-format.ts b/dashboard/src/oven/visual-parity-format.ts new file mode 100644 index 0000000..0ce9d23 --- /dev/null +++ b/dashboard/src/oven/visual-parity-format.ts @@ -0,0 +1,7 @@ +export function percent(value: number) { + return `${(value * 100).toFixed(value < 0.01 ? 3 : 2)}%`; +} + +export function delta(value: number) { + return value.toFixed(4).replace(/0+$/u, "").replace(/\.$/u, ""); +} diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 5a00da0..724350a 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -64,6 +64,12 @@ export const verificationTestFiles = [ "dashboard/src/oven/KpiStrip/checklist-strip.test.mjs", "dashboard/src/oven/LogTable/LogTable.test.mjs", "dashboard/src/oven/SectionHeader/SectionHeader.test.mjs", + "dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs", + "dashboard/src/oven/DomainTabs/DomainTabs.test.mjs", + "dashboard/src/oven/MetricTiles/MetricTiles.test.mjs", + "dashboard/src/oven/DomainNote/DomainNote.test.mjs", + "dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs", + "dashboard/src/oven/FrameCard/FrameCard.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", "dashboard/src/oven/live-data/transports.test.mjs", From edbaf9906ac0e80d288339b3801250724e3dd6a7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 19:28:28 +0200 Subject: [PATCH 09/85] feat: add OvenView declarative composition engine (JSON-pointer bind + rich slots via fixed registries) --- dashboard/src/oven/OvenView/OvenView.test.mjs | 183 ++++++++++++++++++ dashboard/src/oven/OvenView/OvenView.tsx | 62 ++++++ dashboard/src/oven/OvenView/index.ts | 3 + dashboard/src/oven/OvenView/json-pointer.js | 13 ++ .../src/oven/OvenView/json-pointer.test.mjs | 29 +++ dashboard/src/oven/OvenView/registries.ts | 50 +++++ dashboard/src/oven/OvenView/types.ts | 35 ++++ dashboard/src/oven/index.ts | 2 + scripts/verify-test-files.mjs | 2 + 9 files changed, 379 insertions(+) create mode 100644 dashboard/src/oven/OvenView/OvenView.test.mjs create mode 100644 dashboard/src/oven/OvenView/OvenView.tsx create mode 100644 dashboard/src/oven/OvenView/index.ts create mode 100644 dashboard/src/oven/OvenView/json-pointer.js create mode 100644 dashboard/src/oven/OvenView/json-pointer.test.mjs create mode 100644 dashboard/src/oven/OvenView/registries.ts create mode 100644 dashboard/src/oven/OvenView/types.ts diff --git a/dashboard/src/oven/OvenView/OvenView.test.mjs b/dashboard/src/oven/OvenView/OvenView.test.mjs new file mode 100644 index 0000000..58de6b9 --- /dev/null +++ b/dashboard/src/oven/OvenView/OvenView.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { ClipboardList } from "lucide-react"; +import { build } from "esbuild"; + +const ovenViewPath = new URL("./OvenView.tsx", import.meta.url).pathname; +const metricTilesPath = new URL("../MetricTiles/MetricTiles.tsx", import.meta.url).pathname; +const domainNotePath = new URL("../DomainNote/DomainNote.tsx", import.meta.url).pathname; +const kpiItemPath = new URL("../KpiItem/KpiItem.tsx", import.meta.url).pathname; +const progressDonutPath = new URL("../ProgressDonut/ProgressDonut.tsx", import.meta.url).pathname; +const libPath = new URL("../../lib", import.meta.url).pathname; +const layoutPath = new URL("../../layout", import.meta.url).pathname; +const ovenPath = new URL("..", import.meta.url).pathname; + +let outputDir; +let OvenView; +let MetricTiles; +let DomainNote; +let KpiItem; +let ProgressDonut; + +async function bundle(entryPath, name) { + const outputPath = join(outputDir, `${name}.mjs`); + await build({ + entryPoints: [entryPath], + bundle: true, + format: "esm", + outfile: outputPath, + platform: "node", + alias: { "@lib": libPath, "@layout": layoutPath, "@oven": ovenPath }, + jsx: "automatic", + packages: "external", + target: "node18", + }); + return import(`${new URL(`file://${outputPath}`).href}?test=${name}-${Date.now()}`); +} + +before(async () => { + outputDir = await mkdtemp(join(process.cwd(), ".oven-view-test-")); + const modules = await Promise.all([ + bundle(ovenViewPath, "OvenView"), + bundle(metricTilesPath, "MetricTiles"), + bundle(domainNotePath, "DomainNote"), + bundle(kpiItemPath, "KpiItem"), + bundle(progressDonutPath, "ProgressDonut"), + ]); + OvenView = modules[0].OvenView; + MetricTiles = modules[1].MetricTiles; + DomainNote = modules[2].DomainNote; + KpiItem = modules[3].KpiItem; + ProgressDonut = modules[4].ProgressDonut; +}); + +after(async () => { + await rm(outputDir, { force: true, recursive: true }); +}); + +function render(def, payload) { + return renderToString(createElement(OvenView, { def, payload })); +} + +test("resolves declarative component props to the handwritten markup", () => { + const payload = { + total: 3, + summary: { passed: 2, ratio: 0.001234, meanAbsoluteDelta: 0.12, maximumAbsoluteDelta: 7, isTarget: true, rationale: "Target evidence" }, + }; + const def = { + sections: [{ + key: "metrics", + element: "div", + className: "visual-parity-content", + cells: [ + { + component: "MetricTiles", + key: "metrics", + bind: { + passed: { source: "/summary/passed" }, + total: { source: "/total" }, + ratio: { source: "/summary/ratio" }, + meanAbsoluteDelta: { source: "/summary/meanAbsoluteDelta" }, + maximumAbsoluteDelta: { source: "/summary/maximumAbsoluteDelta" }, + }, + }, + { + component: "DomainNote", + key: "note", + bind: { isTarget: { source: "/summary/isTarget" }, rationale: { source: "/summary/rationale" } }, + }, + ], + }], + }; + const props = { passed: 2, total: 3, ratio: 0.001234, meanAbsoluteDelta: 0.12, maximumAbsoluteDelta: 7 }; + const expected = createElement( + "div", + { className: "visual-parity-content" }, + createElement(MetricTiles, props), + createElement(DomainNote, { isTarget: true, rationale: "Target evidence" }), + ); + + assert.equal(render(def, payload), renderToString(expected)); +}); + +test("renders nested component and text slots byte-equivalently", () => { + const def = { + sections: [{ + key: "progress", + element: "div", + className: "kpi-content", + cells: [{ + component: "KpiItem", + key: "progress", + props: { className: "kpi-item", heading: "Progress", title: "Current progress" }, + slots: { + visual: { component: "ProgressDonut", bind: { percent: { source: "/percent" } } }, + value: { text: "72%" }, + }, + }], + }], + }; + const expected = createElement( + "div", + { className: "kpi-content" }, + createElement(KpiItem, { + className: "kpi-item", + heading: "Progress", + title: "Current progress", + visual: createElement(ProgressDonut, { percent: 72 }), + value: "72%", + }), + ); + + assert.equal(render(def, { percent: 72 }), renderToString(expected)); +}); + +test("resolves a named icon slot to the matching handwritten element", () => { + const iconProps = { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" }; + const def = { + sections: [{ + key: "current-section", + element: "div", + cells: [{ + component: "KpiItem", + key: "current", + props: { className: "kpi-item", heading: "Current" }, + slots: { visual: { icon: "ClipboardList" }, value: { text: "Complete" } }, + }], + }], + }; + const expected = createElement("div", null, createElement(KpiItem, { + className: "kpi-item", + heading: "Current", + visual: createElement(ClipboardList, iconProps), + value: "Complete", + })); + + assert.equal(render(def, {}), renderToString(expected)); +}); + +test("applies named format transforms before passing bound props", () => { + const def = { + sections: [{ + key: "formatted-note", + element: "div", + cells: [{ key: "formatted-note", component: "DomainNote", props: { isTarget: true }, bind: { rationale: { source: "/ratio", format: "percent" } } }], + }], + }; + const expected = createElement("div", null, createElement(DomainNote, { isTarget: true, rationale: "12.35%" })); + + assert.equal(render(def, { ratio: 0.12345 }), renderToString(expected)); +}); + +test("rejects unknown registry keys", () => { + assert.throws(() => render({ sections: [{ cells: [{ component: "MissingComponent" }] }] }, {}), /Unknown oven component: MissingComponent/u); + assert.throws(() => render({ sections: [{ cells: [{ component: "DomainNote", bind: { rationale: { source: "/value", format: "missing" } } }] }] }, { value: "x" }), /Unknown oven format: missing/u); + assert.throws(() => render({ sections: [{ cells: [{ component: "KpiItem", slots: { visual: { icon: "MissingIcon" } } }] }] }, {}), /Unknown oven icon: MissingIcon/u); + assert.throws(() => render({ sections: [{ cells: [{ component: "toString" }] }] }, {}), /Unknown oven component: toString/u); + assert.throws(() => render({ sections: [{ cells: [{ component: "DomainNote", bind: { rationale: { source: "/value", format: "toString" } } }] }] }, { value: "x" }), /Unknown oven format: toString/u); + assert.throws(() => render({ sections: [{ cells: [{ component: "KpiItem", slots: { visual: { icon: "toString" } } }] }] }, {}), /Unknown oven icon: toString/u); +}); diff --git a/dashboard/src/oven/OvenView/OvenView.tsx b/dashboard/src/oven/OvenView/OvenView.tsx new file mode 100644 index 0000000..df3e435 --- /dev/null +++ b/dashboard/src/oven/OvenView/OvenView.tsx @@ -0,0 +1,62 @@ +/* + * Tier-C limits: cells do not express event callbacks (DomainTabs.onSelect or + * expand toggles), component-local React state, live-data subscriptions such as + * useOvenLiveData, or arbitrary JSX fragments in rich multi-child value slots. + * Keep those bespoke, or model a value as nested cells. + */ +import { createElement, Fragment, type ReactElement } from "react"; +import { resolvePointer } from "./json-pointer.js"; +import { componentRegistry, formatRegistry, iconRegistry } from "./registries"; +import type { CellDef, JsonValue, OvenViewDef, SlotDef } from "./types"; + +export type OvenViewProps = { + def: OvenViewDef; + payload: JsonValue; +}; + +function resolveSlot(slotDef: SlotDef, payload: JsonValue): ReactElement | string | null { + if ("component" in slotDef) return resolveCell(slotDef, payload); + if ("icon" in slotDef) { + const icon = iconRegistry[slotDef.icon]; + if (!icon) throw new Error(`Unknown oven icon: ${slotDef.icon}`); + return icon as ReactElement; + } + return slotDef.text; +} + +function resolveCell(cell: CellDef, payload: JsonValue): ReactElement { + const Component = componentRegistry[cell.component]; + if (!Component) throw new Error(`Unknown oven component: ${cell.component}`); + + const props: Record = { ...cell.props }; + for (const [name, binding] of Object.entries(cell.bind ?? {})) { + const format = formatRegistry[binding.format ?? "identity"]; + if (!format) throw new Error(`Unknown oven format: ${binding.format}`); + props[name] = format(resolvePointer(payload, binding.source)); + } + for (const [name, slotDef] of Object.entries(cell.slots ?? {})) { + props[name] = resolveSlot(slotDef, payload); + } + + const children = cell.children?.map((child) => resolveCell(child, payload)) ?? []; + return createElement(Component, { key: cell.key, ...props }, ...children); +} + +function resolveSection(section: OvenViewDef["sections"][number], payload: JsonValue): ReactElement { + const element = section.element ?? "section"; + const props: Record = { className: section.className, ...section.props, key: section.key }; + return createElement(element, props, section.cells.map((cell) => resolveCell(cell, payload))); +} + +export function OvenView({ def, payload }: OvenViewProps) { + const sections = def.sections.map((section) => resolveSection(section, payload)); + if (def.shellClassName === undefined && def.bodyClassName === undefined && def.bodyId === undefined) { + return createElement(Fragment, null, ...sections); + } + + return createElement( + "div", + { className: def.shellClassName }, + createElement("main", { className: def.bodyClassName, id: def.bodyId }, ...sections), + ); +} diff --git a/dashboard/src/oven/OvenView/index.ts b/dashboard/src/oven/OvenView/index.ts new file mode 100644 index 0000000..c72d22f --- /dev/null +++ b/dashboard/src/oven/OvenView/index.ts @@ -0,0 +1,3 @@ +export { OvenView } from "./OvenView"; +export type { OvenViewProps } from "./OvenView"; +export type { Binding, CellDef, JsonPrimitive, JsonValue, OvenViewDef, SectionDef, SlotDef } from "./types"; diff --git a/dashboard/src/oven/OvenView/json-pointer.js b/dashboard/src/oven/OvenView/json-pointer.js new file mode 100644 index 0000000..88f08bd --- /dev/null +++ b/dashboard/src/oven/OvenView/json-pointer.js @@ -0,0 +1,13 @@ +export function resolvePointer(payload, pointer) { + if (pointer === "" || pointer === "/") return payload; + if (typeof pointer !== "string" || !pointer.startsWith("/")) return undefined; + + const segments = pointer.slice(1).split("/").map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); + let value = payload; + for (const segment of segments) { + if (value === null || value === undefined || (typeof value !== "object" && typeof value !== "function")) return undefined; + if (!Object.prototype.hasOwnProperty.call(value, segment)) return undefined; + value = value[segment]; + } + return value; +} diff --git a/dashboard/src/oven/OvenView/json-pointer.test.mjs b/dashboard/src/oven/OvenView/json-pointer.test.mjs new file mode 100644 index 0000000..8393616 --- /dev/null +++ b/dashboard/src/oven/OvenView/json-pointer.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolvePointer } from "./json-pointer.js"; + +const payload = { + summary: { passed: 2, label: "nested" }, + rows: [{ id: "first" }, { id: "second" }], + "a/b": { "c~d": "escaped" }, +}; + +test("resolves nested object and array segments", () => { + assert.equal(resolvePointer(payload, "/summary/passed"), 2); + assert.equal(resolvePointer(payload, "/rows/1/id"), "second"); +}); + +test("returns undefined for missing paths", () => { + assert.equal(resolvePointer(payload, "/summary/missing"), undefined); + assert.equal(resolvePointer(payload, "/rows/4"), undefined); + assert.equal(resolvePointer(payload, "summary/passed"), undefined); +}); + +test("returns the payload for empty and root pointers", () => { + assert.equal(resolvePointer(payload, ""), payload); + assert.equal(resolvePointer(payload, "/"), payload); +}); + +test("decodes RFC6901 escaped path segments", () => { + assert.equal(resolvePointer(payload, "/a~1b/c~0d"), "escaped"); +}); diff --git a/dashboard/src/oven/OvenView/registries.ts b/dashboard/src/oven/OvenView/registries.ts new file mode 100644 index 0000000..3ec292b --- /dev/null +++ b/dashboard/src/oven/OvenView/registries.ts @@ -0,0 +1,50 @@ +import { createElement, type ComponentType, type ReactNode } from "react"; +import { ArrowLeft, ClipboardList, Clock3, Gauge, TimerReset } from "lucide-react"; +import { DiffCard } from "../DiffCard"; +import { DomainNote } from "../DomainNote"; +import { DomainTabs } from "../DomainTabs"; +import { FeedList } from "../FeedList"; +import { FileDiff } from "../FileDiff"; +import { FrameCard } from "../FrameCard"; +import { ImageTriptych } from "../ImageTriptych"; +import { KpiItem } from "../KpiItem"; +import { KpiStrip } from "../KpiStrip"; +import { LogTable } from "../LogTable"; +import { MetricTiles } from "../MetricTiles"; +import { ProgressDonut } from "../ProgressDonut"; +import { SectionHeader } from "../SectionHeader"; +import { VerdictHeader } from "../VerdictHeader"; +import { delta as formatDelta, percent as formatPercent } from "../visual-parity-format"; + +export const componentRegistry: Record> = Object.freeze(Object.assign(Object.create(null), { + KpiStrip, + KpiItem, + ProgressDonut, + SectionHeader, + LogTable, + MetricTiles, + VerdictHeader, + DomainTabs, + DomainNote, + FrameCard, + ImageTriptych, + FeedList, + DiffCard, + FileDiff, +})); + +export const formatRegistry: Record unknown> = Object.freeze(Object.assign(Object.create(null), { + identity: (value) => value, + percent: (value) => formatPercent(value as number), + delta: (value) => formatDelta(value as number), +})); + +const kpiIconProps = { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" }; + +export const iconRegistry: Record = Object.freeze(Object.assign(Object.create(null), { + ClipboardList: createElement(ClipboardList, kpiIconProps), + Clock3: createElement(Clock3, kpiIconProps), + Gauge: createElement(Gauge, kpiIconProps), + TimerReset: createElement(TimerReset, kpiIconProps), + ArrowLeft: createElement(ArrowLeft, { "aria-hidden": "true" }), +})); diff --git a/dashboard/src/oven/OvenView/types.ts b/dashboard/src/oven/OvenView/types.ts new file mode 100644 index 0000000..a6e8a8c --- /dev/null +++ b/dashboard/src/oven/OvenView/types.ts @@ -0,0 +1,35 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type Binding = { + source: string; + format?: string; +}; + +export type SlotDef = CellDef | { icon: string } | { text: string }; + +export type CellDef = { + component: string; + props?: Record; + bind?: Record; + slots?: Record; + children?: CellDef[]; + key?: string; +}; + +export type SectionDef = { + element?: "section" | "div" | "nav"; + className?: string; + props?: Record; + cells: CellDef[]; + key?: string; +}; + +export type OvenViewDef = { + shellClassName?: string; + bodyId?: string; + bodyClassName?: string; + // Reserved metadata for a future body-class effect; OvenView must not touch document.body. + bodyClasses?: string[]; + sections: SectionDef[]; +}; diff --git a/dashboard/src/oven/index.ts b/dashboard/src/oven/index.ts index e071b12..fe40741 100644 --- a/dashboard/src/oven/index.ts +++ b/dashboard/src/oven/index.ts @@ -13,3 +13,5 @@ export { MetricTiles } from "./MetricTiles"; export { DomainNote } from "./DomainNote"; export { ImageTriptych } from "./ImageTriptych"; export { FrameCard } from "./FrameCard"; +export { OvenView } from "./OvenView"; +export type { Binding, CellDef, JsonPrimitive, JsonValue, OvenViewDef, OvenViewProps, SectionDef, SlotDef } from "./OvenView"; diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 724350a..85e1abc 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -70,6 +70,8 @@ export const verificationTestFiles = [ "dashboard/src/oven/DomainNote/DomainNote.test.mjs", "dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs", "dashboard/src/oven/FrameCard/FrameCard.test.mjs", + "dashboard/src/oven/OvenView/json-pointer.test.mjs", + "dashboard/src/oven/OvenView/OvenView.test.mjs", "dashboard/src/lib/checklist-progress-chart.test.mjs", "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", "dashboard/src/oven/live-data/transports.test.mjs", From a4d8ab8e4c929f3f1d3070c9a5507d1886336e13 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:00:28 +0200 Subject: [PATCH 10/85] refactor: add esbuild oven-test harness; convert oven-kit utils + checklist chart math to TypeScript --- .../ChecklistDashboard/ChecklistDashboard.tsx | 4 +- dashboard/src/oven/DiffCard/DiffCard.tsx | 2 +- dashboard/src/oven/FeedList/FeedList.tsx | 2 +- dashboard/src/oven/FrameCard/FrameCard.tsx | 2 +- .../src/oven/MetricTiles/MetricTiles.tsx | 2 +- dashboard/src/oven/OvenView/OvenView.tsx | 4 +- dashboard/src/oven/OvenView/registries.ts | 2 +- dashboard/src/oven/index.ts | 3 +- dashboard/src/oven/live-data/index.ts | 2 - .../src/oven/test-support/run-oven-tests.mjs | 81 +++++++++++++++++++ .../adapters.test.ts} | 0 .../utils/checklist-progress-chart.test.ts} | 2 +- .../utils/checklist-progress-chart.ts} | 2 +- .../compact-time-scale.test.ts} | 2 +- .../compact-time-scale.ts} | 0 .../json-pointer.test.ts} | 2 +- .../json-pointer.js => utils/json-pointer.ts} | 0 .../oven/{ => utils}/streaming-diff-time.ts | 0 .../transports.test.ts} | 2 +- .../transports.js => utils/transports.ts} | 0 .../{live-data => utils}/useOvenLiveData.ts | 2 +- .../oven/{ => utils}/visual-parity-format.ts | 0 package-lock.json | 1 + package.json | 1 + scripts/verify-test-files.mjs | 5 -- scripts/verify.mjs | 1 + 26 files changed, 100 insertions(+), 24 deletions(-) delete mode 100644 dashboard/src/oven/live-data/index.ts create mode 100644 dashboard/src/oven/test-support/run-oven-tests.mjs rename dashboard/src/oven/{live-data/adapters.test.mjs => utils/adapters.test.ts} (100%) rename dashboard/src/{lib/checklist-progress-chart.test.mjs => oven/utils/checklist-progress-chart.test.ts} (99%) rename dashboard/src/{lib/checklist-progress-chart.js => oven/utils/checklist-progress-chart.ts} (97%) rename dashboard/src/oven/{chart-core/compact-time-scale.test.mjs => utils/compact-time-scale.test.ts} (99%) rename dashboard/src/oven/{chart-core/compact-time-scale.js => utils/compact-time-scale.ts} (100%) rename dashboard/src/oven/{OvenView/json-pointer.test.mjs => utils/json-pointer.test.ts} (94%) rename dashboard/src/oven/{OvenView/json-pointer.js => utils/json-pointer.ts} (100%) rename dashboard/src/oven/{ => utils}/streaming-diff-time.ts (100%) rename dashboard/src/oven/{live-data/transports.test.mjs => utils/transports.test.ts} (99%) rename dashboard/src/oven/{live-data/transports.js => utils/transports.ts} (100%) rename dashboard/src/oven/{live-data => utils}/useOvenLiveData.ts (99%) rename dashboard/src/oven/{ => utils}/visual-parity-format.ts (100%) diff --git a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx index 27ba312..8a269d5 100644 --- a/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx +++ b/dashboard/src/components/ChecklistDashboard/ChecklistDashboard.tsx @@ -2,9 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { ClipboardList, Clock3, Gauge, TimerReset } from "lucide-react"; import type { ChecklistProgressData, CompletedItem, HistoryPoint } from "@lib"; import "./ChecklistDashboard.css"; -// @ts-expect-error The chart model is plain ESM so the dashboard and Node tests share it. -import { buildChecklistProgressChart } from "../../lib/checklist-progress-chart.js"; -import { KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; +import { buildChecklistProgressChart, KpiItem, KpiStrip, LogTable, ProgressDonut, SectionHeader } from "@oven"; function formatDuration(milliseconds: number) { if (!Number.isFinite(milliseconds) || milliseconds < 0) return "--"; diff --git a/dashboard/src/oven/DiffCard/DiffCard.tsx b/dashboard/src/oven/DiffCard/DiffCard.tsx index c320f5e..ec4df4b 100644 --- a/dashboard/src/oven/DiffCard/DiffCard.tsx +++ b/dashboard/src/oven/DiffCard/DiffCard.tsx @@ -1,7 +1,7 @@ import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@layout"; import { FileDiff } from "../FileDiff"; import type { StreamingDiffCard } from "@lib"; -import { timestamp } from "../streaming-diff-time"; +import { timestamp } from "../utils/streaming-diff-time"; export function DiffCard({ card }: { card: StreamingDiffCard }) { const partial = card.status === "partial"; diff --git a/dashboard/src/oven/FeedList/FeedList.tsx b/dashboard/src/oven/FeedList/FeedList.tsx index 0b99040..a89db77 100644 --- a/dashboard/src/oven/FeedList/FeedList.tsx +++ b/dashboard/src/oven/FeedList/FeedList.tsx @@ -1,6 +1,6 @@ import { streamingDiffFeedKey } from "@lib"; import type { StreamingDiffFeed } from "@lib"; -import { timestamp } from "../streaming-diff-time"; +import { timestamp } from "../utils/streaming-diff-time"; export function FeedList({ feeds, error, loading, showRepository }: { feeds: StreamingDiffFeed[]; error: string; loading: boolean; showRepository: boolean }) { return ( diff --git a/dashboard/src/oven/FrameCard/FrameCard.tsx b/dashboard/src/oven/FrameCard/FrameCard.tsx index b379e6d..ef86125 100644 --- a/dashboard/src/oven/FrameCard/FrameCard.tsx +++ b/dashboard/src/oven/FrameCard/FrameCard.tsx @@ -1,5 +1,5 @@ import type { VisualParityDifference, VisualParityImage } from "@lib"; -import { delta, percent } from "../visual-parity-format"; +import { delta, percent } from "../utils/visual-parity-format"; import { ImageTriptych } from "../ImageTriptych"; type FrameCardProps = { diff --git a/dashboard/src/oven/MetricTiles/MetricTiles.tsx b/dashboard/src/oven/MetricTiles/MetricTiles.tsx index a0811a0..00059c5 100644 --- a/dashboard/src/oven/MetricTiles/MetricTiles.tsx +++ b/dashboard/src/oven/MetricTiles/MetricTiles.tsx @@ -1,4 +1,4 @@ -import { delta, percent } from "../visual-parity-format"; +import { delta, percent } from "../utils/visual-parity-format"; type MetricTilesProps = { passed: number; diff --git a/dashboard/src/oven/OvenView/OvenView.tsx b/dashboard/src/oven/OvenView/OvenView.tsx index df3e435..67c8012 100644 --- a/dashboard/src/oven/OvenView/OvenView.tsx +++ b/dashboard/src/oven/OvenView/OvenView.tsx @@ -1,11 +1,11 @@ /* * Tier-C limits: cells do not express event callbacks (DomainTabs.onSelect or - * expand toggles), component-local React state, live-data subscriptions such as + * expand toggles), component-local React state, live subscriptions such as * useOvenLiveData, or arbitrary JSX fragments in rich multi-child value slots. * Keep those bespoke, or model a value as nested cells. */ import { createElement, Fragment, type ReactElement } from "react"; -import { resolvePointer } from "./json-pointer.js"; +import { resolvePointer } from "../utils/json-pointer"; import { componentRegistry, formatRegistry, iconRegistry } from "./registries"; import type { CellDef, JsonValue, OvenViewDef, SlotDef } from "./types"; diff --git a/dashboard/src/oven/OvenView/registries.ts b/dashboard/src/oven/OvenView/registries.ts index 3ec292b..745a1e2 100644 --- a/dashboard/src/oven/OvenView/registries.ts +++ b/dashboard/src/oven/OvenView/registries.ts @@ -14,7 +14,7 @@ import { MetricTiles } from "../MetricTiles"; import { ProgressDonut } from "../ProgressDonut"; import { SectionHeader } from "../SectionHeader"; import { VerdictHeader } from "../VerdictHeader"; -import { delta as formatDelta, percent as formatPercent } from "../visual-parity-format"; +import { delta as formatDelta, percent as formatPercent } from "../utils/visual-parity-format"; export const componentRegistry: Record> = Object.freeze(Object.assign(Object.create(null), { KpiStrip, diff --git a/dashboard/src/oven/index.ts b/dashboard/src/oven/index.ts index fe40741..d3807b4 100644 --- a/dashboard/src/oven/index.ts +++ b/dashboard/src/oven/index.ts @@ -6,7 +6,7 @@ export { KpiStrip } from "./KpiStrip"; export { FeedList } from "./FeedList"; export { DiffCard } from "./DiffCard"; export { FileDiff } from "./FileDiff"; -export { useOvenLiveData } from "./live-data"; +export { useOvenLiveData } from "./utils/useOvenLiveData"; export { VerdictHeader } from "./VerdictHeader"; export { DomainTabs } from "./DomainTabs"; export { MetricTiles } from "./MetricTiles"; @@ -15,3 +15,4 @@ export { ImageTriptych } from "./ImageTriptych"; export { FrameCard } from "./FrameCard"; export { OvenView } from "./OvenView"; export type { Binding, CellDef, JsonPrimitive, JsonValue, OvenViewDef, OvenViewProps, SectionDef, SlotDef } from "./OvenView"; +export { buildChecklistProgressChart } from "./utils/checklist-progress-chart"; diff --git a/dashboard/src/oven/live-data/index.ts b/dashboard/src/oven/live-data/index.ts deleted file mode 100644 index 61b85ea..0000000 --- a/dashboard/src/oven/live-data/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createPollTransport, createSseTransport } from "./transports.js"; -export { useOvenLiveData } from "./useOvenLiveData"; diff --git a/dashboard/src/oven/test-support/run-oven-tests.mjs b/dashboard/src/oven/test-support/run-oven-tests.mjs new file mode 100644 index 0000000..522c9a5 --- /dev/null +++ b/dashboard/src/oven/test-support/run-oven-tests.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const testSupportDir = dirname(fileURLToPath(import.meta.url)); +const sourceDir = resolve(testSupportDir, "../.."); +const ovenDir = resolve(sourceDir, "oven"); + +const alias = { + "@": sourceDir, + "@layout": resolve(sourceDir, "layout"), + "@components": resolve(sourceDir, "components"), + "@hooks": resolve(sourceDir, "hooks"), + "@lib": resolve(sourceDir, "lib"), + "@oven": ovenDir, +}; + +function discoverTests(directory) { + const files = []; + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const filePath = join(directory, entry.name); + if (entry.isDirectory()) { + if (filePath !== testSupportDir) files.push(...discoverTests(filePath)); + } else if (entry.isFile() && entry.name.endsWith(".test.ts")) { + files.push(filePath); + } + } + return files; +} + +function discoverBundledTests(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const filePath = join(directory, entry.name); + if (entry.isDirectory()) files.push(...discoverBundledTests(filePath)); + else if (entry.isFile() && entry.name.endsWith(".test.mjs")) files.push(filePath); + } + return files.sort(); +} + +const testEntries = discoverTests(ovenDir); +console.log(`=== Oven TypeScript tests (${testEntries.length}) ===`); + +if (testEntries.length === 0) process.exit(0); + +const outputDir = mkdtempSync(join(tmpdir(), "burnlist-oven-tests-")); +let status = 0; +try { + await build({ + entryPoints: testEntries, + bundle: true, + format: "esm", + platform: "node", + target: "node18", + jsx: "automatic", + packages: "external", + sourcemap: "inline", + outdir: outputDir, + outbase: ovenDir, + entryNames: "[dir]/[name]", + outExtension: { ".js": ".mjs" }, + alias, + }); + + const bundledTests = discoverBundledTests(outputDir); + const result = spawnSync(process.execPath, ["--test", ...bundledTests], { stdio: "inherit" }); + status = result.status ?? 1; +} catch (error) { + console.error("Oven TypeScript test harness failed."); + console.error(error); + status = 1; +} finally { + rmSync(outputDir, { force: true, recursive: true }); +} + +process.exit(status); diff --git a/dashboard/src/oven/live-data/adapters.test.mjs b/dashboard/src/oven/utils/adapters.test.ts similarity index 100% rename from dashboard/src/oven/live-data/adapters.test.mjs rename to dashboard/src/oven/utils/adapters.test.ts diff --git a/dashboard/src/lib/checklist-progress-chart.test.mjs b/dashboard/src/oven/utils/checklist-progress-chart.test.ts similarity index 99% rename from dashboard/src/lib/checklist-progress-chart.test.mjs rename to dashboard/src/oven/utils/checklist-progress-chart.test.ts index c10be17..0e52e24 100644 --- a/dashboard/src/lib/checklist-progress-chart.test.mjs +++ b/dashboard/src/oven/utils/checklist-progress-chart.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildChecklistProgressChart } from "./checklist-progress-chart.js"; +import { buildChecklistProgressChart } from "./checklist-progress-chart"; const history = [ { time: "2026-07-13T21:00:00Z", done: 3, remaining: 2, total: 5, percent: 60 }, diff --git a/dashboard/src/lib/checklist-progress-chart.js b/dashboard/src/oven/utils/checklist-progress-chart.ts similarity index 97% rename from dashboard/src/lib/checklist-progress-chart.js rename to dashboard/src/oven/utils/checklist-progress-chart.ts index 6017bac..a681cc8 100644 --- a/dashboard/src/lib/checklist-progress-chart.js +++ b/dashboard/src/oven/utils/checklist-progress-chart.ts @@ -1,4 +1,4 @@ -import { compactTimeScale, niceCeiling, stepPath } from "../oven/chart-core/compact-time-scale.js"; +import { compactTimeScale, niceCeiling, stepPath } from "./compact-time-scale"; function finiteNumber(value, fallback = 0) { const number = Number(value); diff --git a/dashboard/src/oven/chart-core/compact-time-scale.test.mjs b/dashboard/src/oven/utils/compact-time-scale.test.ts similarity index 99% rename from dashboard/src/oven/chart-core/compact-time-scale.test.mjs rename to dashboard/src/oven/utils/compact-time-scale.test.ts index 602cbe3..4edd116 100644 --- a/dashboard/src/oven/chart-core/compact-time-scale.test.mjs +++ b/dashboard/src/oven/utils/compact-time-scale.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { compactTimeScale, niceCeiling, stepPath } from "./compact-time-scale.js"; +import { compactTimeScale, niceCeiling, stepPath } from "./compact-time-scale"; // FROZEN SNAPSHOT — original Checklist compactTimeScale (pre-extraction) const originalChecklistCompactTimeScale = (() => { diff --git a/dashboard/src/oven/chart-core/compact-time-scale.js b/dashboard/src/oven/utils/compact-time-scale.ts similarity index 100% rename from dashboard/src/oven/chart-core/compact-time-scale.js rename to dashboard/src/oven/utils/compact-time-scale.ts diff --git a/dashboard/src/oven/OvenView/json-pointer.test.mjs b/dashboard/src/oven/utils/json-pointer.test.ts similarity index 94% rename from dashboard/src/oven/OvenView/json-pointer.test.mjs rename to dashboard/src/oven/utils/json-pointer.test.ts index 8393616..3f4ff00 100644 --- a/dashboard/src/oven/OvenView/json-pointer.test.mjs +++ b/dashboard/src/oven/utils/json-pointer.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { resolvePointer } from "./json-pointer.js"; +import { resolvePointer } from "./json-pointer"; const payload = { summary: { passed: 2, label: "nested" }, diff --git a/dashboard/src/oven/OvenView/json-pointer.js b/dashboard/src/oven/utils/json-pointer.ts similarity index 100% rename from dashboard/src/oven/OvenView/json-pointer.js rename to dashboard/src/oven/utils/json-pointer.ts diff --git a/dashboard/src/oven/streaming-diff-time.ts b/dashboard/src/oven/utils/streaming-diff-time.ts similarity index 100% rename from dashboard/src/oven/streaming-diff-time.ts rename to dashboard/src/oven/utils/streaming-diff-time.ts diff --git a/dashboard/src/oven/live-data/transports.test.mjs b/dashboard/src/oven/utils/transports.test.ts similarity index 99% rename from dashboard/src/oven/live-data/transports.test.mjs rename to dashboard/src/oven/utils/transports.test.ts index 81b7a24..cca904b 100644 --- a/dashboard/src/oven/live-data/transports.test.mjs +++ b/dashboard/src/oven/utils/transports.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { applyStreamingDiffUpdate, parseStreamingDiffCard } from "../../lib/streaming-diff.mjs"; -import { createPollTransport, createSseTransport } from "./transports.js"; +import { createPollTransport, createSseTransport } from "./transports"; const waitForAsyncWork = () => new Promise((resolve) => setImmediate(resolve)); diff --git a/dashboard/src/oven/live-data/transports.js b/dashboard/src/oven/utils/transports.ts similarity index 100% rename from dashboard/src/oven/live-data/transports.js rename to dashboard/src/oven/utils/transports.ts diff --git a/dashboard/src/oven/live-data/useOvenLiveData.ts b/dashboard/src/oven/utils/useOvenLiveData.ts similarity index 99% rename from dashboard/src/oven/live-data/useOvenLiveData.ts rename to dashboard/src/oven/utils/useOvenLiveData.ts index 9e9c6b1..0e86de5 100644 --- a/dashboard/src/oven/live-data/useOvenLiveData.ts +++ b/dashboard/src/oven/utils/useOvenLiveData.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type DependencyList } from "react"; -import { createPollTransport, createSseTransport } from "./transports.js"; +import { createPollTransport, createSseTransport } from "./transports"; type PropsConfig = { transport: "props"; data: T }; diff --git a/dashboard/src/oven/visual-parity-format.ts b/dashboard/src/oven/utils/visual-parity-format.ts similarity index 100% rename from dashboard/src/oven/visual-parity-format.ts rename to dashboard/src/oven/utils/visual-parity-format.ts diff --git a/package-lock.json b/package-lock.json index 05bed15..00e6717 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@vitejs/plugin-react": "^4.3.4", + "esbuild": "^0.25.12", "lucide-react": "^0.468.0", "radix-ui": "^1.6.2", "react": "^19.0.0", diff --git a/package.json b/package.json index 229ff8e..3f24416 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@vitejs/plugin-react": "^4.3.4", + "esbuild": "^0.25.12", "lucide-react": "^0.468.0", "radix-ui": "^1.6.2", "react": "^19.0.0", diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index 85e1abc..b3a51b7 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -70,12 +70,7 @@ export const verificationTestFiles = [ "dashboard/src/oven/DomainNote/DomainNote.test.mjs", "dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs", "dashboard/src/oven/FrameCard/FrameCard.test.mjs", - "dashboard/src/oven/OvenView/json-pointer.test.mjs", "dashboard/src/oven/OvenView/OvenView.test.mjs", - "dashboard/src/lib/checklist-progress-chart.test.mjs", - "dashboard/src/oven/chart-core/compact-time-scale.test.mjs", - "dashboard/src/oven/live-data/transports.test.mjs", - "dashboard/src/oven/live-data/adapters.test.mjs", "dashboard/src/lib/streaming-diff.test.mjs", "dashboard/src/lib/project-open.test.mjs", "dashboard/src/lib/oven-identity.test.mjs", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index caba978..5575113 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -404,6 +404,7 @@ assertDifferentialTestingContractAssets(); assertPublishablePackage(); run(process.execPath, ["--test", ...verificationTestFiles]); +run(process.execPath, ["dashboard/src/oven/test-support/run-oven-tests.mjs"]); const { BURNLIST_CLAUDE_SKILLS_DIR: ignoredClaudeSkillsDir, From 2bfda2b1d914defe2945315460b8aa54dbe0ffbe Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:13:33 +0200 Subject: [PATCH 11/85] refactor: convert oven-kit component tests to TypeScript via the shared harness --- .gitignore | 1 + .../{DiffCard.test.mjs => DiffCard.test.ts} | 26 +------- .../src/oven/DomainNote/DomainNote.test.mjs | 45 -------------- .../src/oven/DomainNote/DomainNote.test.ts | 23 +++++++ ...DomainTabs.test.mjs => DomainTabs.test.ts} | 26 +------- .../{FeedList.test.mjs => FeedList.test.ts} | 25 +------- .../{FileDiff.test.mjs => FileDiff.test.ts} | 26 +------- .../{FrameCard.test.mjs => FrameCard.test.ts} | 26 +------- ...riptych.test.mjs => ImageTriptych.test.ts} | 26 +------- .../{KpiItem.test.mjs => KpiItem.test.ts} | 24 +------ .../{KpiStrip.test.mjs => KpiStrip.test.ts} | 24 +------ ...strip.test.mjs => checklist-strip.test.ts} | 31 +--------- .../{LogTable.test.mjs => LogTable.test.ts} | 24 +------ ...tricTiles.test.mjs => MetricTiles.test.ts} | 26 +------- .../{OvenView.test.mjs => OvenView.test.ts} | 62 ++----------------- ...ssDonut.test.mjs => ProgressDonut.test.ts} | 17 +---- ...nHeader.test.mjs => SectionHeader.test.ts} | 18 +----- ...tHeader.test.mjs => VerdictHeader.test.ts} | 26 +------- .../src/oven/test-support/run-oven-tests.mjs | 4 +- scripts/verify-test-files.mjs | 16 ----- 20 files changed, 59 insertions(+), 437 deletions(-) rename dashboard/src/oven/DiffCard/{DiffCard.test.mjs => DiffCard.test.ts} (75%) delete mode 100644 dashboard/src/oven/DomainNote/DomainNote.test.mjs create mode 100644 dashboard/src/oven/DomainNote/DomainNote.test.ts rename dashboard/src/oven/DomainTabs/{DomainTabs.test.mjs => DomainTabs.test.ts} (55%) rename dashboard/src/oven/FeedList/{FeedList.test.mjs => FeedList.test.ts} (77%) rename dashboard/src/oven/FileDiff/{FileDiff.test.mjs => FileDiff.test.ts} (64%) rename dashboard/src/oven/FrameCard/{FrameCard.test.mjs => FrameCard.test.ts} (65%) rename dashboard/src/oven/ImageTriptych/{ImageTriptych.test.mjs => ImageTriptych.test.ts} (52%) rename dashboard/src/oven/KpiItem/{KpiItem.test.mjs => KpiItem.test.ts} (81%) rename dashboard/src/oven/KpiStrip/{KpiStrip.test.mjs => KpiStrip.test.ts} (52%) rename dashboard/src/oven/KpiStrip/{checklist-strip.test.mjs => checklist-strip.test.ts} (84%) rename dashboard/src/oven/LogTable/{LogTable.test.mjs => LogTable.test.ts} (92%) rename dashboard/src/oven/MetricTiles/{MetricTiles.test.mjs => MetricTiles.test.ts} (58%) rename dashboard/src/oven/OvenView/{OvenView.test.mjs => OvenView.test.ts} (71%) rename dashboard/src/oven/ProgressDonut/{ProgressDonut.test.mjs => ProgressDonut.test.ts} (66%) rename dashboard/src/oven/SectionHeader/{SectionHeader.test.mjs => SectionHeader.test.ts} (70%) rename dashboard/src/oven/VerdictHeader/{VerdictHeader.test.mjs => VerdictHeader.test.ts} (59%) diff --git a/.gitignore b/.gitignore index 4b39a6d..17abf8b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ notes/burnlists/ dist/ dashboard/dist/ node_modules/ +.oven-test-*/ .playwright-cli/ output/ build/ diff --git a/dashboard/src/oven/DiffCard/DiffCard.test.mjs b/dashboard/src/oven/DiffCard/DiffCard.test.ts similarity index 75% rename from dashboard/src/oven/DiffCard/DiffCard.test.mjs rename to dashboard/src/oven/DiffCard/DiffCard.test.ts index 6f20fa3..0adbb8f 100644 --- a/dashboard/src/oven/DiffCard/DiffCard.test.mjs +++ b/dashboard/src/oven/DiffCard/DiffCard.test.ts @@ -1,30 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./DiffCard.tsx", import.meta.url).pathname; -const layoutPath = new URL("../../layout", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -let outputDir; -let DiffCard; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".diff-card-test-")); - const outputPath = join(outputDir, "DiffCard.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@layout": layoutPath, "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ DiffCard } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { DiffCard } from "./DiffCard"; test("DiffCard preserves a complete card with file content", () => { const card = { diff --git a/dashboard/src/oven/DomainNote/DomainNote.test.mjs b/dashboard/src/oven/DomainNote/DomainNote.test.mjs deleted file mode 100644 index ffc1594..0000000 --- a/dashboard/src/oven/DomainNote/DomainNote.test.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; -import { createElement } from "react"; -import { renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./DomainNote.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; -let outputDir; -let DomainNote; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".domain-note-test-")); - const outputPath = join(outputDir, "DomainNote.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ DomainNote } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); - -function FrozenDomainNote({ isTarget, rationale }) { - return createElement( - "div", - { className: "visual-parity-domain-note" }, - createElement("strong", null, isTarget ? "Qualifying target" : "Diagnostic context"), - createElement("span", null, rationale), - ); -} - -test("DomainNote matches target and diagnostic labels", () => { - for (const props of [ - { isTarget: true, rationale: "Exact zero tolerance." }, - { isTarget: false, rationale: "Used for diagnosis." }, - ]) { - assert.equal(renderToString(createElement(DomainNote, props)), renderToString(createElement(FrozenDomainNote, props))); - } -}); diff --git a/dashboard/src/oven/DomainNote/DomainNote.test.ts b/dashboard/src/oven/DomainNote/DomainNote.test.ts new file mode 100644 index 0000000..0cbd83b --- /dev/null +++ b/dashboard/src/oven/DomainNote/DomainNote.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { DomainNote } from "./DomainNote"; + +function FrozenDomainNote({ isTarget, rationale }) { + return createElement( + "div", + { className: "visual-parity-domain-note" }, + createElement("strong", null, isTarget ? "Qualifying target" : "Diagnostic context"), + createElement("span", null, rationale), + ); +} + +test("DomainNote matches target and diagnostic labels", () => { + for (const props of [ + { isTarget: true, rationale: "Exact zero tolerance." }, + { isTarget: false, rationale: "Used for diagnosis." }, + ]) { + assert.equal(renderToString(createElement(DomainNote, props)), renderToString(createElement(FrozenDomainNote, props))); + } +}); diff --git a/dashboard/src/oven/DomainTabs/DomainTabs.test.mjs b/dashboard/src/oven/DomainTabs/DomainTabs.test.ts similarity index 55% rename from dashboard/src/oven/DomainTabs/DomainTabs.test.mjs rename to dashboard/src/oven/DomainTabs/DomainTabs.test.ts index 2239c21..dba645c 100644 --- a/dashboard/src/oven/DomainTabs/DomainTabs.test.mjs +++ b/dashboard/src/oven/DomainTabs/DomainTabs.test.ts @@ -1,30 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./DomainTabs.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; -let outputDir; -let DomainTabs; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".domain-tabs-test-")); - const outputPath = join(outputDir, "DomainTabs.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ DomainTabs } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { DomainTabs } from "./DomainTabs"; function FrozenDomainTabs({ tabs, activeId, onSelect }) { return createElement( diff --git a/dashboard/src/oven/FeedList/FeedList.test.mjs b/dashboard/src/oven/FeedList/FeedList.test.ts similarity index 77% rename from dashboard/src/oven/FeedList/FeedList.test.mjs rename to dashboard/src/oven/FeedList/FeedList.test.ts index 43e1264..d09e81b 100644 --- a/dashboard/src/oven/FeedList/FeedList.test.mjs +++ b/dashboard/src/oven/FeedList/FeedList.test.ts @@ -1,29 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./FeedList.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -let outputDir; -let FeedList; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".feed-list-test-")); - const outputPath = join(outputDir, "FeedList.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ FeedList } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { FeedList } from "./FeedList"; const heading = "

Streaming Diff

Recent feeds are ordered by published activity time, not process liveness.

"; diff --git a/dashboard/src/oven/FileDiff/FileDiff.test.mjs b/dashboard/src/oven/FileDiff/FileDiff.test.ts similarity index 64% rename from dashboard/src/oven/FileDiff/FileDiff.test.mjs rename to dashboard/src/oven/FileDiff/FileDiff.test.ts index 4271983..5026e3c 100644 --- a/dashboard/src/oven/FileDiff/FileDiff.test.mjs +++ b/dashboard/src/oven/FileDiff/FileDiff.test.ts @@ -1,30 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./FileDiff.tsx", import.meta.url).pathname; -const layoutPath = new URL("../../layout", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -let outputDir; -let FileDiff; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".file-diff-test-")); - const outputPath = join(outputDir, "FileDiff.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@layout": layoutPath, "@lib": libPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ FileDiff } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { FileDiff } from "./FileDiff"; test("FileDiff preserves a chip-kind file and its metadata", () => { const markup = renderToStaticMarkup(createElement(FileDiff, { diff --git a/dashboard/src/oven/FrameCard/FrameCard.test.mjs b/dashboard/src/oven/FrameCard/FrameCard.test.ts similarity index 65% rename from dashboard/src/oven/FrameCard/FrameCard.test.mjs rename to dashboard/src/oven/FrameCard/FrameCard.test.ts index c32874f..81d0303 100644 --- a/dashboard/src/oven/FrameCard/FrameCard.test.mjs +++ b/dashboard/src/oven/FrameCard/FrameCard.test.ts @@ -1,30 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./FrameCard.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; -let outputDir; -let FrameCard; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".frame-card-test-")); - const outputPath = join(outputDir, "FrameCard.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ FrameCard } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { FrameCard } from "./FrameCard"; function referencePercent(value) { return `${(value * 100).toFixed(value < 0.01 ? 3 : 2)}%`; diff --git a/dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs b/dashboard/src/oven/ImageTriptych/ImageTriptych.test.ts similarity index 52% rename from dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs rename to dashboard/src/oven/ImageTriptych/ImageTriptych.test.ts index 1d10d2c..f8fbf75 100644 --- a/dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs +++ b/dashboard/src/oven/ImageTriptych/ImageTriptych.test.ts @@ -1,30 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./ImageTriptych.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; -let outputDir; -let ImageTriptych; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".image-triptych-test-")); - const outputPath = join(outputDir, "ImageTriptych.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ ImageTriptych } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { ImageTriptych } from "./ImageTriptych"; function FrozenImageTriptych({ images, label, frame }) { return createElement( diff --git a/dashboard/src/oven/KpiItem/KpiItem.test.mjs b/dashboard/src/oven/KpiItem/KpiItem.test.ts similarity index 81% rename from dashboard/src/oven/KpiItem/KpiItem.test.mjs rename to dashboard/src/oven/KpiItem/KpiItem.test.ts index 34426d6..679b623 100644 --- a/dashboard/src/oven/KpiItem/KpiItem.test.mjs +++ b/dashboard/src/oven/KpiItem/KpiItem.test.ts @@ -1,28 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { Fragment, createElement } from "react"; import { renderToStaticMarkup, renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./KpiItem.tsx", import.meta.url).pathname; -let outputDir; -let KpiItem; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".kpi-item-test-")); - const outputPath = join(outputDir, "KpiItem.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - jsx: "automatic", packages: "external", target: "node18", - }); - ({ KpiItem } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { KpiItem } from "./KpiItem"; test("KpiItem preserves the exact Checklist item markup", () => { const visual = createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" }); diff --git a/dashboard/src/oven/KpiStrip/KpiStrip.test.mjs b/dashboard/src/oven/KpiStrip/KpiStrip.test.ts similarity index 52% rename from dashboard/src/oven/KpiStrip/KpiStrip.test.mjs rename to dashboard/src/oven/KpiStrip/KpiStrip.test.ts index 287c8bc..e892abe 100644 --- a/dashboard/src/oven/KpiStrip/KpiStrip.test.mjs +++ b/dashboard/src/oven/KpiStrip/KpiStrip.test.ts @@ -1,28 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup, renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./KpiStrip.tsx", import.meta.url).pathname; -let outputDir; -let KpiStrip; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".kpi-strip-test-")); - const outputPath = join(outputDir, "KpiStrip.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - jsx: "automatic", packages: "external", target: "node18", - }); - ({ KpiStrip } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { KpiStrip } from "./KpiStrip"; test("KpiStrip preserves exact attributes and child output", () => { const strip = createElement(KpiStrip, { diff --git a/dashboard/src/oven/KpiStrip/checklist-strip.test.mjs b/dashboard/src/oven/KpiStrip/checklist-strip.test.ts similarity index 84% rename from dashboard/src/oven/KpiStrip/checklist-strip.test.mjs rename to dashboard/src/oven/KpiStrip/checklist-strip.test.ts index 1a0917c..5328881 100644 --- a/dashboard/src/oven/KpiStrip/checklist-strip.test.mjs +++ b/dashboard/src/oven/KpiStrip/checklist-strip.test.ts @@ -1,34 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement, Fragment } from "react"; import { renderToStaticMarkup, renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const itemPath = new URL("../KpiItem/KpiItem.tsx", import.meta.url).pathname; -const stripPath = new URL("./KpiStrip.tsx", import.meta.url).pathname; -let outputDir; -let KpiItem; -let KpiStrip; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".checklist-strip-test-")); - const itemOutputPath = join(outputDir, "KpiItem.mjs"); - const stripOutputPath = join(outputDir, "KpiStrip.mjs"); - const buildOptions = { - bundle: true, format: "esm", platform: "node", - jsx: "automatic", packages: "external", target: "node18", - }; - await build({ ...buildOptions, entryPoints: [itemPath], outfile: itemOutputPath }); - await build({ ...buildOptions, entryPoints: [stripPath], outfile: stripOutputPath }); - ({ KpiItem } = await import(`${new URL(`file://${itemOutputPath}`).href}?test=${Date.now()}`)); - ({ KpiStrip } = await import(`${new URL(`file://${stripOutputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { KpiItem } from "../KpiItem/KpiItem"; +import { KpiStrip } from "./KpiStrip"; test("Checklist KPI strip preserves its complete static structure", () => { const scenarioIcon = createElement("svg", { "aria-hidden": "true", className: "driving-parity-kpi-gauge driving-parity-kpi-scenario-icon" }); diff --git a/dashboard/src/oven/LogTable/LogTable.test.mjs b/dashboard/src/oven/LogTable/LogTable.test.ts similarity index 92% rename from dashboard/src/oven/LogTable/LogTable.test.mjs rename to dashboard/src/oven/LogTable/LogTable.test.ts index 2667f5b..af508b5 100644 --- a/dashboard/src/oven/LogTable/LogTable.test.mjs +++ b/dashboard/src/oven/LogTable/LogTable.test.ts @@ -1,28 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { Fragment, createElement } from "react"; import { renderToStaticMarkup, renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./LogTable.tsx", import.meta.url).pathname; -let outputDir; -let LogTable; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".log-table-test-")); - const outputPath = join(outputDir, "LogTable.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - jsx: "automatic", packages: "external", target: "node18", - }); - ({ LogTable } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { LogTable } from "./LogTable"; function FrozenChecklistLog({ rows, emptyState }) { return createElement( diff --git a/dashboard/src/oven/MetricTiles/MetricTiles.test.mjs b/dashboard/src/oven/MetricTiles/MetricTiles.test.ts similarity index 58% rename from dashboard/src/oven/MetricTiles/MetricTiles.test.mjs rename to dashboard/src/oven/MetricTiles/MetricTiles.test.ts index 78578c5..c3501ef 100644 --- a/dashboard/src/oven/MetricTiles/MetricTiles.test.mjs +++ b/dashboard/src/oven/MetricTiles/MetricTiles.test.ts @@ -1,30 +1,8 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./MetricTiles.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; -let outputDir; -let MetricTiles; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".metric-tiles-test-")); - const outputPath = join(outputDir, "MetricTiles.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ MetricTiles } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { MetricTiles } from "./MetricTiles"; function referencePercent(value) { return `${(value * 100).toFixed(value < 0.01 ? 3 : 2)}%`; diff --git a/dashboard/src/oven/OvenView/OvenView.test.mjs b/dashboard/src/oven/OvenView/OvenView.test.ts similarity index 71% rename from dashboard/src/oven/OvenView/OvenView.test.mjs rename to dashboard/src/oven/OvenView/OvenView.test.ts index 58de6b9..88aca21 100644 --- a/dashboard/src/oven/OvenView/OvenView.test.mjs +++ b/dashboard/src/oven/OvenView/OvenView.test.ts @@ -1,63 +1,13 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; import { ClipboardList } from "lucide-react"; -import { build } from "esbuild"; - -const ovenViewPath = new URL("./OvenView.tsx", import.meta.url).pathname; -const metricTilesPath = new URL("../MetricTiles/MetricTiles.tsx", import.meta.url).pathname; -const domainNotePath = new URL("../DomainNote/DomainNote.tsx", import.meta.url).pathname; -const kpiItemPath = new URL("../KpiItem/KpiItem.tsx", import.meta.url).pathname; -const progressDonutPath = new URL("../ProgressDonut/ProgressDonut.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const layoutPath = new URL("../../layout", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; - -let outputDir; -let OvenView; -let MetricTiles; -let DomainNote; -let KpiItem; -let ProgressDonut; - -async function bundle(entryPath, name) { - const outputPath = join(outputDir, `${name}.mjs`); - await build({ - entryPoints: [entryPath], - bundle: true, - format: "esm", - outfile: outputPath, - platform: "node", - alias: { "@lib": libPath, "@layout": layoutPath, "@oven": ovenPath }, - jsx: "automatic", - packages: "external", - target: "node18", - }); - return import(`${new URL(`file://${outputPath}`).href}?test=${name}-${Date.now()}`); -} - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".oven-view-test-")); - const modules = await Promise.all([ - bundle(ovenViewPath, "OvenView"), - bundle(metricTilesPath, "MetricTiles"), - bundle(domainNotePath, "DomainNote"), - bundle(kpiItemPath, "KpiItem"), - bundle(progressDonutPath, "ProgressDonut"), - ]); - OvenView = modules[0].OvenView; - MetricTiles = modules[1].MetricTiles; - DomainNote = modules[2].DomainNote; - KpiItem = modules[3].KpiItem; - ProgressDonut = modules[4].ProgressDonut; -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { DomainNote } from "../DomainNote/DomainNote"; +import { MetricTiles } from "../MetricTiles/MetricTiles"; +import { KpiItem } from "../KpiItem/KpiItem"; +import { ProgressDonut } from "../ProgressDonut/ProgressDonut"; +import { OvenView } from "./OvenView"; function render(def, payload) { return renderToString(createElement(OvenView, { def, payload })); diff --git a/dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs b/dashboard/src/oven/ProgressDonut/ProgressDonut.test.ts similarity index 66% rename from dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs rename to dashboard/src/oven/ProgressDonut/ProgressDonut.test.ts index b1ce8ee..c15ad38 100644 --- a/dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs +++ b/dashboard/src/oven/ProgressDonut/ProgressDonut.test.ts @@ -1,22 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./ProgressDonut.tsx", import.meta.url).pathname; +import { ProgressDonut } from "./ProgressDonut"; test("ProgressDonut keeps its static markup for representative values", async () => { - const outputDir = await mkdtemp(join(process.cwd(), ".progress-donut-test-")); - try { - const outputPath = join(outputDir, "ProgressDonut.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - jsx: "automatic", packages: "external", target: "node18", - }); - const { ProgressDonut } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); for (const percent of [0, 37.5, 50, 99.999, 100, -5, 150]) { const donePercent = Math.max(0, Math.min(100, percent)); const remainingPercent = Math.max(0, 100 - donePercent); @@ -27,7 +15,4 @@ test("ProgressDonut keeps its static markup for representative values", async () renderToStaticMarkup(createElement(ProgressDonut, { percent: 50, className: "custom-donut" })), ``, ); - } finally { - await rm(outputDir, { force: true, recursive: true }); - } }); diff --git a/dashboard/src/oven/SectionHeader/SectionHeader.test.mjs b/dashboard/src/oven/SectionHeader/SectionHeader.test.ts similarity index 70% rename from dashboard/src/oven/SectionHeader/SectionHeader.test.mjs rename to dashboard/src/oven/SectionHeader/SectionHeader.test.ts index c90cbf9..576a552 100644 --- a/dashboard/src/oven/SectionHeader/SectionHeader.test.mjs +++ b/dashboard/src/oven/SectionHeader/SectionHeader.test.ts @@ -1,23 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup, renderToString } from "react-dom/server"; -import { build } from "esbuild"; - -const componentPath = new URL("./SectionHeader.tsx", import.meta.url).pathname; +import { SectionHeader } from "./SectionHeader"; test("SectionHeader keeps count and child markup stable", async () => { - const outputDir = await mkdtemp(join(process.cwd(), ".section-header-test-")); - try { - const outputPath = join(outputDir, "SectionHeader.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - jsx: "automatic", packages: "external", target: "node18", - }); - const { SectionHeader } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`); - assert.equal( renderToStaticMarkup(createElement(SectionHeader, { title: "Events", count: 3 })), `

Events (3)

`, @@ -45,7 +32,4 @@ test("SectionHeader keeps count and child markup stable", async () => { const referenceOutput = renderToString(createElement(ReferenceSectionHeader, { title: "Events", count: 3 })); assert.equal(sectionHeaderOutput, referenceOutput); assert.match(sectionHeaderOutput, /^

Events /u); - } finally { - await rm(outputDir, { force: true, recursive: true }); - } }); diff --git a/dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs b/dashboard/src/oven/VerdictHeader/VerdictHeader.test.ts similarity index 59% rename from dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs rename to dashboard/src/oven/VerdictHeader/VerdictHeader.test.ts index 27bddca..13756ce 100644 --- a/dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs +++ b/dashboard/src/oven/VerdictHeader/VerdictHeader.test.ts @@ -1,31 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { after, before, test } from "node:test"; +import { test } from "node:test"; import { createElement } from "react"; import { renderToString } from "react-dom/server"; import { ArrowLeft } from "lucide-react"; -import { build } from "esbuild"; - -const componentPath = new URL("./VerdictHeader.tsx", import.meta.url).pathname; -const libPath = new URL("../../lib", import.meta.url).pathname; -const ovenPath = new URL("..", import.meta.url).pathname; -let outputDir; -let VerdictHeader; - -before(async () => { - outputDir = await mkdtemp(join(process.cwd(), ".verdict-header-test-")); - const outputPath = join(outputDir, "VerdictHeader.mjs"); - await build({ - entryPoints: [componentPath], bundle: true, format: "esm", outfile: outputPath, platform: "node", - alias: { "@lib": libPath, "@oven": ovenPath }, jsx: "automatic", packages: "external", target: "node18", - }); - ({ VerdictHeader } = await import(`${new URL(`file://${outputPath}`).href}?test=${Date.now()}`)); -}); - -after(async () => { - await rm(outputDir, { force: true, recursive: true }); -}); +import { VerdictHeader } from "./VerdictHeader"; function FrozenVerdictHeader({ targetPass, framesCount, error }) { return createElement( diff --git a/dashboard/src/oven/test-support/run-oven-tests.mjs b/dashboard/src/oven/test-support/run-oven-tests.mjs index 522c9a5..264ee7a 100644 --- a/dashboard/src/oven/test-support/run-oven-tests.mjs +++ b/dashboard/src/oven/test-support/run-oven-tests.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; import { mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; const testSupportDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(testSupportDir, "../../../.."); const sourceDir = resolve(testSupportDir, "../.."); const ovenDir = resolve(sourceDir, "oven"); @@ -48,7 +48,7 @@ console.log(`=== Oven TypeScript tests (${testEntries.length}) ===`); if (testEntries.length === 0) process.exit(0); -const outputDir = mkdtempSync(join(tmpdir(), "burnlist-oven-tests-")); +const outputDir = mkdtempSync(join(repoRoot, ".oven-test-")); let status = 0; try { await build({ diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index b3a51b7..df33742 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -55,22 +55,6 @@ export const verificationTestFiles = [ "src/server/repo-state.test.mjs", "dashboard/src/components/ProjectGroup/BurnlistRow.test.mjs", "dashboard/src/components/ChecklistDashboard/ChecklistDashboard.test.mjs", - "dashboard/src/oven/ProgressDonut/ProgressDonut.test.mjs", - "dashboard/src/oven/FileDiff/FileDiff.test.mjs", - "dashboard/src/oven/DiffCard/DiffCard.test.mjs", - "dashboard/src/oven/FeedList/FeedList.test.mjs", - "dashboard/src/oven/KpiItem/KpiItem.test.mjs", - "dashboard/src/oven/KpiStrip/KpiStrip.test.mjs", - "dashboard/src/oven/KpiStrip/checklist-strip.test.mjs", - "dashboard/src/oven/LogTable/LogTable.test.mjs", - "dashboard/src/oven/SectionHeader/SectionHeader.test.mjs", - "dashboard/src/oven/VerdictHeader/VerdictHeader.test.mjs", - "dashboard/src/oven/DomainTabs/DomainTabs.test.mjs", - "dashboard/src/oven/MetricTiles/MetricTiles.test.mjs", - "dashboard/src/oven/DomainNote/DomainNote.test.mjs", - "dashboard/src/oven/ImageTriptych/ImageTriptych.test.mjs", - "dashboard/src/oven/FrameCard/FrameCard.test.mjs", - "dashboard/src/oven/OvenView/OvenView.test.mjs", "dashboard/src/lib/streaming-diff.test.mjs", "dashboard/src/lib/project-open.test.mjs", "dashboard/src/lib/oven-identity.test.mjs", From 6bab6cb1b286524e335ac35c90cc24b2ed8e5d03 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:45:56 +0200 Subject: [PATCH 12/85] test: capture deterministic DT+PT DOM goldens across render states (partition oracle) --- .../differential-testing-goldens.test.mjs | 106 ++ .../renderer/golden-harness.mjs | 337 ++++++ .../goldens/dt-chart-current-failed.html | 962 ++++++++++++++++++ .../renderer/goldens/dt-empty.html | 1 + .../renderer/goldens/dt-main.html | 962 ++++++++++++++++++ .../renderer/goldens/dt-progress-mode.html | 962 ++++++++++++++++++ .../renderer/goldens/dt-server-paged.html | 962 ++++++++++++++++++ .../goldens/dt-sorted-filtered-paged.html | 962 ++++++++++++++++++ .../goldens/dt-telemetry-incomparable.html | 962 ++++++++++++++++++ .../renderer/goldens/pt-main.html | 962 ++++++++++++++++++ scripts/verify-test-files.mjs | 1 + 11 files changed, 7179 insertions(+) create mode 100644 ovens/differential-testing/renderer/differential-testing-goldens.test.mjs create mode 100644 ovens/differential-testing/renderer/golden-harness.mjs create mode 100644 ovens/differential-testing/renderer/goldens/dt-chart-current-failed.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-empty.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-main.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-progress-mode.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-server-paged.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-sorted-filtered-paged.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-telemetry-incomparable.html create mode 100644 ovens/differential-testing/renderer/goldens/pt-main.html diff --git a/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs b/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs new file mode 100644 index 0000000..5ed1ba6 --- /dev/null +++ b/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + captureDashboardHtml, + differentialTestingPayload, + differentialTestingEmptyPayload, + differentialTestingIncomparableTelemetryPayload, + ovenLayout, + performanceTracingPayload, +} from "./golden-harness.mjs"; +import { assertDifferentialTestingData } from "../engine/differential-testing-data-contract.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const goldens = resolve(here, "goldens"); +const dtOven = ovenLayout(); +const ptOven = { + id: "performance-tracing", + name: "Performance Tracing", + detail: { cells: JSON.parse(readFileSync(resolve(here, "../../performance-tracing/detail.json"), "utf8")).cells }, +}; + +const base = differentialTestingPayload(); +const failingFields = base.fields.filter((field) => field.failedSampleCount > 0 || field.missingSampleCount > 0); +const serverPage = { + search: "", + filter: "all", + sort: "changed", + page: 0, + pageSize: 25, + pageCount: Math.max(1, Math.ceil(base.fields.length / 25)), + total: base.fields.length, + fields: base.fields, + telemetryFields: base.telemetry?.fields ?? [], +}; +const sortedFilteredPage = { + search: "", + filter: "failing", + sort: "default", + page: 0, + pageSize: 25, + pageCount: Math.max(1, Math.ceil(failingFields.length / 25)), + total: failingFields.length, + fields: failingFields, + telemetryFields: base.telemetry?.fields ?? [], +}; + +const states = [ + ["dt-main", dtOven, base, {}], + ["dt-empty", dtOven, differentialTestingEmptyPayload(), {}], + ["dt-server-paged", dtOven, base, { fieldPage: serverPage }], + ["dt-sorted-filtered-paged", dtOven, base, { fieldPage: sortedFilteredPage }], + ["dt-telemetry-incomparable", dtOven, differentialTestingIncomparableTelemetryPayload(), {}], + ["dt-chart-current-failed", dtOven, base, { initialChart: "current", initialProgressChart: "failed" }], + ["dt-progress-mode", dtOven, base, { initialProgressChart: "progress" }], + ["pt-main", ptOven, performanceTracingPayload(), { initialChart: "current", initialProgressChart: "delta" }], +]; + +function liveState([name, oven, payload, options]) { + return { name, html: captureDashboardHtml(oven, payload, options) }; +} + +test("DOM goldens remain exact for the captured dashboard states", () => { + mkdirSync(goldens, { recursive: true }); + for (const state of states.map(liveState)) { + const path = resolve(goldens, `${state.name}.html`); + if (process.env.WRITE_DT_GOLDENS === "1") { + const temporaryPath = `${path}.tmp-${process.pid}`; + writeFileSync(temporaryPath, state.html); + renameSync(temporaryPath, path); + } + else assert.equal(state.html, readFileSync(path, "utf8"), `${state.name} golden differs`); + } +}); + +test("every DT golden payload satisfies the data contract", () => { + for (const [name, oven, payload] of states) { + if (name.startsWith("dt-")) assert.doesNotThrow(() => assertDifferentialTestingData(payload), name); + } +}); + +test("DOM goldens contain the expected structural markers", () => { + const captured = new Map(states.map((state) => { + const live = liveState(state); + return [live.name, live]; + })); + for (const [name, state] of captured) assert.ok(state.html.length > 100, `${name} capture is unexpectedly small`); + const dtMain = captured.get("dt-main").html; + assert.match(dtMain, /driving-parity-kpi-strip has-burns/u); + assert.match(dtMain, /
/u); + assert.match(dtMain, /checklist-log-list/u); + assert.match(dtMain, /driving-parity-pagination/u); + assert.match(dtMain, /data-row-expand-key="position"/u); + assert.match(dtMain, /[1-9][0-9]*<\/span>/u); + const realLogRows = dtMain.match(/
0, "dt-main must contain a real log row"); + assert.match(captured.get("dt-empty").html, /No Differential Testing scenarios/u); + const serverHtml = captured.get("dt-server-paged").html; + const serverStatus = serverHtml.match(/([^<]+)<\/span>/u)?.[1] ?? ""; + assert.doesNotMatch(serverStatus, /1-0 \/|\/ 0/u); + assert.match(serverStatus, new RegExp(`1-${base.fields.length} \/ ${base.fields.length}`, "u")); + assert.match(captured.get("pt-main").html, /id="progress-chart"[^>]*Frame timing/u); +}); diff --git a/ovens/differential-testing/renderer/golden-harness.mjs b/ovens/differential-testing/renderer/golden-harness.mjs new file mode 100644 index 0000000..02ebd62 --- /dev/null +++ b/ovens/differential-testing/renderer/golden-harness.mjs @@ -0,0 +1,337 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { adaptPerformanceTracingReport } from "../../../dashboard/src/lib/performance-tracing.mjs"; +import { buildPayload } from "../example/adapter.mjs"; +import { + assertDifferentialTestingData, + DIFFERENTIAL_TESTING_TELEMETRY_AUTHORITY, +} from "../engine/differential-testing-data-contract.mjs"; +import { assertPerformanceTracingData } from "../../performance-tracing/engine/performance-tracing-contract.mjs"; +import { mountDifferentialTestingDashboard } from "./differential-testing-renderer.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const exampleDir = resolve(here, "../example"); +const FIXED_NOW = Date.parse("2026-01-01T12:30:00.000Z"); +const PERFORMANCE_TIMESTAMP = "2026-07-15T12:00:00.000Z"; + +export let lastCaptureRoot = null; + +function nsStub() { + return { + setAttribute() {}, + append() {}, + appendChild() {}, + replaceChildren() {}, + }; +} + +function controlStub() { + return { + value: "", + focus() {}, + setSelectionRange() {}, + remove() {}, + append() {}, + }; +} + +function stubFor(selector) { + if (selector === "#progress-chart") return null; + if ([ + "#differential-overview-time", + "#differential-refresh-status", + "#driving-parity-field-search", + "#driving-parity-page-size", + ].includes(selector)) return controlStub(); + return null; +} + +function installGlobals() { + const installed = []; + if (typeof globalThis.document === "undefined") { + globalThis.document = { + querySelector: stubFor, + createElementNS: () => nsStub(), + documentElement: {}, + }; + installed.push("document"); + } + if (typeof globalThis.window === "undefined") { + globalThis.window = { + devicePixelRatio: 1, + setTimeout, + clearTimeout, + addEventListener() {}, + removeEventListener() {}, + }; + installed.push("window"); + } + if (typeof globalThis.getComputedStyle === "undefined") { + globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" }); + installed.push("getComputedStyle"); + } + return () => installed.reverse().forEach((name) => delete globalThis[name]); +} + +function rootStubFor(selector) { + return stubFor(selector); +} + +function fakeRoot() { + return { + className: "", + set innerHTML(value) { this._html = value; }, + get innerHTML() { return this._html ?? ""; }, + addEventListener(type, handler) { + (this._handlers ||= {})[type] = handler; + }, + querySelector: rootStubFor, + querySelectorAll: () => [], + append() {}, + remove() {}, + }; +} + +export function captureDashboardRoot(oven, payload, mountOptions = {}) { + const previousTz = process.env.TZ; + const previousDateNow = Date.now; + const OriginalDTF = Intl.DateTimeFormat; + const Shim = function DateTimeFormat(locales, options) { + return new OriginalDTF(locales == null ? "en-US" : locales, { timeZone: "UTC", ...(options || {}) }); + }; + Shim.prototype = OriginalDTF.prototype; + Object.setPrototypeOf(Shim, OriginalDTF); + const restoreGlobals = installGlobals(); + const root = fakeRoot(); + lastCaptureRoot = root; + process.env.TZ = "UTC"; + Date.now = () => FIXED_NOW; + globalThis.Intl.DateTimeFormat = Shim; + try { + mountDifferentialTestingDashboard(root, oven, payload, mountOptions); + return root; + } finally { + globalThis.Intl.DateTimeFormat = OriginalDTF; + Date.now = previousDateNow; + if (previousTz === undefined) delete process.env.TZ; + else process.env.TZ = previousTz; + restoreGlobals(); + } +} + +export function captureDashboardHtml(oven, payload, mountOptions = {}) { + return captureDashboardRoot(oven, payload, mountOptions).innerHTML; +} + +export function ovenLayout() { + const detail = JSON.parse(readFileSync(resolve(here, "../detail.json"), "utf8")); + return { id: "differential-testing", name: "Differential Testing", detail: { cells: detail.cells } }; +} + +function emptyCaptures() { + return ["reference.json", "candidate.json"].map((name) => JSON.parse(readFileSync(resolve(exampleDir, name), "utf8"))); +} + +function populatedCaptures() { + return [ + { + captureId: "reference-fixture", + generatedAt: "2026-01-01T12:00:00.000Z", + fields: [ + { id: "position", label: "Position", sourceOwner: "engine/state", meaning: "One-dimensional position after the update", unit: "units", tolerance: 0.01 }, + { id: "active", label: "Active", sourceOwner: "engine/state", meaning: "Whether the object is active after the update", unit: null, tolerance: 0 }, + ], + samples: [ + { tick: 0, values: { position: 0, active: false } }, + { tick: 1, values: { position: 1, active: true } }, + { tick: 2, values: { position: 2, active: true } }, + ], + }, + { + captureId: "candidate-fixture", + generatedAt: "2026-01-01T12:00:00.000Z", + samples: [ + { tick: 0, values: { position: 0, active: false } }, + { tick: 1, values: { position: 1.005, active: true } }, + { tick: 2, values: { position: 2.1, active: true } }, + ], + }, + ]; +} + +export function differentialTestingPayload() { + const base = buildPayload(...populatedCaptures()); + assertDifferentialTestingData(base); + return base; +} + +export function differentialTestingEmptyPayload() { + const payload = buildPayload(...emptyCaptures()); + assertDifferentialTestingData(payload); + return payload; +} + +export function differentialTestingIncomparableTelemetryPayload() { + const payload = buildPayload(...populatedCaptures()); + payload.telemetry = { + status: "blocked", + authority: DIFFERENTIAL_TESTING_TELEMETRY_AUTHORITY, + blockers: ["Transition telemetry unavailable."], + }; + assertDifferentialTestingData(payload); + return payload; +} + +export function performanceTracingPayload() { + const report = performanceTracingFixture(); + assertPerformanceTracingData(report); + return adaptPerformanceTracingReport(report); +} + +function performanceTracingFixture() { + const value = { + schema: "performance-tracing-oven@1", + status: "fail", + runId: "fixture", + generatedAt: PERFORMANCE_TIMESTAMP, + trust: { + classification: "browser-output-performance-evidence", + preparedRoute: true, + nativeParityClaim: false, + visualParityClaim: false, + }, + browser: { engine: "chromium", version: "1" }, + scenario: { id: "prepared", route: "/" }, + metrics: { + runCount: 1, + startupReadyMs: 1000, + p95FrameMs: 40, + p99FrameMs: 50, + maxFrameMs: 100, + over33msRatio: 0.1, + p95StepCallMs: 1, + pageErrorCount: 0, + nativeRequestCount: 0, + runtimeConstructionCount: 0, + }, + verdict: { + status: "fail", + passCount: 0, + failCount: 1, + checks: [{ id: "p95FrameMs", actual: 40, limit: 25, operator: "<=", status: "fail" }], + }, + artifacts: { report: "report.json" }, + provenance: { files: { "source.mjs": { sha256: "a", bytes: 1 } } }, + runs: [{ + id: "run-01", + status: "passed", + frameTiming: { p95FrameMs: 40, series: [{ frame: 0, elapsedMs: 40, frameMs: 40 }] }, + stepTiming: { + p95StepCallMs: 1, + slowestSteps: [], + series: [{ tick: 0, stepCallMs: 1 }], + phaseTiming: { + schema: "runtime-dispatch-phase-summary@1", + sampleCount: 1, + phases: { cameraResidency: phase() }, + }, + cameraPhaseTiming: { + schema: "camera-publication-phase-summary@1", + sampleCount: 1, + phases: { assetResidencyDiscovery: phase() }, + }, + }, + trace: { + schema: "browser-performance-trace-summary@2", + attributionMode: "exclusive-classified-thread-time@1", + measurementWindow: { status: "bounded" }, + groups: { scripting: { label: "JS / scripting", durationMs: 100, inclusiveDurationMs: 120, count: 10, maxMs: 4, timeline: { mode: "exclusive-classified-thread-time", bucketDurationMs: 50, values: [1] } } }, + }, + integrity: { pageErrorCount: 0, nativeRequestCount: 0, runtimeConstructionCount: 0 }, + }], + }; + value.history = [{ + schema: "performance-history-point@1", + runId: value.runId, + generatedAt: PERFORMANCE_TIMESTAMP, + status: value.status, + comparisonKey: "fixture-context", + context: { browserTarget: "fixture", scenarioId: "prepared" }, + metrics: { + startupReadyMs: 1000, + p95FrameMs: 40, + p99FrameMs: 50, + maxFrameMs: 100, + over33msRatio: 0.1, + p95StepCallMs: 1, + maxStepCallMs: 4, + residencyTransitionStepCount: 1, + }, + budgets: { p95FrameMs: 25 }, + traceGroups: { scripting: { label: "JS / scripting", durationMs: 100, count: 10, maxMs: 4 } }, + }]; + value.diagnostics = { + schema: "performance-diagnostics@1", + runId: value.runId, + generatedAt: PERFORMANCE_TIMESTAMP, + primaryTarget: action(), + budgetGaps: [{ id: "p95FrameMs", actual: 40, limit: 25, excess: 15, ratioToLimit: 1.6, percentOverLimit: 60 }], + comparison: { comparable: false, previousRunId: null, previousGeneratedAt: null, metricChanges: {} }, + runs: [{ + runId: "run-01", + frameSpikes: [{ frame: 0, elapsedMs: 40, frameMs: 40, overBudgetMs: 15 }], + stepSpikes: [], + phaseBottlenecks: [{ id: "cameraResidency", ...phase() }], + cameraPhaseBottlenecks: [{ id: "assetResidencyDiscovery", ...phase() }], + traceGroups: [], + hotWindows: [{ bucket: 0, startMs: 0, endMs: 50, classifiedThreadTimeMs: 1, contributors: [] }], + topEvents: [], + structure: { integrity: value.runs[0].integrity }, + }], + phaseBottlenecks: [{ id: "cameraResidency", ...phase(), runId: "run-01" }], + cameraPhaseBottlenecks: [{ id: "assetResidencyDiscovery", ...phase(), runId: "run-01" }], + traceGroups: [], + residencySpikes: [], + actionItems: [action()], + rerun: { + command: "pnpm perf:trace -- --runs 1 --no-fail-on-budget", + comparisonKey: "fixture-context", + compareAgainstRunId: value.runId, + protocol: ["Change one producer."], + requiredIntegrity: { pageErrorCount: 0, nativeRequestCount: 0, runtimeConstructionCount: 0 }, + successCriteria: [{ id: "p95FrameMs", operator: "<=", limit: 25 }], + }, + caveats: ["inclusive", "instrumented", "browser output"], + }; + return value; +} + +function phase() { + return { + label: "camera / city residency", + producer: "src/runtime/scene.mjs", + nextProbe: "Separate camera and residency work.", + sampleCount: 1, + totalMs: 4, + averageMs: 4, + p50Ms: 4, + p95Ms: 4, + maxMs: 4, + attributedShare: 1, + }; +} + +function action() { + return { + id: "dispatch-phase-cameraResidency", + priority: 1, + target: "camera / city residency", + producer: "src/runtime/scene.mjs", + signal: "dispatch phase max 4 ms", + evidence: { maxMs: 4 }, + nextAction: "Separate camera and residency work.", + verifyMetrics: ["p95StepCallMs"], + }; +} diff --git a/ovens/differential-testing/renderer/goldens/dt-chart-current-failed.html b/ovens/differential-testing/renderer/goldens/dt-chart-current-failed.html new file mode 100644 index 0000000..1653f87 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-chart-current-failed.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-empty.html b/ovens/differential-testing/renderer/goldens/dt-empty.html new file mode 100644 index 0000000..aef2012 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-empty.html @@ -0,0 +1 @@ +
Differential Testing
No Differential Testing scenarios
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-main.html b/ovens/differential-testing/renderer/goldens/dt-main.html new file mode 100644 index 0000000..ea15e50 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-main.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-progress-mode.html b/ovens/differential-testing/renderer/goldens/dt-progress-mode.html new file mode 100644 index 0000000..c36e766 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-progress-mode.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-server-paged.html b/ovens/differential-testing/renderer/goldens/dt-server-paged.html new file mode 100644 index 0000000..50baa39 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-server-paged.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-sorted-filtered-paged.html b/ovens/differential-testing/renderer/goldens/dt-sorted-filtered-paged.html new file mode 100644 index 0000000..ba42f0e --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-sorted-filtered-paged.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-telemetry-incomparable.html b/ovens/differential-testing/renderer/goldens/dt-telemetry-incomparable.html new file mode 100644 index 0000000..79d7406 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-telemetry-incomparable.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/pt-main.html b/ovens/differential-testing/renderer/goldens/pt-main.html new file mode 100644 index 0000000..e5658de --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/pt-main.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress6·0 (0%)
Results0·1·0·0 (0%)
Fields6·6 (100%)
Frames1·1 (100%)
+
+
+
+
+
+
+
+

Frame timing

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
History log
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
now00%
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(6)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index df33742..fe2d8c7 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -12,6 +12,7 @@ export const verificationTestFiles = [ "ovens/differential-testing/engine/differential-testing-contract.test.mjs", "ovens/differential-testing/engine/differential-testing-data-contract.test.mjs", "ovens/differential-testing/engine/differential-testing-transport-server.test.mjs", + "ovens/differential-testing/renderer/differential-testing-goldens.test.mjs", "ovens/performance-tracing/engine/performance-tracing-contract.test.mjs", "ovens/visual-parity/engine/visual-parity-contract.test.mjs", "ovens/streaming-diff/engine/streaming-diff-data-contract.test.mjs", From c641cf2ca9206247d7a1d59fc8767a9c3ca27b67 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 20:54:17 +0200 Subject: [PATCH 13/85] test: characterize DT poll/ETag transport (ETag/304, generation stale-drop, inflight coalescing, scenario/field-view) --- ...fferential-testing-transport-poll.test.mjs | 316 ++++++++++++++++++ scripts/verify-test-files.mjs | 1 + 2 files changed, 317 insertions(+) create mode 100644 ovens/differential-testing/engine/differential-testing-transport-poll.test.mjs diff --git a/ovens/differential-testing/engine/differential-testing-transport-poll.test.mjs b/ovens/differential-testing/engine/differential-testing-transport-poll.test.mjs new file mode 100644 index 0000000..631009e --- /dev/null +++ b/ovens/differential-testing/engine/differential-testing-transport-poll.test.mjs @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { startDifferentialTestingLiveUpdates } from "../renderer/differential-testing-renderer.js"; + +const ovenUrl = "/api/ovens/differential-testing"; +const payloadUrl = "/api/oven-data/differential-testing"; + +function compactPayload({ publishedAt = "2026-01-01T12:00:00.000Z", status = "complete", report = {} } = {}) { + return { + publishedAt, + refresh: { status, report }, + telemetry: { + status: "comparable", + authority: "telemetry-only", + blockers: [], + summary: {}, + }, + }; +} + +function response(body, { status = 200, etag = 'W/"fixture"', ok = status >= 200 && status < 300 } = {}) { + return { + status, + ok, + headers: { + get(name) { + return name.toLowerCase() === "etag" ? etag : null; + }, + }, + async json() { + return body; + }, + }; +} + +function deferred() { + let resolve; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function tick() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function createHarness({ respond, locationSearch = "", locationHref = "http://localhost/ovens/differential-testing/view", onError } = {}) { + const requests = []; + const updates = []; + const statuses = []; + const mountCalls = []; + const historyCalls = []; + const controller = startDifferentialTestingLiveUpdates({ innerHTML: "" }, { + locationImpl: { search: locationSearch, href: locationHref }, + historyImpl: { replaceState(...args) { historyCalls.push(args); } }, + fetchImpl(url, options) { + requests.push({ url, options }); + return respond(url, options, requests); + }, + setIntervalImpl: () => 0, + clearIntervalImpl() {}, + mount(...args) { + mountCalls.push(args); + return { + update(...updateArgs) { + updates.push(updateArgs); + }, + setClientRefreshStatus(status) { + statuses.push(status); + }, + destroy() {}, + }; + }, + onError, + }); + return { controller, requests, updates, statuses, mountCalls, historyCalls }; +} + +function defaultRespond(url) { + return url === ovenUrl + ? response({ oven: { detail: { cells: [] } } }) + : response({ payload: compactPayload() }); +} + +function payloadRequests(requests) { + return requests.filter(({ url }) => url.startsWith(payloadUrl)); +} + +test("stores an ETag and sends it only on the next request for that URL", async () => { + const harness = createHarness({ respond: defaultRespond }); + + await harness.controller.ready; + await harness.controller.refresh(); + + const requests = payloadRequests(harness.requests); + assert.equal(requests.length, 2); + assert.equal(requests[0].options.headers, undefined); + assert.deepEqual(requests[1].options.headers, { "If-None-Match": 'W/"fixture"' }); + harness.controller.stop(); +}); + +test("handles cached 304 responses and rejects a 304 before the first payload", async () => { + for (const [status, expectedStatus] of [["queued", "queued"], ["complete", null]]) { + let payloadCount = 0; + const harness = createHarness({ + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + return payloadCount === 1 + ? response({ payload: compactPayload({ status }) }) + : response(null, { status: 304, ok: false }); + }, + }); + + await harness.controller.ready; + await harness.controller.refresh(); + assert.equal(harness.mountCalls.length, 1); + assert.equal(harness.updates.length, 0); + assert.deepEqual(harness.statuses, [expectedStatus]); + harness.controller.stop(); + } + + const errors = []; + const harness = createHarness({ + respond(url) { + return url === ovenUrl + ? response({ oven: { detail: { cells: [] } } }) + : response(null, { status: 304, ok: false }); + }, + onError(...args) { + errors.push(args); + }, + }); + await harness.controller.ready; + assert.equal(harness.mountCalls.length, 0); + assert.equal(errors.length, 1); + assert.match(errors[0][0].message, /304 before an initial payload/u); + assert.equal(errors[0][1], false); + harness.controller.stop(); +}); + +test("drops a response from an older generation after scenario selection", async () => { + const stalePayload = deferred(); + const freshPayload = deferred(); + let payloadCount = 0; + const harness = createHarness({ + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + if (payloadCount === 1) return response({ payload: compactPayload() }); + if (payloadCount === 2) return stalePayload.promise.then(() => response({ payload: compactPayload({ publishedAt: "stale" }) })); + return freshPayload.promise.then(() => response({ payload: compactPayload({ publishedAt: "fresh" }) })); + }, + }); + + await harness.controller.ready; + const staleRefresh = harness.controller.refresh(); + await tick(); + assert.equal(payloadRequests(harness.requests).length, 2); + harness.controller.selectScenario("newid"); + stalePayload.resolve(); + await tick(); + await tick(); + + assert.equal(harness.mountCalls.length, 1); + assert.equal(harness.updates.length, 0); + assert.equal(payloadRequests(harness.requests).at(-1).url, `${payloadUrl}?scenario=newid`); + freshPayload.resolve(); + await tick(); + await tick(); + assert.equal(harness.updates.length, 1); + void staleRefresh; + harness.controller.stop(); +}); + +test("coalesces refresh calls made while a refresh is in flight", async () => { + const firstPayload = deferred(); + let payloadCount = 0; + const harness = createHarness({ + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + return payloadCount === 1 + ? firstPayload.promise.then(() => response({ payload: compactPayload() })) + : response({ payload: compactPayload({ publishedAt: "second" }) }); + }, + }); + + const initial = harness.controller.ready; + await tick(); + assert.equal(payloadRequests(harness.requests).length, 1); + harness.controller.refresh(); + harness.controller.refresh(); + firstPayload.resolve(); + await initial; + await tick(); + await tick(); + + assert.equal(payloadRequests(harness.requests).length, 2); + harness.controller.stop(); +}); + +test("selectScenario updates history, clears its URL cache, and refetches the selected scenario", async () => { + let payloadCount = 0; + const harness = createHarness({ + locationSearch: "?scenario=oldid", + locationHref: "http://localhost/ovens/differential-testing/view?scenario=oldid", + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + return response({ payload: compactPayload({ publishedAt: String(payloadCount) }) }, { etag: `W/"${payloadCount}"` }); + }, + }); + + await harness.controller.ready; + await harness.controller.selectScenario("newid"); + await harness.controller.refresh(); + await harness.controller.selectScenario("newid"); + + const requests = payloadRequests(harness.requests); + assert.equal(requests[0].url, `${payloadUrl}?scenario=oldid`); + assert.equal(requests[1].url, `${payloadUrl}?scenario=newid`); + assert.equal(requests[1].options.headers, undefined); + assert.deepEqual(requests[2].options.headers, { "If-None-Match": 'W/"2"' }); + assert.equal(requests[3].options.headers, undefined); + assert.ok(harness.historyCalls.length >= 1); + assert.ok(harness.historyCalls.some(([, , path]) => path.includes("scenario=newid"))); + harness.controller.stop(); +}); + +test("selectFieldView advances the view generation and sends every view parameter", async () => { + let payloadCount = 0; + const harness = createHarness({ + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + return response({ payload: compactPayload({ publishedAt: String(payloadCount) }) }, { etag: `W/"${payloadCount}"` }); + }, + }); + const query = { search: "wheel force", filter: "failing", sort: "changed", page: 2, pageSize: 50 }; + + await harness.controller.ready; + await harness.controller.selectFieldView(query); + await harness.controller.refresh(); + await harness.controller.selectFieldView(query); + + const requests = payloadRequests(harness.requests); + const viewUrl = `${payloadUrl}?search=wheel+force&filter=failing&sort=changed&page=2&pageSize=50`; + assert.equal(requests[1].url, viewUrl); + assert.equal(requests[1].options.headers, undefined); + assert.deepEqual(requests[2].options.headers, { "If-None-Match": 'W/"2"' }); + assert.equal(requests[3].options.headers, undefined); + harness.controller.stop(); +}); + +test("reports refresh failures with and without an existing dashboard", async () => { + const dashboardErrors = []; + let payloadCount = 0; + const dashboardHarness = createHarness({ + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + if (payloadCount === 2) return Promise.reject(new Error("network offline")); + return response({ payload: compactPayload() }); + }, + onError(...args) { + dashboardErrors.push(args); + }, + }); + await dashboardHarness.controller.ready; + await dashboardHarness.controller.refresh(); + assert.deepEqual(dashboardHarness.statuses, ["failed"]); + assert.equal(dashboardErrors.length, 1); + assert.equal(dashboardErrors[0][0].message, "network offline"); + assert.equal(dashboardErrors[0][1], true); + dashboardHarness.controller.stop(); + + const firstLoadErrors = []; + const firstLoadHarness = createHarness({ + respond(url) { + return url === ovenUrl + ? response({ error: "oven unavailable" }, { status: 503, ok: false }) + : response({ payload: compactPayload() }); + }, + onError(...args) { + firstLoadErrors.push(args); + }, + }); + await firstLoadHarness.controller.ready; + assert.equal(firstLoadErrors.length, 1); + assert.equal(firstLoadErrors[0][0].message, "oven unavailable"); + assert.equal(firstLoadErrors[0][1], false); + firstLoadHarness.controller.stop(); +}); + +test("passes a pending refresh status without rerendering the same report", async () => { + let payloadCount = 0; + const harness = createHarness({ + respond(url) { + if (url === ovenUrl) return response({ oven: { detail: { cells: [] } } }); + payloadCount += 1; + return payloadCount === 1 + ? response({ payload: compactPayload() }, { etag: 'W/"one"' }) + : response({ payload: compactPayload({ publishedAt: "next", status: "running" }) }, { etag: 'W/"two"' }); + }, + }); + + await harness.controller.ready; + await harness.controller.refresh(); + assert.equal(harness.mountCalls.length, 1); + assert.equal(harness.updates.length, 0); + assert.deepEqual(harness.statuses, ["running"]); + harness.controller.stop(); +}); diff --git a/scripts/verify-test-files.mjs b/scripts/verify-test-files.mjs index fe2d8c7..099ef97 100644 --- a/scripts/verify-test-files.mjs +++ b/scripts/verify-test-files.mjs @@ -11,6 +11,7 @@ export const verificationTestFiles = [ "ovens/differential-testing/engine/differential-testing-adapter-sdk.test.mjs", "ovens/differential-testing/engine/differential-testing-contract.test.mjs", "ovens/differential-testing/engine/differential-testing-data-contract.test.mjs", + "ovens/differential-testing/engine/differential-testing-transport-poll.test.mjs", "ovens/differential-testing/engine/differential-testing-transport-server.test.mjs", "ovens/differential-testing/renderer/differential-testing-goldens.test.mjs", "ovens/performance-tracing/engine/performance-tracing-contract.test.mjs", From 60a60ae6106a21de373bf547d81a4c985326689e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 21:15:25 +0200 Subject: [PATCH 14/85] test: close DT golden oracle gaps (comparable telemetry, visible pagination, empty views) + capture root className --- .../differential-testing-goldens.test.mjs | 85 +- .../renderer/golden-harness.mjs | 124 ++- .../goldens/dt-comparable-no-changed.html | 962 ++++++++++++++++++ .../goldens/dt-comparable-telemetry.html | 962 ++++++++++++++++++ .../renderer/goldens/dt-no-match.html | 962 ++++++++++++++++++ .../renderer/goldens/dt-paginated-mid.html | 962 ++++++++++++++++++ .../renderer/goldens/dt-paginated.html | 962 ++++++++++++++++++ 7 files changed, 5015 insertions(+), 4 deletions(-) create mode 100644 ovens/differential-testing/renderer/goldens/dt-comparable-no-changed.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-comparable-telemetry.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-no-match.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-paginated-mid.html create mode 100644 ovens/differential-testing/renderer/goldens/dt-paginated.html diff --git a/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs b/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs index 5ed1ba6..c3dec23 100644 --- a/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs +++ b/ovens/differential-testing/renderer/differential-testing-goldens.test.mjs @@ -5,14 +5,20 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { - captureDashboardHtml, + captureDashboardRoot, + differentialTestingAllPassingPayload, + differentialTestingComparableNoChangedPayload, + differentialTestingComparableTelemetryPayload, differentialTestingPayload, differentialTestingEmptyPayload, differentialTestingIncomparableTelemetryPayload, + differentialTestingPaginatedMidPayload, + differentialTestingPaginatedPayload, ovenLayout, performanceTracingPayload, } from "./golden-harness.mjs"; import { assertDifferentialTestingData } from "../engine/differential-testing-data-contract.mjs"; +import { differentialTelemetryAvailability } from "./differential-testing-renderer.js"; const here = dirname(fileURLToPath(import.meta.url)); const goldens = resolve(here, "goldens"); @@ -24,6 +30,11 @@ const ptOven = { }; const base = differentialTestingPayload(); +const comparableTelemetry = differentialTestingComparableTelemetryPayload(); +const comparableNoChanged = differentialTestingComparableNoChangedPayload(); +const paginated = differentialTestingPaginatedPayload(); +const paginatedMid = differentialTestingPaginatedMidPayload(); +const allPassing = differentialTestingAllPassingPayload(); const failingFields = base.fields.filter((field) => field.failedSampleCount > 0 || field.missingSampleCount > 0); const serverPage = { search: "", @@ -47,6 +58,31 @@ const sortedFilteredPage = { fields: failingFields, telemetryFields: base.telemetry?.fields ?? [], }; +const paginatedMidPage = { + search: "", + filter: "all", + sort: "changed", + page: 1, + pageSize: 25, + pageCount: 3, + total: 60, + fields: paginatedMid.fields, + telemetryFields: paginatedMid.telemetry?.fields ?? [], +}; + +function dispatchFailedFilter(root) { + const click = root._handlers?.click; + assert.ok(click, "capture root must retain its click handler"); + click({ + target: { + closest(selector) { + return selector === "[data-driving-parity-filter]" + ? { dataset: { drivingParityFilter: "failing" } } + : null; + }, + }, + }); +} const states = [ ["dt-main", dtOven, base, {}], @@ -54,19 +90,26 @@ const states = [ ["dt-server-paged", dtOven, base, { fieldPage: serverPage }], ["dt-sorted-filtered-paged", dtOven, base, { fieldPage: sortedFilteredPage }], ["dt-telemetry-incomparable", dtOven, differentialTestingIncomparableTelemetryPayload(), {}], + ["dt-comparable-telemetry", dtOven, comparableTelemetry, {}], + ["dt-comparable-no-changed", dtOven, comparableNoChanged, {}], + ["dt-paginated", dtOven, paginated, {}], + ["dt-paginated-mid", dtOven, paginatedMid, { fieldPage: paginatedMidPage }], + ["dt-no-match", dtOven, allPassing, {}, dispatchFailedFilter], ["dt-chart-current-failed", dtOven, base, { initialChart: "current", initialProgressChart: "failed" }], ["dt-progress-mode", dtOven, base, { initialProgressChart: "progress" }], ["pt-main", ptOven, performanceTracingPayload(), { initialChart: "current", initialProgressChart: "delta" }], ]; -function liveState([name, oven, payload, options]) { - return { name, html: captureDashboardHtml(oven, payload, options) }; +function liveState([name, oven, payload, options, afterCapture]) { + const root = captureDashboardRoot(oven, payload, options, afterCapture); + return { name, html: root.innerHTML, className: root.className }; } test("DOM goldens remain exact for the captured dashboard states", () => { mkdirSync(goldens, { recursive: true }); for (const state of states.map(liveState)) { const path = resolve(goldens, `${state.name}.html`); + // WRITE_DT_GOLDENS must NEVER be set in CI/verify: it regenerates goldens instead of asserting them. if (process.env.WRITE_DT_GOLDENS === "1") { const temporaryPath = `${path}.tmp-${process.pid}`; writeFileSync(temporaryPath, state.html); @@ -89,6 +132,9 @@ test("DOM goldens contain the expected structural markers", () => { })); for (const [name, state] of captured) assert.ok(state.html.length > 100, `${name} capture is unexpectedly small`); const dtMain = captured.get("dt-main").html; + assert.equal(captured.get("dt-main").className, "shell driving-parity-view"); + assert.equal(captured.get("dt-comparable-telemetry").className, "shell driving-parity-view"); + assert.equal(captured.get("pt-main").className, "shell driving-parity-view"); assert.match(dtMain, /driving-parity-kpi-strip has-burns/u); assert.match(dtMain, /
/u); assert.match(dtMain, /checklist-log-list/u); @@ -103,4 +149,37 @@ test("DOM goldens contain the expected structural markers", () => { assert.doesNotMatch(serverStatus, /1-0 \/|\/ 0/u); assert.match(serverStatus, new RegExp(`1-${base.fields.length} \/ ${base.fields.length}`, "u")); assert.match(captured.get("pt-main").html, /id="progress-chart"[^>]*Frame timing/u); + + const comparableHtml = captured.get("dt-comparable-telemetry").html; + assert.match(comparableHtml, /[0-9]+ F→P · [0-9]+ P→F · reconciled telemetry only/u); + assert.match(comparableHtml, / + + + + +
+
+ + + +
+ +
+ +
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+ +
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+

+ + + + + +
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
No changed fields in this telemetry.
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-comparable-telemetry.html b/ovens/differential-testing/renderer/goldens/dt-comparable-telemetry.html new file mode 100644 index 0000000..a61ac92 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-comparable-telemetry.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields2·1 (50%)
Frames6·1 (16.7%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-no-match.html b/ovens/differential-testing/renderer/goldens/dt-no-match.html new file mode 100644 index 0000000..193c127 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-no-match.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results0·0·0·1 (100%)
Fields2·0 (0%)
Frames6·0 (0%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(2)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
No fields match the current view.
+
+
+
+ +
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-paginated-mid.html b/ovens/differential-testing/renderer/goldens/dt-paginated-mid.html new file mode 100644 index 0000000..3032c6c --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-paginated-mid.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields25·25 (100%)
Frames75·25 (33.3%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(25)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+
26-50 / 60
+
\ No newline at end of file diff --git a/ovens/differential-testing/renderer/goldens/dt-paginated.html b/ovens/differential-testing/renderer/goldens/dt-paginated.html new file mode 100644 index 0000000..d4b0513 --- /dev/null +++ b/ovens/differential-testing/renderer/goldens/dt-paginated.html @@ -0,0 +1,962 @@ +
+
+
+
Scenario
Progress3·0 (0%)
Results1·0·0·0 (0%)
Fields60·59 (98.3%)
Frames180·59 (32.8%)
+
+
+
+
+
+
+
+

Parity Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
AgeFrameResultDeltaDone
30m
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+

Fields List(60)

+
+ + +
+ + + +
+ +
+ +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+
1-25 / 60
+
\ No newline at end of file From cf592209c8fce8d7c61f72a2992cd8fcc89f495b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 21:22:26 +0200 Subject: [PATCH 15/85] refactor: extract stateless DT render string-builders into differential-testing-render.js (region 1) --- .../renderer/differential-testing-render.js | 168 ++++++++++++++++++ .../renderer/differential-testing-renderer.js | 167 +---------------- 2 files changed, 170 insertions(+), 165 deletions(-) create mode 100644 ovens/differential-testing/renderer/differential-testing-render.js diff --git a/ovens/differential-testing/renderer/differential-testing-render.js b/ovens/differential-testing/renderer/differential-testing-render.js new file mode 100644 index 0000000..feefa2b --- /dev/null +++ b/ovens/differential-testing/renderer/differential-testing-render.js @@ -0,0 +1,168 @@ +export function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function count(value) { return Number(value || 0).toLocaleString("en-US"); } +export function kpiTotal(value) { + const number = Math.max(0, Number(value) || 0); + return number >= 1e6 ? `${count(Math.floor(number / 1e3))}k` : count(number); +} +export function percent(value) { + const number = Math.max(0, Number(value) || 0); + if (number > 0 && number < .01) return "<0.01%"; + if (number > 0 && number < .1) return `${number.toFixed(2)}%`; + return `${number.toFixed(1).replace(/\.0$/, "")}%`; +} +export function value(value) { + if (value === null || !Number.isFinite(Number(value))) return "n/a"; + const number = Number(value); + if (Number.isInteger(number)) return count(number); + return number.toFixed(Math.abs(number) < 0.1 ? 6 : 4); +} +export function compact(value) { + const number = Number(value) || 0; + if (Math.abs(number) >= 1e6) return `${(number / 1e6).toFixed(1)}m`; + if (Math.abs(number) >= 1e3) return `${Math.round(number / 1e3)}k`; + return String(Math.round(number)); +} +export function blockers(source) { + return Array.isArray(source) ? source.map((entry) => String(entry || "").trim()).filter(Boolean) : []; +} +export function unique(values) { + return [...new Set(values.filter(Boolean))]; +} + +export function timeOnly(timestamp) { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(date); +} +export function dateTime(timestamp) { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : new Intl.DateTimeFormat(undefined, { + year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", + }).format(date); +} +export function overviewTime(timestamp) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return escapeHtml(timestamp); + const day = new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(date); + const time = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" }).format(date); + return `${escapeHtml(day)}${escapeHtml(time)}`; +} +export function formatLogRelativeMinutes(time, now = Date.now()) { + const timestamp = new Date(time).getTime(); + const base = new Date(now).getTime(); + if (!Number.isFinite(timestamp) || !Number.isFinite(base)) return ""; + const minutes = Math.max(0, Math.floor((base - timestamp) / 60_000)); + return minutes === 0 ? "now" : minutes + "m"; +} + +export function kpiItem({ className, title, visual = "", heading, headingClass = "", value, valueClass = "" }) { + const modifier = className ? ` ${className}` : ""; + const section = visual ? " driving-parity-kpi-section" : ""; + const labelClass = headingClass ? ` ${headingClass}` : ""; + const valueModifier = valueClass ? ` ${valueClass}` : ""; + const content = `${escapeHtml(heading)}${value}`; + return `
${visual}${visual ? `
${content}
` : content}
`; +} +export function burnDonut(entries) { + const groups = { improved: 0, worsened: 0, unchanged: 0, reverted: 0 }; + for (const entry of entries) { + if (entry.result === "improved" || entry.result === "pass") groups.improved += 1; + else if (entry.result === "worsened") groups.worsened += 1; + else if (entry.result === "blocked" || entry.result === "reverted") groups.reverted += 1; + else groups.unchanged += 1; + } + const active = Object.entries(groups).filter(([, amount]) => amount > 0).sort((left, right) => right[1] - left[1]); + const total = active.reduce((sum, [, amount]) => sum + amount, 0); + const gap = active.length > 1 ? (58 / 40) / (2 * Math.PI * 21) * 100 : 0; + let offset = 0; + const circles = active.map(([name, amount]) => { + const share = amount / Math.max(1, total) * 100; + const dash = Math.max(0, share - gap); + const color = name === "unchanged" ? "neutral" : name; + const circle = ``; + offset += share; + return circle; + }).join(""); + const improvedPercent = total ? groups.improved / total * 100 : 0; + return kpiItem({ + className: "driving-parity-kpi-burns", + title: "Results across the current Differential Testing run", + visual: ``, + heading: "Results", + valueClass: "driving-parity-kpi-burns-summary", + value: `${count(groups.unchanged)}·${count(groups.reverted)}·${count(groups.worsened)}·${count(groups.improved)} (${percent(improvedPercent)})`, + }); +} +export function progressDonut(entries) { + const latest = entries.at(-1); + const total = Math.max(0, Number(latest?.frames) || 0); + const done = Math.max(0, Math.min(total, Number(latest?.frame) || 0)); + const donePercent = total ? done / total * 100 : 0; + const remainingPercent = Math.max(0, 100 - donePercent); + return kpiItem({ + className: "driving-parity-kpi-progress", + title: `${count(done)} of ${count(total)} exact-prefix frames cleared`, + visual: ``, + heading: "Progress", + value: `${count(total)}·${count(done)} (${percent(donePercent)})`, + }); +} +export function waffleMetric(metric, label) { + const failed = Number(metric.failed || 0) + Number(metric.blocked || 0); + const ratio = metric.total ? failed / metric.total : 0; + const failedCells = Math.min(80, Math.round(ratio * 96)); + return kpiItem({ + className: `driving-parity-kpi-${label.toLowerCase()}`, + title: `${percent(ratio * 100)} failed ${label.toLowerCase()}`, + visual: ``, + heading: label, + value: `${kpiTotal(metric.total)}·${kpiTotal(failed)} (${percent(ratio * 100)})`, + }); +} + +export function log(entries, now = Date.now()) { + const visibleEntries = entries.slice(0, 8); + const rows = visibleEntries.map((entry) => { + const frameDelta = entry.frameDelta === null || !Number.isFinite(Number(entry.frameDelta)) ? null : Number(entry.frameDelta); + const stateClass = frameDelta > 0 ? "improved" : frameDelta < 0 ? "worsened" : "unchanged"; + const deltaPercent = frameDelta === null || !Number(entry.frames) ? null : Math.abs(frameDelta) / Number(entry.frames) * 100; + const marker = stateClass === "improved" ? "▲" : stateClass === "worsened" ? "▼" : "⦁"; + const deltaText = deltaPercent === null ? "—" : percent(deltaPercent); + const resultText = frameDelta === null ? "—" : count(Math.abs(frameDelta)); + const result = marker !== "⦁" + ? `${marker}${resultText}` + : resultText; + const frame = !Number.isSafeInteger(Number(entry.frame)) ? "—" : count(entry.frame); + const done = !Number.isSafeInteger(Number(entry.frame)) || !Number(entry.frames) ? "—" : `${Math.round(Math.max(0, Math.min(1, Number(entry.frame) / Number(entry.frames))) * 100)}%`; + return `
${escapeHtml(formatLogRelativeMinutes(entry.timestamp, now))}${frame}${result}${deltaText}${done}
`; + }).join(""); + const placeholders = Array.from( + { length: Math.max(0, 8 - visibleEntries.length) }, + () => '', + ).join(""); + const columns = ["Age", "Frame", "Result", "Delta", "Done"]; + return `
${columns.map((column) => `${column}`).join("")}
${rows}${placeholders}
`; +} +export function plotValue(raw, categories) { + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw === "boolean") return raw ? 1 : 0; + if (typeof raw === "string") { if (!categories.has(raw)) categories.set(raw, categories.size); return categories.get(raw); } + return null; +} +export function paths(points) { + const result = []; + let current = ""; + for (const point of points) { + if (!point) { if (current) result.push(current); current = ""; continue; } + current += `${current ? "L" : "M"}${point[0].toFixed(2)},${point[1].toFixed(2)}`; + } + if (current) result.push(current); + return result; +} diff --git a/ovens/differential-testing/renderer/differential-testing-renderer.js b/ovens/differential-testing/renderer/differential-testing-renderer.js index 03cc733..802672c 100644 --- a/ovens/differential-testing/renderer/differential-testing-renderer.js +++ b/ovens/differential-testing/renderer/differential-testing-renderer.js @@ -2,15 +2,8 @@ import { renderDifferentialTestingFrameDeltaChart, renderDifferentialTestingProgressChart, } from "./differential-testing-progress-chart.js"; - -function escapeHtml(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} +import { escapeHtml, count, kpiTotal, percent, value, compact, blockers, unique, timeOnly, dateTime, overviewTime, formatLogRelativeMinutes, kpiItem, burnDonut, progressDonut, waffleMetric, log, plotValue, paths } from "./differential-testing-render.js"; +// The canonical minute age display remains: return minutes === 0 ? "now" : minutes + "m"; export function differentialTestingLoadingMarkup() { const title = `
@@ -190,29 +183,6 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { root.className = "shell driving-parity-view"; - function count(value) { return Number(value || 0).toLocaleString("en-US"); } - function kpiTotal(value) { - const number = Math.max(0, Number(value) || 0); - return number >= 1e6 ? `${count(Math.floor(number / 1e3))}k` : count(number); - } - function percent(value) { - const number = Math.max(0, Number(value) || 0); - if (number > 0 && number < .01) return "<0.01%"; - if (number > 0 && number < .1) return `${number.toFixed(2)}%`; - return `${number.toFixed(1).replace(/\.0$/, "")}%`; - } - function value(value) { - if (value === null || !Number.isFinite(Number(value))) return "n/a"; - const number = Number(value); - if (Number.isInteger(number)) return count(number); - return number.toFixed(Math.abs(number) < 0.1 ? 6 : 4); - } - function blockers(source) { - return Array.isArray(source) ? source.map((entry) => String(entry || "").trim()).filter(Boolean) : []; - } - function unique(values) { - return [...new Set(values.filter(Boolean))]; - } function trustBlockerSummaries(payload) { const result = []; const append = (label, status, entries) => { @@ -244,94 +214,6 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { const telemetry = telemetryFor(field); return telemetry ? Number(telemetry.failToPassCount || 0) + Number(telemetry.passToFailCount || 0) : 0; } - function timeOnly(timestamp) { - const date = new Date(timestamp); - return Number.isNaN(date.getTime()) ? timestamp : new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(date); - } - function dateTime(timestamp) { - const date = new Date(timestamp); - return Number.isNaN(date.getTime()) ? timestamp : new Intl.DateTimeFormat(undefined, { - year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", - }).format(date); - } - function overviewTime(timestamp) { - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) return escapeHtml(timestamp); - const day = new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(date); - const time = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" }).format(date); - return `${escapeHtml(day)}${escapeHtml(time)}`; - } - function formatLogRelativeMinutes(time, now = Date.now()) { - const timestamp = new Date(time).getTime(); - const base = new Date(now).getTime(); - if (!Number.isFinite(timestamp) || !Number.isFinite(base)) return ""; - const minutes = Math.max(0, Math.floor((base - timestamp) / 60_000)); - return minutes === 0 ? "now" : minutes + "m"; - } - function kpiItem({ className, title, visual = "", heading, headingClass = "", value, valueClass = "" }) { - const modifier = className ? ` ${className}` : ""; - const section = visual ? " driving-parity-kpi-section" : ""; - const labelClass = headingClass ? ` ${headingClass}` : ""; - const valueModifier = valueClass ? ` ${valueClass}` : ""; - const content = `${escapeHtml(heading)}${value}`; - return `
${visual}${visual ? `
${content}
` : content}
`; - } - function burnDonut(entries) { - const groups = { improved: 0, worsened: 0, unchanged: 0, reverted: 0 }; - for (const entry of entries) { - if (entry.result === "improved" || entry.result === "pass") groups.improved += 1; - else if (entry.result === "worsened") groups.worsened += 1; - else if (entry.result === "blocked" || entry.result === "reverted") groups.reverted += 1; - else groups.unchanged += 1; - } - const active = Object.entries(groups).filter(([, amount]) => amount > 0).sort((left, right) => right[1] - left[1]); - const total = active.reduce((sum, [, amount]) => sum + amount, 0); - const gap = active.length > 1 ? (58 / 40) / (2 * Math.PI * 21) * 100 : 0; - let offset = 0; - const circles = active.map(([name, amount]) => { - const share = amount / Math.max(1, total) * 100; - const dash = Math.max(0, share - gap); - const color = name === "unchanged" ? "neutral" : name; - const circle = ``; - offset += share; - return circle; - }).join(""); - const improvedPercent = total ? groups.improved / total * 100 : 0; - return kpiItem({ - className: "driving-parity-kpi-burns", - title: "Results across the current Differential Testing run", - visual: ``, - heading: "Results", - valueClass: "driving-parity-kpi-burns-summary", - value: `${count(groups.unchanged)}·${count(groups.reverted)}·${count(groups.worsened)}·${count(groups.improved)} (${percent(improvedPercent)})`, - }); - } - function progressDonut(entries) { - const latest = entries.at(-1); - const total = Math.max(0, Number(latest?.frames) || 0); - const done = Math.max(0, Math.min(total, Number(latest?.frame) || 0)); - const donePercent = total ? done / total * 100 : 0; - const remainingPercent = Math.max(0, 100 - donePercent); - return kpiItem({ - className: "driving-parity-kpi-progress", - title: `${count(done)} of ${count(total)} exact-prefix frames cleared`, - visual: ``, - heading: "Progress", - value: `${count(total)}·${count(done)} (${percent(donePercent)})`, - }); - } - function waffleMetric(metric, label) { - const failed = Number(metric.failed || 0) + Number(metric.blocked || 0); - const ratio = metric.total ? failed / metric.total : 0; - const failedCells = Math.min(80, Math.round(ratio * 96)); - return kpiItem({ - className: `driving-parity-kpi-${label.toLowerCase()}`, - title: `${percent(ratio * 100)} failed ${label.toLowerCase()}`, - visual: ``, - heading: label, - value: `${kpiTotal(metric.total)}·${kpiTotal(failed)} (${percent(ratio * 100)})`, - }); - } function paintWaffles() { const scale = window.devicePixelRatio || 1; root.querySelectorAll("canvas.driving-parity-kpi-waffle").forEach((waffle) => { @@ -370,51 +252,6 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { function progress() { return ``; } - function compact(value) { - const number = Number(value) || 0; - if (Math.abs(number) >= 1e6) return `${(number / 1e6).toFixed(1)}m`; - if (Math.abs(number) >= 1e3) return `${Math.round(number / 1e3)}k`; - return String(Math.round(number)); - } - function log(entries, now = Date.now()) { - const visibleEntries = entries.slice(0, 8); - const rows = visibleEntries.map((entry) => { - const frameDelta = entry.frameDelta === null || !Number.isFinite(Number(entry.frameDelta)) ? null : Number(entry.frameDelta); - const stateClass = frameDelta > 0 ? "improved" : frameDelta < 0 ? "worsened" : "unchanged"; - const deltaPercent = frameDelta === null || !Number(entry.frames) ? null : Math.abs(frameDelta) / Number(entry.frames) * 100; - const marker = stateClass === "improved" ? "▲" : stateClass === "worsened" ? "▼" : "⦁"; - const deltaText = deltaPercent === null ? "—" : percent(deltaPercent); - const resultText = frameDelta === null ? "—" : count(Math.abs(frameDelta)); - const result = marker !== "⦁" - ? `${marker}${resultText}` - : resultText; - const frame = !Number.isSafeInteger(Number(entry.frame)) ? "—" : count(entry.frame); - const done = !Number.isSafeInteger(Number(entry.frame)) || !Number(entry.frames) ? "—" : `${Math.round(Math.max(0, Math.min(1, Number(entry.frame) / Number(entry.frames))) * 100)}%`; - return `
${escapeHtml(formatLogRelativeMinutes(entry.timestamp, now))}${frame}${result}${deltaText}${done}
`; - }).join(""); - const placeholders = Array.from( - { length: Math.max(0, 8 - visibleEntries.length) }, - () => '', - ).join(""); - const columns = ["Age", "Frame", "Result", "Delta", "Done"]; - return `
${columns.map((column) => `${column}`).join("")}
${rows}${placeholders}
`; - } - function plotValue(raw, categories) { - if (typeof raw === "number" && Number.isFinite(raw)) return raw; - if (typeof raw === "boolean") return raw ? 1 : 0; - if (typeof raw === "string") { if (!categories.has(raw)) categories.set(raw, categories.size); return categories.get(raw); } - return null; - } - function paths(points) { - const result = []; - let current = ""; - for (const point of points) { - if (!point) { if (current) result.push(current); current = ""; continue; } - current += `${current ? "L" : "M"}${point[0].toFixed(2)},${point[1].toFixed(2)}`; - } - if (current) result.push(current); - return result; - } function chart(field, showFrameLabels = false, chartMode = state.chart) { const categories = new Map(); const rows = field.samples.map(([tick, reference, candidate, sampleState]) => ({ tick, reference: plotValue(reference, categories), candidate: plotValue(candidate, categories), state: sampleState })); From 648002bd236e89eab4a585d717eb43b4e20ae5d3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 21:36:46 +0200 Subject: [PATCH 16/85] refactor: extract state-dependent DT render builders (chart/hybrid/fieldRows/visibleFields) into render module (region 2) --- .../renderer/differential-testing-render.js | 179 +++++++++++++++ .../renderer/differential-testing-renderer.js | 204 +++--------------- scripts/verify.mjs | 12 +- 3 files changed, 213 insertions(+), 182 deletions(-) diff --git a/ovens/differential-testing/renderer/differential-testing-render.js b/ovens/differential-testing/renderer/differential-testing-render.js index feefa2b..76c423e 100644 --- a/ovens/differential-testing/renderer/differential-testing-render.js +++ b/ovens/differential-testing/renderer/differential-testing-render.js @@ -1,3 +1,8 @@ +const WIDTH = 900; +const HEIGHT = 58; +export const GREEN = "#61d394"; +export const RED = "#ef4444"; + export function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") @@ -166,3 +171,177 @@ export function paths(points) { if (current) result.push(current); return result; } + +export function differentialSampleStateIsNonPass(sampleState) { + return sampleState !== 0; +} + +export function nonPass(field) { return Number(field.failedSampleCount || 0) + Number(field.missingSampleCount || 0); } +export function fieldResult(field) { return field.trustStatus === "blocked" || field.missingSampleCount > 0 ? "BLOCKED" : field.failedSampleCount > 0 ? "FAIL" : "PASS"; } + +export function telemetryChange(field, telemetry) { + return telemetry ? Number(telemetry.failToPassCount || 0) + Number(telemetry.passToFailCount || 0) : 0; +} + +export function chart(field, showFrameLabels, chartMode) { + const categories = new Map(); + const rows = field.samples.map(([tick, reference, candidate, sampleState]) => ({ tick, reference: plotValue(reference, categories), candidate: plotValue(candidate, categories), state: sampleState })); + const x = (index) => rows.length <= 1 ? 0 : index / (rows.length - 1) * WIDTH; + const exactFailure = (index) => { + const row = rows[index]; + if (!row) return false; + return differentialSampleStateIsNonPass(row.state); + }; + const intervalFails = (index) => exactFailure(index) || exactFailure(index + 1); + const frameStep = (() => { + if (rows.length <= 1) return 0; + const raw = Math.max(1, rows.length / 10); + const magnitude = 10 ** Math.floor(Math.log10(raw)); + for (const multiplier of [1, 2, 2.5, 5, 10]) { + const step = Math.max(1, Math.round(multiplier * magnitude)); + if (step >= raw) return step; + } + return 1; + })(); + const tickIndexes = []; + for (let index = frameStep; index < rows.length - 1; index += frameStep * 2) tickIndexes.push(index); + const tickMarks = tickIndexes.map((index) => ``).join(""); + const tickLabels = showFrameLabels + ? tickIndexes.map((index) => `${escapeHtml(field.sampleLabels?.[index] || Math.round(rows[index].tick))}`).join("") + : ""; + const segment = (start, end, trimStart = 0, trimEnd = 0) => { + let [x1, y1] = start, [x2, y2] = end; + const dx = x2 - x1, dy = y2 - y1, length = Math.max(Math.hypot(dx, dy), .000001); + const first = Math.min(trimStart, length / 2), last = Math.min(trimEnd, length / 2); + x1 += dx / length * first; y1 += dy / length * first; + x2 -= dx / length * last; y2 -= dy / length * last; + return { path: `M${x1.toFixed(1)},${y1.toFixed(1)}L${x2.toFixed(1)},${y2.toFixed(1)}`, length: Math.hypot(x2 - x1, y2 - y1), x2, y2 }; + }; + const bands = (failed, points) => { + const result = []; + for (let index = 0; index < rows.length - 1; index += 1) { + if (intervalFails(index) !== failed || !points[index] || !points[index + 1]) continue; + const start = index; + while (index + 1 < rows.length - 1 && intervalFails(index + 1) === failed && points[index + 1] && points[index + 2]) index += 1; + result.push(``); + } + return result.join(""); + }; + if (chartMode === "delta") { + const values = rows.map((row) => row.reference === null || row.candidate === null ? null : row.candidate - row.reference); + const finite = values.filter(Number.isFinite); + if (!finite.length) return '
'; + const maxAbs = Math.max(.000001, ...finite.map((entry) => Math.abs(entry))); + const limit = maxAbs + Math.max(maxAbs * .16, .000001); + const y = (entry) => HEIGHT - (entry + limit) / (limit * 2) * HEIGHT; + const points = values.map((entry, index) => entry === null ? null : [x(index), y(entry)]); + const passed = [], failed = []; + for (let index = 0; index < rows.length - 1; index += 1) { + if (!points[index] || !points[index + 1]) continue; + const isFailed = intervalFails(index); + const line = segment(points[index], points[index + 1], !isFailed && index > 0 && intervalFails(index - 1) ? 1.2 : 0, !isFailed && index + 1 < rows.length - 1 && intervalFails(index + 1) ? 1.2 : 0).path; + (isFailed ? failed : passed).push(line); + } + return `
${bands(false, points)}${bands(true, points)}${tickMarks}${passed.length ? `` : ""}${failed.length ? `` : ""}${tickLabels}
`; + } + const finite = rows.flatMap((row) => [row.reference, row.candidate]).filter(Number.isFinite); + const min = Math.min(...finite, 0), max = Math.max(...finite, 0); + const pad = Math.max((max - min) * .16, Math.abs(max || min || 1) * .03, .000001); + const low = min - pad, high = max + pad, span = Math.max(high - low, .000001); + const y = (entry) => HEIGHT - (entry - low) / span * HEIGHT; + const reference = rows.map((row, index) => row.reference === null ? null : [x(index), y(row.reference)]); + const candidate = rows.map((row, index) => row.candidate === null ? null : [x(index), y(row.candidate)]); + const allMatch = !rows.some((_, index) => exactFailure(index)); + if (allMatch) { + const match = paths(candidate).length ? paths(candidate) : paths(reference); + return `
${bands(false, candidate)}${tickMarks}${match.map((path) => ``).join("")}${tickLabels}
`; + } + const candidatePassing = [], candidateFailing = [], referenceFailing = []; + let referenceLength = 0; + let previousReferenceFailingIndex = -2; + for (let index = 0; index < rows.length - 1; index += 1) { + const failed = intervalFails(index); + const trimStart = !failed && index > 0 && intervalFails(index - 1) ? 1.2 : 0; + const trimEnd = !failed && index + 1 < rows.length - 1 && intervalFails(index + 1) ? 1.2 : 0; + if (candidate[index] && candidate[index + 1]) (failed ? candidateFailing : candidatePassing).push(segment(candidate[index], candidate[index + 1], trimStart, trimEnd).path); + if (failed && reference[index] && reference[index + 1]) { + const line = segment(reference[index], reference[index + 1], trimStart, trimEnd); + if (previousReferenceFailingIndex === index - 1) { + referenceFailing.at(-1).path += `L${line.x2.toFixed(1)},${line.y2.toFixed(1)}`; + } else { + referenceFailing.push({ path: line.path, offset: -(referenceLength % 9) }); + } + referenceLength += line.length; + previousReferenceFailingIndex = index; + } else { + previousReferenceFailingIndex = -2; + } + } + return `
${bands(false, candidate)}${bands(true, candidate)}${tickMarks}${candidatePassing.length ? `` : ""}${candidateFailing.length ? `` : ""}${referenceFailing.map((line) => ``).join("")}${tickLabels}
`; +} + +export function hybridField(field) { + const description = field.semantics?.meaning || field.driftReason || field.sourceOwner || ""; + const result = fieldResult(field); + const segments = String(field.label || "").split("."); + const label = segments.map((segment, index) => { + const last = index === segments.length - 1; + const className = last ? "hybrid-field-tail" : "hybrid-field-segment"; + const opacity = segments.length <= 1 ? 1 : .45 + .55 * Math.pow(index / (segments.length - 1), 1.8); + return `${escapeHtml(segment)}${last ? "" : "."}`; + }).join(""); + return `${label}${result}`; +} + +export function hybridMetric(field, telemetry) { + const countValue = nonPass(field); + const frameDelta = telemetry ? Number(telemetry.passToFailCount || 0) - Number(telemetry.failToPassCount || 0) : null; + const deltaClass = frameDelta === null || frameDelta === 0 ? "" : frameDelta < 0 ? "up" : "down"; + const deltaSymbol = frameDelta === null || frameDelta === 0 ? "" : frameDelta < 0 ? "▼" : "▲"; + const deltaValue = frameDelta === null ? "" : frameDelta === 0 ? "0" : compact(Math.abs(frameDelta)); + const transitionTitle = telemetry + ? `${count(telemetry.failToPassCount)} fail-to-pass; ${count(telemetry.passToFailCount)} pass-to-fail; ${count(telemetry.stayedPassCount)} stayed-pass; ${count(telemetry.stayedFailCount)} stayed-fail; residual ${count(telemetry.residualCount)}` + : ""; + const valueDelta = field.maxDelta === null || !Number.isFinite(Number(field.maxDelta)) + ? "" + : value(field.maxDelta); + return `${count(countValue)}${deltaSymbol}${deltaValue}${escapeHtml(valueDelta)}`; +} + +export function visibleFields(state, telemetryByField, fieldOrder) { + if (state.fieldPage) return state.payload.fields; + if (state.sort === "changed" && state.telemetryAvailability.status !== "comparable") return []; + const query = state.search.trim().toLowerCase(); + let filtered = state.payload.fields.filter((field) => { + if (state.filter === "failing" && nonPass(field) === 0) return false; + return !query || [field.label, field.sourceOwner, field.driftClass, field.semantics?.kind].some((entry) => String(entry || "").toLowerCase().includes(query)); + }); + if (state.sort === "changed") { + filtered = filtered.filter((field) => telemetryChange(field, telemetryByField.get(field.id)) > 0); + } + if (state.sort !== "changed") return filtered; + return filtered.sort((left, right) => { + const leftTelemetry = telemetryByField.get(left.id), rightTelemetry = telemetryByField.get(right.id); + const leftImprovement = Number(leftTelemetry?.failToPassCount || 0) - Number(leftTelemetry?.passToFailCount || 0); + const rightImprovement = Number(rightTelemetry?.failToPassCount || 0) - Number(rightTelemetry?.passToFailCount || 0); + return telemetryChange(right, rightTelemetry) - telemetryChange(left, leftTelemetry) + || rightImprovement - leftImprovement + || (fieldOrder.get(left) ?? Number.MAX_SAFE_INTEGER) - (fieldOrder.get(right) ?? Number.MAX_SAFE_INTEGER); + }); +} + +export function fieldRows(fields, { state, telemetryByField, chartMode }) { + if (!fields.length) { + const message = state.sort === "changed" + ? state.telemetryAvailability.status === "comparable" + ? "No changed fields in this telemetry." + : state.telemetryAvailability.reason + : "No fields match the current view."; + return `
${escapeHtml(message)}
`; + } + return `
${fields.map((field, index) => { + const expanded = state.expanded.has(field.id); + const telemetry = telemetryByField.get(field.id); + return `
${hybridField(field)}${hybridMetric(field, telemetry)}
${chart(field, index === 0, chartMode)}
`; + }).join("")}
`; +} diff --git a/ovens/differential-testing/renderer/differential-testing-renderer.js b/ovens/differential-testing/renderer/differential-testing-renderer.js index 802672c..4dfd8a4 100644 --- a/ovens/differential-testing/renderer/differential-testing-renderer.js +++ b/ovens/differential-testing/renderer/differential-testing-renderer.js @@ -2,9 +2,34 @@ import { renderDifferentialTestingFrameDeltaChart, renderDifferentialTestingProgressChart, } from "./differential-testing-progress-chart.js"; -import { escapeHtml, count, kpiTotal, percent, value, compact, blockers, unique, timeOnly, dateTime, overviewTime, formatLogRelativeMinutes, kpiItem, burnDonut, progressDonut, waffleMetric, log, plotValue, paths } from "./differential-testing-render.js"; +import { + escapeHtml, + count, + kpiTotal, + percent, + value, + compact, + blockers, + unique, + timeOnly, + dateTime, + overviewTime, + formatLogRelativeMinutes, + kpiItem, + burnDonut, + progressDonut, + waffleMetric, + log, + chart, + fieldRows, + visibleFields, + GREEN, + RED, +} from "./differential-testing-render.js"; // The canonical minute age display remains: return minutes === 0 ? "now" : minutes + "m"; +export { differentialSampleStateIsNonPass } from "./differential-testing-render.js"; + export function differentialTestingLoadingMarkup() { const title = `
Scenario @@ -30,10 +55,6 @@ function differentialTestingDashboardTemplateMarkup() { return mountDifferentialTestingDashboard(null, null, null, { templateOnly: true }); } -export function differentialSampleStateIsNonPass(sampleState) { - return sampleState !== 0; -} - export function differentialPayloadRevision(payload) { return JSON.stringify(payload ?? null); } @@ -155,10 +176,6 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { templateOnly = false, } = {}) { if (templateOnly) return templateHtml(); - const WIDTH = 900; - const HEIGHT = 58; - const GREEN = "#61d394"; - const RED = "#ef4444"; const telemetryAvailability = differentialTelemetryAvailability(payload); const state = { chart: initialChart === "current" ? "current" : "delta", @@ -207,13 +224,6 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { const statusTitle = state.payload.refresh?.status === "failed" ? state.payload.refresh.error || status : status; return `${escapeHtml(status)}`; } - function nonPass(field) { return Number(field.failedSampleCount || 0) + Number(field.missingSampleCount || 0); } - function fieldResult(field) { return field.trustStatus === "blocked" || field.missingSampleCount > 0 ? "BLOCKED" : field.failedSampleCount > 0 ? "FAIL" : "PASS"; } - function telemetryFor(field) { return state.telemetryByField.get(field.id) ?? null; } - function telemetryChange(field) { - const telemetry = telemetryFor(field); - return telemetry ? Number(telemetry.failToPassCount || 0) + Number(telemetry.passToFailCount || 0) : 0; - } function paintWaffles() { const scale = window.devicePixelRatio || 1; root.querySelectorAll("canvas.driving-parity-kpi-waffle").forEach((waffle) => { @@ -252,164 +262,6 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { function progress() { return ``; } - function chart(field, showFrameLabels = false, chartMode = state.chart) { - const categories = new Map(); - const rows = field.samples.map(([tick, reference, candidate, sampleState]) => ({ tick, reference: plotValue(reference, categories), candidate: plotValue(candidate, categories), state: sampleState })); - const x = (index) => rows.length <= 1 ? 0 : index / (rows.length - 1) * WIDTH; - const exactFailure = (index) => { - const row = rows[index]; - if (!row) return false; - return differentialSampleStateIsNonPass(row.state); - }; - const intervalFails = (index) => exactFailure(index) || exactFailure(index + 1); - const frameStep = (() => { - if (rows.length <= 1) return 0; - const raw = Math.max(1, rows.length / 10); - const magnitude = 10 ** Math.floor(Math.log10(raw)); - for (const multiplier of [1, 2, 2.5, 5, 10]) { - const step = Math.max(1, Math.round(multiplier * magnitude)); - if (step >= raw) return step; - } - return 1; - })(); - const tickIndexes = []; - for (let index = frameStep; index < rows.length - 1; index += frameStep * 2) tickIndexes.push(index); - const tickMarks = tickIndexes.map((index) => ``).join(""); - const tickLabels = showFrameLabels - ? tickIndexes.map((index) => `${escapeHtml(field.sampleLabels?.[index] || Math.round(rows[index].tick))}`).join("") - : ""; - const segment = (start, end, trimStart = 0, trimEnd = 0) => { - let [x1, y1] = start, [x2, y2] = end; - const dx = x2 - x1, dy = y2 - y1, length = Math.max(Math.hypot(dx, dy), .000001); - const first = Math.min(trimStart, length / 2), last = Math.min(trimEnd, length / 2); - x1 += dx / length * first; y1 += dy / length * first; - x2 -= dx / length * last; y2 -= dy / length * last; - return { path: `M${x1.toFixed(1)},${y1.toFixed(1)}L${x2.toFixed(1)},${y2.toFixed(1)}`, length: Math.hypot(x2 - x1, y2 - y1), x2, y2 }; - }; - const bands = (failed, points) => { - const result = []; - for (let index = 0; index < rows.length - 1; index += 1) { - if (intervalFails(index) !== failed || !points[index] || !points[index + 1]) continue; - const start = index; - while (index + 1 < rows.length - 1 && intervalFails(index + 1) === failed && points[index + 1] && points[index + 2]) index += 1; - result.push(``); - } - return result.join(""); - }; - if (chartMode === "delta") { - const values = rows.map((row) => row.reference === null || row.candidate === null ? null : row.candidate - row.reference); - const finite = values.filter(Number.isFinite); - if (!finite.length) return '
'; - const maxAbs = Math.max(.000001, ...finite.map((entry) => Math.abs(entry))); - const limit = maxAbs + Math.max(maxAbs * .16, .000001); - const y = (entry) => HEIGHT - (entry + limit) / (limit * 2) * HEIGHT; - const points = values.map((entry, index) => entry === null ? null : [x(index), y(entry)]); - const passed = [], failed = []; - for (let index = 0; index < rows.length - 1; index += 1) { - if (!points[index] || !points[index + 1]) continue; - const isFailed = intervalFails(index); - const line = segment(points[index], points[index + 1], !isFailed && index > 0 && intervalFails(index - 1) ? 1.2 : 0, !isFailed && index + 1 < rows.length - 1 && intervalFails(index + 1) ? 1.2 : 0).path; - (isFailed ? failed : passed).push(line); - } - return `
${bands(false, points)}${bands(true, points)}${tickMarks}${passed.length ? `` : ""}${failed.length ? `` : ""}${tickLabels}
`; - } - const finite = rows.flatMap((row) => [row.reference, row.candidate]).filter(Number.isFinite); - const min = Math.min(...finite, 0), max = Math.max(...finite, 0); - const pad = Math.max((max - min) * .16, Math.abs(max || min || 1) * .03, .000001); - const low = min - pad, high = max + pad, span = Math.max(high - low, .000001); - const y = (entry) => HEIGHT - (entry - low) / span * HEIGHT; - const reference = rows.map((row, index) => row.reference === null ? null : [x(index), y(row.reference)]); - const candidate = rows.map((row, index) => row.candidate === null ? null : [x(index), y(row.candidate)]); - const allMatch = !rows.some((_, index) => exactFailure(index)); - if (allMatch) { - const match = paths(candidate).length ? paths(candidate) : paths(reference); - return `
${bands(false, candidate)}${tickMarks}${match.map((path) => ``).join("")}${tickLabels}
`; - } - const candidatePassing = [], candidateFailing = [], referenceFailing = []; - let referenceLength = 0; - let previousReferenceFailingIndex = -2; - for (let index = 0; index < rows.length - 1; index += 1) { - const failed = intervalFails(index); - const trimStart = !failed && index > 0 && intervalFails(index - 1) ? 1.2 : 0; - const trimEnd = !failed && index + 1 < rows.length - 1 && intervalFails(index + 1) ? 1.2 : 0; - if (candidate[index] && candidate[index + 1]) (failed ? candidateFailing : candidatePassing).push(segment(candidate[index], candidate[index + 1], trimStart, trimEnd).path); - if (failed && reference[index] && reference[index + 1]) { - const line = segment(reference[index], reference[index + 1], trimStart, trimEnd); - if (previousReferenceFailingIndex === index - 1) { - referenceFailing.at(-1).path += `L${line.x2.toFixed(1)},${line.y2.toFixed(1)}`; - } else { - referenceFailing.push({ path: line.path, offset: -(referenceLength % 9) }); - } - referenceLength += line.length; - previousReferenceFailingIndex = index; - } else { - previousReferenceFailingIndex = -2; - } - } - return `
${bands(false, candidate)}${bands(true, candidate)}${tickMarks}${candidatePassing.length ? `` : ""}${candidateFailing.length ? `` : ""}${referenceFailing.map((line) => ``).join("")}${tickLabels}
`; - } - function hybridField(field) { - const description = field.semantics?.meaning || field.driftReason || field.sourceOwner || ""; - const result = fieldResult(field); - const segments = String(field.label || "").split("."); - const label = segments.map((segment, index) => { - const last = index === segments.length - 1; - const className = last ? "hybrid-field-tail" : "hybrid-field-segment"; - const opacity = segments.length <= 1 ? 1 : .45 + .55 * Math.pow(index / (segments.length - 1), 1.8); - return `${escapeHtml(segment)}${last ? "" : "."}`; - }).join(""); - return `${label}${result}`; - } - function hybridMetric(field) { - const countValue = nonPass(field); - const telemetry = telemetryFor(field); - const frameDelta = telemetry ? Number(telemetry.passToFailCount || 0) - Number(telemetry.failToPassCount || 0) : null; - const deltaClass = frameDelta === null || frameDelta === 0 ? "" : frameDelta < 0 ? "up" : "down"; - const deltaSymbol = frameDelta === null || frameDelta === 0 ? "" : frameDelta < 0 ? "▼" : "▲"; - const deltaValue = frameDelta === null ? "" : frameDelta === 0 ? "0" : compact(Math.abs(frameDelta)); - const transitionTitle = telemetry - ? `${count(telemetry.failToPassCount)} fail-to-pass; ${count(telemetry.passToFailCount)} pass-to-fail; ${count(telemetry.stayedPassCount)} stayed-pass; ${count(telemetry.stayedFailCount)} stayed-fail; residual ${count(telemetry.residualCount)}` - : ""; - const valueDelta = field.maxDelta === null || !Number.isFinite(Number(field.maxDelta)) - ? "" - : value(field.maxDelta); - return `${count(countValue)}${deltaSymbol}${deltaValue}${escapeHtml(valueDelta)}`; - } - function visibleFields() { - if (state.fieldPage) return state.payload.fields; - if (state.sort === "changed" && state.telemetryAvailability.status !== "comparable") return []; - const query = state.search.trim().toLowerCase(); - let filtered = state.payload.fields.filter((field) => { - if (state.filter === "failing" && nonPass(field) === 0) return false; - return !query || [field.label, field.sourceOwner, field.driftClass, field.semantics?.kind].some((entry) => String(entry || "").toLowerCase().includes(query)); - }); - if (state.sort === "changed") { - filtered = filtered.filter((field) => telemetryChange(field) > 0); - } - if (state.sort !== "changed") return filtered; - return filtered.sort((left, right) => { - const leftTelemetry = telemetryFor(left), rightTelemetry = telemetryFor(right); - const leftImprovement = Number(leftTelemetry?.failToPassCount || 0) - Number(leftTelemetry?.passToFailCount || 0); - const rightImprovement = Number(rightTelemetry?.failToPassCount || 0) - Number(rightTelemetry?.passToFailCount || 0); - return telemetryChange(right) - telemetryChange(left) - || rightImprovement - leftImprovement - || (fieldOrder.get(left) ?? Number.MAX_SAFE_INTEGER) - (fieldOrder.get(right) ?? Number.MAX_SAFE_INTEGER); - }); - } - function fieldRows(fields) { - if (!fields.length) { - const message = state.sort === "changed" - ? state.telemetryAvailability.status === "comparable" - ? "No changed fields in this telemetry." - : state.telemetryAvailability.reason - : "No fields match the current view."; - return `
${escapeHtml(message)}
`; - } - return `
${fields.map((field, index) => { - const expanded = state.expanded.has(field.id); - return `
${hybridField(field)}${hybridMetric(field)}
${chart(field, index === 0)}
`; - }).join("")}
`; - } function paginationState(total) { const pageCount = Math.max(1, Math.ceil(total / state.pageSize)); state.pageIndex = Math.max(0, Math.min(state.pageIndex, pageCount - 1)); @@ -1426,7 +1278,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { root.innerHTML = `
${escapeHtml(titleText)}
No Differential Testing scenarios
`; return; } - const visible = visibleFields(); + const visible = visibleFields(state, state.telemetryByField, fieldOrder); const serverPage = state.fieldPage; if (!serverPage) { state.pageIndex = Math.max(0, Math.min(state.pageIndex, Math.max(0, Math.ceil(visible.length / state.pageSize) - 1))); @@ -1482,7 +1334,7 @@ export function mountDifferentialTestingDashboard(root, oven, payload, { .replace('', ``) .replace(' - - - - -
-
- - - -
- -
- -
-
-
-
Tasks
-
0/0
-
-
-
Elapsed
-
--
-
-
-
Pace
-
--
-
-
-
Done
-
0%
-
-
- -
-
- - -
-
- -
-
-
Parity Progress
-
- - - - - -
-
-
-
- - - -
-
- - - -
-
-
- -
-
- -
-
-
-
- - -
-
-
-
-
- - - - - - -
- - Last read: ... -
-
-
- - -
-
- - -
-
-
- - - - - - -
- - -
-
-
-
-
-
-
-
-
- -
`; - } function buildDashboardHtml() { const visible = visibleFields(state, state.telemetryByField, fieldOrder); const serverPage = state.fieldPage; diff --git a/ovens/differential-testing/renderer/differential-testing-template.js b/ovens/differential-testing/renderer/differential-testing-template.js new file mode 100644 index 0000000..58e484c --- /dev/null +++ b/ovens/differential-testing/renderer/differential-testing-template.js @@ -0,0 +1,974 @@ +export function templateHtml() { + return `
+ +
+
+
+
+
+
+

Progress

+ +
+
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+
+
Tasks
+
0/0
+
+
+
Elapsed
+
--
+
+
+
Pace
+
--
+
+
+
Done
+
0%
+
+
+ +
+
+ + +
+
+
+
+
+
Parity Progress
+
+ + + + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+ + Last read: ... +
+
+
+ + +
+
+ + +
+
+
+ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ +
`; + } diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 680d1ef..627b579 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -347,7 +347,7 @@ assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-r assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "reconciled telemetry only", "Differential Testing does not visibly distinguish aggregate telemetry from exact authority."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-render.js", "field.samples", "Differential Testing is missing paired sample charts."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-render.js", 'role="button" tabindex="0" aria-expanded=', "Differential Testing rows do not preserve the expand interaction contract."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'placeholder="Search Fields..."', "Differential Testing does not preserve the canonical search control."); +assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", 'placeholder="Search Fields..."', "Differential Testing does not preserve the canonical search control."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'data-driving-parity-chart="delta"', "Differential Testing does not preserve the canonical Value and Delta controls."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'data-driving-parity-sort="improved"', "Differential Testing does not preserve the canonical Changed control."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'data-driving-parity-filter="failing"', "Differential Testing does not preserve the canonical Failed control."); @@ -360,12 +360,15 @@ assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-p assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "spikeThreshold", "Differential Testing history still erases losing telemetry runs that later restore baseline."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-progress-chart.js", "withoutBacktrackedFailedSpikes", "Differential Testing is not using the canonical progress-chart history projection."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "Cards view", "Differential Testing still carries the removed legacy cards view."); +assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", "Cards view", "Differential Testing still carries the removed legacy cards view."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "Table view", "Differential Testing still carries the removed legacy table view."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "grid-template-columns: 20% 10% minmax(0, 70%)", "Differential Testing rows do not use the canonical hybrid geometry."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "height: 90px", "Differential Testing collapsed rows do not use the canonical height."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "height: 220px", "Differential Testing expanded rows do not use the canonical height."); +assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", "Table view", "Differential Testing still carries the removed legacy table view."); +assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", "grid-template-columns: 20% 10% minmax(0, 70%)", "Differential Testing rows do not use the canonical hybrid geometry."); +assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", "height: 90px", "Differential Testing collapsed rows do not use the canonical height."); +assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", "height: 220px", "Differential Testing expanded rows do not use the canonical height."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", ".differential-overview:not([hidden]) + .detail-workspace {\n height: auto;", "Differential Testing Overview and top panels do not preserve intrinsic height."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'class="work-panel-title">Overview', "Differential Testing still renders the removed Overview section title."); +assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", 'class="work-panel-title">Overview', "Differential Testing still renders the removed Overview section title."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "grid-template-columns: 30% minmax(0, 70%)", "Differential Testing top panels do not preserve the canonical 30/70 layout."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "inset: 28px 0 0", "Differential Testing top panels do not preserve the shared-card template."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", ".driving-parity-view .differential-tabs", "Differential Testing tab groups do not share one component style."); @@ -376,12 +379,14 @@ assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.c assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'return minutes === 0 ? "now" : minutes + "m";', "Differential Testing Age values do not preserve the canonical minute display."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", '`${hours}h`', "Differential Testing Age values still collapse minutes into hours."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", '

Parity Progress

', "Differential Testing does not render the canonical progress title."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'id="driving-parity-inline-renderer"', "Differential Testing does not preserve the canonical inline renderer boundary."); +assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", 'id="driving-parity-inline-renderer"', "Differential Testing does not preserve the canonical inline renderer boundary."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "isolateDrivingParityFrame", "Differential Testing still moves the canonical inline renderer into a non-reference frame."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "frame.srcdoc", "Differential Testing still publishes the canonical inline renderer through srcdoc."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "flex: 0 0 auto;", "Differential Testing log rows can stretch to fill the panel."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "differential-exact-session", "Differential Testing adds a non-template exact-authority panel."); +assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", "differential-exact-session", "Differential Testing adds a non-template exact-authority panel."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "Targeted Burn", "Differential Testing renderer still hardcodes a project workflow title."); +assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", "Targeted Burn", "Differential Testing renderer still hardcodes a project workflow title."); assertSourceExcludes("src/server/burnlist-dashboard-server.mjs", "exact comparator when used", "Fallback Run Burn still requests the superseded manual comparator workflow."); assertSourceExcludes("dashboard/src/components/BurnOvens/BurnOvens.tsx", "exact comparator when used", "React Run Burn still requests the superseded manual comparator workflow."); assertSourceIncludes("skills/burnlist/SKILL.md", "references/burnlist-creation.md", "The Burnlist skill does not route creation work."); From 2663f3abc88595af42dd880a4bfaefbc58cba21f Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 22:19:30 +0200 Subject: [PATCH 19/85] refactor: relocate per-oven CSS into the dashboard kit; drop dashboard->ovens css imports; delete VP/SD renderer folders --- .../differential-testing.css | 0 .../components/StreamingDiff/StreamingDiff.tsx | 2 +- .../StreamingDiff}/streaming-diff.css | 0 .../components/VisualParity}/visual-parity.css | 0 dashboard/src/index.css | 4 ++-- scripts/verify-package.mjs | 2 -- scripts/verify.mjs | 18 +++++++++--------- 7 files changed, 12 insertions(+), 14 deletions(-) rename {ovens/differential-testing/renderer => dashboard/src/components/DifferentialTesting}/differential-testing.css (100%) rename {ovens/streaming-diff/renderer => dashboard/src/components/StreamingDiff}/streaming-diff.css (100%) rename {ovens/visual-parity/renderer => dashboard/src/components/VisualParity}/visual-parity.css (100%) diff --git a/ovens/differential-testing/renderer/differential-testing.css b/dashboard/src/components/DifferentialTesting/differential-testing.css similarity index 100% rename from ovens/differential-testing/renderer/differential-testing.css rename to dashboard/src/components/DifferentialTesting/differential-testing.css diff --git a/dashboard/src/components/StreamingDiff/StreamingDiff.tsx b/dashboard/src/components/StreamingDiff/StreamingDiff.tsx index 92a22e5..2404707 100644 --- a/dashboard/src/components/StreamingDiff/StreamingDiff.tsx +++ b/dashboard/src/components/StreamingDiff/StreamingDiff.tsx @@ -3,7 +3,7 @@ import { useStreamingDiffCards, useStreamingDiffFeeds } from "@hooks"; import { DiffCard, FeedList } from "@oven"; import { ovenRepoKey, streamingDiffAutoOpenHref, streamingDiffRepositories, streamingDiffSelection } from "@lib"; import type { Project, StreamingDiffCard } from "@lib"; -import "../../../../ovens/streaming-diff/renderer/streaming-diff.css"; +import "./streaming-diff.css"; function SelectedFeed({ cards, error, session }: { cards: StreamingDiffCard[]; error: string; session: string }) { return
diff --git a/ovens/streaming-diff/renderer/streaming-diff.css b/dashboard/src/components/StreamingDiff/streaming-diff.css similarity index 100% rename from ovens/streaming-diff/renderer/streaming-diff.css rename to dashboard/src/components/StreamingDiff/streaming-diff.css diff --git a/ovens/visual-parity/renderer/visual-parity.css b/dashboard/src/components/VisualParity/visual-parity.css similarity index 100% rename from ovens/visual-parity/renderer/visual-parity.css rename to dashboard/src/components/VisualParity/visual-parity.css diff --git a/dashboard/src/index.css b/dashboard/src/index.css index fe30a45..1fa5302 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -1,5 +1,5 @@ -@import "../../ovens/differential-testing/renderer/differential-testing.css"; -@import "../../ovens/visual-parity/renderer/visual-parity.css"; +@import "./components/DifferentialTesting/differential-testing.css"; +@import "./components/VisualParity/visual-parity.css"; :root { color-scheme: dark; diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 8054e2d..a9e2f9b 100755 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -53,7 +53,6 @@ const required = [ "ovens/differential-testing/example/adapter.mjs", "ovens/differential-testing/example/reference.json", "ovens/differential-testing/example/candidate.json", - "ovens/differential-testing/renderer/differential-testing.css", "ovens/differential-testing/renderer/differential-testing-progress-chart.js", "ovens/differential-testing/renderer/differential-testing-renderer.js", "ovens/performance-tracing/instructions.md", @@ -64,7 +63,6 @@ const required = [ "ovens/visual-parity/detail.json", "ovens/visual-parity/engine/visual-parity-contract.mjs", "ovens/visual-parity/engine/visual-parity-handler.mjs", - "ovens/visual-parity/renderer/visual-parity.css", "dashboard/dist/index.html", ]; diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 627b579..11d654a 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -366,23 +366,23 @@ assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-t assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", "grid-template-columns: 20% 10% minmax(0, 70%)", "Differential Testing rows do not use the canonical hybrid geometry."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", "height: 90px", "Differential Testing collapsed rows do not use the canonical height."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", "height: 220px", "Differential Testing expanded rows do not use the canonical height."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", ".differential-overview:not([hidden]) + .detail-workspace {\n height: auto;", "Differential Testing Overview and top panels do not preserve intrinsic height."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", ".differential-overview:not([hidden]) + .detail-workspace {\n height: auto;", "Differential Testing Overview and top panels do not preserve intrinsic height."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'class="work-panel-title">Overview', "Differential Testing still renders the removed Overview section title."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", 'class="work-panel-title">Overview', "Differential Testing still renders the removed Overview section title."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "grid-template-columns: 30% minmax(0, 70%)", "Differential Testing top panels do not preserve the canonical 30/70 layout."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "inset: 28px 0 0", "Differential Testing top panels do not preserve the shared-card template."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", ".driving-parity-view .differential-tabs", "Differential Testing tab groups do not share one component style."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", '--dashboard-title-font: "Helvetica Neue", Helvetica, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', "Differential Testing does not preserve the canonical title font stack."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", ".driving-parity-view .driving-parity-controls button,\n.driving-parity-view .driving-parity-controls select,\n.driving-parity-view .driving-parity-controls input,\n.driving-parity-view .driving-parity-overall-toggle {\n font: 14px/1.2 var(--dashboard-title-font);\n}", "Differential Testing controls do not preserve the canonical sans-serif typography."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", '.driving-parity-view .driving-parity-controls input[type="search"],\n.driving-parity-view .driving-parity-controls input[type="search"]:focus {\n background: transparent;\n}', "Differential Testing search input does not preserve its transparent background."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "h2 { margin: 0 0 12px; font-size: 16px; font-weight: 400; letter-spacing: 0; }", "Differential Testing panel headings do not preserve the canonical type scale."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", "grid-template-columns: 30% minmax(0, 70%)", "Differential Testing top panels do not preserve the canonical 30/70 layout."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", "inset: 28px 0 0", "Differential Testing top panels do not preserve the shared-card template."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", ".driving-parity-view .differential-tabs", "Differential Testing tab groups do not share one component style."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", '--dashboard-title-font: "Helvetica Neue", Helvetica, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', "Differential Testing does not preserve the canonical title font stack."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", ".driving-parity-view .driving-parity-controls button,\n.driving-parity-view .driving-parity-controls select,\n.driving-parity-view .driving-parity-controls input,\n.driving-parity-view .driving-parity-overall-toggle {\n font: 14px/1.2 var(--dashboard-title-font);\n}", "Differential Testing controls do not preserve the canonical sans-serif typography."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", '.driving-parity-view .driving-parity-controls input[type="search"],\n.driving-parity-view .driving-parity-controls input[type="search"]:focus {\n background: transparent;\n}', "Differential Testing search input does not preserve its transparent background."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", "h2 { margin: 0 0 12px; font-size: 16px; font-weight: 400; letter-spacing: 0; }", "Differential Testing panel headings do not preserve the canonical type scale."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", 'return minutes === 0 ? "now" : minutes + "m";', "Differential Testing Age values do not preserve the canonical minute display."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", '`${hours}h`', "Differential Testing Age values still collapse minutes into hours."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-renderer.js", '

Parity Progress

', "Differential Testing does not render the canonical progress title."); assertSourceIncludes("ovens/differential-testing/renderer/differential-testing-template.js", 'id="driving-parity-inline-renderer"', "Differential Testing does not preserve the canonical inline renderer boundary."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "isolateDrivingParityFrame", "Differential Testing still moves the canonical inline renderer into a non-reference frame."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "frame.srcdoc", "Differential Testing still publishes the canonical inline renderer through srcdoc."); -assertSourceIncludes("ovens/differential-testing/renderer/differential-testing.css", "flex: 0 0 auto;", "Differential Testing log rows can stretch to fill the panel."); +assertSourceIncludes("dashboard/src/components/DifferentialTesting/differential-testing.css", "flex: 0 0 auto;", "Differential Testing log rows can stretch to fill the panel."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "differential-exact-session", "Differential Testing adds a non-template exact-authority panel."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-template.js", "differential-exact-session", "Differential Testing adds a non-template exact-authority panel."); assertSourceExcludes("ovens/differential-testing/renderer/differential-testing-renderer.js", "Targeted Burn", "Differential Testing renderer still hardcodes a project workflow title."); From 97e1607ba9d8c21c50cd50e4b3e1e34ba4f96dbe Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 22:29:13 +0200 Subject: [PATCH 20/85] test: add zero-dep DOM-normalized comparator to gate DT React regions against the goldens --- .../oven/dom-normalize/dom-normalize.test.ts | 49 +++ .../src/oven/test-support/dom-normalize.ts | 280 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 dashboard/src/oven/dom-normalize/dom-normalize.test.ts create mode 100644 dashboard/src/oven/test-support/dom-normalize.ts diff --git a/dashboard/src/oven/dom-normalize/dom-normalize.test.ts b/dashboard/src/oven/dom-normalize/dom-normalize.test.ts new file mode 100644 index 0000000..bae97dc --- /dev/null +++ b/dashboard/src/oven/dom-normalize/dom-normalize.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { + assertDomEquivalent, + domEquivalent, + extractById, + extractFirstByClass, +} from "../test-support/dom-normalize"; + +test("ignores indentation and collapses text whitespace", () => { + assertDomEquivalent("
\n a\n b\n
", "
a b
"); +}); + +test("ignores attribute order and normalizes empty-element syntax", () => { + assertDomEquivalent( + '', + '', + ); +}); + +test("detects changed attributes, structure, and text", () => { + assert.equal(domEquivalent('
', '
').equal, false); + assert.equal(domEquivalent("
x
", "
").equal, false); + assert.equal(domEquivalent("
a
", "
b
").equal, false); +}); + +test("drops comments", () => { + assertDomEquivalent("
a
", "
a
"); +}); + +test("treats void and self-closing elements equivalently", () => { + assertDomEquivalent('', ''); + assertDomEquivalent('', ''); +}); + +test("extracts an element by id and class token", () => { + const html = '
x
'; + assertDomEquivalent(extractById(html, "k"), '
x
'); + assertDomEquivalent(extractFirstByClass(html, "outer"), '
x
'); +}); + +test("extracts a region from the renderer golden", () => { + const golden = readFileSync("ovens/differential-testing/renderer/goldens/dt-main.html", "utf8"); + const slice = extractById(golden, "driving-parity-kpi-strip"); + assert.ok(slice.length > 0); + assert.match(slice, /driving-parity-kpi-item/u); + assert.equal(domEquivalent(golden, golden).equal, true); +}); diff --git a/dashboard/src/oven/test-support/dom-normalize.ts b/dashboard/src/oven/test-support/dom-normalize.ts new file mode 100644 index 0000000..d04117e --- /dev/null +++ b/dashboard/src/oven/test-support/dom-normalize.ts @@ -0,0 +1,280 @@ +export type ElementNode = { + type: "element"; + tag: string; + attrs: Record; + children: Node[]; +}; + +export type TextNode = { + type: "text"; + value: string; +}; + +export type Node = ElementNode | TextNode; + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +function isWhitespace(value: string): boolean { + return /\s/u.test(value); +} + +function findTagEnd(html: string, start: number): number { + let quote = ""; + for (let index = start; index < html.length; index += 1) { + const character = html[index]; + if (quote) { + if (character === quote) quote = ""; + } else if (character === "\"" || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + return -1; +} + +function parseOpenTag(source: string): { tag: string; attrs: Record; selfClosing: boolean } | null { + let cursor = 0; + while (cursor < source.length && isWhitespace(source[cursor])) cursor += 1; + const tagStart = cursor; + while (cursor < source.length && !isWhitespace(source[cursor]) && source[cursor] !== "/") cursor += 1; + const tag = source.slice(tagStart, cursor); + if (!tag) return null; + + const attrs: Record = {}; + let selfClosing = false; + while (cursor < source.length) { + while (cursor < source.length && isWhitespace(source[cursor])) cursor += 1; + if (cursor >= source.length) break; + if (source[cursor] === "/") { + selfClosing = true; + cursor += 1; + continue; + } + + const nameStart = cursor; + while (cursor < source.length && !isWhitespace(source[cursor]) && !"=/>".includes(source[cursor])) cursor += 1; + const name = source.slice(nameStart, cursor); + if (!name) { + cursor += 1; + continue; + } + while (cursor < source.length && isWhitespace(source[cursor])) cursor += 1; + + let value = ""; + if (source[cursor] === "=") { + cursor += 1; + while (cursor < source.length && isWhitespace(source[cursor])) cursor += 1; + const quote = source[cursor]; + if (quote === "\"" || quote === "'") { + cursor += 1; + const valueStart = cursor; + while (cursor < source.length && source[cursor] !== quote) cursor += 1; + value = source.slice(valueStart, cursor); + if (cursor < source.length) cursor += 1; + } else { + const valueStart = cursor; + while (cursor < source.length && !isWhitespace(source[cursor]) && source[cursor] !== ">") { + if (source[cursor] === "/" && source[cursor + 1] === undefined) break; + cursor += 1; + } + value = source.slice(valueStart, cursor); + } + } + if (!(name in attrs)) attrs[name] = value; + } + + return { tag, attrs, selfClosing }; +} + +function appendNode(roots: Node[], stack: ElementNode[], node: Node): void { + const parent = stack[stack.length - 1]; + if (parent) parent.children.push(node); + else roots.push(node); +} + +export function parseHtml(html: string): Node[] { + const roots: Node[] = []; + const stack: ElementNode[] = []; + let cursor = 0; + + while (cursor < html.length) { + const open = html.indexOf("<", cursor); + if (open < 0) { + if (cursor < html.length) appendNode(roots, stack, { type: "text", value: html.slice(cursor) }); + break; + } + if (open > cursor) appendNode(roots, stack, { type: "text", value: html.slice(cursor, open) }); + + if (html.startsWith("", open + 4); + cursor = commentEnd < 0 ? html.length : commentEnd + 3; + continue; + } + + const tagEnd = findTagEnd(html, open + 1); + if (tagEnd < 0) { + appendNode(roots, stack, { type: "text", value: html.slice(open) }); + break; + } + const source = html.slice(open + 1, tagEnd); + if (source.trimStart().startsWith("!")) { + cursor = tagEnd + 1; + continue; + } + if (source.trimStart().startsWith("/")) { + const closingTag = source.trim().slice(1).trim().split(/\s/u)[0]; + if (closingTag) { + for (let index = stack.length - 1; index >= 0; index -= 1) { + if (stack[index].tag.toLowerCase() === closingTag.toLowerCase()) { + stack.length = index; + break; + } + } + } + cursor = tagEnd + 1; + continue; + } + + const parsed = parseOpenTag(source); + if (!parsed) { + appendNode(roots, stack, { type: "text", value: html.slice(open, tagEnd + 1) }); + cursor = tagEnd + 1; + continue; + } + const node: ElementNode = { type: "element", tag: parsed.tag, attrs: parsed.attrs, children: [] }; + appendNode(roots, stack, node); + if (!parsed.selfClosing && !VOID_ELEMENTS.has(parsed.tag.toLowerCase())) stack.push(node); + cursor = tagEnd + 1; + } + + return roots; +} + +export function normalize(nodes: Node[]): Node[] { + const normalized: Node[] = []; + for (const node of nodes) { + if (node.type === "text") { + const value = node.value.replace(/\s+/gu, " ").trim(); + if (value) normalized.push({ type: "text", value }); + continue; + } + normalized.push({ + type: "element", + tag: node.tag, + attrs: { ...node.attrs }, + children: normalize(node.children), + }); + } + return normalized; +} + +function serializeNode(node: Node): string { + if (node.type === "text") return node.value; + const attrs = Object.keys(node.attrs) + .sort() + .map((name) => `${name}="${node.attrs[name]}"`) + .join(" "); + const opening = attrs ? `<${node.tag} ${attrs}>` : `<${node.tag}>`; + return `${opening}${serializeCanonical(node.children)}`; +} + +export function serializeCanonical(nodes: Node[]): string { + return nodes.map(serializeNode).join(""); +} + +function truncated(value: string): string { + const limit = 280; + return value.length <= limit ? value : `${value.slice(0, limit)}…`; +} + +function nodeSource(node: Node | undefined): string { + return node ? truncated(serializeCanonical([node])) : ""; +} + +function mismatch(path: string, actual: Node | undefined, expected: Node | undefined): { equal: false; message: string } { + return { + equal: false, + message: `DOM differs at ${path}: actual ${nodeSource(actual)}; expected ${nodeSource(expected)}`, + }; +} + +function compareNodes(actual: Node | undefined, expected: Node | undefined, path: string): { equal: true } | { equal: false; message: string } { + if (!actual || !expected) return mismatch(path, actual, expected); + if (actual.type !== expected.type) return mismatch(path, actual, expected); + if (actual.type === "text" && expected.type === "text") { + return actual.value === expected.value ? { equal: true } : mismatch(path, actual, expected); + } + if (actual.tag !== expected.tag) return mismatch(path, actual, expected); + + const actualAttrs = Object.keys(actual.attrs).sort(); + const expectedAttrs = Object.keys(expected.attrs).sort(); + if (actualAttrs.length !== expectedAttrs.length || actualAttrs.some((name, index) => name !== expectedAttrs[index] || actual.attrs[name] !== expected.attrs[name])) { + return mismatch(path, actual, expected); + } + if (actual.children.length !== expected.children.length) { + const childIndex = Math.min(actual.children.length, expected.children.length); + return mismatch(`${path}/children[${childIndex}]`, actual.children[childIndex], expected.children[childIndex]); + } + for (let index = 0; index < actual.children.length; index += 1) { + const result = compareNodes(actual.children[index], expected.children[index], `${path}/children[${index}]`); + if (!result.equal) return result; + } + return { equal: true }; +} + +export function domEquivalent(a: string, b: string): { equal: boolean; message?: string } { + const actual = normalize(parseHtml(a)); + const expected = normalize(parseHtml(b)); + if (actual.length !== expected.length) { + const index = Math.min(actual.length, expected.length); + return mismatch(`root[${index}]`, actual[index], expected[index]); + } + for (let index = 0; index < actual.length; index += 1) { + const result = compareNodes(actual[index], expected[index], `root[${index}]`); + if (!result.equal) return result; + } + return { equal: true }; +} + +export function assertDomEquivalent(actualHtml: string, expectedHtml: string, message?: string): void { + const result = domEquivalent(actualHtml, expectedHtml); + if (!result.equal) throw new Error(`${message ? `${message}: ` : ""}${result.message ?? "DOM is not equivalent"}`); +} + +function findElement(nodes: Node[], predicate: (node: ElementNode) => boolean): ElementNode | undefined { + for (const node of nodes) { + if (node.type !== "element") continue; + if (predicate(node)) return node; + const descendant = findElement(node.children, predicate); + if (descendant) return descendant; + } + return undefined; +} + +export function extractById(html: string, id: string): string { + const node = findElement(normalize(parseHtml(html)), (candidate) => candidate.attrs.id === id); + if (!node) throw new Error(`Element with id "${id}" was not found`); + return serializeCanonical([node]); +} + +export function extractFirstByClass(html: string, className: string): string { + const node = findElement(normalize(parseHtml(html)), (candidate) => (candidate.attrs.class ?? "").split(/\s+/u).includes(className)); + if (!node) throw new Error(`Element with class "${className}" was not found`); + return serializeCanonical([node]); +} From 20d9eda6fd100a98fc18511582cf4899f858f8e4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 18 Jul 2026 22:57:44 +0200 Subject: [PATCH 21/85] feat: React DT KPI strip components + entity-faithful DOM comparator (differential-testing region 1) --- .../src/oven/BurnDonut/BurnDonut.test.ts | 29 ++++ dashboard/src/oven/BurnDonut/BurnDonut.tsx | 66 +++++++++ dashboard/src/oven/BurnDonut/index.ts | 1 + .../DifferentialKpiItem.tsx | 32 +++++ .../src/oven/DifferentialKpiItem/index.ts | 2 + .../DifferentialKpiStrip.test.ts | 124 ++++++++++++++++ .../DifferentialKpiStrip.tsx | 135 ++++++++++++++++++ .../src/oven/DifferentialKpiStrip/index.ts | 2 + dashboard/src/oven/KpiStrip/KpiStrip.tsx | 5 +- .../ProgressDonut.oracle.test.ts | 25 ++++ .../oven/WaffleMetric/WaffleMetric.test.ts | 28 ++++ .../src/oven/WaffleMetric/WaffleMetric.tsx | 20 +++ dashboard/src/oven/WaffleMetric/index.ts | 2 + .../oven/dom-normalize/dom-normalize.test.ts | 14 ++ dashboard/src/oven/index.ts | 4 + .../src/oven/test-support/dom-normalize.ts | 26 +++- 16 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 dashboard/src/oven/BurnDonut/BurnDonut.test.ts create mode 100644 dashboard/src/oven/BurnDonut/BurnDonut.tsx create mode 100644 dashboard/src/oven/BurnDonut/index.ts create mode 100644 dashboard/src/oven/DifferentialKpiItem/DifferentialKpiItem.tsx create mode 100644 dashboard/src/oven/DifferentialKpiItem/index.ts create mode 100644 dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.test.ts create mode 100644 dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.tsx create mode 100644 dashboard/src/oven/DifferentialKpiStrip/index.ts create mode 100644 dashboard/src/oven/ProgressDonut/ProgressDonut.oracle.test.ts create mode 100644 dashboard/src/oven/WaffleMetric/WaffleMetric.test.ts create mode 100644 dashboard/src/oven/WaffleMetric/WaffleMetric.tsx create mode 100644 dashboard/src/oven/WaffleMetric/index.ts diff --git a/dashboard/src/oven/BurnDonut/BurnDonut.test.ts b/dashboard/src/oven/BurnDonut/BurnDonut.test.ts new file mode 100644 index 0000000..35ef29d --- /dev/null +++ b/dashboard/src/oven/BurnDonut/BurnDonut.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { burnDonut } from "../../../../ovens/differential-testing/renderer/differential-testing-render.js"; +import { assertDomEquivalent, extractFirstByClass } from "../test-support/dom-normalize"; +import { BurnDonut } from "./BurnDonut"; + +test("BurnDonut matches the DT oracle geometry and segment order", () => { + const cases = [ + [], + [{ result: "improved" }], + [{ result: "improved" }, { result: "worsened" }, { result: "unchanged" }, { result: "blocked" }, { result: "reverted" }], + Array.from({ length: 1_234 }, (_, index) => ({ result: index % 4 === 0 ? "pass" : index % 4 === 1 ? "worsened" : index % 4 === 2 ? "blocked" : "unchanged" })), + ]; + + for (const entries of cases) { + const actual = renderToStaticMarkup(createElement(BurnDonut, { entries })); + const expected = extractFirstByClass(burnDonut(entries), "driving-parity-kpi-burns-donut"); + assertDomEquivalent(actual, expected, `burn donut mismatch for ${entries.length} entries`); + } +}); + +test("BurnDonut hides the track for non-empty runs and keeps it visible when empty", () => { + const empty = renderToStaticMarkup(createElement(BurnDonut, { entries: [] })); + const populated = renderToStaticMarkup(createElement(BurnDonut, { entries: [{ result: "pass" }] })); + assert.doesNotMatch(empty, /driving-parity-kpi-burns-donut-track[^>]*opacity=/u); + assert.match(populated, /driving-parity-kpi-burns-donut-track[^>]*opacity="0"/u); +}); diff --git a/dashboard/src/oven/BurnDonut/BurnDonut.tsx b/dashboard/src/oven/BurnDonut/BurnDonut.tsx new file mode 100644 index 0000000..612edd1 --- /dev/null +++ b/dashboard/src/oven/BurnDonut/BurnDonut.tsx @@ -0,0 +1,66 @@ +type BurnEntry = { result?: string }; + +type BurnGroup = "improved" | "worsened" | "unchanged" | "reverted"; + +type BurnDonutGroup = { + name: BurnGroup; + amount: number; + color: "improved" | "worsened" | "neutral" | "reverted"; + dash: string; + offset: string; +}; + +export function burnDonutCounts(entries: BurnEntry[]): Record { + const groups: Record = { improved: 0, worsened: 0, unchanged: 0, reverted: 0 }; + for (const entry of entries) { + if (entry.result === "improved" || entry.result === "pass") groups.improved += 1; + else if (entry.result === "worsened") groups.worsened += 1; + else if (entry.result === "blocked" || entry.result === "reverted") groups.reverted += 1; + else groups.unchanged += 1; + } + return groups; +} + +export function burnDonutGroups(entries: BurnEntry[]): BurnDonutGroup[] { + const groups = burnDonutCounts(entries); + + const active = (Object.entries(groups) as [BurnGroup, number][]) + .filter(([, amount]) => amount > 0) + .sort((left, right) => right[1] - left[1]); + const total = active.reduce((sum, [, amount]) => sum + amount, 0); + const gap = active.length > 1 ? (58 / 40) / (2 * Math.PI * 21) * 100 : 0; + let offset = 0; + + return active.map(([name, amount]) => { + const share = amount / Math.max(1, total) * 100; + const dash = Math.max(0, share - gap); + const result = { + name, + amount, + color: name === "unchanged" ? "neutral" : name, + dash: dash.toFixed(3), + offset: (-(offset + gap / 2)).toFixed(3), + } as BurnDonutGroup; + offset += share; + return result; + }); +} + +export function BurnDonut({ entries }: { entries: BurnEntry[] }) { + const groups = burnDonutGroups(entries); + const total = groups.reduce((sum, group) => sum + group.amount, 0); + return ; +} diff --git a/dashboard/src/oven/BurnDonut/index.ts b/dashboard/src/oven/BurnDonut/index.ts new file mode 100644 index 0000000..ccb3526 --- /dev/null +++ b/dashboard/src/oven/BurnDonut/index.ts @@ -0,0 +1 @@ +export { BurnDonut, burnDonutCounts, burnDonutGroups } from "./BurnDonut"; diff --git a/dashboard/src/oven/DifferentialKpiItem/DifferentialKpiItem.tsx b/dashboard/src/oven/DifferentialKpiItem/DifferentialKpiItem.tsx new file mode 100644 index 0000000..56e6152 --- /dev/null +++ b/dashboard/src/oven/DifferentialKpiItem/DifferentialKpiItem.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +export type DifferentialKpiItemProps = { + className?: string; + title: string; + visual?: ReactNode; + heading: ReactNode; + headingClass?: string; + value: ReactNode; + valueClass?: string; +}; + +export function DifferentialKpiItem({ + className, + title, + visual, + heading, + headingClass = "", + value, + valueClass = "", +}: DifferentialKpiItemProps) { + const itemClass = `driving-parity-kpi-item${visual ? " driving-parity-kpi-section" : ""}${className ? ` ${className}` : ""}`; + const headingClassName = `driving-parity-kpi-heading${headingClass ? ` ${headingClass}` : ""}`; + const valueClassName = `${visual ? "driving-parity-kpi-ratio" : "driving-parity-kpi-title-subtitle"}${valueClass ? ` ${valueClass}` : ""}`; + + return
+ {visual} + {visual + ?
{heading}{value}
+ : <>{heading}{value}} +
; +} diff --git a/dashboard/src/oven/DifferentialKpiItem/index.ts b/dashboard/src/oven/DifferentialKpiItem/index.ts new file mode 100644 index 0000000..79a8b2f --- /dev/null +++ b/dashboard/src/oven/DifferentialKpiItem/index.ts @@ -0,0 +1,2 @@ +export { DifferentialKpiItem } from "./DifferentialKpiItem"; +export type { DifferentialKpiItemProps } from "./DifferentialKpiItem"; diff --git a/dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.test.ts b/dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.test.ts new file mode 100644 index 0000000..033528e --- /dev/null +++ b/dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { escapeHtml, kpiItem } from "../../../../ovens/differential-testing/renderer/differential-testing-render.js"; +import { assertDomEquivalent, extractById, extractFirstByClass } from "../test-support/dom-normalize"; +import { buildDifferentialKpiData, DifferentialKpiStrip, type DifferentialPayload } from "./DifferentialKpiStrip"; + +const goldenDir = resolve("ovens/differential-testing/renderer/goldens"); +const goldenHarnessPath = resolve("ovens/differential-testing/renderer/golden-harness.mjs"); +const FIXED_NOW = Date.parse("2026-01-01T12:30:00.000Z"); +const scenarioVisual = ''; + +function withGoldenEnvironment(callback: () => T): T { + const previousTz = process.env.TZ; + const previousDateNow = Date.now; + const OriginalDTF = Intl.DateTimeFormat; + const Shim = function DateTimeFormat(locales: string | string[] | undefined, options?: Intl.DateTimeFormatOptions) { + return new OriginalDTF(locales == null ? "en-US" : locales, { timeZone: "UTC", ...(options || {}) }); + } as unknown as typeof Intl.DateTimeFormat; + Shim.prototype = OriginalDTF.prototype; + Object.setPrototypeOf(Shim, OriginalDTF); + process.env.TZ = "UTC"; + Date.now = () => FIXED_NOW; + globalThis.Intl.DateTimeFormat = Shim; + try { + return callback(); + } finally { + globalThis.Intl.DateTimeFormat = OriginalDTF; + Date.now = previousDateNow; + if (previousTz === undefined) delete process.env.TZ; + else process.env.TZ = previousTz; + } +} + +function scenarioSelectorMarkup(scenarios: { id: string }[], selectedScenarioId: string | null | undefined): string { + const selected = selectedScenarioId || ""; + const options = scenarios.map((scenario) => ``).join(""); + return ``; +} + +function scenarioControlMarkup(payload: DifferentialPayload): string { + return extractFirstByClass(renderToStaticMarkup(createElement(DifferentialKpiStrip, { payload })), "differential-scenario-control"); +} + +test("DifferentialKpiStrip matches every non-empty DT/PT KPI golden", async () => { + const harness = await import(goldenHarnessPath); + const states = [ + ["dt-main", harness.differentialTestingPayload], + ["dt-server-paged", harness.differentialTestingPayload], + ["dt-sorted-filtered-paged", harness.differentialTestingPayload], + ["dt-telemetry-incomparable", harness.differentialTestingIncomparableTelemetryPayload], + ["dt-comparable-telemetry", harness.differentialTestingComparableTelemetryPayload], + ["dt-comparable-no-changed", harness.differentialTestingComparableNoChangedPayload], + ["dt-paginated", harness.differentialTestingPaginatedPayload], + ["dt-paginated-mid", harness.differentialTestingPaginatedMidPayload], + ["dt-no-match", harness.differentialTestingAllPassingPayload], + ["dt-chart-current-failed", harness.differentialTestingPayload], + ["dt-progress-mode", harness.differentialTestingPayload], + ["pt-main", harness.performanceTracingPayload], + ] as const; + withGoldenEnvironment(() => { + for (const [name, payloadBuilder] of states) { + const payload = payloadBuilder(); + const actual = extractById(renderToStaticMarkup(createElement(DifferentialKpiStrip, { payload })), "driving-parity-kpi-strip"); + const golden = readFileSync(resolve(goldenDir, `${name}.html`), "utf8"); + assertDomEquivalent(actual, extractById(golden, "driving-parity-kpi-strip"), `${name} KPI strip differs`); + } + }); +}); + +test("DifferentialKpiStrip forwards scenario changes and disables a singleton catalog", async () => { + const { differentialTestingPayload } = await import(goldenHarnessPath); + const payload = differentialTestingPayload(); + let selected = ""; + const markup = renderToStaticMarkup(createElement(DifferentialKpiStrip, { payload, onScenarioChange: (id) => { selected = id; } })); + assert.match(markup, /id="differential-scenario-selector"/u); + assert.match(markup, /disabled=""/u); + assert.equal(selected, ""); +}); + +test("DifferentialKpiStrip matches the vanilla scenario selector for a selected multi-scenario catalog", () => { + const payload: DifferentialPayload = { + scenarioCatalog: { selectedScenarioId: "beta", scenarios: [{ id: "alpha" }, { id: "beta" }, { id: "gamma" }] }, + }; + assertDomEquivalent(scenarioControlMarkup(payload), scenarioSelectorMarkup(payload.scenarioCatalog?.scenarios ?? [], "beta")); +}); + +test("DifferentialKpiStrip leaves every option unselected when the catalog selection is missing", () => { + const payload: DifferentialPayload = { + scenarioCatalog: { selectedScenarioId: "missing", scenarios: [{ id: "alpha" }, { id: "beta" }] }, + }; + assertDomEquivalent(scenarioControlMarkup(payload), scenarioSelectorMarkup(payload.scenarioCatalog?.scenarios ?? [], "missing")); +}); + +test("DifferentialKpiStrip disables the selector for singleton and empty catalogs", () => { + for (const scenarioCatalog of [ + { selectedScenarioId: "alpha", scenarios: [{ id: "alpha" }] }, + { selectedScenarioId: null, scenarios: [] }, + ]) { + const payload: DifferentialPayload = { scenarioCatalog }; + assertDomEquivalent(scenarioControlMarkup(payload), scenarioSelectorMarkup(scenarioCatalog.scenarios, scenarioCatalog.selectedScenarioId)); + } +}); + +test("DifferentialKpiStrip keeps an apostrophe-bearing scenario title equivalent to the vanilla KPI oracle", () => { + const payload: DifferentialPayload = { + subtitle: 'Owner\'s & "run"', + scenarioCatalog: { selectedScenarioId: "alpha", scenarios: [{ id: "alpha" }, { id: "beta" }] }, + }; + const actual = extractFirstByClass(renderToStaticMarkup(createElement(DifferentialKpiStrip, { payload })), "driving-parity-kpi-scenario"); + const data = buildDifferentialKpiData(payload); + const expected = kpiItem({ + className: "driving-parity-kpi-scenario", + title: data.scenario.title, + visual: scenarioVisual, + heading: "Scenario", + headingClass: "differential-scenario-heading", + value: scenarioSelectorMarkup(payload.scenarioCatalog?.scenarios ?? [], payload.scenarioCatalog?.selectedScenarioId), + }); + assertDomEquivalent(actual, expected); +}); diff --git a/dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.tsx b/dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.tsx new file mode 100644 index 0000000..91a15e9 --- /dev/null +++ b/dashboard/src/oven/DifferentialKpiStrip/DifferentialKpiStrip.tsx @@ -0,0 +1,135 @@ +import type { ReactNode } from "react"; +import { BurnDonut, burnDonutCounts } from "../BurnDonut/BurnDonut"; +import { DifferentialKpiItem } from "../DifferentialKpiItem/DifferentialKpiItem"; +import { KpiStrip } from "../KpiStrip/KpiStrip"; +import { ProgressDonut } from "../ProgressDonut/ProgressDonut"; +import { WaffleMetric } from "../WaffleMetric/WaffleMetric"; +import { blockers, count, dateTime, kpiTotal, percent, unique } from "../../../../ovens/differential-testing/renderer/differential-testing-render.js"; + +type Scenario = { id: string }; +type ProgressEntry = { frames?: number; frame?: number }; +type BurnEntry = { result?: string }; +type Metric = { total?: number; failed?: number; blocked?: number }; + +export type DifferentialPayload = { + scenarioCatalog?: { selectedScenarioId?: string | null; scenarios?: Scenario[] }; + progress?: ProgressEntry[]; + log?: BurnEntry[]; + summary?: { fields?: Metric; frames?: Metric }; + subtitle?: string; + publishedAt?: string; + telemetry?: { status?: string; summary?: { failToPassCount?: number; passToFailCount?: number }; blockers?: unknown[] }; + trust?: { status?: string; blockers?: unknown[] }; + exactSession?: { status?: string; blockers?: unknown[] }; +}; + +type DifferentialKpiData = { + scenario: { + title: string; + selectedScenarioId: string; + scenarios: Scenario[]; + }; + progress: { title: string; total: number; done: number; donePercent: number }; + burns: { title: string; counts: ReturnType; improvedPercent: number }; + fields: { metric: Metric; failed: number; ratio: number; failedCells: number; empty: boolean }; + frames: { metric: Metric; failed: number; ratio: number; failedCells: number; empty: boolean }; +}; + +function trustBlockerSummaries(payload: DifferentialPayload): string[] { + const result: string[] = []; + const append = (label: string, status: string | undefined, entries: unknown[] | undefined) => { + if (status !== "blocked") return; + const reasons = blockers(entries); + result.push(`${label} blocked${reasons.length ? `: ${reasons.join("; ")}` : ""}`); + }; + append("primary", payload.trust?.status, payload.trust?.blockers); + append("telemetry", payload.telemetry?.status, payload.telemetry?.blockers); + append("exact", payload.exactSession?.status, payload.exactSession?.blockers); + return unique(result); +} + +function metricData(metric: Metric = {}) { + const failed = Number(metric.failed || 0) + Number(metric.blocked || 0); + const ratio = metric.total ? failed / metric.total : 0; + return { + metric, + failed, + ratio, + failedCells: Math.min(80, Math.round(ratio * 96)), + empty: metric.total ? false : true, + }; +} + +export function buildDifferentialKpiData(payload: DifferentialPayload): DifferentialKpiData { + const catalog = payload.scenarioCatalog ?? {}; + const scenarios = Array.isArray(catalog.scenarios) ? catalog.scenarios : []; + const selectedScenarioId = catalog.selectedScenarioId || ""; + const latest = payload.progress?.[payload.progress.length - 1]; + const total = Math.max(0, Number(latest?.frames) || 0); + const done = Math.max(0, Math.min(total, Number(latest?.frame) || 0)); + const donePercent = total ? done / total * 100 : 0; + const counts = burnDonutCounts(payload.log ?? []); + const burnTotal = Object.values(counts).reduce((sum, amount) => sum + amount, 0); + const subtitleParts = [payload.subtitle, dateTime(payload.publishedAt), payload.telemetry?.status === "comparable" + ? `${count(payload.telemetry.summary?.failToPassCount)} F→P · ${count(payload.telemetry.summary?.passToFailCount)} P→F · reconciled telemetry only` + : "", ...trustBlockerSummaries(payload)].filter(Boolean); + + return { + scenario: { + title: subtitleParts.join(" · "), + selectedScenarioId, + scenarios, + }, + progress: { title: `${count(done)} of ${count(total)} exact-prefix frames cleared`, total, done, donePercent }, + burns: { + title: "Results across the current Differential Testing run", + counts, + improvedPercent: burnTotal ? counts.improved / burnTotal * 100 : 0, + }, + fields: metricData(payload.summary?.fields), + frames: metricData(payload.summary?.frames), + }; +} + +function progressValue(data: DifferentialKpiData["progress"]): ReactNode { + return <>{count(data.total)}·{count(data.done)} ({percent(data.donePercent)}); +} + +function burnsValue(data: DifferentialKpiData["burns"]): ReactNode { + const { counts } = data; + return <>{count(counts.unchanged)}·{count(counts.reverted)}·{count(counts.worsened)}·{count(counts.improved)} ({percent(data.improvedPercent)}); +} + +function waffleValue(data: DifferentialKpiData["fields"] | DifferentialKpiData["frames"]): ReactNode { + return <>{kpiTotal(data.metric.total)}·{kpiTotal(data.failed)} ({percent(data.ratio * 100)}); +} + +/** + * Assumes a strip-bearing payload. When scenarioCatalog.selectedScenarioId is null and scenarioCatalog.scenarios is empty, + * the vanilla renderer shows differential-testing-empty-state; region-8 assembly must branch to that empty state instead. + */ +export function DifferentialKpiStrip({ payload, onScenarioChange }: { payload: DifferentialPayload; onScenarioChange?: (id: string) => void }) { + const data = buildDifferentialKpiData(payload); + const scenarioVisual = ; + const scenarioValue = ; + + return + + } heading="Progress" value={progressValue(data.progress)} /> + } heading="Results" valueClass="driving-parity-kpi-burns-summary" value={burnsValue(data.burns)} /> + } heading="Fields" value={waffleValue(data.fields)} /> + } heading="Frames" value={waffleValue(data.frames)} /> + ; +} diff --git a/dashboard/src/oven/DifferentialKpiStrip/index.ts b/dashboard/src/oven/DifferentialKpiStrip/index.ts new file mode 100644 index 0000000..133cbdf --- /dev/null +++ b/dashboard/src/oven/DifferentialKpiStrip/index.ts @@ -0,0 +1,2 @@ +export { DifferentialKpiStrip, buildDifferentialKpiData } from "./DifferentialKpiStrip"; +export type { DifferentialPayload } from "./DifferentialKpiStrip"; diff --git a/dashboard/src/oven/KpiStrip/KpiStrip.tsx b/dashboard/src/oven/KpiStrip/KpiStrip.tsx index 0a35a9f..569d84f 100644 --- a/dashboard/src/oven/KpiStrip/KpiStrip.tsx +++ b/dashboard/src/oven/KpiStrip/KpiStrip.tsx @@ -3,9 +3,10 @@ import type { ReactNode } from "react"; type KpiStripProps = { className?: string; ariaLabel?: string; + id?: string; children?: ReactNode; }; -export function KpiStrip({ className, ariaLabel, children }: KpiStripProps) { - return
{children}
; +export function KpiStrip({ className, ariaLabel, id, children }: KpiStripProps) { + return
{children}
; } diff --git a/dashboard/src/oven/ProgressDonut/ProgressDonut.oracle.test.ts b/dashboard/src/oven/ProgressDonut/ProgressDonut.oracle.test.ts new file mode 100644 index 0000000..686d03c --- /dev/null +++ b/dashboard/src/oven/ProgressDonut/ProgressDonut.oracle.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { progressDonut } from "../../../../ovens/differential-testing/renderer/differential-testing-render.js"; +import { assertDomEquivalent, extractFirstByClass } from "../test-support/dom-normalize"; +import { ProgressDonut } from "./ProgressDonut"; + +test("ProgressDonut matches the DT oracle dash formatting", () => { + const cases = [ + [], + [{ frames: 0, frame: 0 }], + [{ frames: 3, frame: 1 }], + [{ frames: 1_000_000, frame: 999_999 }], + [{ frames: 10, frame: 15 }], + ]; + + for (const entries of cases) { + const latest = entries[entries.length - 1]; + const total = Math.max(0, Number(latest?.frames) || 0); + const done = Math.max(0, Math.min(total, Number(latest?.frame) || 0)); + const actual = renderToStaticMarkup(createElement(ProgressDonut, { percent: total ? done / total * 100 : 0 })); + const expected = extractFirstByClass(progressDonut(entries), "driving-parity-kpi-progress-donut"); + assertDomEquivalent(actual, expected, `progress mismatch for ${entries.length} entries`); + } +}); diff --git a/dashboard/src/oven/WaffleMetric/WaffleMetric.test.ts b/dashboard/src/oven/WaffleMetric/WaffleMetric.test.ts new file mode 100644 index 0000000..5e8aa5e --- /dev/null +++ b/dashboard/src/oven/WaffleMetric/WaffleMetric.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { waffleMetric } from "../../../../ovens/differential-testing/renderer/differential-testing-render.js"; +import { assertDomEquivalent, extractFirstByClass } from "../test-support/dom-normalize"; +import { WaffleMetric } from "./WaffleMetric"; + +test("WaffleMetric matches the DT oracle static canvas metadata", () => { + const metrics = [ + { total: 0, failed: 0, blocked: 0 }, + { total: 6, failed: 1, blocked: 5 }, + { total: 1_000_000, failed: 999_999, blocked: 1 }, + { total: 100, failed: 0, blocked: 0 }, + ]; + + for (const metric of metrics) { + const actual = renderToStaticMarkup(createElement(WaffleMetric, { metric })); + const expected = extractFirstByClass(waffleMetric(metric, "Fields"), "driving-parity-kpi-waffle"); + assertDomEquivalent(actual, expected, `waffle mismatch for total ${metric.total}`); + } +}); + +test("WaffleMetric computes failed cells from failed plus blocked", () => { + const markup = renderToStaticMarkup(createElement(WaffleMetric, { metric: { total: 10, failed: 1, blocked: 1 } })); + assert.match(markup, /data-failed-cells="19"/u); + assert.match(markup, /data-empty="false"/u); +}); diff --git a/dashboard/src/oven/WaffleMetric/WaffleMetric.tsx b/dashboard/src/oven/WaffleMetric/WaffleMetric.tsx new file mode 100644 index 0000000..c22f8a3 --- /dev/null +++ b/dashboard/src/oven/WaffleMetric/WaffleMetric.tsx @@ -0,0 +1,20 @@ +export type WaffleMetricData = { + total?: number; + failed?: number; + blocked?: number; +}; + +export function waffleMetricData(metric: WaffleMetricData) { + const failed = Number(metric.failed || 0) + Number(metric.blocked || 0); + const ratio = metric.total ? failed / metric.total : 0; + return { + failed, + failedCells: Math.min(80, Math.round(ratio * 96)), + empty: metric.total ? false : true, + }; +} + +export function WaffleMetric({ metric }: { metric: WaffleMetricData }) { + const data = waffleMetricData(metric); + return