feat: add console workspace shell
This commit is contained in:
@@ -1,192 +1,26 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "./App.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppRoutes } from "./AppRoutes.js";
|
||||
import { callOperation, connectToServer } from "../connection/api.js";
|
||||
import type { RpcResponse } from "../connection/contracts.js";
|
||||
|
||||
vi.mock("../connection/api.js", () => ({
|
||||
connectToServer: vi.fn(),
|
||||
callOperation: vi.fn(),
|
||||
}));
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const mockedConnectToServer = vi.mocked(connectToServer);
|
||||
const mockedCallOperation = vi.mocked(callOperation);
|
||||
|
||||
const successfulConnection = (target: string) => ({
|
||||
ok: true as const,
|
||||
connection: {
|
||||
status: "connected" as const,
|
||||
target,
|
||||
serverStatus: "ok" as const,
|
||||
storeRoot: "/tmp/store",
|
||||
durationMs: 11,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf status",
|
||||
});
|
||||
|
||||
const successfulSources = (id: string): RpcResponse => ({
|
||||
ok: true,
|
||||
operation: "workflow.sources.list",
|
||||
label: "List sources",
|
||||
interpreted: {
|
||||
sources: [
|
||||
{
|
||||
id,
|
||||
kind: "python",
|
||||
enabled: true,
|
||||
description: null,
|
||||
counts: {
|
||||
tools: 1,
|
||||
nodeSpecs: 1,
|
||||
reducers: 0,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
nextCursor: null,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf source list",
|
||||
durationMs: 7,
|
||||
});
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedConnectToServer.mockReset();
|
||||
mockedCallOperation.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const lifecycleOk = {
|
||||
ok: true as const,
|
||||
operation: "workflow.artifacts.list" as const,
|
||||
label: "List artifacts",
|
||||
interpreted: { items: [], total: 0, nextCursor: null },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf artifact list",
|
||||
durationMs: 5,
|
||||
};
|
||||
|
||||
describe("App", () => {
|
||||
it("shows source inventory errors from rejected source refreshes", async () => {
|
||||
mockedConnectToServer.mockResolvedValue(
|
||||
successfulConnection("http://127.0.0.1:8765/rpc"),
|
||||
describe("AppRoutes", () => {
|
||||
it.each([
|
||||
["/", "Discover"],
|
||||
["/console", "Discover"],
|
||||
])("redirects %s to the discover workspace leaf", async (entry, label) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
mockedCallOperation.mockRejectedValue(new Error("source refresh failed"));
|
||||
|
||||
render(<App />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(await screen.findByTestId("sources-error")).toHaveTextContent(
|
||||
"source refresh failed",
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: label })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores stale source inventory responses after reconnect", async () => {
|
||||
const firstSources = deferred<RpcResponse>();
|
||||
const secondSources = deferred<RpcResponse>();
|
||||
let latestSourcesDeferred = firstSources;
|
||||
mockedConnectToServer
|
||||
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
|
||||
.mockResolvedValueOnce(successfulConnection("http://second.example/rpc"));
|
||||
mockedCallOperation.mockImplementation((op: string) => {
|
||||
if (op === "workflow.sources.list") return latestSourcesDeferred.promise;
|
||||
return Promise.resolve(lifecycleOk);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
latestSourcesDeferred = secondSources;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Reconnect" }));
|
||||
secondSources.resolve(successfulSources("local.second"));
|
||||
firstSources.resolve(successfulSources("local.first"));
|
||||
|
||||
expect(await screen.findByTestId("source-id-local.second")).toHaveTextContent(
|
||||
"local.second",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("source-id-local.first")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("mounts lifecycle explorer after connect", async () => {
|
||||
mockedConnectToServer.mockResolvedValue(
|
||||
successfulConnection("http://127.0.0.1:8765/rpc"),
|
||||
);
|
||||
mockedCallOperation.mockImplementation((op: string) => {
|
||||
if (op === "workflow.sources.list") {
|
||||
return Promise.resolve({
|
||||
ok: true as const,
|
||||
operation: "workflow.sources.list" as const,
|
||||
label: "List sources",
|
||||
interpreted: { sources: [], total: 0, nextCursor: null },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf source list",
|
||||
durationMs: 5,
|
||||
});
|
||||
}
|
||||
if (op === "workflow.deployments.inspect") {
|
||||
return Promise.resolve({
|
||||
ok: false as const,
|
||||
error: { code: "rpc_remote_error", message: "not found" },
|
||||
exchange: { request: {}, response: {} },
|
||||
});
|
||||
}
|
||||
return Promise.resolve(lifecycleOk);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("lifecycle-explorer")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("always renders demo panel even without connection", async () => {
|
||||
mockedConnectToServer.mockRejectedValue(new Error("connection refused"));
|
||||
|
||||
render(<App />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows replay mode button even without connection", async () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Replay" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Replay" }));
|
||||
expect(screen.getByRole("button", { name: /start presentation/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("routes to presentation mode separately from the console", () => {
|
||||
it("keeps presentation routes outside console navigation", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/present"]}>
|
||||
<AppRoutes />
|
||||
@@ -194,10 +28,9 @@ describe("App", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("main", { name: /lda.chat presentation/i })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Lifecycle Explorer")).toBeNull();
|
||||
});
|
||||
expect(screen.queryByRole("navigation", { name: "Workflow lifecycle" })).toBeNull();
|
||||
|
||||
it("routes to read-only presenter notes separately from presentation mode", async () => {
|
||||
cleanup();
|
||||
window.location.hash = "#scene/thesis/title";
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/presenter"]}>
|
||||
@@ -208,7 +41,33 @@ describe("App", () => {
|
||||
expect(
|
||||
await screen.findByRole("main", { name: /lda.chat presenter notes/i }, { timeout: 5_000 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("main", { name: /lda.chat presentation/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Workflow lifecycle" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the connection prompt and pending route message while disconnected", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/console/discover"]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Discover is unavailable until a workflow server is connected."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Connect a workflow server to view Discover.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates between lifecycle links without leaving the workspace shell", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/console/discover"]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("link", { name: "Runs" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Runs" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Workflow lifecycle" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ConsoleHome } from "./ConsoleHome.js";
|
||||
import { ConsoleWorkspace } from "../workspace/ConsoleWorkspace.js";
|
||||
import { useConsoleWorkspace } from "../workspace/context.js";
|
||||
import { PresentationRoute } from "../presentation/PresentationRoute.js";
|
||||
|
||||
const PresenterRoute = lazy(() => import("../presentation/presenter/PresenterRoute.js").then((module) => ({
|
||||
@@ -13,12 +14,42 @@ const PresenterRouteFallback = () => (
|
||||
</main>
|
||||
);
|
||||
|
||||
const WorkspaceRoutePending = ({ label }: { readonly label: string }) => {
|
||||
const { connection } = useConsoleWorkspace();
|
||||
const connected = connection.phase === "connected";
|
||||
|
||||
return (
|
||||
<section className="workspace-route-pending">
|
||||
<p className="workspace-route-pending__eyebrow">Workspace route</p>
|
||||
<h1>{label}</h1>
|
||||
<p>
|
||||
{connected
|
||||
? `${label} is ready for its workflow surface.`
|
||||
: `${label} is unavailable until a workflow server is connected.`}
|
||||
</p>
|
||||
{!connected && <p>Connect a workflow server to view {label}.</p>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppRoutes = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<ConsoleHome />} />
|
||||
<Route path="/console" element={<ConsoleHome />} />
|
||||
<Route path="/" element={<Navigate to="/console/discover" replace />} />
|
||||
<Route path="/console" element={<ConsoleWorkspace />}>
|
||||
<Route index element={<Navigate to="discover" replace />} />
|
||||
<Route path="discover" element={<WorkspaceRoutePending label="Discover" />} />
|
||||
<Route path="drafts" element={<WorkspaceRoutePending label="Drafts" />} />
|
||||
<Route path="drafts/:workspaceId" element={<WorkspaceRoutePending label="Draft" />} />
|
||||
<Route path="artifacts" element={<WorkspaceRoutePending label="Artifacts" />} />
|
||||
<Route path="artifacts/:artifactId/:version" element={<WorkspaceRoutePending label="Artifact" />} />
|
||||
<Route path="deployments" element={<WorkspaceRoutePending label="Deployments" />} />
|
||||
<Route path="deployments/:deploymentId" element={<WorkspaceRoutePending label="Deployment" />} />
|
||||
<Route path="runs" element={<WorkspaceRoutePending label="Runs" />} />
|
||||
<Route path="runs/:runId" element={<WorkspaceRoutePending label="Run" />} />
|
||||
<Route path="results" element={<WorkspaceRoutePending label="Results" />} />
|
||||
</Route>
|
||||
<Route path="/present" element={<PresentationRoute />} />
|
||||
<Route path="/presenter" element={<Suspense fallback={<PresenterRouteFallback />}><PresenterRoute /></Suspense>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/console/discover" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user