feat: show source inventory and rpc evidence

This commit is contained in:
lda
2026-07-02 12:34:20 +07:00 Verified
parent 4d95b22e0b
commit 6c7522123d
11 changed files with 1041 additions and 114 deletions
@@ -2,25 +2,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionHeader } from "./ConnectionHeader.js";
import { initialState, connectionReducer } from "../app/state.js";
vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(),
}));
const getFirstSection = () => {
const sections = document.querySelectorAll('section[aria-label="Connection"]');
return sections[0] as HTMLElement;
};
import { connectToServer } from "../connection/api.js";
const mockConnect = vi.mocked(connectToServer);
beforeEach(() => {
cleanup();
mockConnect.mockReset();
try {
sessionStorage.clear();
} catch {
// jsdom may not provide sessionStorage
}
});
const successResponse = {
const successData = {
ok: true,
connection: {
status: "connected",
@@ -33,14 +22,36 @@ const successResponse = {
equivalentCli: "uv run wf status",
} as const;
const getFirstSection = () => {
const sections = document.querySelectorAll('section[aria-label="Connection"]');
return sections[0] as HTMLElement;
beforeEach(() => {
cleanup();
try {
sessionStorage.clear();
} catch {
// jsdom may not provide sessionStorage
}
});
afterEach(() => {
cleanup();
});
const renderWithDefaults = (overrides?: {
onSubmit?: (target: string) => void;
onDraftChange?: (value: string) => void;
state?: ReturnType<typeof initialState>;
}) => {
const state = overrides?.state ?? initialState();
const onSubmit = overrides?.onSubmit ?? vi.fn();
const onDraftChange = overrides?.onDraftChange ?? vi.fn();
render(
<ConnectionHeader state={state} onSubmit={onSubmit} onDraftChange={onDraftChange} />,
);
return { onSubmit, onDraftChange, state };
};
describe("ConnectionHeader", () => {
it("renders default target and Connect button", () => {
render(<ConnectionHeader />);
renderWithDefaults();
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
"http://127.0.0.1:8765/rpc",
);
@@ -50,23 +61,16 @@ describe("ConnectionHeader", () => {
});
it("does not automatically call the server", () => {
render(<ConnectionHeader />);
expect(mockConnect).not.toHaveBeenCalled();
const { onSubmit } = renderWithDefaults();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows connecting state and disables button during request", async () => {
const user = userEvent.setup();
let resolveConnect!: (value: typeof successResponse) => void;
mockConnect.mockReturnValue(
new Promise((r) => {
resolveConnect = r;
}),
);
render(<ConnectionHeader />);
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
);
it("shows connecting state and disables button during request", () => {
const connectingState = connectionReducer(initialState(), {
type: "submit",
target: "http://127.0.0.1:8765/rpc",
});
renderWithDefaults({ state: connectingState });
expect(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
@@ -74,18 +78,14 @@ describe("ConnectionHeader", () => {
expect(
within(getFirstSection()).getByTestId("phase-label"),
).toHaveTextContent("Connecting\u2026");
resolveConnect(successResponse);
});
it("shows connected state with server details", async () => {
const user = userEvent.setup();
mockConnect.mockResolvedValue(successResponse);
render(<ConnectionHeader />);
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
);
it("shows connected state with server details", () => {
const connectedState = connectionReducer(initialState(), {
type: "success",
data: successData,
});
renderWithDefaults({ state: connectedState });
expect(
within(getFirstSection()).getByTestId("phase-label"),
@@ -104,29 +104,38 @@ describe("ConnectionHeader", () => {
).toBeDefined();
});
it("retains typed value on failure", async () => {
const user = userEvent.setup();
mockConnect.mockResolvedValue({
ok: false,
error: { code: "invalid_target", message: "bad target" },
exchange: { request: null, response: null },
it("retains typed value on failure", () => {
const errorState = connectionReducer(initialState(), {
type: "failure",
code: "invalid_target",
message: "bad target",
});
render(<ConnectionHeader />);
const input = screen.getByLabelText("Workflow JSON-RPC URL");
await user.clear(input);
await user.type(input, "http://bad:9999/rpc");
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
renderWithDefaults({
state: { ...errorState, draftTarget: "http://bad:9999/rpc" },
});
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
"http://bad:9999/rpc",
);
expect(input).toHaveValue("http://bad:9999/rpc");
expect(
within(getFirstSection()).getByTestId("error-message"),
).toHaveTextContent("bad target");
});
it("restored target still requires explicit connect", async () => {
it("calls onSubmit with target when form submitted", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
renderWithDefaults({ onSubmit });
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
);
expect(onSubmit).toHaveBeenCalledWith(
"http://127.0.0.1:8765/rpc",
);
});
it("restored target still requires explicit connect", () => {
try {
sessionStorage.setItem(
"lda.workflowConsole.target",
@@ -135,8 +144,7 @@ describe("ConnectionHeader", () => {
} catch {
// jsdom may not support sessionStorage
}
render(<ConnectionHeader />);
expect(mockConnect).not.toHaveBeenCalled();
const { onSubmit } = renderWithDefaults();
expect(onSubmit).not.toHaveBeenCalled();
});
});
@@ -1,35 +1,5 @@
import { useReducer, type FormEvent } from "react";
import {
connectionReducer,
initialState,
type ConnectionAction,
} from "../app/state.js";
import { connectToServer } from "../connection/api.js";
const handleSubmit = async (
dispatch: React.Dispatch<ConnectionAction>,
target: string,
) => {
dispatch({ type: "submit", target });
try {
const response = await connectToServer(target);
if (response.ok) {
dispatch({ type: "success", data: response });
} else {
dispatch({
type: "failure",
code: response.error.code,
message: response.error.message,
});
}
} catch (e: unknown) {
dispatch({
type: "failure",
code: "rpc_protocol_error",
message: e instanceof Error ? e.message : "unknown error",
});
}
};
import type { FormEvent } from "react";
import type { ConnectionState } from "../app/state.js";
const phaseLabel = (phase: string): string => {
switch (phase) {
@@ -52,25 +22,27 @@ const phaseLabel = (phase: string): string => {
}
};
export const ConnectionHeader = () => {
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
type Props = {
readonly state: ConnectionState;
readonly onSubmit: (target: string) => void;
readonly onDraftChange: (value: string) => void;
};
const onSubmit = (e: FormEvent) => {
export const ConnectionHeader = ({ state, onSubmit, onDraftChange }: Props) => {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
void handleSubmit(dispatch, state.draftTarget);
onSubmit(state.draftTarget);
};
return (
<section aria-label="Connection">
<form onSubmit={onSubmit}>
<form onSubmit={handleSubmit}>
<label htmlFor="target-input">Workflow JSON-RPC URL</label>
<input
id="target-input"
type="text"
value={state.draftTarget}
onChange={(e) =>
dispatch({ type: "draft_changed", value: e.target.value })
}
onChange={(e) => onDraftChange(e.target.value)}
disabled={state.phase === "connecting"}
/>
<button type="submit" disabled={state.phase === "connecting"}>
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, screen, within, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProtocolEvidence } from "./ProtocolEvidence.js";
import type { EvidenceRecord } from "../app/state.js";
beforeEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
});
const makeRecord = (
overrides: Partial<EvidenceRecord> & { id: string },
): EvidenceRecord => ({
operation: "workflow.health",
label: "Health check",
equivalentCli: "uv run wf status",
request: { method: "workflow.health" },
response: { status: "ok" },
durationMs: 12,
...overrides,
});
describe("ProtocolEvidence", () => {
it("shows empty state when no evidence", () => {
render(<ProtocolEvidence evidence={[]} />);
expect(screen.getByTestId("evidence-empty")).toHaveTextContent(
"No evidence recorded yet.",
);
});
it("lists evidence records as selectable toggles", () => {
const evidence = [
makeRecord({ id: "health-1", operation: "workflow.health" }),
makeRecord({ id: "sources-1", operation: "workflow.sources.list" }),
];
render(<ProtocolEvidence evidence={evidence} />);
expect(screen.getByTestId("evidence-toggle-health-1")).toBeDefined();
expect(screen.getByTestId("evidence-toggle-sources-1")).toBeDefined();
});
it("expands to show equivalent CLI, request, and response", async () => {
const user = userEvent.setup();
const evidence = [
makeRecord({
id: "health-1",
operation: "workflow.health",
equivalentCli: "uv run wf status",
request: { jsonrpc: "2.0", method: "workflow.health" },
response: { status: "ok", storeRoot: "/tmp" },
durationMs: 8,
}),
];
render(<ProtocolEvidence evidence={evidence} />);
await user.click(screen.getByTestId("evidence-toggle-health-1"));
const detail = screen.getByTestId("evidence-detail-health-1");
expect(detail).toBeDefined();
const cliSection = within(detail).getAllByRole("heading", {
name: "Equivalent CLI",
});
expect(cliSection).toHaveLength(1);
expect(detail.textContent).toContain("uv run wf status");
const preElements = detail.querySelectorAll("pre code");
expect(preElements.length).toBeGreaterThanOrEqual(2);
expect(preElements[0]?.textContent).toContain("uv run wf status");
expect(preElements[1]?.textContent).toContain("workflow.health");
});
it("shows 'No response received.' for null response", async () => {
const user = userEvent.setup();
const evidence = [
makeRecord({ id: "health-1", response: null }),
];
render(<ProtocolEvidence evidence={evidence} />);
await user.click(screen.getByTestId("evidence-toggle-health-1"));
const detail = screen.getByTestId("evidence-detail-health-1");
expect(detail.textContent).toContain("No response received.");
});
it("renders evidence through pre/code elements, never HTML", async () => {
const user = userEvent.setup();
const evidence = [
makeRecord({
id: "health-1",
request: { html: "<script>alert('xss')</script>" },
response: null,
}),
];
render(<ProtocolEvidence evidence={evidence} />);
await user.click(screen.getByTestId("evidence-toggle-health-1"));
const detail = screen.getByTestId("evidence-detail-health-1");
const preCodes = detail.querySelectorAll("pre code");
expect(preCodes.length).toBeGreaterThanOrEqual(2);
const requestCode = preCodes[1] as Element;
expect(requestCode.textContent).toContain("<script>");
expect(requestCode.innerHTML).not.toContain("<script>");
});
it("is keyboard operable with native button", async () => {
const user = userEvent.setup();
const evidence = [
makeRecord({ id: "health-1" }),
];
render(<ProtocolEvidence evidence={evidence} />);
const toggle = screen.getByTestId("evidence-toggle-health-1");
expect(toggle).toHaveAttribute("aria-expanded", "false");
await user.tab();
await user.keyboard("{Enter}");
expect(toggle).toHaveAttribute("aria-expanded", "true");
await user.keyboard("{Enter}");
expect(toggle).toHaveAttribute("aria-expanded", "false");
});
it("collapses previous when opening another", async () => {
const user = userEvent.setup();
const evidence = [
makeRecord({ id: "health-1", operation: "workflow.health" }),
makeRecord({ id: "sources-1", operation: "workflow.sources.list" }),
];
render(<ProtocolEvidence evidence={evidence} />);
await user.click(screen.getByTestId("evidence-toggle-health-1"));
expect(screen.getByTestId("evidence-detail-health-1")).toBeDefined();
await user.click(screen.getByTestId("evidence-toggle-sources-1"));
expect(screen.getByTestId("evidence-detail-sources-1")).toBeDefined();
expect(screen.queryByTestId("evidence-detail-health-1")).toBeNull();
});
});
@@ -0,0 +1,72 @@
import { useState } from "react";
import type { EvidenceRecord } from "../app/state.js";
type Props = {
readonly evidence: ReadonlyArray<EvidenceRecord>;
};
export const ProtocolEvidence = ({ evidence }: Props) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const toggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<section aria-label="Protocol Evidence">
<h2>Protocol Evidence</h2>
{evidence.length === 0 ? (
<p data-testid="evidence-empty">No evidence recorded yet.</p>
) : (
<ul data-testid="evidence-list" className="evidence-list">
{evidence.map((record) => (
<li key={record.id}>
<button
type="button"
aria-expanded={expandedId === record.id}
data-testid={`evidence-toggle-${record.id}`}
onClick={() => toggle(record.id)}
className="evidence-toggle"
>
<span className="evidence-op">{record.operation}</span>
<span className="evidence-label">{record.label}</span>
<span className="evidence-duration">
{record.durationMs}ms
</span>
</button>
{expandedId === record.id && (
<div
data-testid={`evidence-detail-${record.id}`}
className="evidence-detail"
>
<div className="evidence-field">
<h3>Equivalent CLI</h3>
<pre>
<code>{record.equivalentCli}</code>
</pre>
</div>
<div className="evidence-field">
<h3>Request</h3>
<pre>
<code>{JSON.stringify(record.request, null, 2)}</code>
</pre>
</div>
<div className="evidence-field">
<h3>Response</h3>
<pre>
<code>
{record.response !== null
? JSON.stringify(record.response, null, 2)
: "No response received."}
</code>
</pre>
</div>
</div>
)}
</li>
))}
</ul>
)}
</section>
);
};
@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { render, screen, within, cleanup } from "@testing-library/react";
import { SourceInventory } from "./SourceInventory.js";
import type { SourceRecord } from "../app/state.js";
beforeEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
});
const makeSource = (overrides: Partial<SourceRecord> & { id: string }): SourceRecord => ({
kind: "tool",
enabled: true,
description: null,
toolCount: 1,
nodeSpecCount: 0,
reducerCount: 0,
promptCount: 0,
resourceCount: 0,
...overrides,
});
describe("SourceInventory", () => {
it("shows loading state", () => {
render(<SourceInventory sources={[]} loading={true} error={null} />);
expect(screen.getByTestId("sources-loading")).toHaveTextContent(
"Loading sources\u2026",
);
});
it("shows empty state when no sources", () => {
render(<SourceInventory sources={[]} loading={false} error={null} />);
expect(screen.getByTestId("sources-empty")).toHaveTextContent(
"No workflow sources reported.",
);
});
it("renders source rows with id, kind, and enabled status", () => {
const sources = [
makeSource({ id: "tools-core", kind: "tool", enabled: true }),
makeSource({ id: "reducers-main", kind: "reducer", enabled: false }),
];
render(<SourceInventory sources={sources} loading={false} error={null} />);
expect(screen.getByTestId("source-id-tools-core")).toHaveTextContent(
"tools-core",
);
expect(screen.getByTestId("source-kind-tools-core")).toHaveTextContent(
"tool",
);
expect(
screen.getByTestId("source-status-tools-core"),
).toHaveTextContent("enabled");
expect(
screen.getByTestId("source-status-reducers-main"),
).toHaveTextContent("disabled");
});
it("shows description when present", () => {
const sources = [
makeSource({
id: "tools-core",
description: "Core workflow tools",
}),
];
render(<SourceInventory sources={sources} loading={false} error={null} />);
expect(screen.getByText("Core workflow tools")).toBeDefined();
});
it("renders total counts across categories", () => {
const sources = [
makeSource({
id: "tools-core",
toolCount: 5,
nodeSpecCount: 3,
reducerCount: 2,
promptCount: 1,
resourceCount: 4,
}),
];
render(<SourceInventory sources={sources} loading={false} error={null} />);
expect(screen.getByTestId("source-tools-tools-core")).toHaveTextContent("5");
expect(
screen.getByTestId("source-nodes-tools-core"),
).toHaveTextContent("3");
expect(
screen.getByTestId("source-reducers-tools-core"),
).toHaveTextContent("2");
expect(
screen.getByTestId("source-prompts-tools-core"),
).toHaveTextContent("1");
expect(
screen.getByTestId("source-resources-tools-core"),
).toHaveTextContent("4");
});
it("renders error without replacing connection status", () => {
render(
<SourceInventory
sources={[]}
loading={false}
error="Failed to load sources"
/>,
);
expect(screen.getByTestId("sources-error")).toHaveTextContent(
"Failed to load sources",
);
expect(screen.getByTestId("sources-error")).toHaveAttribute(
"role",
"alert",
);
});
});
@@ -0,0 +1,64 @@
import type { SourceRecord } from "../app/state.js";
type Props = {
readonly sources: ReadonlyArray<SourceRecord>;
readonly loading: boolean;
readonly error: string | null;
};
export const SourceInventory = ({ sources, loading, error }: Props) => {
return (
<section aria-label="Source Inventory">
<h2>Sources</h2>
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
{error && (
<p data-testid="sources-error" role="alert">
{error}
</p>
)}
{!loading && !error && sources.length === 0 && (
<p data-testid="sources-empty">No workflow sources reported.</p>
)}
{!loading && !error && sources.length > 0 && (
<table data-testid="sources-table">
<thead>
<tr>
<th>Source</th>
<th>Kind</th>
<th>Description</th>
<th>Status</th>
<th>Tools</th>
<th>Nodes</th>
<th>Reducers</th>
<th>Prompts</th>
<th>Resources</th>
</tr>
</thead>
<tbody>
{sources.map((s) => (
<tr key={s.id} data-testid={`source-row-${s.id}`}>
<td data-testid={`source-id-${s.id}`}>{s.id}</td>
<td data-testid={`source-kind-${s.id}`}>{s.kind}</td>
<td data-testid={`source-desc-${s.id}`}>
{s.description ?? ""}
</td>
<td data-testid={`source-status-${s.id}`}>
{s.enabled ? "enabled" : "disabled"}
</td>
<td data-testid={`source-tools-${s.id}`}>{s.toolCount}</td>
<td data-testid={`source-nodes-${s.id}`}>{s.nodeSpecCount}</td>
<td data-testid={`source-reducers-${s.id}`}>
{s.reducerCount}
</td>
<td data-testid={`source-prompts-${s.id}`}>{s.promptCount}</td>
<td data-testid={`source-resources-${s.id}`}>
{s.resourceCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
};