import { afterEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse } from "@wrnexus/syntax"; import { generateTargets } from "../src/targets.ts"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); function browserModule(inner: string): string { return generateTargets( parse(`page Repro { client { ${inner} } functions { client async function run(): Promise { const users = await api.searchUsers({ name: "Ajay" }) console.log(users) } } view {
} } `), ).browser; } const BLOCK = ` api searchUsers POST /api/users { request { body { name?: string age?: number } } response { return data.users } error { return [] } }`; test("emits an api member that calls the transport with the block's path and method", () => { const generated = browserModule(BLOCK); expect(generated).toContain("const api ="); expect(generated).toContain("searchUsers"); expect(generated).toContain('"/api/users"'); expect(generated).toContain('"POST"'); }); test("declared field types never reach the browser module", () => { // The artifact is written as .mjs and parsed as JavaScript. const generated = browserModule(BLOCK); expect(generated).not.toContain("name?: string"); expect(generated).not.toContain("age?: number"); }); test("the emitted module is valid JavaScript", () => { const generated = browserModule(BLOCK); expect(() => { new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); }); test("a block without an error section still emits its response body", () => { const generated = browserModule(` api plainUsers GET /api/users { request { parameters { team: string } } response { return data.users } }`); expect(generated).toContain("plainUsers"); expect(generated).toContain("data.users"); }); test("type annotations in response/error bodies are erased before emission (B4)", () => { // Every other browser-bound body in the repo passes through eraseFunctionTypes // (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and // store-codegen.ts); response/error bodies must too, for the same reason: // eraseFunctionTypes strips function-signature annotations (params, return // type, typed catch clauses) so a locally-declared helper function inside a // response/error body no longer ships raw TypeScript into the .mjs artifact. const generated = browserModule(` api searchUsers POST /api/users { request { body { name?: string } } response { function pick(list: string[]): string[] { return list } return pick(data.users) } error { function describe(e: unknown): string { return String(e) } return describe(error) } }`); expect(generated).not.toContain("list: string[]"); expect(generated).not.toContain("): string[] {"); expect(generated).not.toContain("e: unknown"); expect(generated).not.toContain("): string {"); expect(() => { new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); }); test("a page with state api and no client api blocks still reads that state (B5)", () => { // "api" is normally excluded from state/prop destructuring because the // emitted `const api = {...}` binding would shadow it -- but that binding // only exists when the page has client-mode api blocks. Without one, the // exclusion left `api` completely undeclared: a ReferenceError. const generated = generateTargets( parse(`page Repro { state { api = "hello" } functions { client function run(): void { console.log(api) } } view {
} } `), ).browser; expect(generated).toContain("context.state"); expect(() => { new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); }); /** * Builds a browser module whose `run()` function calls api.searchUsers and * reports the outcome through `output.report(...)` so the test can observe * whether the call resolved or rejected without reaching into codegen * internals. */ function reportingBrowserModule(apiBlock: string): string { return generateTargets( parse(`page Repro { client { ${apiBlock} } outputs { report(payload: any) } functions { client async function run(): Promise { try { const users = await api.searchUsers({ name: "Ajay" }) output.report({ ok: true, users }) } catch (e) { output.report({ ok: false, message: String(e && e.message || e) }) } } } view {
} } `), ).browser; } async function importBrowserModule(source: string): Promise { const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-")); roots.push(root); mkdirSync(root, { recursive: true }); const file = join(root, "page.mjs"); writeFileSync(file, source); return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`); } test("a response body error is not swallowed by the error section (client)", async () => { const mod = await importBrowserModule( reportingBrowserModule(` api searchUsers GET /api/users { request { parameters { name: string } } response { return data.users.missing.length } error { return [] } }`), ); const reports: unknown[] = []; const context = { state: {}, props: {}, output: { report: (value: unknown) => reports.push(value) }, server: {}, refs: {}, callApi: async () => ({ users: [] }), }; await mod.__wrnexusClientFunctions.run(context); expect(reports).toEqual([{ ok: false, message: expect.any(String) }]); // The error section's own fallback ("[]" / an empty array) must not have // been what the caller observed -- a bug in the response body is a // rejection, not a silently-returned fallback value. expect(reports[0]).not.toEqual({ ok: true, users: [] }); }); test("a genuine transport failure still runs the error section's fallback (client)", async () => { const mod = await importBrowserModule( reportingBrowserModule(` api searchUsers GET /api/users { request { parameters { name: string } } response { return data.users } error { return ["fallback"] } }`), ); const reports: unknown[] = []; const context = { state: {}, props: {}, output: { report: (value: unknown) => reports.push(value) }, server: {}, refs: {}, callApi: async () => { throw Object.assign(new Error("transport failed"), { status: 500 }); }, }; await mod.__wrnexusClientFunctions.run(context); expect(reports).toEqual([{ ok: true, users: ["fallback"] }]); }); test("a state field named api does not collide with the emitted api object", () => { const generated = generateTargets( parse(`page Repro { state { api = "" } client { ${BLOCK} } functions { client async function run(): Promise { const users = await api.searchUsers({ name: "Ajay" }) console.log(users) } } view {
} } `), ).browser; expect(() => { new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); });