huskies: merge 949

This commit is contained in:
dave
2026-05-13 07:10:00 +00:00
parent d87722f6c8
commit 4a0fbcaa95
15 changed files with 1454 additions and 231 deletions
+5 -35
View File
@@ -2,47 +2,17 @@
* WS-RPC client for chat-bot transport config (Matrix / Slack / WhatsApp).
*/
import { rpcCall } from "./rpc";
import type { BotConfigPayload } from "./rpcContract";
export interface BotConfig {
transport: string | null;
enabled: boolean | null;
homeserver: string | null;
username: string | null;
password: string | null;
room_ids: string[] | null;
slack_bot_token: string | null;
slack_signing_secret: string | null;
slack_channel_ids: string[] | null;
}
const DEFAULT_API_BASE = "/api";
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
/** Re-export of the wire-format `BotConfigPayload` as the client-facing `BotConfig` alias. */
export type BotConfig = BotConfigPayload;
export const botConfigApi = {
getConfig(_baseUrl?: string): Promise<BotConfig> {
return rpcCall<BotConfig>("bot_config.get");
},
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
return requestJson<BotConfig>(
"/bot/config",
{ method: "PUT", body: JSON.stringify(config) },
baseUrl,
);
saveConfig(config: BotConfig, _baseUrl?: string): Promise<BotConfig> {
return rpcCall<BotConfigPayload>("bot_config.save", config);
},
};
+84 -18
View File
@@ -45,31 +45,88 @@ describe("api client", () => {
});
describe("openProject", () => {
it("sends POST with path", async () => {
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
it("dispatches project.open RPC with path and returns the canonical path", async () => {
const rpc = installRpcMock();
rpc.respond("project.open", { path: "/home/user/project" });
await api.openProject("/home/user/project");
const result = await api.openProject("/home/user/project");
expect(mockFetch).toHaveBeenCalledWith(
"/api/project",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ path: "/home/user/project" }),
}),
);
expect(rpc.calls).toEqual([
{
method: "project.open",
params: { path: "/home/user/project" },
},
]);
expect(result).toBe("/home/user/project");
});
});
describe("closeProject", () => {
it("sends DELETE to /project", async () => {
mockFetch.mockResolvedValueOnce(okResponse(true));
it("dispatches project.close RPC and returns ok", async () => {
const rpc = installRpcMock();
rpc.respond("project.close", { ok: true });
await api.closeProject();
const result = await api.closeProject();
expect(mockFetch).toHaveBeenCalledWith(
"/api/project",
expect.objectContaining({ method: "DELETE" }),
);
expect(rpc.calls).toEqual([{ method: "project.close", params: {} }]);
expect(result).toBe(true);
});
});
describe("forgetKnownProject", () => {
it("dispatches project.forget RPC with path", async () => {
const rpc = installRpcMock();
rpc.respond("project.forget", { ok: true });
const result = await api.forgetKnownProject("/some/path");
expect(rpc.calls).toEqual([
{ method: "project.forget", params: { path: "/some/path" } },
]);
expect(result).toBe(true);
});
});
describe("setModelPreference", () => {
it("dispatches model.set_preference RPC", async () => {
const rpc = installRpcMock();
rpc.respond("model.set_preference", { ok: true });
await api.setModelPreference("claude-sonnet-4-6");
expect(rpc.calls).toEqual([
{
method: "model.set_preference",
params: { model: "claude-sonnet-4-6" },
},
]);
});
});
describe("setAnthropicApiKey", () => {
it("dispatches anthropic.set_api_key RPC", async () => {
const rpc = installRpcMock();
rpc.respond("anthropic.set_api_key", { ok: true });
await api.setAnthropicApiKey("sk-ant-xxx");
expect(rpc.calls).toEqual([
{
method: "anthropic.set_api_key",
params: { api_key: "sk-ant-xxx" },
},
]);
});
});
describe("cancelChat", () => {
it("dispatches chat.cancel RPC", async () => {
const rpc = installRpcMock();
rpc.respond("chat.cancel", { ok: true });
await api.cancelChat();
expect(rpc.calls).toEqual([{ method: "chat.cancel", params: {} }]);
});
});
@@ -92,10 +149,19 @@ describe("api client", () => {
await expect(api.getCurrentProject()).rejects.toThrow("store offline");
});
it("surfaces RPC errors visibly for write methods", async () => {
const rpc = installRpcMock();
rpc.respondError("project.open", "No such directory", "INTERNAL");
await expect(api.openProject("/some/path")).rejects.toThrow(
"No such directory",
);
});
it("throws on non-ok HTTP response for legacy POST endpoints", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(api.openProject("/some/path")).rejects.toThrow(
await expect(api.searchFiles("query")).rejects.toThrow(
"Request failed (500)",
);
});
+26 -28
View File
@@ -6,6 +6,12 @@
*/
import { rpcCall } from "../rpc";
import type {
OkResult,
OpenProjectResult,
SetAnthropicApiKeyParams,
SetModelPreferenceParams,
} from "../rpcContract";
import type {
AllTokenUsageResponse,
AnthropicModelInfo,
@@ -94,32 +100,25 @@ export const api = {
getKnownProjects(_baseUrl?: string) {
return rpcCall<string[]>("project.known");
},
forgetKnownProject(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/projects/forget",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
async forgetKnownProject(path: string, _baseUrl?: string) {
const r = await rpcCall<OkResult>("project.forget", { path });
return r.ok;
},
openProject(path: string, baseUrl?: string) {
return requestJson<string>(
"/project",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
async openProject(path: string, _baseUrl?: string) {
const r = await rpcCall<OpenProjectResult>("project.open", { path });
return r.path;
},
closeProject(baseUrl?: string) {
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
async closeProject(_baseUrl?: string) {
const r = await rpcCall<OkResult>("project.close");
return r.ok;
},
getModelPreference(_baseUrl?: string) {
return rpcCall<string | null>("model.get_preference");
},
setModelPreference(model: string, baseUrl?: string) {
return requestJson<boolean>(
"/model",
{ method: "POST", body: JSON.stringify({ model }) },
baseUrl,
);
async setModelPreference(model: string, _baseUrl?: string) {
const params: SetModelPreferenceParams = { model };
const r = await rpcCall<OkResult>("model.set_preference", params);
return r.ok;
},
getOllamaModels(baseUrlParam?: string, _baseUrl?: string) {
return rpcCall<string[]>(
@@ -133,12 +132,10 @@ export const api = {
getAnthropicModels(_baseUrl?: string) {
return rpcCall<AnthropicModelInfo[]>("anthropic.list_models");
},
setAnthropicApiKey(api_key: string, baseUrl?: string) {
return requestJson<boolean>(
"/anthropic/key",
{ method: "POST", body: JSON.stringify({ api_key }) },
baseUrl,
);
async setAnthropicApiKey(api_key: string, _baseUrl?: string) {
const params: SetAnthropicApiKeyParams = { api_key };
const r = await rpcCall<OkResult>("anthropic.set_api_key", params);
return r.ok;
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
@@ -195,8 +192,9 @@ export const api = {
baseUrl,
);
},
cancelChat(baseUrl?: string) {
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
async cancelChat(_baseUrl?: string) {
const r = await rpcCall<OkResult>("chat.cancel");
return r.ok;
},
getWorkItemContent(storyId: string, _baseUrl?: string) {
return rpcCall<WorkItemContent>("work_items.get", { story_id: storyId });
+62 -28
View File
@@ -56,7 +56,7 @@ const RETRY_DELAY_MS = 250;
*/
function rpcAttempt<T>(
method: string,
params: Record<string, unknown>,
params: object,
timeoutMs: number,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
@@ -102,34 +102,68 @@ function rpcAttempt<T>(
};
ws.onmessage = (event) => {
let data: unknown;
try {
const data = JSON.parse(event.data);
if (
data.kind === "rpc_response" &&
data.correlation_id === correlationId
) {
settled = true;
clearTimeout(timer);
try {
ws.close();
} catch {
/* ignore */
}
if (data.ok) {
resolve(data.result as T);
} else {
reject(
new RpcError(
data.error || `RPC error: ${data.code || "UNKNOWN"}`,
data.code,
method,
),
);
}
}
// Ignore other frames (pipeline_state, onboarding_status, etc.)
data = JSON.parse(event.data);
} catch {
/* ignore non-JSON / malformed frames */
// Non-JSON frame is not ours — keep waiting.
return;
}
if (!data || typeof data !== "object") {
return;
}
const frame = data as {
kind?: unknown;
correlation_id?: unknown;
ok?: unknown;
result?: unknown;
error?: unknown;
code?: unknown;
};
if (frame.kind !== "rpc_response" || frame.correlation_id !== correlationId) {
// Not addressed to this call — ignore (pipeline_state, etc.).
return;
}
settled = true;
clearTimeout(timer);
try {
ws.close();
} catch {
/* ignore */
}
if (typeof frame.ok !== "boolean") {
reject(
new RpcError(
`Malformed RPC response for ${method}: missing or non-boolean 'ok' field`,
"MALFORMED",
method,
),
);
return;
}
if (frame.ok) {
if (!("result" in frame)) {
reject(
new RpcError(
`Malformed RPC response for ${method}: 'ok:true' frame missing 'result' field`,
"MALFORMED",
method,
),
);
return;
}
resolve(frame.result as T);
} else {
const errMsg =
typeof frame.error === "string" ? frame.error : undefined;
const errCode = typeof frame.code === "string" ? frame.code : undefined;
reject(
new RpcError(
errMsg || `RPC error: ${errCode || "UNKNOWN"}`,
errCode,
method,
),
);
}
};
@@ -183,7 +217,7 @@ function sleep(ms: number): Promise<void> {
*/
export async function rpcCall<T = unknown>(
method: string,
params: Record<string, unknown> = {},
params: object = {},
timeoutMs = 5000,
): Promise<T> {
let lastErr: unknown;
+117
View File
@@ -0,0 +1,117 @@
{
"model.set_preference": {
"params": {
"model": "claude-sonnet-4-6"
},
"result": {
"ok": true
}
},
"anthropic.set_api_key": {
"params": {
"api_key": "sk-ant-..."
},
"result": {
"ok": true
}
},
"settings.put_editor": {
"params": {
"editor_command": "zed"
},
"result": {
"editor_command": "zed"
}
},
"settings.open_file": {
"params": {
"path": "src/main.rs",
"line": 42
},
"result": {
"ok": true
}
},
"settings.put_project": {
"params": {
"default_qa": "server",
"default_coder_model": null,
"max_coders": null,
"max_retries": 2,
"base_branch": null,
"rate_limit_notifications": true,
"timezone": null,
"rendezvous": null,
"watcher_sweep_interval_secs": 60,
"watcher_done_retention_secs": 86400
},
"result": {
"default_qa": "server",
"default_coder_model": null,
"max_coders": null,
"max_retries": 2,
"base_branch": null,
"rate_limit_notifications": true,
"timezone": null,
"rendezvous": null,
"watcher_sweep_interval_secs": 60,
"watcher_done_retention_secs": 86400
}
},
"project.open": {
"params": {
"path": "/path/to/project"
},
"result": {
"path": "/path/to/project"
}
},
"project.close": {
"params": {},
"result": {
"ok": true
}
},
"project.forget": {
"params": {
"path": "/path/to/project"
},
"result": {
"ok": true
}
},
"bot_config.save": {
"params": {
"transport": "matrix",
"enabled": true,
"homeserver": "https://matrix.example",
"username": "bot",
"password": "secret",
"room_ids": [
"!room:example"
],
"slack_bot_token": null,
"slack_signing_secret": null,
"slack_channel_ids": null
},
"result": {
"transport": "matrix",
"enabled": true,
"homeserver": "https://matrix.example",
"username": "bot",
"password": "secret",
"room_ids": [
"!room:example"
],
"slack_bot_token": null,
"slack_signing_secret": null,
"slack_channel_ids": null
}
},
"chat.cancel": {
"params": {},
"result": {
"ok": true
}
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Snapshot test: the frontend `CONTRACT_FIXTURES` table must match the
* Rust-side snapshot. When the Rust contract changes, the snapshot file
* regenerates (via `UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test`) and this
* test catches any TS shapes that have drifted.
*/
import { describe, expect, it } from "vitest";
import { CONTRACT_FIXTURES } from "./rpcContract";
import snapshot from "./rpcContract.snapshot.json";
describe("rpcContract", () => {
it("CONTRACT_FIXTURES matches the Rust-generated snapshot", () => {
// Convert TS fixtures into the same shape the Rust snapshot serialises
// to: a method-keyed object of `{ params, result }`.
const fromTs = Object.fromEntries(
Object.entries(CONTRACT_FIXTURES).map(([method, payloads]) => [
method,
{ params: payloads.params, result: payloads.result },
]),
);
expect(fromTs).toEqual(snapshot);
});
it("declares the same method names as the snapshot", () => {
const tsMethods = Object.keys(CONTRACT_FIXTURES).sort();
const rustMethods = Object.keys(snapshot).sort();
expect(tsMethods).toEqual(rustMethods);
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* Frontend mirror of the Rust typed RPC contract in
* `server/src/crdt_sync/rpc_contract.rs`.
*
* Every typed write method declared on the backend has matching TypeScript
* params/result types here. The `CONTRACT_FIXTURES` table also exposes the
* same canonical example payloads as the Rust `CONTRACT_METHODS` slice — the
* `rpcContract.test.ts` test compares them against the committed
* `rpcContract.snapshot.json` that the Rust test regenerates. If the Rust
* shapes drift from the TS shapes, the snapshot drifts and one side fails in
* CI — surfacing the mismatch as a compile / test error instead of a runtime
* one.
*
* When adding a method on the backend:
* 1. Add the params + result type here.
* 2. Add the entry to `CONTRACT_FIXTURES` with a canonical example.
* 3. Re-run `UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test` to refresh
* `rpcContract.snapshot.json`.
*/
// ── Params types ────────────────────────────────────────────────────────────
/** Params for `model.set_preference`. */
export interface SetModelPreferenceParams {
model: string;
}
/** Params for `anthropic.set_api_key`. */
export interface SetAnthropicApiKeyParams {
api_key: string;
}
/** Params for `settings.put_editor`. */
export interface PutEditorParams {
editor_command: string | null;
}
/** Params for `settings.open_file`. */
export interface OpenFileParams {
path: string;
line: number | null;
}
/** Params for `project.open`. */
export interface OpenProjectParams {
path: string;
}
/** Params for `project.forget`. */
export interface ForgetProjectParams {
path: string;
}
/** Payload for `bot_config.save` (and result of `bot_config.get`). */
export interface BotConfigPayload {
transport: string | null;
enabled: boolean | null;
homeserver: string | null;
username: string | null;
password: string | null;
room_ids: string[] | null;
slack_bot_token: string | null;
slack_signing_secret: string | null;
slack_channel_ids: string[] | null;
}
/** Payload for `settings.put_project` (also returned by `settings.get_project`). */
export interface ProjectSettingsPayload {
default_qa: string;
default_coder_model: string | null;
max_coders: number | null;
max_retries: number;
base_branch: string | null;
rate_limit_notifications: boolean;
timezone: string | null;
rendezvous: string | null;
watcher_sweep_interval_secs: number;
watcher_done_retention_secs: number;
}
// ── Result types ────────────────────────────────────────────────────────────
/** Result envelope for write methods that simply succeed or fail. */
export interface OkResult {
ok: boolean;
}
/** Result for `settings.put_editor`. */
export interface EditorSettingsResult {
editor_command: string | null;
}
/** Result for `project.open`. */
export interface OpenProjectResult {
path: string;
}
// ── Method → params/result mapping ──────────────────────────────────────────
/**
* Compile-time mapping from typed RPC method name to its params + result
* shapes. Used by `callTypedRpc` to enforce that callers pass the right
* params and receive the right return type for a method.
*/
export interface TypedRpcMethods {
"model.set_preference": {
params: SetModelPreferenceParams;
result: OkResult;
};
"anthropic.set_api_key": {
params: SetAnthropicApiKeyParams;
result: OkResult;
};
"settings.put_editor": {
params: PutEditorParams;
result: EditorSettingsResult;
};
"settings.open_file": {
params: OpenFileParams;
result: OkResult;
};
"settings.put_project": {
params: ProjectSettingsPayload;
result: ProjectSettingsPayload;
};
"project.open": {
params: OpenProjectParams;
result: OpenProjectResult;
};
"project.close": {
params: Record<string, never>;
result: OkResult;
};
"project.forget": {
params: ForgetProjectParams;
result: OkResult;
};
"bot_config.save": {
params: BotConfigPayload;
result: BotConfigPayload;
};
"chat.cancel": {
params: Record<string, never>;
result: OkResult;
};
}
/** Union of all typed RPC method names declared in the contract. */
export type TypedRpcMethodName = keyof TypedRpcMethods;
// ── Canonical fixtures (mirror of Rust `CONTRACT_METHODS`) ──────────────────
/**
* One canonical example payload per typed RPC method. The shape *must*
* match the corresponding Rust `CONTRACT_METHODS` entry. Drift between this
* table and `rpcContract.snapshot.json` (regenerated by the Rust side) fails
* the `rpcContract.test.ts` snapshot check.
*/
export const CONTRACT_FIXTURES: {
[K in TypedRpcMethodName]: {
params: TypedRpcMethods[K]["params"];
result: TypedRpcMethods[K]["result"];
};
} = {
"model.set_preference": {
params: { model: "claude-sonnet-4-6" },
result: { ok: true },
},
"anthropic.set_api_key": {
params: { api_key: "sk-ant-..." },
result: { ok: true },
},
"settings.put_editor": {
params: { editor_command: "zed" },
result: { editor_command: "zed" },
},
"settings.open_file": {
params: { path: "src/main.rs", line: 42 },
result: { ok: true },
},
"settings.put_project": {
params: {
default_qa: "server",
default_coder_model: null,
max_coders: null,
max_retries: 2,
base_branch: null,
rate_limit_notifications: true,
timezone: null,
rendezvous: null,
watcher_sweep_interval_secs: 60,
watcher_done_retention_secs: 86_400,
},
result: {
default_qa: "server",
default_coder_model: null,
max_coders: null,
max_retries: 2,
base_branch: null,
rate_limit_notifications: true,
timezone: null,
rendezvous: null,
watcher_sweep_interval_secs: 60,
watcher_done_retention_secs: 86_400,
},
},
"project.open": {
params: { path: "/path/to/project" },
result: { path: "/path/to/project" },
},
"project.close": {
params: {},
result: { ok: true },
},
"project.forget": {
params: { path: "/path/to/project" },
result: { ok: true },
},
"bot_config.save": {
params: {
transport: "matrix",
enabled: true,
homeserver: "https://matrix.example",
username: "bot",
password: "secret",
room_ids: ["!room:example"],
slack_bot_token: null,
slack_signing_secret: null,
slack_channel_ids: null,
},
result: {
transport: "matrix",
enabled: true,
homeserver: "https://matrix.example",
username: "bot",
password: "secret",
room_ids: ["!room:example"],
slack_bot_token: null,
slack_signing_secret: null,
slack_channel_ids: null,
},
},
"chat.cancel": {
params: {},
result: { ok: true },
},
};
+63 -60
View File
@@ -1,29 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** Tests for the `settings` WS-RPC client (project settings read/write). */
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ProjectSettings } from "./settings";
import { settingsApi } from "./settings";
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
const defaultProjectSettings: ProjectSettings = {
default_qa: "server",
default_coder_model: null,
@@ -62,25 +46,25 @@ describe("settingsApi", () => {
});
describe("putProjectSettings", () => {
it("sends PUT to /settings with settings body", async () => {
it("dispatches settings.put_project RPC with settings", async () => {
const updated = { ...defaultProjectSettings, default_qa: "agent" };
mockFetch.mockResolvedValueOnce(okResponse(updated));
const rpc = installRpcMock();
rpc.respond("settings.put_project", updated);
const result = await settingsApi.putProjectSettings(updated);
expect(mockFetch).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
method: "PUT",
body: JSON.stringify(updated),
}),
);
expect(rpc.calls).toEqual([
{ method: "settings.put_project", params: updated },
]);
expect(result.default_qa).toBe("agent");
});
it("throws on validation error", async () => {
mockFetch.mockResolvedValueOnce(
errorResponse(400, "Invalid default_qa value"),
it("throws on validation error from RPC", async () => {
const rpc = installRpcMock();
rpc.respondError(
"settings.put_project",
"Invalid default_qa value",
"INVALID",
);
await expect(
settingsApi.putProjectSettings({
@@ -115,47 +99,65 @@ describe("settingsApi", () => {
});
describe("setEditorCommand", () => {
it("sends PUT to /settings/editor with command body", async () => {
const expected = { editor_command: "zed" };
mockFetch.mockResolvedValueOnce(okResponse(expected));
it("dispatches settings.put_editor RPC with command", async () => {
const rpc = installRpcMock();
rpc.respond("settings.put_editor", { editor_command: "zed" });
const result = await settingsApi.setEditorCommand("zed");
expect(mockFetch).toHaveBeenCalledWith(
"/api/settings/editor",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ editor_command: "zed" }),
}),
);
expect(result).toEqual(expected);
expect(rpc.calls).toEqual([
{
method: "settings.put_editor",
params: { editor_command: "zed" },
},
]);
expect(result).toEqual({ editor_command: "zed" });
});
it("sends PUT with null to clear the editor command", async () => {
const expected = { editor_command: null };
mockFetch.mockResolvedValueOnce(okResponse(expected));
it("dispatches settings.put_editor with null to clear", async () => {
const rpc = installRpcMock();
rpc.respond("settings.put_editor", { editor_command: null });
const result = await settingsApi.setEditorCommand(null);
expect(mockFetch).toHaveBeenCalledWith(
"/api/settings/editor",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ editor_command: null }),
}),
);
expect(rpc.calls).toEqual([
{
method: "settings.put_editor",
params: { editor_command: null },
},
]);
expect(result.editor_command).toBeNull();
});
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "vim" }));
describe("openFile", () => {
it("dispatches settings.open_file RPC with path and line", async () => {
const rpc = installRpcMock();
rpc.respond("settings.open_file", { ok: true });
await settingsApi.setEditorCommand("vim", "http://localhost:4000/api");
const result = await settingsApi.openFile("src/main.rs", 42);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:4000/api/settings/editor",
expect.objectContaining({ method: "PUT" }),
);
expect(rpc.calls).toEqual([
{
method: "settings.open_file",
params: { path: "src/main.rs", line: 42 },
},
]);
expect(result).toEqual({ success: true });
});
it("dispatches settings.open_file with null line when omitted", async () => {
const rpc = installRpcMock();
rpc.respond("settings.open_file", { ok: true });
await settingsApi.openFile("src/main.rs");
expect(rpc.calls).toEqual([
{
method: "settings.open_file",
params: { path: "src/main.rs", line: null },
},
]);
});
});
@@ -169,8 +171,9 @@ describe("settingsApi", () => {
);
});
it("throws on setEditorCommand error", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
it("surfaces RPC errors for setEditorCommand", async () => {
const rpc = installRpcMock();
rpc.respondError("settings.put_editor", "Forbidden", "FORBIDDEN");
await expect(settingsApi.setEditorCommand("code")).rejects.toThrow(
"Forbidden",
+21 -55
View File
@@ -2,6 +2,13 @@
* WS-RPC client for editor and project settings.
*/
import { rpcCall } from "./rpc";
import type {
EditorSettingsResult,
OkResult,
OpenFileParams,
ProjectSettingsPayload,
PutEditorParams,
} from "./rpcContract";
export interface EditorSettings {
editor_command: string | null;
@@ -24,80 +31,39 @@ export interface OpenFileResult {
success: boolean;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const settingsApi = {
getProjectSettings(_baseUrl?: string): Promise<ProjectSettings> {
return rpcCall<ProjectSettings>("settings.get_project");
},
putProjectSettings(
async putProjectSettings(
settings: ProjectSettings,
baseUrl?: string,
_baseUrl?: string,
): Promise<ProjectSettings> {
return requestJson<ProjectSettings>(
"/settings",
{ method: "PUT", body: JSON.stringify(settings) },
baseUrl,
);
const params: ProjectSettingsPayload = settings;
return rpcCall<ProjectSettingsPayload>("settings.put_project", params);
},
getEditorCommand(_baseUrl?: string): Promise<EditorSettings> {
return rpcCall<EditorSettings>("settings.get_editor");
},
setEditorCommand(
async setEditorCommand(
command: string | null,
baseUrl?: string,
_baseUrl?: string,
): Promise<EditorSettings> {
return requestJson<EditorSettings>(
"/settings/editor",
{
method: "PUT",
body: JSON.stringify({ editor_command: command }),
},
baseUrl,
);
const params: PutEditorParams = { editor_command: command };
const r = await rpcCall<EditorSettingsResult>("settings.put_editor", params);
return { editor_command: r.editor_command };
},
openFile(
async openFile(
path: string,
line?: number,
baseUrl?: string,
_baseUrl?: string,
): Promise<OpenFileResult> {
const params = new URLSearchParams({ path });
if (line !== undefined) {
params.set("line", String(line));
}
return requestJson<OpenFileResult>(
`/settings/open-file?${params.toString()}`,
{ method: "POST" },
baseUrl,
);
const params: OpenFileParams = { path, line: line ?? null };
const r = await rpcCall<OkResult>("settings.open_file", params);
return { success: r.ok };
},
};
@@ -0,0 +1,196 @@
/**
* Frontend seam test: drive a real React component against a fixture derived
* from the actual RPC response (the canonical `CONTRACT_FIXTURES` shared with
* the Rust side via the snapshot file).
*
* The first test renders `SettingsPage` against the well-formed fixture and
* asserts the form populates with values from the RPC response — proving the
* backend ↔ frontend wire shape lines up end-to-end without hand-rolled
* fixtures.
*
* The second test feeds a *malformed* RPC response (a frame missing the
* required envelope `ok` field) and asserts the `rpc.ts` client surfaces a
* visible error in the rendered UI instead of leaving the page empty.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SettingsPage } from "./SettingsPage";
import { CONTRACT_FIXTURES } from "../api/rpcContract";
import snapshot from "../api/rpcContract.snapshot.json";
afterEach(() => {
vi.restoreAllMocks();
});
interface MockSocket {
url: string;
onopen: ((ev: Event) => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
onerror: ((ev: Event) => void) | null;
onclose: ((ev: CloseEvent) => void) | null;
readyState: number;
send(data: string): void;
close(): void;
}
/**
* Install a `WebSocket` shim that hands each registered method a single
* canned frame. Callers register either a normal RPC result or a
* deliberately malformed frame body (returned verbatim — i.e. the body
* literally has no `ok` field, simulating a server bug).
*/
function installSeamWs(replies: {
[method: string]: { kind: "ok"; result: unknown } | { kind: "raw"; body: object };
}) {
const instances: MockSocket[] = [];
class SeamWs implements MockSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
url: string;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
readyState = 0;
constructor(url: string) {
this.url = url;
instances.push(this);
queueMicrotask(() => {
this.readyState = 1;
this.onopen?.(new Event("open"));
});
}
send(data: string) {
let frame: {
correlation_id?: string;
method?: string;
};
try {
frame = JSON.parse(data);
} catch {
return;
}
const { correlation_id, method } = frame;
if (!correlation_id || !method) return;
queueMicrotask(() => {
const reply = replies[method];
if (!reply) {
this.onmessage?.({
data: JSON.stringify({
kind: "rpc_response",
version: 1,
correlation_id,
ok: false,
error: `no fixture for ${method}`,
code: "NOT_FOUND",
}),
});
return;
}
if (reply.kind === "ok") {
this.onmessage?.({
data: JSON.stringify({
kind: "rpc_response",
version: 1,
correlation_id,
ok: true,
result: reply.result,
}),
});
return;
}
// raw: deliberately malformed envelope (no `ok` field)
this.onmessage?.({
data: JSON.stringify({
kind: "rpc_response",
version: 1,
correlation_id,
...reply.body,
}),
});
});
}
close() {
this.readyState = 3;
}
}
vi.stubGlobal("WebSocket", SeamWs);
return instances;
}
describe("SettingsPage seam test", () => {
it("renders ProjectSettings from the typed RPC contract fixture", async () => {
// Sanity: the in-source fixture mirrors the on-disk snapshot file. If
// this trips, the contract has drifted from the Rust side.
expect(CONTRACT_FIXTURES["settings.put_project"].result).toEqual(
snapshot["settings.put_project"].result,
);
const fixture = CONTRACT_FIXTURES["settings.put_project"].result;
installSeamWs({
"settings.get_project": { kind: "ok", result: fixture },
});
const onBack = vi.fn();
render(<SettingsPage onBack={onBack} />);
await waitFor(() => {
expect(screen.getByDisplayValue(String(fixture.max_retries))).toBeInTheDocument();
});
// Field driven directly by the RPC payload populates the form.
expect(
screen.getByDisplayValue(String(fixture.watcher_sweep_interval_secs)),
).toBeInTheDocument();
expect(
screen.getByDisplayValue(String(fixture.watcher_done_retention_secs)),
).toBeInTheDocument();
});
it("shows a visible error when the RPC response is malformed", async () => {
// `body` lacks the envelope `ok` field. The fixed `rpc.ts` client
// should reject loudly with a `MALFORMED` error instead of letting
// the page render empty.
installSeamWs({
"settings.get_project": {
kind: "raw",
body: { result: { not_actually_settings: true } },
},
});
const onBack = vi.fn();
render(<SettingsPage onBack={onBack} />);
await waitFor(() => {
expect(screen.getByText(/Malformed RPC response/i)).toBeInTheDocument();
});
// And critically — no empty form is rendered.
expect(screen.queryByText(/default qa/i)).not.toBeInTheDocument();
});
it("user can edit and the new value flows through settings.put_project RPC", async () => {
const fixture = CONTRACT_FIXTURES["settings.put_project"].result;
const updated = { ...fixture, max_retries: 9 };
installSeamWs({
"settings.get_project": { kind: "ok", result: fixture },
"settings.put_project": { kind: "ok", result: updated },
});
const onBack = vi.fn();
render(<SettingsPage onBack={onBack} />);
const maxRetriesInput = (await screen.findByDisplayValue(
String(fixture.max_retries),
)) as HTMLInputElement;
fireEvent.change(maxRetriesInput, { target: { value: "9" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(screen.getByDisplayValue("9")).toBeInTheDocument();
});
});
});