From de9c90966d9906b8ba23eea8dc647a00020ae50a Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 08:47:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20binder=20le=20fetch=20global?= =?UTF-8?q?=20=C3=A0=20globalThis=20(pairing=20Firefox)=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/adapters/http/httpInvoker.test.ts | 76 ++++++++++++++++++- frontend/src/adapters/http/httpInvoker.ts | 29 ++++++- frontend/src/adapters/http/webSession.test.ts | 38 ++++++++++ frontend/src/adapters/http/webSession.ts | 5 +- 4 files changed, 141 insertions(+), 7 deletions(-) diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index dcbbe59..7950694 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -7,9 +7,37 @@ import { describe, it, expect, vi } from "vitest"; import type { FetchLike } from "./httpInvoker"; -import { HttpInvoker } 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, +): Promise { + 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, @@ -110,6 +138,52 @@ describe("HttpInvoker", () => { 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", () => { diff --git a/frontend/src/adapters/http/httpInvoker.ts b/frontend/src/adapters/http/httpInvoker.ts index 9db8ec1..bca9db2 100644 --- a/frontend/src/adapters/http/httpInvoker.ts +++ b/frontend/src/adapters/http/httpInvoker.ts @@ -41,6 +41,29 @@ export type FetchLike = ( text(): Promise; }>; +/** + * Resolves the default `fetch`, **bound to `globalThis`**. + * + * WHATWG `fetch` is a method of the global object and checks its receiver: once + * stored in a field and called as `this.fetchImpl(…)`, its `this` is the owning + * adapter instance, not `window`. A real browser rejects that — Firefox with + * `TypeError: 'fetch' called on an object that does not implement interface + * Window`, Chrome with "Illegal invocation". jsdom does not enforce the receiver, + * so the unit tests never caught it; the bind is what keeps the fallback callable + * from a field. An *injected* `fetchImpl` is left untouched (a test stub is a + * plain function and needs no receiver). + * + * When there is no global `fetch` (SSR / bare Node), the value is returned as-is + * so the failure still happens at call time, exactly as before, rather than + * throwing during construction. + */ +export function defaultFetch(): FetchLike { + const globalFetch = globalThis.fetch; + return ( + typeof globalFetch === "function" ? globalFetch.bind(globalThis) : globalFetch + ) as unknown as FetchLike; +} + /** Configuration for the HTTP invoker. */ export interface HttpInvokerConfig { /** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */ @@ -85,10 +108,8 @@ export class HttpInvoker { this.baseUrl = config.baseUrl.replace(/\/+$/, ""); this.token = config.token; this.onUnauthorized = config.onUnauthorized; - // `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it - // to the minimal shape used here. - this.fetchImpl = - config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + // The global fallback must stay bound to `globalThis` — see `defaultFetch`. + this.fetchImpl = config.fetchImpl ?? defaultFetch(); } /** diff --git a/frontend/src/adapters/http/webSession.test.ts b/frontend/src/adapters/http/webSession.test.ts index d5092e7..bfbc76a 100644 --- a/frontend/src/adapters/http/webSession.test.ts +++ b/frontend/src/adapters/http/webSession.test.ts @@ -91,6 +91,44 @@ describe("WebSession unauthorized handling", () => { }); }); +describe("WebSession default fetch receiver", () => { + /** + * Regression (live Firefox, ticket #13): the default global `fetch` was stored + * unbound, so `this.fetchImpl(…)` called it with the WebSession as receiver ⇒ + * `TypeError: 'fetch' called on an object that does not implement interface + * Window` at pairing. jsdom does not enforce the receiver, so assert it here. + */ + async function pairWithRecordingGlobalFetch(): Promise { + const original = globalThis.fetch; + const receivers: unknown[] = []; + globalThis.fetch = function (this: unknown) { + receivers.push(this); + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => "{}", + }); + } as unknown as typeof globalThis.fetch; + try { + // No `fetchImpl` injected ⇒ the global fallback is used. + const session = new WebSession({ baseUrl: "https://host", store: memStore() }); + await session.pair("4821-93"); + } finally { + globalThis.fetch = original; + } + return receivers; + } + + it("pairs using the global fetch bound to globalThis, not the session", async () => { + const receivers = await pairWithRecordingGlobalFetch(); + + expect(receivers).toHaveLength(1); + expect(receivers[0]).toBe(globalThis); + expect(receivers[0]).not.toBeInstanceOf(WebSession); + }); +}); + describe("WebSession logout", () => { it("POSTs /api/logout same-origin and clears the flag on success", async () => { const store = memStore(); diff --git a/frontend/src/adapters/http/webSession.ts b/frontend/src/adapters/http/webSession.ts index e3fdf67..d31dd17 100644 --- a/frontend/src/adapters/http/webSession.ts +++ b/frontend/src/adapters/http/webSession.ts @@ -17,7 +17,7 @@ */ import type { GatewayError, Unsubscribe } from "@/domain"; -import type { FetchLike } from "./httpInvoker"; +import { defaultFetch, type FetchLike } from "./httpInvoker"; const PAIRED_FLAG_KEY = "idea.web.paired"; @@ -77,7 +77,8 @@ export class WebSession { constructor(config: WebSessionConfig = {}) { this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, ""); - this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + // The global fallback must stay bound to `globalThis` — see `defaultFetch`. + this.fetchImpl = config.fetchImpl ?? defaultFetch(); this.store = config.store ?? defaultStore(); }