Files
WRNexusJS/packages/compiler/test/api-block-ssr.test.ts
T
Clintchiz 3ae5d7cf97 fix(compiler): stop error {} from swallowing response {} bugs in api blocks
Client codegen chained .then().catch(), so a .catch() after .then()
caught exceptions thrown by the response body too. Switched to the
two-argument then(onFulfilled, onRejected) form, whose rejection
handler cannot see errors from the fulfilment handler.

SSR codegen wrapped both the transport call and the response-body eval
in the same try; only __wrnexusCallApi is now inside the try, and
__wrnexusEvalData runs after it, outside.

Also verifies (and locks in with a regression test) that GET query
numbers already coerce correctly through defineEndpoint + checkField,
and documents that in the typed-api-block spec.
2026-08-19 21:11:26 +05:30

283 lines
8.0 KiB
TypeScript

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 { generate } from "../src/codegen.ts";
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 (observed: it rejected `ignoreDeprecations: "6.0"` and couldn't
// find the `bun` type-definition entry point), 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 `__wrnexusSsrBindings` annotation (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-ssr-tsc-"));
roots.push(root);
const file = join(root, "page.ts");
writeFileSync(file, source);
// Reuse the repo's own tsconfig (paths, lib, types, jsx, ...) so this only
// checks the one file we care about instead of hand-duplicating the whole
// compiler configuration (and drifting from it over time).
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 };
}
function serverModule(inner: string): string {
return generate(
parse(`page Repro {
ssr {
${inner}
}
view { <main><p api="ssrUsers">loading</p></main> }
}
`),
);
}
test("a sectioned ssr block binds the payload to data", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
}`);
expect(generated).toContain("data.users.length");
});
test("a legacy ssr block is unchanged", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
return users.length
}`);
expect(generated).toContain("users.length");
});
test("an ssr block with an error section emits the error body and binds status/message/data", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return message + status + data
}
}`);
expect(generated).toContain('"errorBody"');
expect(generated).toContain("return message + status + data");
expect(generated).toContain("const status = $status");
expect(generated).toContain("const message = $message");
expect(generated).toContain("const data = $data");
expect(generated).toContain("__wrnexusEvalError");
});
test("an ssr block without an error section emits no catch entry for that binding", () => {
const generated = serverModule(` api ssrUsers GET /api/users {
response {
return data.users.length
}
}`);
expect(generated).not.toContain('"errorBody"');
});
test("tsc: a sectioned ssr block's generated module has no diagnostics", () => {
const generated = serverModule(` api 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);
});
test("an ssr block used in {#each} with an error section runs the error body on failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block's response body error is not swallowed by the error section", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.missing.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-response-throws-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => ({ users: [] }),
}),
).rejects.toThrow();
});
test("an ssr block still runs the error body on a genuine transport failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users.length
}
error {
return ["fallback"]
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-transport-fails-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
const html = await mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
});
expect(html).toContain("fallback");
});
test("an ssr block used in {#each} without an error section still propagates a failure", async () => {
const generated = generate(
parse(`page Repro {
ssr {
api ssrUsers GET /api/users {
response {
return data.users
}
}
}
view { <main>{#each ssrUsers as u}<p>{u}</p>{/each}</main> }
}
`),
);
const root = mkdtempSync(join(tmpdir(), "wrnexus-ssr-each-propagate-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.ts");
writeFileSync(file, generated);
const mod = await import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
await expect(
mod.default({
req: { url: "http://localhost/", headers: new Headers() },
cookies: {},
session: {},
localStorage: {},
__wrnexusCallApi: async () => {
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
});