fix(compiler): type-check the emitted ssr binding array and share error handling across call sites
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,58 @@
|
||||
import { expect, test } from "bun:test";
|
||||
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 {
|
||||
@@ -60,3 +111,95 @@ test("an ssr block without an error section emits no catch entry for that bindin
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user