fix: harden capability playground receipts

This commit is contained in:
lda
2026-08-11 20:50:15 +07:00 Verified
parent 0fd4b561c7
commit ecf72a8603
4 changed files with 554 additions and 126 deletions
+24 -4
View File
@@ -1303,7 +1303,7 @@ tbody tr:hover {
.capability-playground__receipt-facts dt {
color: var(--color-slate);
font-family: var(--font-heading);
font-size: 0.72rem;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1317,7 +1317,7 @@ tbody tr:hover {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-size: 0.75rem;
font-size: 0.82rem;
}
.capability-playground__description,
@@ -1344,7 +1344,7 @@ tbody tr:hover {
padding: 0.5rem 0.65rem;
cursor: pointer;
font-family: var(--font-heading);
font-size: 0.78rem;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
@@ -1359,7 +1359,7 @@ tbody tr:hover {
background: var(--color-ink);
color: var(--color-paper);
font-family: var(--font-mono);
font-size: 0.72rem;
font-size: 0.82rem;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -1463,6 +1463,25 @@ tbody tr:hover {
background: var(--color-paper);
}
.capability-playground__submitted-request {
margin: 0 0 0.9rem;
padding: 0.65rem;
border: 1px solid var(--color-border);
background: var(--color-paper);
}
.capability-playground__submitted-request h4 {
margin: 0 0 0.35rem;
font-family: var(--font-heading);
font-size: 0.82rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.capability-playground__submitted-request .capability-playground__receipt-facts {
margin-bottom: 0.55rem;
}
.capability-playground__receipt-label {
margin: 0 0 0.25rem;
text-transform: uppercase;
@@ -1510,6 +1529,7 @@ tbody tr:hover {
gap: 0.45rem;
margin: 0;
padding-left: 1.1rem;
font-size: 0.85rem;
}
.capability-playground__diagnostics li {
@@ -1,7 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initialState } from "../../app/state.js";
import { initialState, type EvidenceRecord } from "../../app/state.js";
import { useConsoleWorkspace } from "../context.js";
import type { CapabilityCallResult, CapabilityDetail } from "../domain/capability-models.js";
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
@@ -23,6 +23,7 @@ const mockedUseCapabilityPlayground = vi.mocked(useCapabilityPlayground);
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const writeExecutor = {} as ConsoleWriteExecutor;
const target = "http://workflow.example/rpc";
const nodeCapability: CapabilityDetail = {
kind: "node_spec",
@@ -59,6 +60,34 @@ const wrapperCapability: CapabilityDetail = {
requiredCapabilities: {},
};
const capabilityEvidence = (
id: string,
qualifiedName: string,
durationMs: number,
evidenceTarget = target,
): EvidenceRecord => ({
id,
target: evidenceTarget,
operation: "workflow.capabilities.call",
label: "Call capability",
equivalentCli: "wf capability call",
request: { qualified_name: qualifiedName, payload: {} },
response: {},
durationMs,
});
const workspaceWithEvidence = (
evidence: ReadonlyArray<EvidenceRecord>,
connectedTarget: string | null = target,
executor: ConsoleWriteExecutor | null = writeExecutor,
) => ({
connection: { ...initialState(), evidence },
connectedTarget,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: executor,
});
const callResult = (overrides: Partial<CapabilityCallResult> = {}): CapabilityCallResult => ({
qualifiedName: nodeCapability.name,
sourceId: nodeCapability.sourceId,
@@ -99,28 +128,26 @@ const renderPlayground = (capability: CapabilityDetail = nodeCapability) =>
<CapabilityPlayground
capability={capability}
executor={writeExecutor}
target="http://workflow.example/rpc"
target={target}
/>,
);
const submitNodeCall = async (query = "README.md"): Promise<void> => {
const user = userEvent.setup();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
await user.type(screen.getByRole("textbox", { name: "Query" }), query);
await user.click(screen.getByRole("button", { name: "Call capability" }));
};
beforeEach(() => {
mockedUseConsoleWorkspace.mockReturnValue({
connection: {
...initialState(),
evidence: [
{
id: "call-1",
target: "http://workflow.example/rpc",
operation: "workflow.capabilities.call",
label: "Call capability",
equivalentCli: "wf capability call",
request: {},
response: {},
durationMs: 18,
},
capabilityEvidence("call-1", "local.documents.other", 18),
],
},
connectedTarget: "http://workflow.example/rpc",
connectedTarget: target,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor,
@@ -146,6 +173,36 @@ describe("CapabilityPlayground", () => {
expect(screen.getByRole("button", { name: "Add to draft" })).toBeInTheDocument();
});
it("implements a roving keyboard tab pattern with activation and stable panel names", async () => {
const user = userEvent.setup();
renderPlayground();
const contractTab = screen.getByRole("tab", { name: "Contract" });
const tryTab = screen.getByRole("tab", { name: "Try capability" });
contractTab.focus();
expect(contractTab).toHaveAttribute("tabindex", "0");
expect(tryTab).toHaveAttribute("tabindex", "-1");
await user.keyboard("{ArrowRight}");
expect(tryTab).toHaveFocus();
expect(tryTab).toHaveAttribute("aria-selected", "true");
expect(contractTab).toHaveAttribute("tabindex", "-1");
expect(screen.getByRole("tabpanel", { name: "Try capability" })).toHaveAttribute(
"aria-labelledby",
tryTab.id,
);
await user.keyboard("{End}");
expect(tryTab).toHaveFocus();
await user.keyboard("{Home}");
expect(contractTab).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(tryTab).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(contractTab).toHaveFocus();
});
it("shows the literal-only form and immediate execution warning in Try", async () => {
const user = userEvent.setup();
renderPlayground();
@@ -202,6 +259,116 @@ describe("CapabilityPlayground", () => {
expect(screen.getByRole("textbox", { name: "Wrapper deployment ID" })).toBeInTheDocument();
});
it("keeps the submitted payload and deployment immutable when the form changes", async () => {
const user = userEvent.setup();
const call = vi.fn();
let activeController = controller({
acknowledged: true,
call,
deploymentId: "docs.default",
});
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
const view = renderPlayground(wrapperCapability);
await user.click(screen.getByRole("tab", { name: "Try capability" }));
await user.type(screen.getByRole("textbox", { name: "Query" }), "README.md");
await user.click(screen.getByRole("button", { name: "Call capability" }));
await user.clear(screen.getByRole("textbox", { name: "Query" }));
await user.type(screen.getByRole("textbox", { name: "Query" }), "changed.md");
await user.clear(screen.getByRole("textbox", { name: "Wrapper deployment ID" }));
await user.type(screen.getByRole("textbox", { name: "Wrapper deployment ID" }), "changed.default");
activeController = controller({
acknowledged: true,
call,
deploymentId: "changed.default",
});
view.rerender(
<CapabilityPlayground
capability={wrapperCapability}
executor={writeExecutor}
target={target}
/>,
);
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult({
qualifiedName: wrapperCapability.name,
sourceId: wrapperCapability.sourceId,
kind: wrapperCapability.kind,
deploymentId: "docs.default",
}),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([
capabilityEvidence("call-submitted", wrapperCapability.name, 18),
]),
);
view.rerender(
<CapabilityPlayground
capability={wrapperCapability}
executor={writeExecutor}
target={target}
/>,
);
expect(screen.getByLabelText("Submitted payload")).toHaveTextContent("README.md");
expect(screen.getByLabelText("Submitted payload")).not.toHaveTextContent("changed.md");
expect(screen.getByLabelText("Submitted deployment")).toHaveTextContent("docs.default");
expect(screen.getByLabelText("Submitted deployment")).not.toHaveTextContent("changed.default");
});
it("rejects serialization issues before calling and shows an inline alert", async () => {
const user = userEvent.setup();
const call = vi.fn();
mockedUseCapabilityPlayground.mockReturnValue(
controller({ acknowledged: true, call }),
);
renderPlayground();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
await user.click(screen.getByRole("button", { name: "Call capability" }));
expect(call).not.toHaveBeenCalled();
expect(screen.getAllByRole("alert").at(-1)).toHaveTextContent(
"Required field is incomplete.",
);
});
it("bounds long output in the result receipt", async () => {
const user = userEvent.setup();
let activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
const view = renderPlayground();
await user.click(screen.getByRole("tab", { name: "Try capability" }));
await user.type(screen.getByRole("textbox", { name: "Query" }), "README.md");
await user.click(screen.getByRole("button", { name: "Call capability" }));
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult({ output: { content: "x".repeat(14_000) } }),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([capabilityEvidence("call-output", nodeCapability.name, 18)]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
const output = screen.getByLabelText("Capability output");
expect(output).toHaveTextContent("... truncated ...");
expect(output.textContent?.length).toBeLessThanOrEqual(12_000);
});
it.each([
["calling", controller({ phase: "calling", acknowledged: true }), "Calling capability..."],
["rejected", controller({ phase: "error", message: "Operation rejected by policy" }), "Operation rejected by policy"],
@@ -219,34 +386,65 @@ describe("CapabilityPlayground", () => {
});
it("renders outcome, evidence provenance, diagnostics, and bounded output in the receipt", async () => {
mockedUseCapabilityPlayground.mockReturnValue(
controller({ phase: "result", acknowledged: true, result: callResult() }),
let activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
const view = renderPlayground();
await submitNodeCall();
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([
capabilityEvidence("call-1", "local.documents.other", 18),
capabilityEvidence("call-2", nodeCapability.name, 24),
]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
renderPlayground();
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.getByText("18 ms")).toBeInTheDocument();
expect(screen.getByText("24 ms")).toBeInTheDocument();
expect(screen.getByText("workflow.capabilities.call")).toBeInTheDocument();
expect(screen.getByText("Document source was checked.")).toBeInTheDocument();
expect(screen.getByText("A direct capability call creates no workflow run or trace.")).toBeInTheDocument();
expect(screen.getByLabelText("Capability output")).toHaveTextContent("README.md");
});
it("labels runtime_error as completed without claiming a workflow run or trace", async () => {
mockedUseCapabilityPlayground.mockReturnValue(
controller({
phase: "result",
acknowledged: true,
result: callResult({ outcome: "runtime_error", output: null, diagnostics: [] }),
}),
let activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
const view = renderPlayground();
await submitNodeCall();
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult({ outcome: "runtime_error", output: null, diagnostics: [] }),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([
capabilityEvidence("call-1", "local.documents.other", 18),
capabilityEvidence("call-runtime", nodeCapability.name, 31),
]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
renderPlayground();
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.getByText("Completed with runtime error")).toBeInTheDocument();
expect(screen.getByText("No workflow run or trace was created.")).toBeInTheDocument();
expect(screen.getByText("A direct capability call creates no workflow run or trace.")).toBeInTheDocument();
});
it("rejects a non-object serialized root locally", async () => {
@@ -271,11 +469,7 @@ describe("CapabilityPlayground", () => {
});
it("clears the receipt and acknowledgement when the capability changes", async () => {
const firstState = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
let firstState = controller({ acknowledged: true });
const secondState = controller();
mockedUseCapabilityPlayground.mockImplementation((qualifiedName: string | null) =>
qualifiedName === nodeCapability.name ? firstState : secondState,
@@ -289,6 +483,26 @@ describe("CapabilityPlayground", () => {
);
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
await userEvent.type(screen.getByRole("textbox", { name: "Query" }), "README.md");
await userEvent.click(screen.getByRole("button", { name: "Call capability" }));
firstState = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([
capabilityEvidence("call-1", "local.documents.other", 18),
capabilityEvidence("call-clear", nodeCapability.name, 22),
]),
);
rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target="http://workflow.example/rpc"
/>,
);
expect(screen.getByText("Completed")).toBeInTheDocument();
rerender(
@@ -302,4 +516,155 @@ describe("CapabilityPlayground", () => {
expect(screen.queryByText("Completed")).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /I understand/i })).not.toBeChecked();
});
it("matches the submitted capability and ignores an interleaved call", async () => {
let activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
const view = renderPlayground();
await submitNodeCall();
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([
capabilityEvidence("call-submitted", nodeCapability.name, 24),
capabilityEvidence("call-interleaved", "local.documents.other", 99),
]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
expect(screen.getByText("24 ms")).toBeInTheDocument();
expect(screen.queryByText("99 ms")).not.toBeInTheDocument();
});
it("matches only evidence added after the current repeated call", async () => {
let activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([capabilityEvidence("call-first", nodeCapability.name, 11)]),
);
const view = renderPlayground();
await submitNodeCall("first.md");
mockedUseCapabilityPlayground.mockReturnValue(
controller({
phase: "result",
acknowledged: true,
result: callResult(),
}),
);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([capabilityEvidence("call-first", nodeCapability.name, 11)]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
expect(screen.queryByText("11 ms")).not.toBeInTheDocument();
activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockReturnValue(activeController);
const user = userEvent.setup();
await user.clear(screen.getByRole("textbox", { name: "Query" }));
await user.type(screen.getByRole("textbox", { name: "Query" }), "second.md");
await user.click(screen.getByRole("button", { name: "Call capability" }));
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([
capabilityEvidence("call-first", nodeCapability.name, 11),
capabilityEvidence("call-second", nodeCapability.name, 22),
]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
expect(screen.getByText("22 ms")).toBeInTheDocument();
});
it("does not attach evidence from an arbitrary target when the target is null", async () => {
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence(
[capabilityEvidence("call-other-target", nodeCapability.name, 18, target)],
null,
null,
),
);
mockedUseCapabilityPlayground.mockReturnValue(
controller({
phase: "result",
acknowledged: true,
result: callResult(),
}),
);
render(
<CapabilityPlayground capability={nodeCapability} executor={null} target={null} />,
);
await userEvent.click(screen.getByRole("tab", { name: "Try capability" }));
expect(screen.queryByText("18 ms")).not.toBeInTheDocument();
expect(screen.getByText(/Result receipt unavailable/)).toBeInTheDocument();
});
it("invalidates a receipt through disconnect and reconnect", async () => {
let activeController = controller({ acknowledged: true });
mockedUseCapabilityPlayground.mockImplementation(() => activeController);
const view = renderPlayground();
await submitNodeCall();
activeController = controller({
phase: "result",
acknowledged: true,
result: callResult(),
});
mockedUseCapabilityPlayground.mockReturnValue(activeController);
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([capabilityEvidence("call-connected", nodeCapability.name, 24)]),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={writeExecutor}
target={target}
/>,
);
expect(screen.getByText("Completed")).toBeInTheDocument();
const reconnectedExecutor = {} as ConsoleWriteExecutor;
mockedUseConsoleWorkspace.mockReturnValue(
workspaceWithEvidence([], "http://workflow-reconnected.example/rpc", reconnectedExecutor),
);
view.rerender(
<CapabilityPlayground
capability={nodeCapability}
executor={reconnectedExecutor}
target="http://workflow-reconnected.example/rpc"
/>,
);
expect(screen.queryByText("Completed")).not.toBeInTheDocument();
expect(screen.getByText(/Result receipt unavailable/)).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type KeyboardEvent } from "react";
import { AlertCircle, CheckCircle2, Clock3, Play } from "lucide-react";
import type { EvidenceRecord } from "../../app/state.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js";
@@ -21,22 +21,53 @@ export type CapabilityPlaygroundProps = {
type PlaygroundTab = "contract" | "try";
type SubmittedCall = {
readonly baselineEvidenceIds: ReadonlySet<string>;
readonly deploymentId: string;
readonly executor: ConsoleWriteExecutor | null;
readonly payloadText: string;
readonly qualifiedName: string;
readonly target: string | null;
};
const PLAYGROUND_TABS: readonly PlaygroundTab[] = ["contract", "try"];
const TAB_IDS: Record<PlaygroundTab, string> = {
contract: "capability-playground-tab-contract",
try: "capability-playground-tab-try",
};
const PANEL_IDS: Record<PlaygroundTab, string> = {
contract: "capability-playground-contract-panel",
try: "capability-playground-try-panel",
};
const formatKind = (kind: CapabilityDetail["kind"]): string =>
kind === "node_spec" ? "Node spec" : "Wrapper artifact";
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const latestCallEvidence = (
const requestQualifiedName = (request: unknown): string | null => {
if (!isRecord(request) || typeof request.qualified_name !== "string") {
return null;
}
return request.qualified_name;
};
const submittedCallEvidence = (
evidence: ReadonlyArray<EvidenceRecord>,
target: string | null,
submittedCall: SubmittedCall,
): EvidenceRecord | null => {
// Evidence is an append-only stream, so the baseline prevents an older
// matching call from being mistaken for the call that produced this receipt.
if (submittedCall.target === null) return null;
for (let index = evidence.length - 1; index >= 0; index -= 1) {
const record = evidence[index];
if (record === undefined) continue;
if (
!submittedCall.baselineEvidenceIds.has(record.id) &&
record.operation === "workflow.capabilities.call" &&
(target === null || record.target === target)
record.target === submittedCall.target &&
requestQualifiedName(record.request) === submittedCall.qualifiedName
) {
return record;
}
@@ -71,9 +102,9 @@ const ContractView = ({
readonly onAddToDraft: (() => void) | undefined;
}) => (
<section
aria-labelledby="capability-playground-contract-heading"
aria-labelledby={TAB_IDS.contract}
className="capability-playground__panel"
id="capability-playground-contract-panel"
id={PANEL_IDS.contract}
role="tabpanel"
tabIndex={0}
>
@@ -124,15 +155,20 @@ const formatSerializationIssues = (
result: SchemaSerializationResult,
): string => result.issues.map((issue) => issue.message).join(" ");
const diagnosticKey = (diagnostic: CapabilityCallResult["diagnostics"][number]): string =>
[diagnostic.code, diagnostic.logicalRef, diagnostic.severity, diagnostic.message].join("|");
const outcomeLabel = (outcome: string): string =>
outcome === "runtime_error" ? "Completed with runtime error" : "Completed";
const ResultReceipt = ({
result,
evidence,
submittedCall,
}: {
readonly result: CapabilityCallResult;
readonly evidence: EvidenceRecord | null;
readonly submittedCall: SubmittedCall;
}) => (
<section
aria-labelledby="capability-playground-result-heading"
@@ -151,6 +187,21 @@ const ResultReceipt = ({
<span>{outcomeLabel(result.outcome)}</span>
</p>
</div>
<div
aria-labelledby="capability-playground-submitted-heading"
className="capability-playground__submitted-request"
>
<h4 id="capability-playground-submitted-heading">Submitted request</h4>
<dl className="capability-playground__receipt-facts">
<div>
<dt>Deployment</dt>
<dd aria-label="Submitted deployment">
{submittedCall.deploymentId || "default"}
</dd>
</div>
</dl>
<pre aria-label="Submitted payload">{submittedCall.payloadText}</pre>
</div>
<dl className="capability-playground__receipt-facts">
<div>
<dt>Outcome</dt>
@@ -180,17 +231,20 @@ const ResultReceipt = ({
</>
)}
</dl>
{result.outcome === "runtime_error" && (
<p className="capability-playground__runtime-note">
No workflow run or trace was created.
{!evidence && (
<p className="capability-playground__muted">
Call evidence was not retained for this connection.
</p>
)}
<p className="capability-playground__runtime-note">
A direct capability call creates no workflow run or trace.
</p>
<div className="capability-playground__receipt-section">
<h4>Diagnostics</h4>
{result.diagnostics.length > 0 ? (
<ul className="capability-playground__diagnostics">
{result.diagnostics.map((diagnostic, index) => (
<li key={`${diagnostic.code}-${index}`}>
{result.diagnostics.map((diagnostic) => (
<li key={diagnosticKey(diagnostic)}>
<span className="capability-playground__diagnostic-meta">
{diagnostic.severity} / {diagnostic.code}
</span>
@@ -230,10 +284,20 @@ const TryView = ({
const controller = useCapabilityPlayground(capability.name);
const { connection } = useConsoleWorkspace();
const [localError, setLocalError] = useState<string | null>(null);
const evidence =
controller.phase === "result"
? latestCallEvidence(connection.evidence, target)
const [submittedCall, setSubmittedCall] = useState<SubmittedCall | null>(null);
const currentSubmittedCall =
submittedCall !== null &&
submittedCall.executor === executor &&
submittedCall.qualifiedName === capability.name &&
submittedCall.target === target &&
controller.phase === "result" &&
controller.result?.qualifiedName === submittedCall.qualifiedName
? submittedCall
: null;
const evidence = currentSubmittedCall
? submittedCallEvidence(connection.evidence, currentSubmittedCall)
: null;
const operationAvailable = executor !== null && target !== null && controller.phase !== "disconnected";
const handleSubmit = (result: SchemaSerializationResult): void => {
@@ -246,14 +310,22 @@ const TryView = ({
setLocalError("Capability inputs must serialize to an object.");
return;
}
setSubmittedCall({
baselineEvidenceIds: new Set(connection.evidence.map((record) => record.id)),
deploymentId: controller.deploymentId.trim(),
executor,
payloadText: formatBoundedJson(result.value),
qualifiedName: capability.name,
target,
});
controller.call(result.value);
};
return (
<section
aria-labelledby="capability-playground-try-heading"
aria-labelledby={TAB_IDS.try}
className="capability-playground__panel capability-playground__panel--try"
id="capability-playground-try-panel"
id={PANEL_IDS.try}
role="tabpanel"
tabIndex={0}
>
@@ -322,7 +394,17 @@ const TryView = ({
</p>
)}
{controller.phase === "result" && controller.result && (
<ResultReceipt result={controller.result} evidence={evidence} />
currentSubmittedCall ? (
<ResultReceipt
evidence={evidence}
result={controller.result}
submittedCall={currentSubmittedCall}
/>
) : (
<p className="capability-playground__muted" role="status">
Result receipt unavailable; call again to capture the submitted request.
</p>
)
)}
</section>
);
@@ -335,7 +417,29 @@ export const CapabilityPlayground = ({
onAddToDraft,
}: CapabilityPlaygroundProps) => {
const [activeTab, setActiveTab] = useState<PlaygroundTab>("contract");
const tabId = (tab: PlaygroundTab): string => `capability-playground-tab-${tab}`;
const activateTab = (tab: PlaygroundTab): void => {
setActiveTab(tab);
document.getElementById(TAB_IDS[tab])?.focus();
};
const handleTabKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
tab: PlaygroundTab,
): void => {
const currentIndex = PLAYGROUND_TABS.indexOf(tab);
let nextIndex: number | null = null;
if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % PLAYGROUND_TABS.length;
if (event.key === "ArrowLeft") {
nextIndex = (currentIndex - 1 + PLAYGROUND_TABS.length) % PLAYGROUND_TABS.length;
}
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = PLAYGROUND_TABS.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const nextTab = PLAYGROUND_TABS[nextIndex];
if (nextTab !== undefined) activateTab(nextTab);
};
return (
<section
@@ -352,23 +456,25 @@ export const CapabilityPlayground = ({
className="capability-playground__tabs"
role="tablist"
>
{([
["contract", "Contract"],
["try", "Try capability"],
] as const).map(([tab, label]) => (
{PLAYGROUND_TABS.map((tab) => {
const label = tab === "contract" ? "Contract" : "Try capability";
return (
<button
aria-controls={`capability-playground-${tab}-panel`}
aria-controls={PANEL_IDS[tab]}
aria-selected={activeTab === tab}
className="capability-playground__tab"
id={tabId(tab)}
id={TAB_IDS[tab]}
key={tab}
onClick={() => setActiveTab(tab)}
onKeyDown={(event) => handleTabKeyDown(event, tab)}
role="tab"
tabIndex={activeTab === tab ? 0 : -1}
type="button"
>
{label}
</button>
))}
);
})}
</div>
{activeTab === "contract" ? (
<ContractView capability={capability} onAddToDraft={onAddToDraft} />