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:
lda
2026-07-02 12:26:36 +07:00 Verified
parent 60a7dd2830
commit 4d95b22e0b
8 changed files with 824 additions and 1 deletions
@@ -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">
{" "}
&middot; {state.serverStatus}
</span>
<span data-testid="store-root">
{" "}
&middot; {state.storeRoot}
</span>
<span data-testid="duration-ms">
{" "}
&middot; {state.durationMs}ms
</span>
</>
)}
{state.message && (
<span data-testid="error-message">
{" "}
&middot; {state.message}
</span>
)}
</div>
</section>
);
};