feat: add console workspace shell

This commit is contained in:
lda
2026-08-04 20:25:31 +07:00 Verified
parent 38b1cb8d03
commit ad3090e5be
11 changed files with 789 additions and 190 deletions
@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { initialState } from "../app/state.js";
import { ConsoleShell } from "./ConsoleShell.js";
afterEach(() => cleanup());
describe("ConsoleShell", () => {
it("renders the connection header, lifecycle rail, main content, and evidence surface", () => {
render(
<MemoryRouter initialEntries={["/console/discover"]}>
<ConsoleShell
connection={initialState()}
onConnect={() => undefined}
onDraftChange={() => undefined}
>
<p>Discover content</p>
</ConsoleShell>
</MemoryRouter>,
);
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Workflow lifecycle" })).toBeInTheDocument();
expect(screen.getByRole("main", { name: "Console workspace" })).toHaveTextContent(
"Discover content",
);
expect(screen.getByRole("complementary", { name: "Operation evidence" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Discover" })).toHaveAttribute(
"aria-current",
"page",
);
});
it("renders all lifecycle links with route destinations", () => {
render(
<MemoryRouter>
<ConsoleShell
connection={initialState()}
onConnect={() => undefined}
onDraftChange={() => undefined}
>
<p>Content</p>
</ConsoleShell>
</MemoryRouter>,
);
for (const { label, href } of [
{ label: "Discover", href: "/console/discover" },
{ label: "Drafts", href: "/console/drafts" },
{ label: "Artifacts", href: "/console/artifacts" },
{ label: "Deployments", href: "/console/deployments" },
{ label: "Runs", href: "/console/runs" },
{ label: "Results", href: "/console/results" },
]) {
expect(screen.getByRole("link", { name: label })).toHaveAttribute("href", href);
}
});
});
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { NavLink } from "react-router-dom";
import type { ConnectionState } from "../app/state.js";
import { ConnectionHeader } from "../components/ConnectionHeader.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
type Props = {
readonly connection: ConnectionState;
readonly onConnect: (target: string) => void;
readonly onDraftChange: (value: string) => void;
readonly children: ReactNode;
};
const lifecycleLinks = [
{ label: "Discover", to: "/console/discover" },
{ label: "Drafts", to: "/console/drafts" },
{ label: "Artifacts", to: "/console/artifacts" },
{ label: "Deployments", to: "/console/deployments" },
{ label: "Runs", to: "/console/runs" },
{ label: "Results", to: "/console/results" },
] as const;
export const ConsoleShell = ({
connection,
onConnect,
onDraftChange,
children,
}: Props) => (
<div className="console-workspace">
<header className="console-workspace__header">
<ConnectionHeader
state={connection}
onSubmit={onConnect}
onDraftChange={onDraftChange}
/>
</header>
<nav aria-label="Workflow lifecycle" className="console-workspace__nav">
<ul>
{lifecycleLinks.map((link) => (
<li key={link.to}>
<NavLink
to={link.to}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
{link.label}
</NavLink>
</li>
))}
</ul>
</nav>
<main id="console-workspace-main" aria-label="Console workspace">
{children}
</main>
<aside aria-label="Operation evidence" className="console-workspace__evidence">
<h2>Operation evidence</h2>
<EvidenceLedger records={connection.evidence} />
</aside>
</div>
);
@@ -0,0 +1,132 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef } from "react";
import { MemoryRouter, Outlet, Route, Routes, useNavigate } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { connectToServer, callOperation } from "../connection/api.js";
import type { ConnectResponse } from "../connection/contracts.js";
import { useConsoleWorkspace } from "./context.js";
import { ConsoleWorkspace } from "./ConsoleWorkspace.js";
vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(),
callOperation: vi.fn(),
}));
const mockedConnectToServer = vi.mocked(connectToServer);
const mockedCallOperation = vi.mocked(callOperation);
const successfulConnection = (target: string): ConnectResponse => ({
ok: true,
connection: {
status: "connected",
target,
serverStatus: "ok",
storeRoot: "/tmp/store",
durationMs: 11,
},
exchange: { request: { target }, response: { status: 200 } },
equivalentCli: "uv run wf status",
});
const OutletProbe = () => {
const workspace = useConsoleWorkspace();
const executorIdentity = useRef<NonNullable<typeof workspace.readExecutor> | null>(null);
if (workspace.readExecutor && executorIdentity.current === null) {
executorIdentity.current = workspace.readExecutor;
}
const navigate = useNavigate();
return (
<>
<output data-testid="connected-target">{workspace.connectedTarget ?? "none"}</output>
<output data-testid="executor-state">{workspace.readExecutor ? "available" : "unavailable"}</output>
<output data-testid="executor-stable">
{workspace.readExecutor === executorIdentity.current ? "yes" : "no"}
</output>
<button type="button" onClick={() => navigate("/console/drafts")}>Navigate to drafts</button>
<Outlet />
</>
);
};
const renderWorkspace = (initialEntry = "/console/discover") =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/console" element={<ConsoleWorkspace />}>
<Route element={<OutletProbe />}>
<Route path="discover" element={<p>Discover route</p>} />
<Route path="drafts" element={<p>Drafts route</p>} />
</Route>
</Route>
</Routes>
</MemoryRouter>,
);
beforeEach(() => {
mockedConnectToServer.mockReset();
mockedCallOperation.mockReset();
sessionStorage.clear();
});
afterEach(() => cleanup());
describe("ConsoleWorkspace", () => {
it("exposes no executor and issues no reads while disconnected", () => {
renderWorkspace();
expect(screen.getByTestId("executor-state")).toHaveTextContent("unavailable");
expect(mockedConnectToServer).not.toHaveBeenCalled();
expect(mockedCallOperation).not.toHaveBeenCalled();
});
it("records health once, exposes one target-scoped executor, and preserves evidence across routes", async () => {
mockedConnectToServer.mockResolvedValue(successfulConnection("http://one.example/rpc"));
renderWorkspace();
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByTestId("connected-target")).toHaveTextContent(
"http://one.example/rpc",
);
expect(screen.getByTestId("executor-state")).toHaveTextContent("available");
expect(screen.getByTestId("executor-stable")).toHaveTextContent("yes");
expect(screen.getAllByText("Health check")).toHaveLength(1);
expect(mockedCallOperation).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Navigate to drafts" }));
expect(await screen.findByText("Drafts route")).toBeInTheDocument();
expect(screen.getAllByText("Health check")).toHaveLength(1);
expect(screen.getByTestId("connected-target")).toHaveTextContent("http://one.example/rpc");
});
it("ignores a stale health response after a newer target connects", async () => {
let resolveFirst!: (response: ConnectResponse) => void;
const first = new Promise<ConnectResponse>((resolve) => {
resolveFirst = resolve;
});
mockedConnectToServer.mockReturnValueOnce(first).mockResolvedValueOnce(
successfulConnection("http://two.example/rpc"),
);
renderWorkspace();
const user = userEvent.setup();
const form = screen.getByLabelText("Workflow JSON-RPC URL").closest("form");
expect(form).not.toBeNull();
fireEvent.submit(form!);
fireEvent.submit(form!);
expect(await screen.findByTestId("connected-target")).toHaveTextContent(
"http://two.example/rpc",
);
resolveFirst(successfulConnection("http://one.example/rpc"));
await waitFor(() => {
expect(screen.getByTestId("connected-target")).toHaveTextContent(
"http://two.example/rpc",
);
});
expect(screen.getAllByText("Health check")).toHaveLength(1);
});
});
@@ -0,0 +1,102 @@
import { useCallback, useMemo, useReducer, useRef } from "react";
import { Outlet } from "react-router-dom";
import { connectToServer } from "../connection/api.js";
import {
connectionReducer,
initialState,
type EvidenceRecord,
} from "../app/state.js";
import { createConsoleReadExecutor } from "./domain/read-executor.js";
import { ConsoleShell } from "./ConsoleShell.js";
import type { ConsoleWorkspaceContextValue } from "./context.js";
export const ConsoleWorkspace = () => {
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
const connectGeneration = useRef(0);
const connectedTarget = state.phase === "connected" ? state.connectedTarget : null;
const recordEvidence = useCallback(
(record: EvidenceRecord) => dispatch({ type: "evidence_recorded", record }),
[],
);
const readExecutor = useMemo(
() =>
connectedTarget
? createConsoleReadExecutor({ target: connectedTarget, recordEvidence })
: null,
[connectedTarget, recordEvidence],
);
const onDraftChange = useCallback(
(value: string) => dispatch({ type: "draft_changed", value }),
[],
);
const onConnect = useCallback((target: string) => {
const generation = ++connectGeneration.current;
dispatch({ type: "submit", target });
void connectToServer(target).then(
(response) => {
if (connectGeneration.current !== generation) return;
if (response.ok) {
dispatch({ type: "success", data: response });
dispatch({
type: "evidence_recorded",
record: {
id: `health-${Date.now()}`,
operation: "workflow.health",
label: "Health check",
equivalentCli: response.equivalentCli,
request: response.exchange.request,
response: response.exchange.response,
durationMs: response.connection.durationMs,
},
});
} else {
dispatch({
type: "failure",
code: response.error.code,
message: response.error.message,
});
}
},
(error: unknown) => {
if (connectGeneration.current !== generation) return;
dispatch({
type: "failure",
code: errorCodeFromThrown(error),
message: error instanceof Error ? error.message : "unknown error",
});
},
);
}, []);
const workspaceContext = useMemo<ConsoleWorkspaceContextValue>(
() => ({
connection: state,
connectedTarget,
recordEvidence,
readExecutor,
}),
[connectedTarget, readExecutor, recordEvidence, state],
);
return (
<ConsoleShell
connection={state}
onConnect={onConnect}
onDraftChange={onDraftChange}
>
<Outlet context={workspaceContext} />
</ConsoleShell>
);
};
const errorCodeFromThrown = (error: unknown): string => {
if (!(error instanceof Error)) return "rpc_protocol_error";
return error.message.toLowerCase().includes("malformed")
? "malformed_response"
: "rpc_protocol_error";
};
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { EvidenceRecord } from "../app/state.js";
import { EvidenceLedger } from "./EvidenceLedger.js";
const record: EvidenceRecord = {
id: "health-0",
operation: "workflow.health",
label: "Health check",
equivalentCli: "uv run wf status",
request: { target: "console" },
response: { status: "ok" },
durationMs: 11,
};
describe("EvidenceLedger", () => {
it("renders each operation as a collapsed detail row with its receipt fields", () => {
render(<EvidenceLedger records={[record]} />);
const row = screen.getByRole("group", { name: "Health check" });
expect(row.tagName).toBe("DETAILS");
expect(row).not.toHaveAttribute("open");
expect(row).toHaveTextContent("workflow.health");
expect(row).toHaveTextContent("11ms");
expect(row).toHaveTextContent("uv run wf status");
expect(row).toHaveTextContent('"target": "console"');
expect(row).toHaveTextContent('"status": "ok"');
});
it("renders a useful empty state", () => {
render(<EvidenceLedger records={[]} />);
expect(screen.getByText("No operation evidence yet.")).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import type { EvidenceRecord } from "../app/state.js";
type Props = {
readonly records: ReadonlyArray<EvidenceRecord>;
};
const formatValue = (value: unknown): string => {
const formatted = JSON.stringify(value, null, 2);
return formatted === undefined ? String(value) : formatted;
};
export const EvidenceLedger = ({ records }: Props) => (
<div className="evidence-ledger">
{records.length === 0 ? (
<p className="empty-state">No operation evidence yet.</p>
) : (
<ol className="evidence-list">
{records.map((record) => (
<li key={record.id}>
<details className="evidence-record" aria-label={record.label}>
<summary>
<span className="evidence-op">{record.operation}</span>
<span className="evidence-label">{record.label}</span>
<span className="evidence-duration">{record.durationMs}ms</span>
</summary>
<dl className="evidence-detail">
<div className="evidence-field">
<dt>Equivalent CLI</dt>
<dd><code>{record.equivalentCli}</code></dd>
</div>
<div className="evidence-field">
<dt>Request</dt>
<dd><pre>{formatValue(record.request)}</pre></dd>
</div>
<div className="evidence-field">
<dt>Response</dt>
<dd><pre>{formatValue(record.response)}</pre></dd>
</div>
</dl>
</details>
</li>
))}
</ol>
)}
</div>
);
+13
View File
@@ -0,0 +1,13 @@
import { useOutletContext } from "react-router-dom";
import type { ConnectionState, EvidenceRecord } from "../app/state.js";
import type { ConsoleReadExecutor } from "./domain/read-executor.js";
export type ConsoleWorkspaceContextValue = {
readonly connection: ConnectionState;
readonly connectedTarget: string | null;
readonly recordEvidence: (record: EvidenceRecord) => void;
readonly readExecutor: ConsoleReadExecutor | null;
};
export const useConsoleWorkspace = (): ConsoleWorkspaceContextValue =>
useOutletContext<ConsoleWorkspaceContextValue>();