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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user