feat: add presentation evidence inspector
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user