import { afterEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse } from "@wrnexus/syntax"; import { runWithRequestContext } from "@wrnexus/core"; import { generate } from "../src/codegen.ts"; // The generated module dynamically imported below is written to an OS // tmpdir with no node_modules of its own, so Node's bare-specifier // resolution for "@wrnexus/core" would otherwise walk up to whatever // (possibly stale, globally-installed) copy happens to sit outside the // workspace. Symlink the workspace package in so it resolves to the real, // currently-built `@wrnexus/core` — the same one every other package in // this repo gets via its own `node_modules/@wrnexus/core` symlink. const WORKSPACE_CORE = join(import.meta.dir, "../../core"); function linkWorkspaceCore(root: string): void { const scopeDir = join(root, "node_modules", "@wrnexus"); mkdirSync(scopeDir, { recursive: true }); symlinkSync( WORKSPACE_CORE, join(scopeDir, "core"), process.platform === "win32" ? "junction" : "dir", ); } const ROOT_TSCONFIG = join(import.meta.dir, "../../../tsconfig.json").replace(/\\/g, "/"); // The repo's own tsc, not a `bunx`-fetched one — `bunx tsc` can resolve an // unrelated TypeScript version that doesn't understand this repo's tsconfig // options, unlike `bun run typecheck`, which uses this same local binary. const LOCAL_TSC = join(import.meta.dir, "../../../node_modules/.bin/tsc").replace(/\\/g, "/"); // `types`/`typeRoots` in an extended tsconfig resolve relative to the config // file that's actually invoked (our temp one), not the base file — so the // ambient `bun` types need an explicit path back to the repo's node_modules. const TYPE_ROOTS = join(import.meta.dir, "../../../node_modules/@types").replace(/\\/g, "/"); const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); /** * Runs the real TypeScript compiler over a generated server module. Proves * the emitted server `api` object (and everything else in the module) * actually type-checks — string-containment assertions alone can't catch a * declared type that omits a field every emitted object literal has. */ function typecheckGenerated(source: string): { ok: boolean; output: string } { const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-server-tsc-")); roots.push(root); const file = join(root, "page.ts"); writeFileSync(file, source); writeFileSync( join(root, "tsconfig.json"), JSON.stringify({ extends: ROOT_TSCONFIG, compilerOptions: { noEmit: true, typeRoots: [TYPE_ROOTS] }, include: ["page.ts"], }), ); const result = Bun.spawnSync([LOCAL_TSC, "--project", join(root, "tsconfig.json")], { cwd: root, stdout: "pipe", stderr: "pipe", }); const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`; return { ok: result.exitCode === 0, output }; } async function importServerModule(source: string): Promise { const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-server-exec-")); roots.push(root); mkdirSync(root, { recursive: true }); linkWorkspaceCore(root); const file = join(root, "page.ts"); writeFileSync(file, source); return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`); } /** * Renders a page module the way the dev-server runtime does: runs the * generated `__wrnexusLoad` export (if any) and merges its result onto ctx * before calling the default render export. `callApi` fakes the transport * that the generated server `api` object's members call out to. The same * ctx object must be both the AsyncLocalStorage store (so the api member's * `requireRequestContext` call finds it) and the ctx passed to load/render, * or the fake transport is never reached. */ function renderPage( mod: any, callApi: (path: string, method: string, input?: unknown) => Promise, ): Promise { const ctx: any = { req: { url: "http://localhost/", headers: new Headers() }, cookies: {}, session: {}, __wrnexusCallApi: callApi, }; return runWithRequestContext(ctx, async () => { if (typeof mod.__wrnexusLoad === "function") { const data = await mod.__wrnexusLoad(ctx); if (data && typeof data === "object") Object.assign(ctx, data); } return mod.default(ctx); }); } const SOURCE = `page Probe { apis { searchUsers POST /api/users { request { body { name?: string } } response { return data.users } error { return [] } } } load server directory { return await api.searchUsers({ name: "a" }) } view {
x
} } `; test("the server module declares an api object with the block's path and method", () => { const generated = generate(parse(SOURCE)); expect(generated).toContain("const api ="); expect(generated).toContain("searchUsers"); expect(generated).toContain('"/api/users"'); expect(generated).toContain('"POST"'); }); test("the response body is spliced in", () => { expect(generate(parse(SOURCE))).toContain("data.users"); }); test("a block with no error section rethrows rather than resolving undefined", () => { const generated = generate( parse(`page P { apis { a GET /api/a { response { return data } } } load server x { return await api.a() } view {
x
} } `), ); expect(generated).toContain("throw"); }); // --- Execution tests -------------------------------------------------------- // // Everything above only asserts on emitted *text*. These instead run the real // TypeScript compiler and dynamically import + execute the generated module, // proving the response/error sections actually run with the right bindings — // a string-containment check cannot prove that. function renderModule(inner: string): string { return generate( parse(`page Repro { apis { ${inner} } view {

loading

} } `), ); } test("a sectioned block's response body binds the payload to data", async () => { const mod = await importServerModule( renderModule(` ssrUsers GET /api/users { response { return data.users.length } }`), ); const html = await renderPage(mod, async () => ({ users: [1, 2, 3] })); expect(html).toContain("3"); }); test("an error section emits the error body and binds status/message/data", async () => { const mod = await importServerModule( renderModule(` ssrUsers GET /api/users { response { return data.users.length } error { return message + ":" + status + ":" + data } }`), ); const html = await renderPage(mod, async () => { throw Object.assign(new Error("boom"), { status: 500, data: "extra" }); }); expect(html).toContain("boom:500:extra"); }); test("a block without an error section still propagates a genuine transport failure", async () => { const mod = await importServerModule( renderModule(` ssrUsers GET /api/users { response { return data.users.length } }`), ); await expect( renderPage(mod, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); }); test("a response body error is not swallowed by the error section", async () => { const mod = await importServerModule( renderModule(` ssrUsers GET /api/users { response { return data.users.missing.length } error { return "fallback" } }`), ); // The transport call itself succeeds; the bug is in the response body. That // must surface as a rejection, not be swallowed by the error section's own // fallback -- a bug in response handling is not a transport failure. await expect(renderPage(mod, async () => ({ users: [] }))).rejects.toThrow(); }); test("a genuine transport failure still runs the error section's fallback", async () => { const mod = await importServerModule( renderModule(` ssrUsers GET /api/users { response { return data.users.length } error { return "fallback" } }`), ); const html = await renderPage(mod, async () => { throw new Error("boom"); }); expect(html).toContain("fallback"); }); test("tsc: a sectioned block's generated server module has no diagnostics", () => { const generated = renderModule(` ssrUsers GET /api/users { response { return data.users.length } error { return message + status + data } }`); const { ok, output } = typecheckGenerated(generated); expect(output.trim()).toBe(""); expect(ok).toBe(true); }); // {#each} iterates a plain, already-resolved value — an `await` inside the // list expression itself is not supported (the list expression runs outside // an async boundary). A `load server` block is the idiomatic way to resolve // an api call before the view iterates it, and it goes through the exact // same server `api` member (and thus the same response/error handling) as // every other apis {} call site. function eachModule(inner: string): string { return generate( parse(`page Repro { apis { ${inner} } load server users { return await api.ssrUsers() } view {
{#each users as u}

{u}

{/each}
} } `), ); } test("an api block used in {#each} with an error section runs the error body on failure", async () => { const mod = await importServerModule( eachModule(` ssrUsers GET /api/users { response { return data.users } error { return ["fallback"] } }`), ); const html = await renderPage(mod, async () => { throw new Error("boom"); }); expect(html).toContain("fallback"); }); test("an api block used in {#each} without an error section still propagates a failure", async () => { const mod = await importServerModule( eachModule(` ssrUsers GET /api/users { response { return data.users } }`), ); await expect( renderPage(mod, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); });