Firefox exige que fetch soit appelé sur Window : passer la référence nue `fetch` en dépendance déclenchait « 'fetch' called on an object that does not implement interface Window » au pairing. Nouveau helper defaultFetch() qui binde globalThis.fetch, utilisé par webSession. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
/**
|
|
* 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, defaultFetch } from "./httpInvoker";
|
|
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
|
|
|
|
/**
|
|
* Installs a global `fetch` that records its receiver (`this`), runs `body`, then
|
|
* restores the original. A real browser's `fetch` rejects a non-global receiver
|
|
* (Firefox: "'fetch' called on an object that does not implement interface
|
|
* Window"); jsdom does not enforce it, so the receiver is asserted explicitly.
|
|
*/
|
|
async function withRecordingGlobalFetch(
|
|
body: () => Promise<void>,
|
|
): Promise<unknown[]> {
|
|
const original = globalThis.fetch;
|
|
const receivers: unknown[] = [];
|
|
globalThis.fetch = function (this: unknown) {
|
|
receivers.push(this);
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
text: async () => "{}",
|
|
});
|
|
} as unknown as typeof globalThis.fetch;
|
|
try {
|
|
await body();
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
return receivers;
|
|
}
|
|
|
|
/** 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("fires onUnauthorized on a 401 (and still throws)", async () => {
|
|
const { fetchImpl } = fakeFetch(
|
|
{ code: "UNAUTHENTICATED", message: "no session" },
|
|
{ ok: false, status: 401 },
|
|
);
|
|
const onUnauthorized = vi.fn();
|
|
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
|
|
|
|
await expect(http.invoke("list_projects")).rejects.toMatchObject({
|
|
code: "UNAUTHENTICATED",
|
|
});
|
|
expect(onUnauthorized).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not fire onUnauthorized on a non-401 error", async () => {
|
|
const { fetchImpl } = fakeFetch({ code: "NOT_FOUND", message: "x" }, { ok: false, status: 404 });
|
|
const onUnauthorized = vi.fn();
|
|
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
|
|
|
|
await expect(http.invoke("open_project", { projectId: "x" })).rejects.toBeTruthy();
|
|
expect(onUnauthorized).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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",
|
|
});
|
|
});
|
|
|
|
// Regression: the default global `fetch` was stored unbound, so calling it as
|
|
// `this.fetchImpl(…)` handed it the invoker as receiver — a TypeError in
|
|
// Firefox/Chrome at pairing. It must stay bound to `globalThis`.
|
|
it("calls the default global fetch with globalThis as receiver", async () => {
|
|
const receivers = await withRecordingGlobalFetch(async () => {
|
|
const http = new HttpInvoker({ baseUrl: "https://host" });
|
|
await http.invoke("health");
|
|
});
|
|
|
|
expect(receivers).toHaveLength(1);
|
|
expect(receivers[0]).toBe(globalThis);
|
|
});
|
|
|
|
it("leaves an injected fetchImpl untouched (no receiver rebinding)", async () => {
|
|
const { fetchImpl, calls } = fakeFetch({ ok: true });
|
|
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
|
|
|
|
await http.invoke("health");
|
|
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("defaultFetch", () => {
|
|
it("returns the global fetch bound to globalThis", async () => {
|
|
const receivers = await withRecordingGlobalFetch(async () => {
|
|
const fn = defaultFetch();
|
|
// Called as a bare reference (worst case: no receiver at all).
|
|
await fn("https://host/x");
|
|
});
|
|
|
|
expect(receivers).toEqual([globalThis]);
|
|
});
|
|
|
|
it("passes through a missing global fetch instead of throwing at construction", () => {
|
|
const original = globalThis.fetch;
|
|
// @ts-expect-error — simulating an environment without a global fetch.
|
|
delete globalThis.fetch;
|
|
try {
|
|
// Must not throw here; the failure surfaces at call time, as before.
|
|
expect(defaultFetch()).toBeUndefined();
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|