diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts index d897d761..13688462 100644 --- a/examples/basic-app/app/types/wrnexus.generated.d.ts +++ b/examples/basic-app/app/types/wrnexus.generated.d.ts @@ -57,7 +57,13 @@ declare namespace WRNexusGenerated { "welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>; } type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"]; - type AssertAssignable = [Actual] extends [Expected] ? true : false; + type AssertAssignable = unknown extends Expected + ? true + : [Actual] extends [Expected] + ? [Exclude] extends [never] + ? true + : false + : false; type __wrn_expect_true = T; type ApiInput

= ApiContracts[P][M]["input"]; type ApiOutput

= ApiContracts[P][M]["output"]; diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index dbd653f2..31d06b2c 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -258,7 +258,13 @@ declare namespace WRNexusGenerated { ${generatedContractMap("RealtimeMessages", realtimeContracts)} ${generatedContractMap("QueuePayloads", queueContracts)} type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record"}; - type AssertAssignable = [Actual] extends [Expected] ? true : false; + type AssertAssignable = unknown extends Expected + ? true + : [Actual] extends [Expected] + ? [Exclude] extends [never] + ? true + : false + : false; type __wrn_expect_true = T; type ApiInput

= ApiContracts[P][M]["input"]; type ApiOutput

= ApiContracts[P][M]["output"]; diff --git a/packages/cli/test/api-block-types.test.ts b/packages/cli/test/api-block-types.test.ts index 1cb2708e..2cb4d28c 100644 --- a/packages/cli/test/api-block-types.test.ts +++ b/packages/cli/test/api-block-types.test.ts @@ -84,3 +84,153 @@ test("the api-checks file has no runtime code and is a module", () => { expect(checks).toContain("AUTO-GENERATED"); expect(checks.trim().endsWith("export {};")).toBe(true); }); + +// --- Real-compiler enforcement tests --------------------------------------------- +// +// Everything above only asserts on the emitted *text*. That proves nothing about +// whether the assertions actually make `tsc` fail — a build that reverted to the +// original inert `never`-based design, or one where `AssertAssignable` is merely +// one-directional (so it misses an *extra* declared field), would pass every test +// above unchanged. These tests instead run the real TypeScript compiler over the +// generated output and assert on its diagnostics. +// +// The fixture endpoint takes a second (body) parameter so `ApiContract`'s fallback +// branch infers a real input type (`{ name: string; email: string }`) instead of +// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing +// could ever fail, which would make these tests meaningless. +function typedFixture(block: string): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-")); + roots.push(root); + mkdirSync(join(root, "app/pages"), { recursive: true }); + mkdirSync(join(root, "app/api"), { recursive: true }); + writeFileSync( + join(root, "app/api/users.ts"), + `export const POST = async (ctx: unknown, body: { name: string; email: string }) => Response.json(body);\n`, + ); + writeFileSync( + join(root, "app/pages/search.wrn"), + `page Search {\n client {\n${block}\n }\n\n view {

x
}\n}\n`, + ); + return root; +} + +/** + * Compiles the two generated files (and whatever they reference on disk) with the + * real TypeScript compiler and returns its stdout plus whether it reported any + * diagnostics. + */ +function typecheckGenerated(root: string): { ok: boolean; output: string } { + const dts = join(root, "app/types/wrnexus.generated.d.ts"); + const checks = join(root, "app/types/wrnexus.generated.api-checks.ts"); + const result = Bun.spawnSync( + [ + "bunx", + "tsc", + "--noEmit", + "--strict", + "--skipLibCheck", + "--moduleResolution", + "bundler", + "--target", + "ES2022", + "--module", + "ESNext", + dts, + checks, + ], + { cwd: root, stdout: "pipe", stderr: "pipe" }, + ); + const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`; + return { ok: result.exitCode === 0, output }; +} + +const MATCHING_BLOCK = ` api searchUsers POST /api/users { + request { + body { + name: string + email: string + } + } + + response { + return data + } + }`; + +test("tsc: a block whose fields match the contract has no diagnostics", () => { + const root = typedFixture(MATCHING_BLOCK); + generateApplicationTypes(root); + const { ok, output } = typecheckGenerated(root); + + expect(output.trim()).toBe(""); + expect(ok).toBe(true); +}); + +test("tsc: a field with the wrong type fails, naming the block's assertion", () => { + const root = typedFixture(` api searchUsers POST /api/users { + request { + body { + name: number + email: string + } + } + + response { + return data + } + }`); + generateApplicationTypes(root); + const { ok, output } = typecheckGenerated(root); + + expect(ok).toBe(false); + expect(output).toContain("wrnexus.generated.api-checks.ts"); + const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]); + expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers"); +}); + +test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => { + const root = typedFixture(` api searchUsers POST /api/users { + request { + body { + name: string + email: string + extra: string + } + } + + response { + return data + } + }`); + generateApplicationTypes(root); + const { ok, output } = typecheckGenerated(root); + + expect(ok).toBe(false); + expect(output).toContain("wrnexus.generated.api-checks.ts"); + const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]); + expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers"); +}); + +test("tsc: a missing required field fails", () => { + const root = typedFixture(` api searchUsers POST /api/users { + request { + body { + name: string + } + } + + response { + return data + } + }`); + generateApplicationTypes(root); + const { ok, output } = typecheckGenerated(root); + + expect(ok).toBe(false); + expect(output).toContain("wrnexus.generated.api-checks.ts"); + const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]); + expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers"); +});