diff --git a/packages/core/test/api-request.test.ts b/packages/core/test/api-request.test.ts index e2a94b27..82393bd6 100644 --- a/packages/core/test/api-request.test.ts +++ b/packages/core/test/api-request.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; import { buildApiRequest } from "../src/api-request.ts"; -import { REACTIVE_RUNTIME } from "../../csr/src/reactive-runtime.ts"; test("GET builds a query string", () => { expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).url).toBe("/api/users?name=Ajay"); @@ -47,9 +46,7 @@ test("values are encoded", () => { ); }); -test("the browser runtime and the shared builder agree", () => { - // Cheap structural guard: the runtime must apply the same omission rule. - // If someone changes one side's rules, this fails. - expect(REACTIVE_RUNTIME).toContain('value === ""'); - expect(REACTIVE_RUNTIME).toContain("application/json"); -}); +// The behavioural agreement test between this builder and the browser +// runtime's wrnexusCallApi lives in packages/csr/test/api-request-agreement.test.ts, +// which can host the happy-dom harness needed to run the runtime and compare +// outputs. This file has no DOM available. diff --git a/packages/csr/test/api-request-agreement.test.ts b/packages/csr/test/api-request-agreement.test.ts new file mode 100644 index 00000000..26a1f8d4 --- /dev/null +++ b/packages/csr/test/api-request-agreement.test.ts @@ -0,0 +1,128 @@ +import { expect, test, beforeEach } from "bun:test"; +import { Window } from "happy-dom"; +import { buildApiRequest } from "@wrnexus/core"; +import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts"; +import { restoreGlobalsAfterAll } from "./global-restore.ts"; + +// This test compares the two transports by OUTPUT, not by text. A substring +// search over REACTIVE_RUNTIME cannot detect a logic change on either side +// (it never touches buildApiRequest at all, and it isn't anchored to the +// right line on the runtime side). Only running both implementations on the +// same input and diffing what they actually send can catch drift. + +const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"]; +restoreGlobalsAfterAll(REPLACED_GLOBALS); + +beforeEach(() => { + for (const name of REPLACED_GLOBALS) delete (globalThis as Record)[name]; +}); + +interface RecordedCall { + url: string; + method: string; + body: string | undefined; + contentType: string | undefined; +} + +/** Mount the runtime with a recording fetch and run one call through it. */ +async function callThroughRuntime( + path: string, + method: string, + input: Record | undefined, +): Promise { + const win = new Window() as unknown as Window & Record; + win.document.body.innerHTML = `
`; + let recorded: RecordedCall | undefined; + + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; + (globalThis as Record).NodeFilter = ( + win as unknown as { NodeFilter: unknown } + ).NodeFilter; + (globalThis as Record).fetch = (url: string, init: RequestInit) => { + const headers = init.headers as Record; + recorded = { + url, + method: init.method as string, + body: init.body as string | undefined, + contentType: headers ? headers["content-type"] : undefined, + }; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + }; + + (0, eval)(REACTIVE_RUNTIME); + const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise }) + .__wrnexusCallApi; + + await callApi(path, method, input); + if (!recorded) throw new Error("runtime did not call fetch"); + return recorded; +} + +function callThroughBuilder( + path: string, + method: string, + input: Record | undefined, +): RecordedCall { + const built = buildApiRequest(path, method, input); + return { + url: built.url, + method: String(method || "GET").toUpperCase(), + body: built.body, + contentType: built.contentType, + }; +} + +const CASES: Array<{ + name: string; + path: string; + method: string; + input: Record | undefined; +}> = [ + { + name: "GET with a mix of present and omitted values", + path: "/api/users", + method: "GET", + input: { name: "Ajay", age: undefined, team: null, note: "" }, + }, + { + name: "GET carrying 0 and false", + path: "/api/users", + method: "GET", + input: { count: 0, active: false }, + }, + { + name: "GET whose values need encoding", + path: "/api/users", + method: "GET", + input: { name: "a b&c" }, + }, + { + name: "POST with a body", + path: "/api/users", + method: "POST", + input: { name: "Ajay" }, + }, + { + name: "POST with no input", + path: "/api/users", + method: "POST", + input: undefined, + }, +]; + +for (const { name, path, method, input } of CASES) { + test(`the browser runtime and buildApiRequest agree: ${name}`, async () => { + const fromRuntime = await callThroughRuntime(path, method, input); + const fromBuilder = callThroughBuilder(path, method, input); + + expect(fromRuntime.url).toBe(fromBuilder.url); + expect(fromRuntime.body).toBe(fromBuilder.body); + expect(fromRuntime.contentType).toBe(fromBuilder.contentType); + }); +}