feat: add presentation evidence inspector

This commit is contained in:
lda
2026-07-06 04:02:01 +07:00 Verified
parent 5f3fccec8c
commit edf983019d
2 changed files with 214 additions and 0 deletions
@@ -0,0 +1,89 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EvidenceRecord } from "../../app/state.js";
import { EvidenceInspector } from "./EvidenceInspector.js";
afterEach(() => cleanup());
const record: EvidenceRecord = {
id: "run-start",
operation: "workflow.runs.start",
label: "Start run",
equivalentCli: "uv run wf run start demo.default",
request: { deployment_id: "demo.default" },
response: { result: { status: "interrupted", run_id: "run_demo" } },
durationMs: 88,
};
const traceRecord: EvidenceRecord = {
...record,
id: "run-trace",
operation: "workflow.runs.trace",
label: "Inspect trace",
equivalentCli: "uv run wf run trace run_demo",
response: { result: { status: "completed", frames: [] } },
durationMs: 34,
};
describe("EvidenceInspector", () => {
it("opens on interpreted evidence and switches to raw request and response", async () => {
const user = userEvent.setup();
render(<EvidenceInspector records={[record]} open onClose={vi.fn()} />);
expect(screen.getByRole("dialog", { name: /evidence inspector/i })).toBeInTheDocument();
expect(screen.getAllByText("workflow.runs.start").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("88 ms")).toBeInTheDocument();
expect(screen.getByText("demo.default")).toBeInTheDocument();
expect(screen.getByText("run_demo")).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: /raw/i }));
expect(screen.getByText(/uv run wf run start/i)).toBeInTheDocument();
expect(screen.getByText(/deployment_id/i)).toBeInTheDocument();
expect(screen.getByText(/interrupted/i)).toBeInTheDocument();
});
it("keeps the selected record while changing views", async () => {
const user = userEvent.setup();
render(<EvidenceInspector records={[record, traceRecord]} open onClose={vi.fn()} />);
await user.selectOptions(screen.getByLabelText(/evidence record/i), record.id);
await user.click(screen.getByRole("tab", { name: /raw/i }));
expect(screen.getByLabelText(/evidence record/i)).toHaveValue(record.id);
});
it("focuses close on open and restores the previous trigger on unmount", () => {
const trigger = document.createElement("button");
document.body.append(trigger);
trigger.focus();
const view = render(<EvidenceInspector records={[record]} open onClose={vi.fn()} />);
expect(screen.getByRole("button", { name: /close evidence/i })).toHaveFocus();
view.unmount();
expect(trigger).toHaveFocus();
trigger.remove();
});
it("wraps keyboard focus inside the inspector", async () => {
const user = userEvent.setup();
render(<EvidenceInspector records={[record]} open onClose={vi.fn()} />);
const close = screen.getByRole("button", { name: /close evidence/i });
const raw = screen.getByRole("tab", { name: /raw/i });
close.focus();
await user.tab({ shift: true });
expect(raw).toHaveFocus();
await user.tab();
expect(close).toHaveFocus();
});
it("renders a bounded unavailable state with no records", () => {
render(<EvidenceInspector records={[]} open onClose={vi.fn()} />);
expect(screen.getByText(/evidence unavailable/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/evidence record/i)).not.toBeInTheDocument();
});
it("shows a formatting note for non-serializable raw evidence", async () => {
const user = userEvent.setup();
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
render(<EvidenceInspector records={[{ ...record, response: cyclic }]} open onClose={vi.fn()} />);
await user.click(screen.getByRole("tab", { name: /raw/i }));
expect(screen.getByText(/could not format as json/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import type { EvidenceRecord } from "../../app/state.js";
import { projectEvidenceDetail } from "./evidence-model.js";
type EvidenceInspectorProps = {
readonly records: readonly EvidenceRecord[];
readonly open: boolean;
readonly onClose: () => void;
};
export const EvidenceInspector = ({
records,
open,
onClose,
}: EvidenceInspectorProps) => {
const [view, setView] = useState<"interpreted" | "raw">("interpreted");
const [selectedRecordId, setSelectedRecordId] = useState(
() => records.at(-1)?.id ?? "",
);
const dialogRef = useRef<HTMLElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setSelectedRecordId((current) =>
records.some((record) => record.id === current)
? current
: records.at(-1)?.id ?? "",
);
}, [records]);
const selectedRecord = records.find((record) => record.id === selectedRecordId)
?? records.at(-1)
?? null;
const detail = selectedRecord ? projectEvidenceDetail(selectedRecord) : null;
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
closeButtonRef.current?.focus();
return () => previouslyFocused?.focus();
}, [open]);
const trapTabWithinDialog = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Tab") return;
const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
) ?? [])].filter((element) => !element.hasAttribute("disabled"));
const first = focusable.at(0);
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
if (!open) return null;
return (
<div className="evidence-inspector-layer">
<section
ref={dialogRef}
className="evidence-inspector"
role="dialog"
aria-modal="true"
aria-label="Evidence inspector"
onKeyDown={trapTabWithinDialog}
>
<header>
<h2>Evidence</h2>
<button ref={closeButtonRef} type="button" onClick={onClose}>
Close evidence
</button>
</header>
{selectedRecord && (
<label>
Evidence record
<select
value={selectedRecord.id}
onChange={(event) => setSelectedRecordId(event.currentTarget.value)}
>
{records.map((record) => (
<option key={record.id} value={record.id}>{record.operation}</option>
))}
</select>
</label>
)}
<div role="tablist" aria-label="Evidence view">
<button role="tab" aria-selected={view === "interpreted"} onClick={() => setView("interpreted")}>
Interpreted
</button>
<button role="tab" aria-selected={view === "raw"} onClick={() => setView("raw")}>
Raw
</button>
</div>
<div className="evidence-inspector__body">
{!detail ? (
<p>Evidence unavailable</p>
) : view === "interpreted" ? (
<dl>
<dt>Operation</dt><dd>{detail.operation}</dd>
<dt>Status</dt><dd>{detail.status ?? "Unavailable"}</dd>
<dt>Duration</dt><dd>{detail.durationMs} ms</dd>
<dt>Deployment</dt><dd>{detail.deploymentId ?? "Unavailable"}</dd>
<dt>Run</dt><dd>{detail.runId ?? "Unavailable"}</dd>
</dl>
) : (
<>
<h3>Equivalent CLI</h3><pre><code>{detail.equivalentCli}</code></pre>
<h3>Request</h3><pre><code>{detail.request.text}</code></pre>
{detail.request.note && <p>{detail.request.note}</p>}
<h3>Response</h3><pre><code>{detail.response.text}</code></pre>
{detail.response.note && <p>{detail.response.note}</p>}
</>
)}
</div>
</section>
</div>
);
};