206 lines
6.2 KiB
TypeScript
206 lines
6.2 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 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");
|
|
});
|