From ab4c33ccebd76e4c17d7938b3742429c3f436991 Mon Sep 17 00:00:00 2001 From: pulk17 Date: Mon, 14 Sep 2026 12:59:29 +0530 Subject: [PATCH] Web-Runs Run list, run detail and queueing a new run. A failing output opens a drawer with the expected/actual diff, or plays the sample against both caption tracks so a mismatch can be judged against the picture. --- web/src/components/DiffDrawer.tsx | 372 ++++++++++++++ web/src/lib/cues.ts | 62 +++ web/src/pages/RunDetail.tsx | 813 ++++++++++++++++++++++++++++++ web/src/pages/RunNew.tsx | 216 ++++++++ web/src/pages/Runs.tsx | 257 ++++++++++ 5 files changed, 1720 insertions(+) create mode 100644 web/src/components/DiffDrawer.tsx create mode 100644 web/src/lib/cues.ts create mode 100644 web/src/pages/RunDetail.tsx create mode 100644 web/src/pages/RunNew.tsx create mode 100644 web/src/pages/Runs.tsx diff --git a/web/src/components/DiffDrawer.tsx b/web/src/components/DiffDrawer.tsx new file mode 100644 index 000000000..a148aab58 --- /dev/null +++ b/web/src/components/DiffDrawer.tsx @@ -0,0 +1,372 @@ +import { useQuery } from "@tanstack/react-query"; +import { Download, FileText, PlayCircle } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { AnimatedSheet, SheetDescription, SheetTitle } from "@/components/ui/sheet"; +import { fetchDiff, outputText, sampleFile } from "@/lib/api"; +import { clock, cuesToVtt, parseCues, type Cue } from "@/lib/cues"; +import { cn } from "@/lib/utils"; + +export interface DiffTarget { + runId: number; + sampleId: number; + regressionId: number; + outputId: number; + command: string; + sampleName: string; +} + +type View = "diff" | "playback"; + +/** MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED — nothing here can decode the file. */ +const MEDIA_ERR_SRC_NOT_SUPPORTED = 4; + +/** + * One failing output, either as a unified diff or played back against the + * sample it came from. + * + * Playback is the view for deciding whether a mismatch is a real regression or + * a stale baseline: captions only mean something next to the picture they + * describe. The diff stays the default, because most differences are obvious + * without watching anything. + */ +export function DiffDrawer({ + target, + onClose, +}: Readonly<{ + target: DiffTarget | null; + onClose: () => void; +}>) { + const [view, setView] = useState("diff"); + + return ( + !o && onClose()} + className={view === "playback" ? "max-w-5xl" : "max-w-2xl"} + > + {target && ( +
+
+ + Test #{target.regressionId} + + + {target.command} · {target.sampleName} · run {target.runId} + +
+ setView("diff")} + /> + setView("playback")} + /> +
+
+ {view === "diff" ? : } +
+ )} +
+ ); +} + +function ViewTab({ + icon: Icon, + label, + on, + go, +}: Readonly<{ + icon: typeof FileText; + label: string; + on: boolean; + go: () => void; +}>) { + return ( + + ); +} + +/** Pair each line with a stable id, so the render key is never an index. */ +function numbered(lines: string[]): { id: string; line: string }[] { + return lines.map((line, i) => ({ id: `line-${i}`, line })); +} + +function DiffPane({ target }: Readonly<{ target: DiffTarget }>) { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["diff", target.runId, target.regressionId, target.outputId], + staleTime: Infinity, + retry: false, + queryFn: () => fetchDiff(target), + }); + + return ( +
+ {isLoading && } + {isError && ( + + {error instanceof Error ? error.message : "Could not load diff"} — the output + file for this result isn't present in storage. + + )} + {data && ( +
+          {data.content.trim() === "" ? (
+            Diff is empty.
+          ) : (
+            numbered(data.content.split("\n")).map(({ id, line }) => (
+              
+ {line || " "} +
+ )) + )} +
+ )} +
+ ); +} + +/** + * The sample playing above its two caption tracks. + * + * Browsers decode very little of this library — most of it is broadcast + * captures in containers no browser ships a demuxer for — so the player is + * offered optimistically and the element's own error event decides whether it + * worked. When it cannot play, the two tracks are still worth reading side by + * side, and the sample is one click away for a local player. + */ +function PlaybackPane({ target }: Readonly<{ target: DiffTarget }>) { + const video = useRef(null); + const [at, setAt] = useState(0); + // The element's own error code, kept rather than a boolean: a container no + // browser can demux and a signed URL that never arrived look identical on + // screen but mean completely different things to whoever has to fix it. + const [failure, setFailure] = useState(null); + + const file = useQuery({ + queryKey: ["sample-file", target.sampleId], + staleTime: 300_000, + retry: false, + queryFn: () => sampleFile(target.sampleId), + }); + + const expected = useOutput(target, "expected"); + const actual = useOutput(target, "actual"); + + // The baseline is what the sample is supposed to say, so it doubles as the + // player's caption track. Held as a blob rather than a request, since the + // cues are already parsed and in memory. + const captions = useMemo(() => { + const vtt = cuesToVtt(expected.data?.cues ?? []); + return URL.createObjectURL(new Blob([vtt], { type: "text/vtt" })); + }, [expected.data]); + useEffect(() => () => URL.revokeObjectURL(captions), [captions]); + + const seek = (t: number) => { + if (!video.current) return; + video.current.currentTime = t; + video.current.play().catch(() => {}); + }; + + return ( +
+ {file.isLoading && ( +
+ )} + + {file.data?.download_url && failure === null && ( + + )} + + {(failure !== null || (file.data && !file.data.download_url) || file.isError) && ( + + {file.data?.download_url ? ( + <> + {failure === MEDIA_ERR_SRC_NOT_SUPPORTED + ? "No browser ships a demuxer for this container, which covers most of the broadcast captures in this library." + : "The sample did not download — its storage link may have expired."} + + Download {file.data.filename} + + + ) : ( + <> + This sample has no shared copy to stream — the only one is on the + platform's own disk. + + )}{" "} + The captions below still line up by timecode. + + )} + +
+ + +
+
+ ); +} + +function useOutput(target: DiffTarget, which: "expected" | "actual") { + return useQuery({ + queryKey: ["output-text", target.runId, target.regressionId, target.outputId, which], + staleTime: Infinity, + retry: false, + queryFn: async () => { + const file = await outputText(target, which); + return { ...file, cues: parseCues(file.content) }; + }, + }); +} + +type OutputQuery = ReturnType; + +function CueColumn({ + title, + tone, + q, + at, + onSeek, +}: Readonly<{ + title: string; + tone: "ok" | "bad"; + q: OutputQuery; + at: number; + onSeek: (t: number) => void; +}>) { + return ( +
+
+ + {title} + + {q.data && ( + + {q.data.cues.length > 0 ? `${q.data.cues.length} cues` : q.data.filename} + + )} +
+
+ {q.isLoading && ( +
+ +
+ )} + {q.isError && ( +

+ {title === "Actual" + ? "This run produced no output for the test." + : "No baseline file is present in storage."} +

+ )} + {q.data?.cues.length === 0 && ( + // Plain transcripts carry no timings, so there is nothing to sync. +
+            {q.data.content.trim() || Empty file.}
+          
+ )} + {q.data?.cues.map((c) => ( + = c.start && at < c.end} onSeek={onSeek} /> + ))} + {q.data?.truncated && ( +

Truncated at 1 MiB.

+ )} +
+
+ ); +} + +function CueRow({ + cue, + active, + onSeek, +}: Readonly<{ + cue: Cue; + active: boolean; + onSeek: (t: number) => void; +}>) { + return ( + + ); +} + +function Skeleton({ rows }: Readonly<{ rows: number }>) { + return ( +
+ {Array.from({ length: rows }, (_, n) => `placeholder-${n}`).map((id) => ( +
+ ))} +
+ ); +} + +function Notice({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/lib/cues.ts b/web/src/lib/cues.ts new file mode 100644 index 000000000..5c144dc2c --- /dev/null +++ b/web/src/lib/cues.ts @@ -0,0 +1,62 @@ +/** + * Timestamped lines pulled out of a caption file. + * + * CCExtractor writes SubRip and WebVTT with the same timing shape, differing + * only in the decimal separator, so one expression covers both. Formats with + * no timings at all — the plain .txt transcripts — yield nothing, and the + * caller falls back to showing the file as it is. + */ +export interface Cue { + start: number; + end: number; + text: string; +} + +const TIMING = + /(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/; + +const seconds = (h: string, m: string, s: string, ms: string) => + +h * 3600 + +m * 60 + +s + +ms / 1000; + +export function parseCues(source: string): Cue[] { + const cues: Cue[] = []; + for (const block of source.replaceAll("\r", "").split(/\n{2,}/)) { + const lines = block.split("\n"); + // The timing line is the anchor: what precedes it is a cue number or a + // WEBVTT header, and what follows is the caption itself. + const at = lines.findIndex((l) => TIMING.test(l)); + if (at === -1) continue; + const m = TIMING.exec(lines[at])!; + const text = lines.slice(at + 1).join("\n").trim(); + if (text === "") continue; + cues.push({ + start: seconds(m[1], m[2], m[3], m[4]), + end: seconds(m[5], m[6], m[7], m[8]), + text, + }); + } + return cues; +} + +/** mm:ss for the cue gutter — samples are minutes long, never hours. */ +export function clock(t: number): string { + const m = Math.floor(t / 60); + const s = Math.floor(t % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +/** Render cues back as a WebVTT file, for a on the player. */ +export function cuesToVtt(cues: Cue[]): string { + const stamp = (t: number) => { + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const s = Math.floor(t % 60); + const ms = Math.round((t % 1) * 1000); + const pad = (n: number, w = 2) => String(n).padStart(w, "0"); + return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(ms, 3)}`; + }; + const body = cues + .map((c) => `${stamp(c.start)} --> ${stamp(c.end)}\n${c.text}`) + .join("\n\n"); + return `WEBVTT\n\n${body}`; +} diff --git a/web/src/pages/RunDetail.tsx b/web/src/pages/RunDetail.tsx new file mode 100644 index 000000000..cef52881f --- /dev/null +++ b/web/src/pages/RunDetail.tsx @@ -0,0 +1,813 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { Link, useParams } from "@tanstack/react-router"; +import { + AlertTriangle, + Ban, + Binary, + Bug, + Check, + ChevronDown, + ChevronLeft, + Download, + ExternalLink, + FileText, + GitCommitHorizontal, + GitPullRequest, + Loader2, + RotateCcw, + Terminal, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useMemo, useState } from "react"; + +import { DiffDrawer, type DiffTarget } from "@/components/DiffDrawer"; +import { RunStatusBadge } from "@/components/StatusBadge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm"; +import { + cancelRun, + restartRun, + fetchRunLog, + useInfraErrors, + useRun, + useRunArtifacts, + useRunProgress, + useRunSamples, + type RunArtifact, + type RunFailure, +} from "@/lib/api"; +import { canOperateCi, getSession } from "@/lib/auth"; +import { githubUrl, httpsUrl } from "@/lib/validate"; +import { cn } from "@/lib/utils"; + +const STAGES = ["preparation", "testing", "completed"] as const; +const STAGE_LABEL: Record = { + preparation: "Preparation", + testing: "Testing", + completed: "Completed", + canceled: "Canceled", +}; + +/** + * One platform run: metadata, progress stepper, and results grouped by + * category. Failing tests open their diff in a drawer. + */ +export function RunDetail() { + const { runId } = useParams({ from: "/runs/$runId" }); + const id = Number(runId); + const { data: run } = useRun(id); + const { data: progress = [] } = useRunProgress(id); + const { data: samples = [], isLoading } = useRunSamples(id); + const { data: infraErrors = [] } = useInfraErrors(id); + const gh = githubUrl(run?.github_link); + const qc = useQueryClient(); + + // Reached-stage set for the stepper. + const reached = new Set(progress.map((p) => p.status)); + const canceled = reached.has("canceled"); + + const finished = + !run || ["pass", "fail", "canceled", "error"].includes(run.status); + const [confirmCancel, setConfirmCancel] = useState(false); + const [canceling, setCanceling] = useState(false); + const [confirmRestart, setConfirmRestart] = useState(false); + const [restarting, setRestarting] = useState(false); + + const doRestart = async () => { + setRestarting(true); + try { + await restartRun(id); + await Promise.all([ + qc.invalidateQueries({ queryKey: ["run", id] }), + qc.invalidateQueries({ queryKey: ["run-progress", id] }), + qc.invalidateQueries({ queryKey: ["runs"] }), + ]); + } finally { + setRestarting(false); + setConfirmRestart(false); + } + }; + + const doCancel = async () => { + setCanceling(true); + try { + await cancelRun(id); + await Promise.all([ + qc.invalidateQueries({ queryKey: ["run", id] }), + qc.invalidateQueries({ queryKey: ["run-progress", id] }), + qc.invalidateQueries({ queryKey: ["runs"] }), + ]); + } finally { + setCanceling(false); + setConfirmCancel(false); + } + }; + + // Group results by category; a category "fails" if any test in it fails. + const categories = useMemo(() => { + const byCat = new Map(); + for (const s of samples) { + const cat = s.categories[0] ?? "Uncategorized"; + byCat.set(cat, [...(byCat.get(cat) ?? []), s]); + } + return [...byCat.entries()] + .map(([name, rows]) => ({ + name, + rows, + failed: rows.filter((r) => r.status !== "pass").length, + })) + .sort((a, b) => b.failed - a.failed || a.name.localeCompare(b.name)); + }, [samples]); + + return ( +
+
+
+ + + +

Run {id}

+ {run && } +
+ {!finished && canOperateCi(getSession()) && ( + + )} + {finished && canOperateCi(getSession()) && ( + + )} + {gh && ( + + + + )} +
+
+
+ + + The results and the progress trail for this run are cleared so CI + picks it up again. The run keeps its id, and{" "} + the existing results are replaced, not kept alongside. + + } + confirmLabel={restarting ? "Queueing…" : "Run again"} + busy={restarting} + onConfirm={doRestart} + /> + + + +
+ {infraErrors.length > 0 && ( +
+ +
+
+ Infrastructure problem — failures here may not be caused by the code +
+
    + {infraErrors.slice(0, 3).map((e) => ( +
  • + {e.type}{" "} + {e.message} +
  • + ))} + {infraErrors.length > 3 && ( +
  • + {infraErrors.length - 3} more
  • + )} +
+
+
+ )} + + {/* Metadata */} + {run && ( +
+ + {run.pr_number ? ( + + + {gh ? ( + + #{run.pr_number} + + ) : ( + #{run.pr_number} + )} + (commit {run.commit_sha.slice(0, 7)}) + + ) : ( + + {run.commit_sha.slice(0, 9)} + + )} + + {run.platform} + {run.repository} + {run.branch} + {fmt(run.started_at ?? run.created_at)} + {fmt(run.completed_at)} +
+ )} + + {/* Progress stepper */} +
+
+ {STAGES.map((stage, i) => { + const done = reached.has(stage) || (stage === "completed" && reached.has("completed")); + const isCanceledEnd = canceled && stage === "completed"; + return ( +
+
+
+ {isCanceledEnd ? "Canceled" : STAGE_LABEL[stage]} +
+
+ {done && } +
+
+ {i < STAGES.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ + {finished && } + + {/* Results by category */} +

Test results

+

+ Click a category to expand; click a failing test to see its diff. +

+ {isLoading && ( +
+ {Array.from({ length: 6 }, (_, n) => `placeholder-${n}`).map((id) => ( +
+ ))} +
+ )} +
+ {categories.map((c) => ( + + ))} +
+
+
+ ); +} + +function CategoryBlock({ + name, rows, failed, runId, +}: Readonly<{ + name: string; + rows: RunFailure[]; + failed: number; + runId: number; +}>) { + const [open, setOpen] = useState(failed > 0); + const [diff, setDiff] = useState(null); + + return ( +
+ + + + {open && ( + +
+ {rows.map((r) => { + const failing = r.status !== "pass"; + const output = r.outputs.find((o) => o.status === "fail") ?? r.outputs[0]; + return ( +
+ + + #{r.regression_test_id} + {r.command} + + {failing && output ? ( + + ) : ( + + {r.status === "pass" ? "Pass" : r.status} + + )} +
+ ); + })} +
+
+ )} +
+ + setDiff(null)} /> +
+ ); +} + +/** One expected-vs-actual output, paired from the run's artifacts. A single + * regression-test output can carry several accepted expected variants. */ +interface OutputComparison { + key: string; + rtId: number; + outputId: number; + ext: string; + expected: RunArtifact[]; + actual: RunArtifact | null; +} + +/** Run-level (non per-output) artifacts, in display order. */ +const RUN_FILE_TYPES = ["build_log", "binary", "coredump", "combined_stdout"] as const; +const RUN_FILE_LABEL: Record = { + build_log: "Build log", + binary: "Binary", + coredump: "Core dump", + combined_stdout: "Combined stdout", +}; +const RUN_FILE_ICON: Record = { + build_log: FileText, + binary: Binary, + coredump: Bug, + combined_stdout: Terminal, +}; + +/** Recover the (regression-test, output) a per-output artifact belongs to from + * its id — the backend encodes it as `expected|actual_{run}_{rt}_{output}`. */ +function parseOutputKey(id: string): { rtId: number; outputId: number } | null { + const m = /^(?:expected|actual)_\d+_(\d+)_(\d+)$/.exec(id); + return m ? { rtId: Number(m[1]), outputId: Number(m[2]) } : null; +} + +function extOf(filename: string): string { + const dot = filename.lastIndexOf("."); + return dot > 0 ? filename.slice(dot) : ""; +} + +/** Output filenames are `` — show a readable head. */ +function shortHash(filename: string): string { + const dot = filename.lastIndexOf("."); + const base = dot > 0 ? filename.slice(0, dot) : filename; + const ext = dot > 0 ? filename.slice(dot) : ""; + return base.length > 14 ? `${base.slice(0, 10)}…${ext}` : filename; +} + +/** Build log and output files of a finished run, grouped into run-level files + * and paired expected/actual comparisons. Download links appear where storage + * still has the file (GCS-backed runs get signed URLs). */ +function ArtifactsSection({ + runId, + samples, +}: Readonly<{ + runId: number; + samples: RunFailure[]; +}>) { + const { data: artifacts = [], isLoading } = useRunArtifacts(runId); + const [showAll, setShowAll] = useState(false); + const [showOutputs, setShowOutputs] = useState(false); + const [diff, setDiff] = useState(null); + + const { runFiles, comparisons } = useMemo(() => { + const runFiles = RUN_FILE_TYPES.map((t) => + artifacts.find((a) => a.type === t), + ).filter((a): a is RunArtifact => a != null); + + const groups = new Map(); + for (const a of artifacts) { + if (a.type !== "expected_output" && a.type !== "actual_output") continue; + const p = parseOutputKey(a.artifact_id); + const key = p ? `${p.rtId}_${p.outputId}` : a.artifact_id; + let g = groups.get(key); + if (!g) { + g = { + key, + rtId: p?.rtId ?? -1, + outputId: p?.outputId ?? -1, + ext: extOf(a.filename), + expected: [], + actual: null, + }; + groups.set(key, g); + } + if (a.type === "expected_output") g.expected.push(a); + else g.actual = a; + } + return { runFiles, comparisons: [...groups.values()] }; + }, [artifacts]); + + const samplesByRt = useMemo(() => { + const m = new Map(); + for (const s of samples) m.set(s.regression_test_id, s); + return m; + }, [samples]); + + if (!isLoading && artifacts.length === 0) return null; + + const visible = showAll ? comparisons : comparisons.slice(0, 6); + + return ( +
+

Artifacts

+

+ Files this run produced — build log and expected vs actual outputs. +

+ {isLoading && ( +
+ loading artifact list +
+ )} + + {runFiles.length > 0 && ( +
+ Run files +
+ {runFiles.map((a) => ( + + ))} +
+
+ )} + + {comparisons.length > 0 && ( +
+ {/* Collapsed by default: a full run carries hundreds of these, and + expanding them all buries the run files above. */} + + {showOutputs && ( + <> +
+ {visible.map((cmp) => { + const sample = samplesByRt.get(cmp.rtId); + return ( + + setDiff({ + runId, + sampleId: sample.sample_id, + regressionId: cmp.rtId, + outputId: cmp.outputId, + command: sample.command, + sampleName: sample.sample_name, + }) + : undefined + } + /> + ); + })} +
+ {comparisons.length > 6 && ( + + )} + + )} +
+ )} + + setDiff(null)} /> +
+ ); +} + +function SubLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} + +/** Availability of one artifact file: a download link, on-server, or missing. */ +function FileStatus({ a, compact }: Readonly<{ a: RunArtifact; compact?: boolean }>) { + const url = httpsUrl(a.download_url); + if (url) { + return ( + + + {!compact && "Download"} + + ); + } + const ok = a.storage_status === "ok"; + return ( + + {ok ? "On server" : "Missing"} + + ); +} + +/** + * Build-log download. The artifacts endpoint reports the log with a null + * download_url because it is read through /runs/{id}/logs instead of being + * served as a file, so the log is paged in and saved from a blob here rather + * than linked to. + */ +/** Download button wording for each of its three states. */ +function logLabel(busy: boolean, failed: boolean): string { + if (busy) return "Preparing"; + return failed ? "Retry" : "Download"; +} + +function LogDownload({ runId, filename }: Readonly<{ runId: number; filename: string }>) { + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + const save = async () => { + setBusy(true); + setFailed(false); + try { + const blob = new Blob([await fetchRunLog(runId)], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + } catch { + setFailed(true); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} + +function RunFileCard({ a }: Readonly<{ a: RunArtifact }>) { + const Icon = RUN_FILE_ICON[a.type] ?? FileText; + const viaLogEndpoint = + a.type === "build_log" && a.storage_status === "ok" && !httpsUrl(a.download_url); + return ( +
+ +
+
+ {RUN_FILE_LABEL[a.type] ?? a.type.replaceAll("_", " ")} +
+ + {a.filename} + +
+ {a.size_bytes != null && ( + + {(a.size_bytes / 1024).toFixed(0)} KB + + )} + {viaLogEndpoint ? ( + + ) : ( + + )} +
+ ); +} + +type CompareTone = "match" | "differs" | "noout"; + +function StatusPill({ tone }: Readonly<{ tone: CompareTone }>) { + const map: Record = { + match: ["Match", "text-success bg-success/10"], + differs: ["Differs", "text-destructive bg-destructive/10"], + noout: ["No output", "text-warning bg-warning/10"], + }; + const [label, cls] = map[tone]; + return ( + + {label} + + ); +} + +function OutputCol({ + title, + files, + empty, +}: Readonly<{ + title: string; + files: RunArtifact[]; + empty?: string; +}>) { + return ( +
+
+ {title} +
+ {files.length === 0 ? ( + {empty ?? "—"} + ) : ( + files.map((f, i) => ( +
+ {files.length > 1 && ( + v{i + 1} + )} + + {shortHash(f.filename)} + + +
+ )) + )} +
+ ); +} + +/** No output at all, a match, or a genuine difference. */ +function toneOf(hasActual: boolean, matches: boolean): CompareTone { + if (!hasActual) return "noout"; + return matches ? "match" : "differs"; +} + +function ComparisonCard({ + cmp, + sample, + onDiff, +}: Readonly<{ + cmp: OutputComparison; + sample: RunFailure | undefined; + onDiff?: () => void; +}>) { + const label = sample?.sample_name ?? `Test #${cmp.rtId}`; + const outStatus = sample?.outputs.find((o) => o.output_id === cmp.outputId)?.status; + const matched = + cmp.actual != null && + cmp.expected.some((e) => e.filename === cmp.actual!.filename); + const tone: CompareTone = toneOf(cmp.actual != null, outStatus === "pass" || matched); + + return ( +
+
+ + {label} + + {cmp.ext && ( + + {cmp.ext} + + )} + +
+ {sample?.command && ( + + #{cmp.rtId} · {sample.command} + + )} +
+ + +
+ {onDiff && cmp.actual && tone === "differs" && ( +
+ +
+ )} +
+ ); +} + +function MetaRow({ + label, children, last, +}: Readonly<{ + label: string; + children: React.ReactNode; + last?: boolean; +}>) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function fmt(iso: string | null) { + if (!iso) return "—"; + return new Date(iso).toLocaleString([], { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); +} diff --git a/web/src/pages/RunNew.tsx b/web/src/pages/RunNew.tsx new file mode 100644 index 000000000..54a67fed8 --- /dev/null +++ b/web/src/pages/RunNew.tsx @@ -0,0 +1,216 @@ +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { ChevronLeft, GitBranch, Loader2, Play, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { createRun, deriveCategories, useRegressionTests } from "@/lib/api"; +import { isBranchName, isCommitSha, isRepoSlug } from "@/lib/validate"; +import type { Platform } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +/** + * Start a run against any fork/commit — replaces the old "Customized tests" + * page. POSTs one run per selected platform via POST /api/v1/runs. + */ +export function RunNew() { + const { data: tests = [] } = useRegressionTests(); + const navigate = useNavigate(); + const search = useSearch({ from: "/runs/new" }); + + // Set when arriving from the test builder: run just this one test. + const [onlyTest, setOnlyTest] = useState(search.test); + + const [repository, setRepository] = useState("CCExtractor/ccextractor"); + const [branch, setBranch] = useState("master"); + const [commit, setCommit] = useState(""); + const [platforms, setPlatforms] = useState(["linux", "windows"]); + const [cats, setCats] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const categories = deriveCategories(tests); + const commitOk = isCommitSha(commit); + const repoOk = isRepoSlug(repository); + const branchOk = isBranchName(branch); + + const selectedIds = useMemo(() => { + if (onlyTest !== undefined) return [onlyTest]; + if (cats.length === 0) return undefined; // full suite + return tests + .filter((t) => t.active && t.categories.some((c) => cats.includes(c))) + .map((t) => t.id); + }, [onlyTest, cats, tests]); + + const estimate = useMemo(() => { + if (onlyTest !== undefined) return { count: 1, minutes: 1 }; + const pool = + selectedIds === undefined + ? tests.filter((t) => t.active) + : tests.filter((t) => selectedIds.includes(t.id)); + const ms = pool.reduce((acc, t) => acc + (t.avg_runtime_ms ?? 15_000), 0); + return { count: pool.length, minutes: Math.max(1, Math.round(ms / 60_000)) }; + }, [onlyTest, selectedIds, tests]); + + const submit = async () => { + setBusy(true); + setError(null); + try { + await Promise.all( + platforms.map((platform) => + createRun({ + commit_sha: commit, + platform, + branch, + repository, + ...(selectedIds ? { regression_test_ids: selectedIds } : {}), + }), + ), + ); + navigate({ to: "/runs" }); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to queue run"); + setBusy(false); + } + }; + + return ( +
+
+ + + +
+

+ New run +

+

+ Run the regression suite against any fork or commit. +

+
+
+ +
+
+
+ + setRepository(e.target.value)} + placeholder="owner/repo" + className={cn(!repoOk && repository && "border-destructive/50")} + /> +
+
+ + setBranch(e.target.value.trim())} + className={cn(!branchOk && branch && "border-destructive/50")} + /> +
+
+ +
+ + setCommit(e.target.value.trim())} + placeholder="e.g. cf7c396176271462b9a29c62a1c3c6d8b723bd2b" + className={cn("font-mono text-xs", !commitOk && commit && "border-destructive/50")} + /> +
+ +
+ +
+ {(["linux", "windows"] as Platform[]).map((p) => { + const on = platforms.includes(p); + return ( + + ); + })} +
+
+ +
+ + {onlyTest !== undefined && ( +
+ + Verification run for test #{onlyTest} only + + +
+ )} +
+ {categories.map((c) => { + const on = cats.includes(c.name); + return ( + + ); + })} +
+
+ +
+ + ~{estimate.count} tests · est. {estimate.minutes} min per platform ×{" "} + {platforms.length} platform{platforms.length === 1 ? "" : "s"} + +
+ {error && {error}} + +
+
+
+
+ ); +} + +function Label({ children }: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} diff --git a/web/src/pages/Runs.tsx b/web/src/pages/Runs.tsx new file mode 100644 index 000000000..fc0b75d6c --- /dev/null +++ b/web/src/pages/Runs.tsx @@ -0,0 +1,257 @@ +import { ChevronDown, ExternalLink, GitCommitHorizontal, GitPullRequest, Timer } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; + +import { Link } from "@tanstack/react-router"; + +import { RunStatusBadge } from "@/components/StatusBadge"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useRunFailures, useRuns, useRunSummary } from "@/lib/api"; +import { githubUrl } from "@/lib/validate"; +import type { LogicalPlatformRun, LogicalRun } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +/** CI runs, newest first, with Linux and Windows grouped per commit. */ +/** + * Timeline dot for a commit: red if either platform broke, green only when + * both passed, grey while anything is still unresolved. + */ +function dotColour(run: LogicalRun): string { + if (run.platforms.some((p) => p.status === "fail" || p.status === "error")) { + return "bg-destructive"; + } + return run.platforms.every((p) => p.status === "pass") ? "bg-success" : "bg-faint"; +} + +export function Runs() { + const { data: runs = [], isLoading } = useRuns(); + const [open, setOpen] = useState(null); + + return ( +
+
+
+

Test results

+ + Linux and Windows grouped per commit · refreshes every 30s + + + + +
+
+ +
+ {isLoading && } + {runs.map((run, i) => ( + + + + +
+ + + + {open === run.id && ( + + + + )} + +
+
+ ))} +
+
+ ); +} + +function RunDetail({ run }: Readonly<{ run: LogicalRun }>) { + const gh = githubUrl(run.github_link); + return ( +
+
+ {run.platforms.map((p) => ( + + ))} +
+ {gh && ( + + )} +
+ ); +} + +/** Counts arrive lazily from GET /runs//summary when the row expands. */ +function PlatformDetail({ p }: Readonly<{ p: LogicalPlatformRun }>) { + const { data: s, isLoading } = useRunSummary(p.run_id); + const hasFailures = (s?.fail_count ?? 0) + (s?.error_count ?? 0) > 0; + const { data: failures = [] } = useRunFailures(hasFailures ? p.run_id : null); + const dur = s?.duration_ms != null ? Math.round(s.duration_ms / 60000) : null; + + return ( +
+
+ + {p.platform}{" "} + + run {p.run_id} → + + + + {dur != null && ( + <> + {dur}m + + )} + + +
+ + {isLoading &&
} + + {s && ( + <> +
+ {s.total_samples > 0 && ( + <> + + + + + )} +
+
+ {s.pass_count} passed + {" · "} + 0 ? "text-destructive" : ""}> + {s.fail_count + s.error_count} failed + + {" · "} + {s.missing_output_count} missing output · {s.skipped_count} skipped ·{" "} + {s.total_samples} total +
+ {failures.length > 0 && ( +
+ {failures.slice(0, 14).map((f) => ( + + #{f.regression_test_id} + + ))} + {failures.length > 14 && ( + +{failures.length - 14} + )} +
+ )} + + )} +
+ ); +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 4 }, (_, n) => `placeholder-${n}`).map((id) => ( +
+ ))} +
+ ); +}