import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { getRun, getStep, screenshotUrl } from "../api"; import type { Run } from "../types"; import ActionList from "../panels/ActionList"; import HierarchyPanel from "../panels/HierarchyPanel"; import Screenshot from "../panels/Screenshot"; import SnapshotTable from "../panels/SnapshotTable"; import ViolationsPanel from "../panels/ViolationsPanel"; import ExceptionsPanel from "../panels/ExceptionsPanel"; import MetricsChart from "../panels/MetricsChart"; import Tabs, { type TabDefinition } from "../components/Tabs"; import { useStep } from "../hooks/useStep"; import { useKeyboardNav } from "../hooks/useKeyboardNav"; import { useTheme } from "../hooks/useTheme"; import { deviceSpaceOf } from "../lib/device-space"; import { buildRunHistory, type RunHistory } from "../lib/run-history"; function basename(specPath: string): string { const index = specPath.lastIndexOf("/"); return index >= 0 ? specPath.slice(index + 1) : specPath; } export default function RunDetail() { const [run, setRun] = useState(null); const [history, setHistory] = useState(null); const [error, setError] = useState(null); const { theme, toggle } = useTheme(); const stepCount = run?.steps.length; const { runId, stepIndex, goTo } = useStep(stepCount); useEffect(() => { if (!runId) return; let cancelled = false; setRun(null); setHistory(null); setError(null); getRun(runId) .then(async (loaded) => { if (cancelled) return; setRun(loaded); const computed = await loadHistory(loaded); if (!cancelled) { setHistory(computed); } }) .catch((failure: unknown) => { if (!cancelled) { setError(failure instanceof Error ? failure.message : String(failure)); } }); return () => { cancelled = true; }; }, [runId]); const currentStep = history?.steps[stepIndex - 1] ?? null; const previousStep = stepIndex > 1 ? history?.steps[stepIndex - 2] ?? null : null; const nextStep = history && stepIndex < history.steps.length ? history.steps[stepIndex] ?? null : null; const jumpToFirstViolation = useCallback(() => { if (history?.firstViolationStep) { goTo(history.firstViolationStep); } }, [history, goTo]); const jumpToFirstException = useCallback(() => { if (history?.firstExceptionStep) { goTo(history.firstExceptionStep); } }, [history, goTo]); const jumpToNextViolation = useCallback(() => { if (!run) return; const next = run.steps.find((entry) => entry.index > stepIndex && entry.has_violations); if (next) goTo(next.index); }, [run, stepIndex, goTo]); useKeyboardNav({ onPrev: () => goTo(stepIndex - 1), onNext: () => goTo(stepIndex + 1), onJumpStart: () => goTo(1), onJumpEnd: () => stepCount && goTo(stepCount), onJumpPrev10: () => goTo(stepIndex - 10), onJumpNext10: () => goTo(stepIndex + 10), onJumpNextViolation: jumpToNextViolation, }); // One screenshot per step depicts the UI at observation time (post-prior- // action, post-settle). The "after" view for step N is therefore step // (N+1)'s screenshot: the same moment, observed from the next iteration. const beforeScreenshot = useMemo(() => { if (!runId || !currentStep) return undefined; return screenshotUrl(runId, `step-${String(currentStep.step).padStart(5, "0")}.png`); }, [runId, currentStep]); const afterScreenshot = useMemo(() => { if (!runId || !nextStep) return undefined; return screenshotUrl(runId, `step-${String(nextStep.step).padStart(5, "0")}.png`); }, [runId, nextStep]); const runStartMillis = useMemo(() => { if (!run?.steps.length) return 0; return new Date(run.steps[0].timestamp).getTime(); }, [run]); if (error) { return
failed: {error}
; } if (!run) { return
loading run...
; } const violationsBefore = currentStep?.violations ?? []; const violationsAfter = nextStep?.violations ?? violationsBefore; const residualsBefore = currentStep?.residuals; const residualsAfter = nextStep?.residuals ?? residualsBefore; const witnessesBefore = currentStep?.witnesses; const witnessesAfter = nextStep?.witnesses ?? witnessesBefore; const exceptionsForStep = currentStep?.exceptions; const beforeSpace = deviceSpaceOf(currentStep?.hierarchy); const afterSpace = deviceSpaceOf(nextStep?.hierarchy ?? currentStep?.hierarchy); const beforeTabs: TabDefinition[] = [ { id: "screenshot", label: "Screenshot", content: ( ), }, { id: "snapshots", label: "Snapshots", content: ( ), }, { id: "hierarchy", label: "Hierarchy", content: , }, { id: "properties", label: "Properties", content: ( ), }, { id: "violations", label: "Violations", badge: violationsBefore.length > 0 ? ( {violationsBefore.length} ) : undefined, content: ( ), }, ]; const afterTabs: TabDefinition[] = [ { id: "screenshot", label: "Screenshot", content: ( ), }, { id: "snapshots", label: "Snapshots", content: ( ), }, { id: "hierarchy", label: "Hierarchy", content: , }, { id: "properties", label: "Properties", content: ( ), }, { id: "violations", label: "Violations", badge: violationsAfter.length > 0 ? ( {violationsAfter.length} ) : undefined, content: ( ), }, ]; return (
runs {run.id} {basename(run.spec_path)} seed={run.seed} step {stepIndex} / {stepCount ?? 0}

state before

state after

{(exceptionsForStep && exceptionsForStep.length > 0) || history?.firstExceptionStep !== undefined ? (
) : null}
); } async function loadHistory(run: Run): Promise { const responses = await Promise.all( run.steps.map((entry) => getStep(run.id, entry.index).catch(() => null)), ); return buildRunHistory(run, responses); }