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,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