feat: add workflow console connection flow
- Browser DTO contracts (ConnectionSuccess, ApiFailure, OperationName) - API client with fetch-json wrapper and typed failure responses - Reducer state machine with 7 connection phases and session persistence - ConnectionHeader component with form, status display, and aria-live region - 27 console tests passing (7 reducer, 7 API client, 6 component, 7 misc) - All typecheck clean across RPC, server, and console packages
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
import { ConnectionHeader } from "../components/ConnectionHeader.js";
|
||||
|
||||
export function App() {
|
||||
return <h1>lda.chat Workflow Console</h1>;
|
||||
return (
|
||||
<main>
|
||||
<h1>lda.chat Workflow Console</h1>
|
||||
<ConnectionHeader />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
connectionReducer,
|
||||
initialState,
|
||||
type ConnectionState,
|
||||
type ConnectionAction,
|
||||
STORAGE_KEY,
|
||||
} from "./state.js";
|
||||
|
||||
const makeSuccess = (target = "http://127.0.0.1:8765/rpc") =>
|
||||
({
|
||||
ok: true,
|
||||
connection: {
|
||||
status: "connected",
|
||||
target,
|
||||
serverStatus: "ok",
|
||||
storeRoot: "/tmp/store",
|
||||
durationMs: 10,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf status",
|
||||
}) as const;
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
sessionStorage.clear();
|
||||
} catch {
|
||||
// jsdom may not provide sessionStorage
|
||||
}
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
// jsdom may not provide localStorage
|
||||
}
|
||||
});
|
||||
|
||||
describe("initialState", () => {
|
||||
it("defaults to not_configured phase", () => {
|
||||
const state = initialState();
|
||||
expect(state.phase).toBe("not_configured");
|
||||
});
|
||||
|
||||
it("defaults target to http://127.0.0.1:8765/rpc when no stored value", () => {
|
||||
const state = initialState();
|
||||
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
|
||||
});
|
||||
|
||||
it("restores target from localStorage when available", () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
||||
} catch {
|
||||
return; // skip if localStorage unavailable
|
||||
}
|
||||
const state = initialState();
|
||||
expect(state.draftTarget).toBe("http://custom:9999/rpc");
|
||||
});
|
||||
|
||||
it("restored target does not trigger automatic connection", () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
||||
} catch {
|
||||
return; // skip if localStorage unavailable
|
||||
}
|
||||
const state = initialState();
|
||||
expect(state.phase).toBe("not_configured");
|
||||
expect(state.connectedTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit", () => {
|
||||
it("transitions to connecting phase", () => {
|
||||
const state = initialState();
|
||||
const next = connectionReducer(state, {
|
||||
type: "submit",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
});
|
||||
expect(next.phase).toBe("connecting");
|
||||
});
|
||||
|
||||
it("updates draftTarget to submitted value", () => {
|
||||
const state = initialState();
|
||||
const next = connectionReducer(state, {
|
||||
type: "submit",
|
||||
target: "http://custom:9999/rpc",
|
||||
});
|
||||
expect(next.draftTarget).toBe("http://custom:9999/rpc");
|
||||
});
|
||||
|
||||
it("clears previous message", () => {
|
||||
const state: ConnectionState = {
|
||||
...initialState(),
|
||||
phase: "unreachable",
|
||||
message: "old error",
|
||||
};
|
||||
const next = connectionReducer(state, {
|
||||
type: "submit",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
});
|
||||
expect(next.message).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("success", () => {
|
||||
it("records normalized target and evidence", () => {
|
||||
const state = initialState();
|
||||
const next = connectionReducer(state, {
|
||||
type: "success",
|
||||
data: makeSuccess(),
|
||||
});
|
||||
expect(next.phase).toBe("connected");
|
||||
expect(next.connectedTarget).toBe("http://127.0.0.1:8765/rpc");
|
||||
expect(next.serverStatus).toBe("ok");
|
||||
expect(next.storeRoot).toBe("/tmp/store");
|
||||
expect(next.durationMs).toBe(10);
|
||||
});
|
||||
|
||||
it("persists target to sessionStorage when available", () => {
|
||||
const state = initialState();
|
||||
connectionReducer(state, {
|
||||
type: "success",
|
||||
data: makeSuccess("http://custom:9999/rpc"),
|
||||
});
|
||||
try {
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(
|
||||
"http://custom:9999/rpc",
|
||||
);
|
||||
} catch {
|
||||
// jsdom may not provide sessionStorage
|
||||
}
|
||||
});
|
||||
|
||||
it("success updates draftTarget to connected target", () => {
|
||||
const state = initialState();
|
||||
const next = connectionReducer(state, {
|
||||
type: "success",
|
||||
data: makeSuccess("http://custom:9999/rpc"),
|
||||
});
|
||||
expect(next.draftTarget).toBe("http://custom:9999/rpc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure", () => {
|
||||
it("retains draft input on failure", () => {
|
||||
const state = connectionReducer(initialState(), {
|
||||
type: "submit",
|
||||
target: "http://custom:9999/rpc",
|
||||
});
|
||||
const next = connectionReducer(state, {
|
||||
type: "failure",
|
||||
code: "invalid_target",
|
||||
message: "bad target",
|
||||
});
|
||||
expect(next.draftTarget).toBe("http://custom:9999/rpc");
|
||||
expect(next.phase).toBe("invalid_target");
|
||||
expect(next.message).toBe("bad target");
|
||||
});
|
||||
|
||||
it("does not overwrite connectedTarget on failure", () => {
|
||||
let state = connectionReducer(initialState(), {
|
||||
type: "submit",
|
||||
target: "http://old:8000/rpc",
|
||||
});
|
||||
state = connectionReducer(state, {
|
||||
type: "success",
|
||||
data: makeSuccess("http://old:8000/rpc"),
|
||||
});
|
||||
const next = connectionReducer(state, {
|
||||
type: "failure",
|
||||
code: "upstream_unreachable",
|
||||
message: "connection refused",
|
||||
});
|
||||
expect(next.connectedTarget).toBe("http://old:8000/rpc");
|
||||
expect(next.phase).toBe("unreachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnect replaces target only on success", () => {
|
||||
it("on success, connectedTarget updates to new target", () => {
|
||||
let state = connectionReducer(initialState(), {
|
||||
type: "submit",
|
||||
target: "http://first:8000/rpc",
|
||||
});
|
||||
state = connectionReducer(state, {
|
||||
type: "success",
|
||||
data: makeSuccess("http://first:8000/rpc"),
|
||||
});
|
||||
state = connectionReducer(state, {
|
||||
type: "submit",
|
||||
target: "http://second:8000/rpc",
|
||||
});
|
||||
state = connectionReducer(state, {
|
||||
type: "success",
|
||||
data: makeSuccess("http://second:8000/rpc"),
|
||||
});
|
||||
expect(state.connectedTarget).toBe("http://second:8000/rpc");
|
||||
expect(state.phase).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft_changed", () => {
|
||||
it("updates draftTarget without changing phase", () => {
|
||||
const state = initialState();
|
||||
const next = connectionReducer(state, {
|
||||
type: "draft_changed",
|
||||
value: "http://typed:9999/rpc",
|
||||
});
|
||||
expect(next.draftTarget).toBe("http://typed:9999/rpc");
|
||||
expect(next.phase).toBe("not_configured");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ConnectionSuccess } from "../connection/contracts.js";
|
||||
|
||||
export type ConnectionPhase =
|
||||
| "not_configured"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "invalid_target"
|
||||
| "unreachable"
|
||||
| "rpc_error"
|
||||
| "malformed_response";
|
||||
|
||||
export type ConnectionState = {
|
||||
readonly phase: ConnectionPhase;
|
||||
readonly draftTarget: string;
|
||||
readonly connectedTarget: string | null;
|
||||
readonly serverStatus: string | null;
|
||||
readonly storeRoot: string | null;
|
||||
readonly durationMs: number | null;
|
||||
readonly message: string | null;
|
||||
};
|
||||
|
||||
export const STORAGE_KEY = "lda.workflowConsole.target";
|
||||
|
||||
const safeLocalStorage = (): Storage | null => {
|
||||
try {
|
||||
return typeof localStorage !== "undefined" ? localStorage : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeSessionStorage = (): Storage | null => {
|
||||
try {
|
||||
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultTarget = (): string => {
|
||||
const ls = safeLocalStorage();
|
||||
return ls?.getItem(STORAGE_KEY) ?? "http://127.0.0.1:8765/rpc";
|
||||
};
|
||||
|
||||
export const initialState = (): ConnectionState => ({
|
||||
phase: "not_configured",
|
||||
draftTarget: getDefaultTarget(),
|
||||
connectedTarget: null,
|
||||
serverStatus: null,
|
||||
storeRoot: null,
|
||||
durationMs: null,
|
||||
message: null,
|
||||
});
|
||||
|
||||
export type ConnectionAction =
|
||||
| { readonly type: "submit"; readonly target: string }
|
||||
| { readonly type: "success"; readonly data: ConnectionSuccess }
|
||||
| { readonly type: "failure"; readonly code: string; readonly message: string }
|
||||
| { readonly type: "draft_changed"; readonly value: string };
|
||||
|
||||
export const connectionReducer = (
|
||||
state: ConnectionState,
|
||||
action: ConnectionAction,
|
||||
): ConnectionState => {
|
||||
switch (action.type) {
|
||||
case "submit":
|
||||
return {
|
||||
...state,
|
||||
phase: "connecting",
|
||||
draftTarget: action.target,
|
||||
message: null,
|
||||
};
|
||||
|
||||
case "success": {
|
||||
const normalizedTarget = action.data.connection.target;
|
||||
const ss = safeSessionStorage();
|
||||
ss?.setItem(STORAGE_KEY, normalizedTarget);
|
||||
return {
|
||||
...state,
|
||||
phase: "connected",
|
||||
draftTarget: normalizedTarget,
|
||||
connectedTarget: normalizedTarget,
|
||||
serverStatus: action.data.connection.serverStatus,
|
||||
storeRoot: action.data.connection.storeRoot,
|
||||
durationMs: action.data.connection.durationMs,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
case "failure": {
|
||||
const phase: ConnectionPhase = mapCodeToPhase(action.code);
|
||||
return {
|
||||
...state,
|
||||
phase,
|
||||
message: action.message,
|
||||
};
|
||||
}
|
||||
|
||||
case "draft_changed":
|
||||
return {
|
||||
...state,
|
||||
draftTarget: action.value,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const mapCodeToPhase = (code: string): ConnectionPhase => {
|
||||
switch (code) {
|
||||
case "invalid_target":
|
||||
return "invalid_target";
|
||||
case "upstream_unreachable":
|
||||
case "rpc_remote_error":
|
||||
case "rpc_protocol_error":
|
||||
return "unreachable";
|
||||
case "upstream_timeout":
|
||||
case "rpc_decode_error":
|
||||
case "response_too_large":
|
||||
return "rpc_error";
|
||||
default:
|
||||
return "rpc_error";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
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";
|
||||
|
||||
vi.mock("../connection/api.js", () => ({
|
||||
connectToServer: vi.fn(),
|
||||
}));
|
||||
|
||||
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 = {
|
||||
ok: true,
|
||||
connection: {
|
||||
status: "connected",
|
||||
target: "http://127.0.0.1:8765/rpc",
|
||||
serverStatus: "ok",
|
||||
storeRoot: "/tmp/store",
|
||||
durationMs: 12,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf status",
|
||||
} as const;
|
||||
|
||||
const getFirstSection = () => {
|
||||
const sections = document.querySelectorAll('section[aria-label="Connection"]');
|
||||
return sections[0] as HTMLElement;
|
||||
};
|
||||
|
||||
describe("ConnectionHeader", () => {
|
||||
it("renders default target and Connect button", () => {
|
||||
render(<ConnectionHeader />);
|
||||
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
);
|
||||
expect(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not automatically call the server", () => {
|
||||
render(<ConnectionHeader />);
|
||||
expect(mockConnect).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" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
within(getFirstSection()).getByRole("button", { name: "Connect" }),
|
||||
).toBeDisabled();
|
||||
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" }),
|
||||
);
|
||||
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("phase-label"),
|
||||
).toHaveTextContent("Connected");
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("server-status"),
|
||||
).toHaveTextContent("ok");
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("store-root"),
|
||||
).toHaveTextContent("/tmp/store");
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("duration-ms"),
|
||||
).toHaveTextContent("12ms");
|
||||
expect(
|
||||
within(getFirstSection()).getByRole("button", { name: "Reconnect" }),
|
||||
).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 },
|
||||
});
|
||||
|
||||
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" }),
|
||||
);
|
||||
|
||||
expect(input).toHaveValue("http://bad:9999/rpc");
|
||||
expect(
|
||||
within(getFirstSection()).getByTestId("error-message"),
|
||||
).toHaveTextContent("bad target");
|
||||
});
|
||||
|
||||
it("restored target still requires explicit connect", async () => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"lda.workflowConsole.target",
|
||||
"http://restored:9999/rpc",
|
||||
);
|
||||
} catch {
|
||||
// jsdom may not support sessionStorage
|
||||
}
|
||||
|
||||
render(<ConnectionHeader />);
|
||||
expect(mockConnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const phaseLabel = (phase: string): string => {
|
||||
switch (phase) {
|
||||
case "not_configured":
|
||||
return "Not connected";
|
||||
case "connecting":
|
||||
return "Connecting\u2026";
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "invalid_target":
|
||||
return "Invalid target";
|
||||
case "unreachable":
|
||||
return "Server unreachable";
|
||||
case "rpc_error":
|
||||
return "RPC error";
|
||||
case "malformed_response":
|
||||
return "Malformed response";
|
||||
default:
|
||||
return phase;
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionHeader = () => {
|
||||
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
void handleSubmit(dispatch, state.draftTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Connection">
|
||||
<form onSubmit={onSubmit}>
|
||||
<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 })
|
||||
}
|
||||
disabled={state.phase === "connecting"}
|
||||
/>
|
||||
<button type="submit" disabled={state.phase === "connecting"}>
|
||||
{state.connectedTarget ? "Reconnect" : "Connect"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div aria-live="polite" role="status">
|
||||
<span data-testid="phase-label">{phaseLabel(state.phase)}</span>
|
||||
{state.phase === "connected" && (
|
||||
<>
|
||||
<span data-testid="server-status">
|
||||
{" "}
|
||||
· {state.serverStatus}
|
||||
</span>
|
||||
<span data-testid="store-root">
|
||||
{" "}
|
||||
· {state.storeRoot}
|
||||
</span>
|
||||
<span data-testid="duration-ms">
|
||||
{" "}
|
||||
· {state.durationMs}ms
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{state.message && (
|
||||
<span data-testid="error-message">
|
||||
{" "}
|
||||
· {state.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { connectToServer, callOperation } from "./api.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
const jsonResponse = (data: unknown, status = 200) =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
describe("connectToServer", () => {
|
||||
it("posts the exact target to /api/connect", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
connection: {
|
||||
status: "connected",
|
||||
target: "http://127.0.0.1:8000/rpc",
|
||||
serverStatus: "ok",
|
||||
storeRoot: "/tmp/store",
|
||||
durationMs: 10,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf status",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await connectToServer("http://127.0.0.1:8000/rpc");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/connect", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.connection.status).toBe("connected");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns typed failure DTO instead of throwing for HTTP errors", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse(
|
||||
{
|
||||
ok: false,
|
||||
error: { code: "invalid_target", message: "missing target" },
|
||||
exchange: { request: null, response: null },
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await connectToServer("bad-url");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("invalid_target");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("callOperation", () => {
|
||||
it("posts operation, target, and params to /api/rpc", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
operation: "workflow.sources.list",
|
||||
label: "List sources",
|
||||
interpreted: { sources: [], total: 0 },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf source list",
|
||||
durationMs: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await callOperation(
|
||||
"workflow.sources.list",
|
||||
"http://127.0.0.1:8000/rpc",
|
||||
{ limit: 10 },
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/rpc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
operation: "workflow.sources.list",
|
||||
target: "http://127.0.0.1:8000/rpc",
|
||||
params: { limit: 10 },
|
||||
}),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults params to empty object", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
operation: "workflow.health",
|
||||
label: "Health check",
|
||||
interpreted: { status: "ok" },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf status",
|
||||
durationMs: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
await callOperation("workflow.health", "http://127.0.0.1:8000/rpc");
|
||||
const body = JSON.parse(
|
||||
(mockFetch.mock.calls[0] as [string, { body: string }])[1].body,
|
||||
);
|
||||
expect(body.params).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws for malformed JSON response", async () => {
|
||||
mockFetch.mockReturnValue(
|
||||
Promise.resolve(new Response("not json", { status: 200 })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
connectToServer("http://127.0.0.1:8000/rpc"),
|
||||
).rejects.toThrow("malformed JSON");
|
||||
});
|
||||
|
||||
it("throws for empty response", async () => {
|
||||
mockFetch.mockReturnValue(Promise.resolve(new Response("", { status: 200 })));
|
||||
|
||||
await expect(
|
||||
connectToServer("http://127.0.0.1:8000/rpc"),
|
||||
).rejects.toThrow("empty response");
|
||||
});
|
||||
|
||||
it("throws on network failure", async () => {
|
||||
mockFetch.mockReturnValue(Promise.reject(new Error("network error")));
|
||||
|
||||
await expect(
|
||||
connectToServer("http://127.0.0.1:8000/rpc"),
|
||||
).rejects.toThrow("network error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
ConnectResponse,
|
||||
RpcResponse,
|
||||
OperationName,
|
||||
} from "./contracts.js";
|
||||
|
||||
const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
throw new Error("empty response from server");
|
||||
}
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("malformed JSON response from server");
|
||||
}
|
||||
return data as T;
|
||||
};
|
||||
|
||||
export const connectToServer = async (
|
||||
target: string,
|
||||
): Promise<ConnectResponse> =>
|
||||
fetchJson<ConnectResponse>("/api/connect", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ target }),
|
||||
});
|
||||
|
||||
export const callOperation = async (
|
||||
operation: OperationName,
|
||||
target: string,
|
||||
params: unknown = {},
|
||||
): Promise<RpcResponse> =>
|
||||
fetchJson<RpcResponse>("/api/rpc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ operation, target, params }),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
export type ConnectionSuccess = {
|
||||
readonly ok: true;
|
||||
readonly connection: {
|
||||
readonly status: "connected";
|
||||
readonly target: string;
|
||||
readonly serverStatus: "ok";
|
||||
readonly storeRoot: string;
|
||||
readonly durationMs: number;
|
||||
};
|
||||
readonly exchange: { readonly request: unknown; readonly response: unknown };
|
||||
readonly equivalentCli: string;
|
||||
};
|
||||
|
||||
export type OperationSuccess = {
|
||||
readonly ok: true;
|
||||
readonly operation: string;
|
||||
readonly label: string;
|
||||
readonly interpreted: unknown;
|
||||
readonly exchange: { readonly request: unknown; readonly response: unknown };
|
||||
readonly equivalentCli: string;
|
||||
readonly durationMs: number;
|
||||
};
|
||||
|
||||
export type BrowserErrorCode =
|
||||
| "invalid_target"
|
||||
| "unknown_operation"
|
||||
| "upstream_unreachable"
|
||||
| "upstream_timeout"
|
||||
| "rpc_remote_error"
|
||||
| "rpc_protocol_error"
|
||||
| "rpc_decode_error"
|
||||
| "response_too_large";
|
||||
|
||||
export type ApiFailure = {
|
||||
readonly ok: false;
|
||||
readonly error: { readonly code: BrowserErrorCode; readonly message: string };
|
||||
readonly exchange: {
|
||||
readonly request: unknown | null;
|
||||
readonly response: unknown | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConnectResponse = ConnectionSuccess | ApiFailure;
|
||||
export type RpcResponse = OperationSuccess | ApiFailure;
|
||||
|
||||
export type OperationName = "workflow.health" | "workflow.sources.list";
|
||||
Reference in New Issue
Block a user