197 lines
5.8 KiB
TypeScript
197 lines
5.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|