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() {
|
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