fix: address all seven final-gate findings for typed api blocks
B1: qualify each generated __wrn_api_check_* assertion name with a short hash of the page's path (relative to app/, for reproducibility across checkouts) so two pages declaring a same-named block no longer collide with an identical type alias (TS2300). B2: skip assertion emission for any block that is not client-mode, or that has zero declared request fields. ssr sectioned blocks can never declare a request and always fell back to Record<string, never>, whose keyof is `string` -- making the key-exactness arm of AssertAssignable evaluate to false unconditionally (TS2344) on every ssr sectioned block regardless of correctness. Chose to skip both non-client blocks and zero-field client blocks, since neither has anything meaningful to assert type-safety about. B3: only resolve the endpoint's input (query params / ctx.req.json()) when the endpoint declares an input schema. Previously the router-set fix accidentally read the request body unconditionally, so a handler with no input schema that parses the request itself hit ERR_BODY_ALREADY_USED. B4: run response/error bodies in client-mode api blocks through eraseFunctionTypes, matching every other browser-bound body in client-codegen.ts, so a TypeScript-only construct inside one (e.g. an annotated locally-declared function) doesn't reach the .mjs artifact. B5: only exclude "api" from state/prop destructuring in the generated browser module when the page actually has client-mode api blocks (i.e. there is a real `api` binding to shadow). Previously "api" was always excluded, so a page with `state api` and no api blocks got an undeclared `api` reference (ReferenceError) in client code. B6: prefix each emitted assertion with `export`, so it isn't flagged as an unused local under a downstream project's noUnusedLocals (TS6196). B7: wrnexusCallApi now resolves with undefined for an ok 204/205 response, or an ok response with an empty/unparseable body, instead of rejecting with "Response was not valid JSON" -- matching the spec's failure table (error path only for non-2xx, network failure, or an actually unparseable body on a non-empty response). Regenerated examples/basic-app's generated types and editor bundles to match. Confirmed the example's type gate still fails when an unaccepted field is added to a request body, and passes cleanly otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ test("emits one assertion per sectioned block, naming its route and method", ()
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).toContain("__wrn_api_check_searchUsers");
|
||||
expect(checks).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||
expect(checks).toContain('WRNexusGenerated.ApiInput<"/api/users", "POST">');
|
||||
expect(checks).toContain("name?: string");
|
||||
expect(checks).toContain("age?: number");
|
||||
@@ -85,6 +85,79 @@ test("the api-checks file has no runtime code and is a module", () => {
|
||||
expect(checks.trim().endsWith("export {};")).toBe(true);
|
||||
});
|
||||
|
||||
test("B1: two pages each declaring a block with the same name do not collide", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-collide-"));
|
||||
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 () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/one.wrn"),
|
||||
`page One {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/two.wrn"),
|
||||
`page Two {\n client {\n${BLOCK}\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
const names = [...checks.matchAll(/__wrn_api_check_\S+(?=\s*=)/g)].map((m) => m[0]);
|
||||
expect(names.length).toBe(2);
|
||||
expect(new Set(names).size).toBe(2);
|
||||
});
|
||||
|
||||
test("B2: an ssr sectioned block emits no assertion (it can never declare a request)", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-ssr-"));
|
||||
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 GET = async () => Response.json({ users: [] });\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "app/pages/ssr.wrn"),
|
||||
`page Ssr {\n ssr {\n api loadUsers GET /api/users {\n response {\n return data.users\n }\n }\n }\n\n view { <main>x</main> }\n}\n`,
|
||||
);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check");
|
||||
expect(checks).not.toContain("loadUsers");
|
||||
});
|
||||
|
||||
test("B2: a client block with an empty request emits no assertion", () => {
|
||||
const root = fixture(` api pingServer GET /api/users {
|
||||
response {
|
||||
return data.users
|
||||
}
|
||||
}`);
|
||||
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
expect(checks).not.toContain("__wrn_api_check");
|
||||
expect(checks).not.toContain("pingServer");
|
||||
});
|
||||
|
||||
test("B6: each emitted assertion is exported, so noUnusedLocals cannot flag it", () => {
|
||||
const root = fixture(BLOCK);
|
||||
generateApplicationTypes(root);
|
||||
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
|
||||
|
||||
const assertionLine = checks
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.includes("__wrn_api_check_") && line.includes("="));
|
||||
expect(assertionLine).toBeDefined();
|
||||
expect(assertionLine).toMatch(/^export type __wrn_api_check_/);
|
||||
});
|
||||
|
||||
// --- Real-compiler enforcement tests ---------------------------------------------
|
||||
//
|
||||
// Everything above only asserts on the emitted *text*. That proves nothing about
|
||||
@@ -186,7 +259,7 @@ test("tsc: a field with the wrong type fails, naming the block's assertion", ()
|
||||
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");
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||
});
|
||||
|
||||
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
|
||||
@@ -210,7 +283,7 @@ test("tsc: an extra field the contract does not accept fails (Finding A regressi
|
||||
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");
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||
});
|
||||
|
||||
test("tsc: a missing required field fails", () => {
|
||||
@@ -232,5 +305,5 @@ test("tsc: a missing required field fails", () => {
|
||||
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");
|
||||
expect(checks.split(/\r?\n/)[failingLine - 1]).toMatch(/__wrn_api_check_[\w$]*_searchUsers\b/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user