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
+148
View File
@@ -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");
});
});
+40
View File
@@ -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";