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