feat: add presentation evidence receipt
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import { PresentationFooter } from "./PresentationFooter.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("PresentationFooter", () => {
|
||||
it("combines scene progress and evidence provenance", () => {
|
||||
const evidence: EvidenceRecord = {
|
||||
id: "trace",
|
||||
operation: "workflow.runs.trace",
|
||||
label: "Inspect trace",
|
||||
equivalentCli: "uv run wf run trace run_demo",
|
||||
request: { run_id: "run_demo" },
|
||||
response: { result: { status: "completed" } },
|
||||
durationMs: 34,
|
||||
};
|
||||
render(
|
||||
<PresentationFooter
|
||||
location={{
|
||||
kind: "main",
|
||||
sceneId: "architecture",
|
||||
beatId: "runtime",
|
||||
focusPath: [],
|
||||
}}
|
||||
evidence={[evidence]}
|
||||
showEvidenceReceipt
|
||||
inspectEvidence={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
|
||||
expect(within(footer).getByText("6 / 12")).toBeInTheDocument();
|
||||
expect(within(footer).getByText("3 / 4")).toBeInTheDocument();
|
||||
expect(within(footer).getByText(/workflow\.runs\.trace/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import { SceneProgress } from "./SceneProgress.js";
|
||||
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
||||
import type { MainLocation } from "./storyboard.js";
|
||||
|
||||
type PresentationFooterProps = {
|
||||
readonly location: MainLocation;
|
||||
readonly evidence: readonly EvidenceRecord[];
|
||||
readonly showEvidenceReceipt: boolean;
|
||||
readonly inspectEvidence: () => void;
|
||||
};
|
||||
|
||||
export const PresentationFooter = ({
|
||||
location,
|
||||
evidence,
|
||||
showEvidenceReceipt,
|
||||
inspectEvidence,
|
||||
}: PresentationFooterProps) => (
|
||||
<footer className="presentation-footer" aria-label="presentation footer">
|
||||
<SceneProgress location={location} />
|
||||
<EvidenceReceipt
|
||||
records={evidence}
|
||||
visible={showEvidenceReceipt}
|
||||
onInspect={inspectEvidence}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
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 { EvidenceReceipt } from "./EvidenceReceipt.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,
|
||||
};
|
||||
|
||||
describe("EvidenceReceipt", () => {
|
||||
it("shows latest operation, status, count, and opens inspection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const inspect = vi.fn();
|
||||
render(<EvidenceReceipt records={[record]} visible onInspect={inspect} />);
|
||||
expect(screen.getByText(/workflow\.runs\.start/)).toBeInTheDocument();
|
||||
expect(screen.getByText("interrupted")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 record/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /inspect evidence/i }));
|
||||
expect(inspect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders no receipt when the beat keeps evidence hidden", () => {
|
||||
render(<EvidenceReceipt records={[record]} visible={false} onInspect={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /inspect evidence/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables inspection when evidence is unavailable", () => {
|
||||
render(<EvidenceReceipt records={[]} visible onInspect={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /inspect evidence/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { EvidenceRecord } from "../../app/state.js";
|
||||
import { projectEvidenceReceipt } from "./evidence-model.js";
|
||||
|
||||
type EvidenceReceiptProps = {
|
||||
readonly records: readonly EvidenceRecord[];
|
||||
readonly visible: boolean;
|
||||
readonly onInspect: () => void;
|
||||
};
|
||||
|
||||
export const EvidenceReceipt = ({
|
||||
records,
|
||||
visible,
|
||||
onInspect,
|
||||
}: EvidenceReceiptProps) => {
|
||||
if (!visible) return null;
|
||||
const receipt = projectEvidenceReceipt(records);
|
||||
const countLabel = `${receipt.recordCount} ${receipt.recordCount === 1 ? "record" : "records"}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="evidence-receipt"
|
||||
aria-label="Inspect evidence"
|
||||
disabled={!receipt.available}
|
||||
onClick={onInspect}
|
||||
>
|
||||
<span>Evidence: {receipt.operation}</span>
|
||||
{receipt.status && <span data-status={receipt.status}>{receipt.status}</span>}
|
||||
<span>{countLabel}</span>
|
||||
{receipt.available && <span aria-hidden="true">Inspect</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EvidenceRecord } from "../../app/state.js";
|
||||
import {
|
||||
formatEvidenceValue,
|
||||
projectEvidenceDetail,
|
||||
projectEvidenceReceipt,
|
||||
} from "./evidence-model.js";
|
||||
|
||||
const record = (response: unknown): 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,
|
||||
durationMs: 88,
|
||||
});
|
||||
|
||||
describe("evidence projection", () => {
|
||||
it("uses the latest record and extracts a nested result status", () => {
|
||||
const model = projectEvidenceReceipt([
|
||||
record({ result: { status: "interrupted" } }),
|
||||
{ ...record({ result: { status: "completed" } }), id: "trace", operation: "workflow.runs.trace" },
|
||||
]);
|
||||
expect(model).toMatchObject({
|
||||
available: true,
|
||||
operation: "workflow.runs.trace",
|
||||
status: "completed",
|
||||
recordCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an unavailable receipt for no records", () => {
|
||||
expect(projectEvidenceReceipt([])).toEqual({
|
||||
available: false,
|
||||
operation: "Evidence unavailable",
|
||||
status: null,
|
||||
recordCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("projects run and deployment identifiers without requiring status", () => {
|
||||
expect(projectEvidenceDetail({
|
||||
...record({ result: { run_id: "run_demo" } }),
|
||||
request: { deployment_id: "demo.default" },
|
||||
})).toMatchObject({
|
||||
status: null,
|
||||
durationMs: 88,
|
||||
deploymentId: "demo.default",
|
||||
runId: "run_demo",
|
||||
equivalentCli: "uv run wf run start demo.default",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns bounded text and a note when raw evidence is not JSON serializable", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
const formatted = formatEvidenceValue(cyclic);
|
||||
expect(formatted.text).toBe("[object Object]");
|
||||
expect(formatted.note).toMatch(/could not format as json/i);
|
||||
});
|
||||
|
||||
it("truncates oversized evidence before it reaches the inspector", () => {
|
||||
const formatted = formatEvidenceValue("x".repeat(120_000));
|
||||
expect(formatted.text.length).toBeLessThanOrEqual(100_003);
|
||||
expect(formatted.note).toMatch(/truncated/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { EvidenceRecord } from "../../app/state.js";
|
||||
|
||||
export type EvidenceReceiptModel = {
|
||||
readonly available: boolean;
|
||||
readonly operation: string;
|
||||
readonly status: string | null;
|
||||
readonly recordCount: number;
|
||||
};
|
||||
|
||||
export type EvidenceDetailModel = EvidenceReceiptModel & {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly equivalentCli: string;
|
||||
readonly durationMs: number;
|
||||
readonly deploymentId: string | null;
|
||||
readonly runId: string | null;
|
||||
readonly request: FormattedEvidenceValue;
|
||||
readonly response: FormattedEvidenceValue;
|
||||
};
|
||||
|
||||
export type FormattedEvidenceValue = {
|
||||
readonly text: string;
|
||||
readonly note: string | null;
|
||||
};
|
||||
|
||||
const MAX_EVIDENCE_TEXT_LENGTH = 100_000;
|
||||
|
||||
// Guarded object helper: reads nested properties without assuming the full
|
||||
// response shape or casting the complete payload.
|
||||
const objectValue = (value: unknown): Readonly<Record<string, unknown>> | null =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Readonly<Record<string, unknown>>
|
||||
: null;
|
||||
|
||||
const responseStatus = (response: unknown): string | null => {
|
||||
const result = objectValue(objectValue(response)?.result);
|
||||
return typeof result?.status === "string" ? result.status : null;
|
||||
};
|
||||
|
||||
const stringField = (
|
||||
value: Readonly<Record<string, unknown>> | null,
|
||||
field: string,
|
||||
): string | null => {
|
||||
const candidate = value?.[field];
|
||||
return typeof candidate === "string" ? candidate : null;
|
||||
};
|
||||
|
||||
const boundEvidenceText = (
|
||||
text: string,
|
||||
note: string | null,
|
||||
): FormattedEvidenceValue => {
|
||||
if (text.length <= MAX_EVIDENCE_TEXT_LENGTH) return { text, note };
|
||||
const truncation = `Evidence truncated to ${MAX_EVIDENCE_TEXT_LENGTH} characters.`;
|
||||
return {
|
||||
text: `${text.slice(0, MAX_EVIDENCE_TEXT_LENGTH)}...`,
|
||||
note: note ? `${note} ${truncation}` : truncation,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatEvidenceValue = (value: unknown): FormattedEvidenceValue => {
|
||||
try {
|
||||
const encoded = JSON.stringify(value, null, 2);
|
||||
return boundEvidenceText(encoded ?? String(value), null);
|
||||
} catch {
|
||||
let text: string;
|
||||
try {
|
||||
text = String(value);
|
||||
} catch {
|
||||
text = "[Unprintable evidence]";
|
||||
}
|
||||
return boundEvidenceText(
|
||||
text,
|
||||
"Could not format as JSON; showing a bounded text representation.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const projectEvidenceReceipt = (
|
||||
records: readonly EvidenceRecord[],
|
||||
): EvidenceReceiptModel => {
|
||||
const latest = records.at(-1);
|
||||
if (!latest) {
|
||||
return { available: false, operation: "Evidence unavailable", status: null, recordCount: 0 };
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
operation: latest.operation,
|
||||
status: responseStatus(latest.response),
|
||||
recordCount: records.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const projectEvidenceDetail = (record: EvidenceRecord): EvidenceDetailModel => {
|
||||
const request = objectValue(record.request);
|
||||
const result = objectValue(objectValue(record.response)?.result);
|
||||
return {
|
||||
...projectEvidenceReceipt([record]),
|
||||
id: record.id,
|
||||
label: record.label,
|
||||
equivalentCli: record.equivalentCli,
|
||||
durationMs: record.durationMs,
|
||||
deploymentId: stringField(result, "deployment_id") ?? stringField(request, "deployment_id"),
|
||||
runId: stringField(result, "run_id") ?? stringField(request, "run_id"),
|
||||
request: formatEvidenceValue(record.request),
|
||||
response: formatEvidenceValue(record.response),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user