feat: add console workspace shell
This commit is contained in:
@@ -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>
|
||||
);
|
||||
@@ -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>();
|
||||
Reference in New Issue
Block a user