feat(frontend): adapter web HTTP+WebSocket derrière les ports (#13)

Lot F1 du chantier server/client mode : nouvel adaptateur web branché
derrière les ports d'invocation et de flux live, permettant au frontend de
dialoguer avec le backend via HTTP + WebSocket en mode client/serveur. Le
mode desktop (Tauri IPC) reste inchangé.

- frontend/src/adapters/http : invoker HTTP, client live WebSocket, gateways
  request/response et stream, frames, garde unsupported (7 fichiers + 2 tests).
- frontend/src/app : câblage DI (di.tsx) et son test, typage vite-env.d.ts.

Validé : build vert, garde no-direct-invoke verte, 724 tests verts,
desktop inchangé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:50:38 +02:00
parent c8fef2a76a
commit e0cdb4aa56
12 changed files with 1783 additions and 5 deletions

View File

@ -0,0 +1,126 @@
/**
* F1 — the HTTP invoker forwards `{command, args}` to `POST /api/invoke`,
* preserves the backend `ErrorDto` on failure, and maps empty bodies to
* `undefined`. Also verifies a couple of request/response gateways emit the
* exact command + argument envelope of their Tauri siblings.
*/
import { describe, it, expect, vi } from "vitest";
import type { FetchLike } from "./httpInvoker";
import { HttpInvoker } from "./httpInvoker";
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
/** Builds a fake fetch that records the request and returns `body`. */
function fakeFetch(
body: unknown,
opts: { ok?: boolean; status?: number } = {},
): { fetchImpl: FetchLike; calls: { url: string; init: unknown }[] } {
const calls: { url: string; init: unknown }[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return {
ok: opts.ok ?? true,
status: opts.status ?? 200,
json: async () => body,
text: async () => JSON.stringify(body),
};
};
return { fetchImpl, calls };
}
describe("HttpInvoker", () => {
it("POSTs {command, args} to /api/invoke and returns the parsed body", async () => {
const { fetchImpl, calls } = fakeFetch({ ok: true });
const http = new HttpInvoker({ baseUrl: "https://host:9000/", fetchImpl });
const result = await http.invoke<{ ok: boolean }>("health", {
request: { note: "hi" },
});
expect(result).toEqual({ ok: true });
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe("https://host:9000/api/invoke");
const init = calls[0].init as { method: string; body: string; headers: Record<string, string> };
expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body)).toEqual({
command: "health",
args: { request: { note: "hi" } },
});
});
it("sends the pairing token as an Authorization header", async () => {
const { fetchImpl, calls } = fakeFetch([]);
const http = new HttpInvoker({ baseUrl: "https://host", token: "tok-123", fetchImpl });
await http.invoke("list_projects");
const init = calls[0].init as { headers: Record<string, string> };
expect(init.headers.Authorization).toBe("Bearer tok-123");
// No args ⇒ empty object envelope.
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
command: "list_projects",
args: {},
});
});
it("rejects with the backend ErrorDto on a non-2xx response", async () => {
const { fetchImpl } = fakeFetch(
{ code: "NOT_FOUND", message: "project x" },
{ ok: false, status: 404 },
);
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
await expect(http.invoke("open_project", { projectId: "x" })).rejects.toEqual({
code: "NOT_FOUND",
message: "project x",
});
});
it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => {
const fetchImpl: FetchLike = async () => {
throw new Error("boom");
};
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
await expect(http.invoke("health")).rejects.toMatchObject({
code: "TRANSPORT_ERROR",
});
});
});
describe("request/response gateways preserve the Tauri command contract", () => {
it("HttpProjectGateway.createProject maps to create_project with a request envelope", async () => {
const http = new HttpInvoker({ baseUrl: "https://h" });
const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never);
const gw = new HttpProjectGateway(http);
await gw.createProject("My proj", "/abs/root");
expect(spy).toHaveBeenCalledWith("create_project", {
request: { name: "My proj", root: "/abs/root" },
});
});
it("HttpGitGateway.stage maps to git_stage with a request envelope", async () => {
const http = new HttpInvoker({ baseUrl: "https://h" });
const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never);
const gw = new HttpGitGateway(http);
await gw.stage("p1", "src/a.ts");
expect(spy).toHaveBeenCalledWith("git_stage", {
request: { projectId: "p1", path: "src/a.ts" },
});
});
it("HttpGitGateway.status maps to git_status with a flat projectId", async () => {
const http = new HttpInvoker({ baseUrl: "https://h" });
const spy = vi.spyOn(http, "invoke").mockResolvedValue([] as never);
const gw = new HttpGitGateway(http);
await gw.status("p1");
expect(spy).toHaveBeenCalledWith("git_status", { projectId: "p1" });
});
});