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 }); }); const withCalls = (calls: string) => `page Probe { apis { used POST /api/used { request { body { name?: string } } response { return data.users } } unused GET /api/unused { response { return data.secretShape } } } functions { client async function go(): Promise { ${calls} } } view {
} } `; test("a block the client calls is emitted into the browser module", () => { const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; expect(browser).toContain("used"); expect(browser).toContain('"/api/used"'); }); test("a block the client never calls is NOT emitted into the browser module", () => { // Server-only transforms must not ship. This is the point of usage-driven emission. const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; expect(browser).not.toContain("secretShape"); expect(browser).not.toContain('"/api/unused"'); }); test("no api object at all when the client calls none", () => { const browser = generateTargets(parse(withCalls(` console.log("nothing")`))).browser; expect(browser).not.toContain("const api ="); }); test("the emitted browser module is valid JavaScript", () => { const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; expect(() => { new Function(browser.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); }); test("declared field types never reach the browser module", () => { const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; expect(browser).not.toContain("name?: string"); }); test("dynamic bracket access on api is a compile error naming the construct", () => { expect(() => generateTargets(parse(withCalls(` await api["used"]({ name: "a" })`)))).toThrow( /api/, ); }); test("passing api to a helper is a compile error", () => { expect(() => generateTargets(parse(withCalls(` callHelper(api)`)))).toThrow(/api/); }); test("a plain api.name(...) call still compiles", () => { expect(() => generateTargets(parse(withCalls(` await api.used({ name: "a" })`))), ).not.toThrow(); }); test("an identifier that merely contains 'api' does not trigger the dynamic-access error", () => { expect(() => generateTargets( parse( withCalls( ` const rapidCheck = 1; this.apiary = rapidCheck; await api.used({ name: "a" })`, ), ), ), ).not.toThrow(); }); test("the word 'api' inside a comment or string literal does not trigger the dynamic-access error", () => { expect(() => generateTargets( parse( withCalls( ` // this mentions api in a comment\n const note = "the api is great";\n await api.used({ name: "a" })`, ), ), ), ).not.toThrow(); }); 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 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 browser = generateTargets( parse(`page Probe { apis { 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) } } } functions { client async function go(): Promise { await api.searchUsers({ name: "a" }) } } view {
} } `), ).browser; expect(browser).not.toContain("list: string[]"); expect(browser).not.toContain("): string[] {"); expect(browser).not.toContain("e: unknown"); expect(browser).not.toContain("): string {"); expect(() => { new Function(browser.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); }); test("a state field named api does not collide with the emitted api object", () => { const browser = generateTargets( parse(`page Probe { state { api = "" } apis { used POST /api/used { request { body { name?: string } } response { return data.users } } } functions { client async function go(): Promise { const users = await api.used({ name: "a" }) console.log(users) } } view {
} } `), ).browser; expect(() => { new Function(browser.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); }).not.toThrow(); }); /** * Builds a browser module whose `go()` function calls api.searchUsers and * reports the outcome through `output.report(...)` so a test can observe * whether the call resolved or rejected without reaching into codegen * internals. */ function reportingBrowserModule(apiBlock: string): string { return generateTargets( parse(`page Probe { apis { ${apiBlock} } outputs { report(payload: any) } functions { client async function go(): Promise { try { const users = await api.searchUsers({ name: "a" }) 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(` 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.go(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(` 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.go(context); expect(reports).toEqual([{ ok: true, users: ["fallback"] }]); });