fix: harden capability playground receipts
This commit is contained in:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user