Compare commits

..
5 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 281615a4b0 fix(validation): stop unrecognised boolean strings coercing to a silent false
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
checkField in packages/validation/src/index.ts (and its browser mirror in
runtime.ts) treated any string other than "true"/"on" as false with no
error, so typos like "treu" or values like "yes"/"1"/"TRUE" silently
passed as false.

Now:
- true/false booleans pass through unchanged
- recognised true strings (case-insensitive, trimmed): true, on, 1, yes
- recognised false strings: false, off, 0, no
- numeric 1/0 coerce (JSON payloads)
- undefined/null/"" still coerce to false (unchecked-checkbox semantics)
- anything else is now a type error (desc.typeMessage or "Must be true or
  false") instead of a silent false

Locked-in behaviours preserved: a required boolean given false still
errors, and parseEnv DEBUG: "true" coercion still works.

Added coverage for recognised strings, numeric 1/0, the type-error
regression guard, absent/empty handling, the required+false case, and a
client/server parity test driving both checkField and the browser runtime
through the same inputs.

Blast radius: searched packages/, examples/, services/ for v.boolean()
usage; all existing call sites (auth consent/rememberDevice, db 'active'
default, example consent checkboxes) feed true/false/'on'/absent values,
none of which change behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:26:20 +05:30
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
ClintchizandClaude Opus 5 b5029889a5 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>
2026-08-19 20:38:22 +05:30
ClintchizandClaude Opus 5 e9db4ca24d test(core): cover defineEndpoint's no-second-argument request-parsing path
Every existing test in endpoint-schema.test.ts passed rawInput
explicitly, so the branch added to endpoint.ts's fix (GET/HEAD query
parsing, JSON body parsing, malformed/absent body fallback) was
exercised by nothing but a manual curl. Add coverage that calls the
endpoint with only a context, matching the real router's calling
convention:

- GET with query parameters populates input from ctx.url.searchParams.
- POST with a JSON body populates input from the parsed body.
- POST with a malformed or absent body does not throw; the schema's
  own validation decides the outcome (asserted on the real response).
- An explicit rawInput argument still wins and the request is never
  read (the body is drained first, so a second .json() call would
  reject if the endpoint tried to read it again) -- the regression
  guard for the branch intentionally left untouched.

Confirmed the GET and POST-body tests fail against the pre-fix
endpoint.ts (input resolves as undefined/null instead of the sent
value); the malformed/absent-body test does not distinguish pre- and
post-fix, because in that specific edge case both normalize to an
effectively empty input -- noted in the report rather than forced.

Added a CHANGELOG entry documenting the behavior change for
downstream apps: a request that previously passed vacuous validation
on a defineEndpoint route can now legitimately fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:16:32 +05:30
ClintchizandClaude Opus 5 5318320c70 fix(examples): make the api-block demo endpoint actually type-check
directory.ts previously exported a plain (ctx: Context) => ... handler.
With that shape ApiInput<> resolved to unknown, so the generated
__wrn_api_check assertion for the demo page passed trivially even with
a field the endpoint does not accept -- the worked example did not
demonstrate the type safety it exists to demonstrate.

Rewrite directory.ts to use defineEndpoint with a schema (matching
typed-user.ts), which gives the generated assertion a real input type
to check against. Confirmed: adding an unaccepted field to the block's
request body now fails typecheck naming
__wrn_api_check_searchDirectory; removing it passes with zero net
diff.

Fix a real bug this surfaced: packages/core/src/endpoint.ts only read
its input from a second 'rawInput' argument, but the actual HTTP
router (packages/dev-server/src/runtime.ts handleApi) invokes route
handlers as handler(ctx) with no second argument. Every
defineEndpoint-based route -- including the pre-existing typed-user.ts
example -- silently received an empty/undefined input through the
real router (confirmed via curl: valid typed-user payloads were
rejected as 'Required'; directory's name filter matched every record
regardless of query). Fixed by having the endpoint wrapper parse the
request itself (query params for GET/HEAD, JSON body otherwise) when
no rawInput is explicitly supplied, while still honoring an explicit
rawInput for direct/unit-test callers.

Also update api-block-demo.wrn's response section: defineEndpoint
wraps handler output as { data: ... }, so the block's raw response
body is now { data: { users: [...] } } -- response reads
data.data.users instead of data.users.

Re-verified in a real browser after the endpoint rewrite and the
router fix: search returns exactly "Ajay, Asha", exactly one
POST /api/directory carrying x-csrf-token, and the error section
still runs cleanly (no exception, empty result) on a missing route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:05:10 +05:30
21 changed files with 824 additions and 40 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## Unreleased
- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts`
and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or
`"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g.
`"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now
recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and
false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce
(for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to
`false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for
downstream apps:** any other value — an unrecognised string, an object, an array — is now a
type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A
required boolean field given `false` still errors, as before (checkbox-required semantics
are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no
existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected
to start failing.
- Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router
(which calls handlers as `handler(ctx)`, with no second argument) actually receive their
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
when no input is passed explicitly. Previously such endpoints silently validated
`undefined`, so an `input` schema with only optional fields passed vacuously regardless of
what was sent. **Behavior change for downstream apps:** a request that previously passed
vacuous validation on a `defineEndpoint` route can now legitimately fail (400
`VALIDATION_ERROR`) if it does not actually satisfy the schema. Explicitly passing a second
argument (e.g. from a unit test or an internal caller) is unaffected and still takes
priority over reading the request.
## 0.8.8
- Added the framework request context to `.wrn` language-server type environments.
@@ -158,6 +158,22 @@ A plain handler returning `Response.json` has no `defineEndpoint` contract, so `
to `unknown`. The block's declared types are used directly and the generator emits a warning naming
the route. Untyped endpoints stay visible rather than silently passing.
### GET parameters travel as strings
A `GET` block's `parameters` become a query string (see Request assembly below), and every
`URLSearchParams` value is text on the wire regardless of the declared field type — a block
declaring `age?: number` still sends and receives `"30"`, not `30`. The declared type is honest
only because the endpoint's own schema coerces it back: `checkField` in
`packages/validation/src/index.ts` calls `Number(pre)` for every `v.number()` field — optional or
required — before the handler ever sees it, so `defineEndpoint({ input: v.object({ age:
v.number() }) })` invoked as `?age=30` hands the handler an actual `number` (verified end to end;
regression-tested in `packages/core/test/endpoint-schema.test.ts`, "a GET request coerces a
v.number() query param to an actual number"). This is a property of the endpoint's schema, not of
the `api` block or the generated contract types — a route that reads `ctx.url.searchParams`
directly, with no `defineEndpoint` schema, receives raw strings and gets no coercion, but that
route also has no contract for the generator to check against, so it already falls under "Routes
without a contract" above and is flagged there.
### Staleness
Checking is only as current as the generated file, so this stays wired into the existing
+18 -8
View File
@@ -1,6 +1,6 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
// WRN editor compiler source hash: 38942756eaa627215931b5268592f6e0825f8a8011a25ee7d532f2547176757e
// WRN editor compiler source hash: b0e3094d8c2a70ee2b34fe961c186b58de9527aa072ca12292b715d3d5f51c87
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -724,12 +724,12 @@ function apiBindings(ast) {
.filter((block) => block.mode === "client" && block.sections)
.map((block) => {
const sections = block.sections;
const response = sections.response.trim() || "return data;";
const error = sections.error.trim();
const response = (0, syntax_1.eraseFunctionTypes)(sections.response).trim() || "return data;";
const error = (0, syntax_1.eraseFunctionTypes)(sections.error).trim();
const failure = error
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
: `(error) => { throw error; }`;
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }).catch(${failure})`;
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
}
@@ -740,10 +740,19 @@ function generateBrowserModule(ast) {
const selectedImports = selectedBrowserImports(ast, functions);
const imports = selectedImports.map((entry) => entry.code).join("\n");
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
// `api` is only defined as a client-scope binding when the page actually has
// client-mode api blocks (see apiBindings below). A page that declares
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
const sharedState = state.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name));
const sharedProps = ast.props
.map((entry) => entry.name)
.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name));
.filter((name) => safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name));
const callableAliases = functionNames.filter((name) => safeIdentifier(name) &&
!RUNTIME_BINDINGS.has(name) &&
!sharedState.includes(name) &&
@@ -1682,12 +1691,13 @@ async function __wrnexusResolveApiBinding(
ctx: __WrnexusContext,
): Promise<unknown> {
if (binding.errorBody) {
let data: unknown;
try {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
} catch (err) {
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
}
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
}
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 7ecb4672607b87fdb848f1b52e80430129a5bfda31c9724e14595e4acc1fb1b7
// WRN editor extension source hash: 548e7e0c27d5c951325c977cc22f2ee0340dc3e34bd896904e519ee357ca37a8
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
+20 -7
View File
@@ -1,14 +1,27 @@
import { defineEndpoint } from "@wrnexus/core";
import type { Context } from "@wrnexus/core";
import { SearchDirectorySchema } from "../schemas/search-directory.ts";
const ALL = [
interface DirectoryUser {
name: string;
designation: string;
}
const ALL: DirectoryUser[] = [
{ name: "Ajay", designation: "UI" },
{ name: "Asha", designation: "Backend" },
{ name: "Chen", designation: "UI" },
];
export const POST = async (ctx: Context) => {
const body = (await ctx.req.json().catch(() => ({}))) as { name?: string };
const needle = String(body.name ?? "").toLowerCase();
return Response.json({ users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) });
};
/**
* Schema-typed handler: input resolves to `{ name?: string }` from
* SearchDirectorySchema, so the block's request shape is checked for real
* (not against `unknown`, which the untyped-handler shape resolved to).
*/
export const POST = defineEndpoint<{ name?: string }, { users: DirectoryUser[] }>({
input: SearchDirectorySchema,
description: "Case-insensitive substring search over the demo directory by name.",
handler(input) {
const needle = String(input.name ?? "").toLowerCase();
return { users: ALL.filter((user) => user.name.toLowerCase().includes(needle)) };
},
});
@@ -12,7 +12,7 @@ page ApiBlockDemo {
}
response {
return data.users
return data.data.users
}
error {
@@ -0,0 +1,5 @@
import { v } from "@wrnexus/validation";
export const SearchDirectorySchema = v.object({
name: v.string().trim().optional(),
});
@@ -3,5 +3,5 @@
// Type-only assertions for sectioned `api` blocks. Kept as a real .ts file (not
// wrnexus.generated.d.ts) because `skipLibCheck` exempts .d.ts contents from being
// checked; this file is compiled and checked normally by the project's own tsc.
type __wrn_api_check_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
export type __wrn_api_check_1fljm5i_searchDirectory = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<{ name?: string }, WRNexusGenerated.ApiInput<"/api/directory", "POST">>>;
export {};
+43 -7
View File
@@ -106,26 +106,62 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions)
* `app/` and is therefore compiled by the project's own tsc, while generated
* build artifacts are not type-checked at all.
*/
function apiBlockAssertions(pages: { path: string; ast: PageAst }[], apiContracts: string): string {
function pageSlug(path: string): string {
// Block names are only unique within a single page (see apiBindingMap), so
// two pages each declaring e.g. `api search` is legal and would otherwise
// emit the identical `__wrn_api_check_search` type alias twice into this
// one flat file — TS2300 ("duplicate identifier"). Qualify every emitted
// name with a short deterministic hash of the page's path (not the whole
// path itself, which can be arbitrarily long/ugly once made identifier-safe)
// to keep names unique across the whole app while staying compact.
const normalized = path.replace(/\\/g, "/");
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
hash = (Math.imul(hash, 31) + normalized.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}
function apiBlockAssertions(
pages: { path: string; ast: PageAst }[],
apiContracts: string,
appDir: string,
): string {
const lines: string[] = [];
for (const page of pages) {
// Hash the path relative to `app/`, not the absolute path: the absolute
// path varies with where the project checkout lives (e.g. a CI runner's
// temp clone vs. a developer's local path), which would make this file
// spuriously "stale" every time it's regenerated somewhere else.
const slug = pageSlug(relative(appDir, page.path).replace(/\\/g, "/"));
for (const block of page.ast.dataApis) {
if (!block.sections) continue;
// ssr-mode sectioned blocks can never declare a `request` (they are
// render-time only), so they always fall back to the empty-shape
// `Record<string, never>` below. `keyof Record<string, never>` is
// `string`, which makes the key-exactness arm of AssertAssignable
// evaluate to `false` unconditionally and raises TS2344 on every such
// block regardless of whether the block author did anything wrong.
// We choose to skip emission for both (a) any non-client-mode block,
// since it structurally can never have a request to check, and (b) any
// block -- client included -- that has zero declared request fields,
// since there is nothing to assert type-safety about. This is more
// honest about intent than emitting a vacuous/always-failing check.
const fields = [...block.sections.parameters, ...block.sections.body];
if (block.mode !== "client" || fields.length === 0) continue;
if (!apiContracts.includes(JSON.stringify(block.path))) {
console.warn(
`[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`,
);
}
const fields = [...block.sections.parameters, ...block.sections.body];
const shape = fields.length
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
: "Record<string, never>";
const shape = `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`;
lines.push(
`type __wrn_api_check_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
`export type __wrn_api_check_${slug}_${block.name} = WRNexusGenerated.__wrn_expect_true<WRNexusGenerated.AssertAssignable<${shape}, WRNexusGenerated.ApiInput<${JSON.stringify(
block.path,
)}, ${JSON.stringify(block.method)}>>>;`,
);
@@ -281,7 +317,7 @@ declare namespace WRNexusGenerated {
// Type-only assertions for sectioned \`api\` blocks. Kept as a real .ts file (not
// wrnexus.generated.d.ts) because \`skipLibCheck\` exempts .d.ts contents from being
// checked; this file is compiled and checked normally by the project's own tsc.
${apiBlockAssertions(pageAsts, apiContracts)}
${apiBlockAssertions(pageAsts, apiContracts, app)}
export {};
`;
writeFileSync(join(typeDir, "wrnexus.generated.api-checks.ts"), apiChecksCode, "utf8");
+77 -4
View File
@@ -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/);
});
+17 -5
View File
@@ -321,15 +321,15 @@ function apiBindings(ast: PageAst): string {
.filter((block) => block.mode === "client" && block.sections)
.map((block) => {
const sections = block.sections!;
const response = sections.response.trim() || "return data;";
const error = sections.error.trim();
const response = eraseFunctionTypes(sections.response).trim() || "return data;";
const error = eraseFunctionTypes(sections.error).trim();
const failure = error
? `(error) => { const status = error.status; const message = error.message; const data = error.data; ${error} }`
: `(error) => { throw error; }`;
return ` ${JSON.stringify(block.name)}: async (input) => context.callApi(${JSON.stringify(
block.path,
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }).catch(${failure})`;
)}, ${JSON.stringify(block.method)}, input).then((data) => { ${response} }, ${failure})`;
});
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
@@ -344,11 +344,23 @@ export function generateBrowserModule(ast: PageAst): string {
const selectedImports = selectedBrowserImports(ast, functions);
const imports = selectedImports.map((entry) => entry.code).join("\n");
const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))];
const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name));
// `api` is only defined as a client-scope binding when the page actually has
// client-mode api blocks (see apiBindings below). A page that declares
// `state api` without any client api blocks must keep reading/writing that
// state as before, so only exclude the "api" name from destructuring when
// there is a real `api` binding to shadow it.
const hasClientApi = ast.dataApis.some((block) => block.mode === "client" && block.sections);
const localRuntimeBindings = hasClientApi
? RUNTIME_BINDINGS
: new Set([...RUNTIME_BINDINGS].filter((name) => name !== "api"));
const sharedState = state.filter(
(name) => safeIdentifier(name) && !localRuntimeBindings.has(name),
);
const sharedProps = ast.props
.map((entry) => entry.name)
.filter(
(name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name) && !sharedState.includes(name),
(name) =>
safeIdentifier(name) && !localRuntimeBindings.has(name) && !sharedState.includes(name),
);
const callableAliases = functionNames.filter(
(name) =>
+3 -2
View File
@@ -1056,12 +1056,13 @@ async function __wrnexusResolveApiBinding(
ctx: __WrnexusContext,
): Promise<unknown> {
if (binding.errorBody) {
let data: unknown;
try {
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
data = await __wrnexusCallApi(binding.path, binding.method, ctx);
} catch (err) {
return __wrnexusEvalError(err, binding.errorBody, binding.helpers, ctx);
}
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
}
const data = await __wrnexusCallApi(binding.path, binding.method, ctx);
return __wrnexusEvalData(data, binding.body, binding.helpers, ctx);
@@ -1,7 +1,15 @@
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 { generateTargets } from "../src/targets.ts";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function browserModule(inner: string): string {
return generateTargets(
parse(`page Repro {
@@ -81,6 +89,173 @@ test("a block without an error section still emits its response body", () => {
expect(generated).toContain("data.users");
});
test("type annotations in response/error bodies are erased before emission (B4)", () => {
// Every other browser-bound body in the repo passes through eraseFunctionTypes
// (see the fn.body call sites in client-codegen.ts ~line 288 and ~371, and
// store-codegen.ts); response/error bodies must too, for the same reason:
// eraseFunctionTypes strips function-signature annotations (params, return
// type, typed catch clauses) so a locally-declared helper function inside a
// response/error body no longer ships raw TypeScript into the .mjs artifact.
const generated = browserModule(` api searchUsers POST /api/users {
request {
body {
name?: string
}
}
response {
function pick(list: string[]): string[] { return list }
return pick(data.users)
}
error {
function describe(e: unknown): string { return String(e) }
return describe(error)
}
}`);
expect(generated).not.toContain("list: string[]");
expect(generated).not.toContain("): string[] {");
expect(generated).not.toContain("e: unknown");
expect(generated).not.toContain("): string {");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
test("a page with state api and no client api blocks still reads that state (B5)", () => {
// "api" is normally excluded from state/prop destructuring because the
// emitted `const api = {...}` binding would shadow it -- but that binding
// only exists when the page has client-mode api blocks. Without one, the
// exclusion left `api` completely undeclared: a ReferenceError.
const generated = generateTargets(
parse(`page Repro {
state {
api = "hello"
}
functions {
client function run(): void {
console.log(api)
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
expect(generated).toContain("context.state");
expect(() => {
new Function(generated.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, ""));
}).not.toThrow();
});
/**
* Builds a browser module whose `run()` function calls api.searchUsers and
* reports the outcome through `output.report(...)` so the test can observe
* whether the call resolved or rejected without reaching into codegen
* internals.
*/
function reportingBrowserModule(apiBlock: string): string {
return generateTargets(
parse(`page Repro {
client {
${apiBlock}
}
outputs {
report(payload: any)
}
functions {
client async function run(): Promise<void> {
try {
const users = await api.searchUsers({ name: "Ajay" })
output.report({ ok: true, users })
} catch (e) {
output.report({ ok: false, message: String(e && e.message || e) })
}
}
}
view { <main><button @click="run()">go</button></main> }
}
`),
).browser;
}
async function importBrowserModule(source: string): Promise<any> {
const root = mkdtempSync(join(tmpdir(), "wrnexus-client-exec-"));
roots.push(root);
mkdirSync(root, { recursive: true });
const file = join(root, "page.mjs");
writeFileSync(file, source);
return import(`${file.replace(/\\/g, "/")}?t=${Date.now()}`);
}
test("a response body error is not swallowed by the error section (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users.missing.length
}
error {
return []
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => ({ users: [] }),
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: false, message: expect.any(String) }]);
// The error section's own fallback ("[]" / an empty array) must not have
// been what the caller observed -- a bug in the response body is a
// rejection, not a silently-returned fallback value.
expect(reports[0]).not.toEqual({ ok: true, users: [] });
});
test("a genuine transport failure still runs the error section's fallback (client)", async () => {
const mod = await importBrowserModule(
reportingBrowserModule(` api searchUsers GET /api/users {
request { parameters { name: string } }
response {
return data.users
}
error {
return ["fallback"]
}
}`),
);
const reports: unknown[] = [];
const context = {
state: {},
props: {},
output: { report: (value: unknown) => reports.push(value) },
server: {},
refs: {},
callApi: async () => {
throw Object.assign(new Error("transport failed"), { status: 500 });
},
};
await mod.__wrnexusClientFunctions.run(context);
expect(reports).toEqual([{ ok: true, users: ["fallback"] }]);
});
test("a state field named api does not collide with the emitted api object", () => {
const generated = generateTargets(
parse(`page Repro {
@@ -167,6 +167,83 @@ test("an ssr block used in {#each} with an error section runs the error body on
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 {
+15 -1
View File
@@ -96,7 +96,21 @@ export function defineEndpoint(
if (definition.auth === "required" && !ctx.user) {
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
}
const input = definition.input ? schemaValue(definition.input, rawInput) : rawInput;
// The real HTTP router invokes route handlers as `handler(ctx)` — it never
// supplies a second argument. Callers that already have a parsed payload
// (unit tests, internal RPC-style calls) may still pass one explicitly, and
// that always wins. Otherwise, read the request ourselves: query params for
// GET/HEAD, JSON body for everything else.
let input: unknown = rawInput;
if (definition.input) {
const resolvedInput =
rawInput !== undefined
? rawInput
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
? Object.fromEntries(ctx.url.searchParams)
: await ctx.req.json().catch(() => ({}));
input = schemaValue(definition.input, resolvedInput);
}
const rawOutput = await definition.handler(input, ctx);
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
return output instanceof Response ? output : json({ data: output });
+105
View File
@@ -29,3 +29,108 @@ test("typed endpoints unwrap official validation schemas and return bounded vali
const valid = await endpoint(ctx, { name: "Ada", email: "ada@example.test" });
expect(await valid.json()).toEqual({ data: { name: "Ada", email: "ada@example.test" } });
});
// The real HTTP router (packages/dev-server/src/runtime.ts handleApi) invokes route
// handlers as `handler(ctx)` — it never supplies a second argument. Every test above
// passes rawInput explicitly, so it never exercises that calling convention. These
// tests call the endpoint with only a context, matching what actually happens in
// production, to guard against silently validating `undefined` again.
const search = v.object({ name: v.string().trim().optional() });
const searchEndpoint = defineEndpoint<{ name?: string }, { name: string | null }>({
input: search,
handler(input) {
return { name: input.name ?? null };
},
});
test("with no second argument, a GET request reads input from the URL's query string", async () => {
const request = new Request("https://example.test/api/search?name=Ada");
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Ada" } });
});
test("with no second argument, a POST request reads input from the parsed JSON body", async () => {
const request = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Ada" }),
});
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Ada" } });
});
test("with no second argument, a malformed or absent POST body falls back without throwing, and schema validation decides the outcome", async () => {
const malformedRequest = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not json",
});
const malformedCtx = createContext(malformedRequest, new URL(malformedRequest.url));
const malformedResponse = await searchEndpoint(malformedCtx);
// `name` is optional, so an empty resolved input ({}) still validates and succeeds —
// the point is that the malformed body did not throw an unhandled parse error.
expect(malformedResponse.status).toBe(200);
expect(await malformedResponse.json()).toEqual({ data: { name: null } });
const requiredField = v.object({ name: v.string().min(1) });
const requiredEndpoint = defineEndpoint({
input: requiredField,
handler(input) {
return input;
},
});
const emptyRequest = new Request("https://example.test/api/search", { method: "POST" });
const emptyCtx = createContext(emptyRequest, new URL(emptyRequest.url));
const emptyResponse = await requiredEndpoint(emptyCtx);
// With no body at all, resolved input is {} — the schema's own required-field
// validation is what turns that into a 400, not a thrown parse error.
expect(emptyResponse.status).toBe(400);
expect(await emptyResponse.json()).toEqual({
error: {
code: "VALIDATION_ERROR",
message: "Endpoint validation failed.",
details: { name: "Required" },
},
});
});
// GET query strings travel as text (`URLSearchParams` values are always
// strings), so a `v.number()` field must come back as a real number, not the
// string the wire actually carried, or a page declaring `age?: number` on a
// GET api block would be lying about the type. checkField in
// @wrnexus/validation coerces via Number(pre) for both optional and required
// number fields (see packages/validation/src/index.ts); this locks that in
// end-to-end through defineEndpoint's own GET query-string resolution path.
test("a GET request coerces a v.number() query param to an actual number", async () => {
const ageSchema = v.object({ age: v.number() });
const ageEndpoint = defineEndpoint<{ age: number }, { age: number; typeofAge: string }>({
input: ageSchema,
handler(input) {
return { age: input.age, typeofAge: typeof input.age };
},
});
const request = new Request("https://example.test/api/age?age=30");
const ctx = createContext(request, new URL(request.url));
const response = await ageEndpoint(ctx);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { age: 30, typeofAge: "number" } });
});
test("an explicit rawInput argument still wins and the request is never read", async () => {
// A request whose body has already been consumed: if the endpoint tried to read it
// again (rather than trusting the explicit rawInput), this would throw.
const request = new Request("https://example.test/api/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "ignored-body" }),
});
await request.json(); // drain the body so a second .json() call would reject
const ctx = createContext(request, new URL(request.url));
const response = await searchEndpoint(ctx, { name: "Explicit" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ data: { name: "Explicit" } });
});
+7
View File
@@ -4284,6 +4284,12 @@ export const REACTIVE_RUNTIME = String.raw`
}
return fetch(url, init).then(function (response) {
// A 2xx with no body (204/205, or a genuinely empty response) is a
// success, not a parse failure -- the failure table only calls for the
// error path on non-2xx, network failure, or an unparseable body.
if (response.ok && (response.status === 204 || response.status === 205)) {
return undefined;
}
return response.json().then(
function (data) {
if (response.ok) return data;
@@ -4295,6 +4301,7 @@ export const REACTIVE_RUNTIME = String.raw`
throw failure;
},
function () {
if (response.ok) return undefined;
var failure = new Error("Response was not valid JSON");
failure.status = response.status;
failure.data = undefined;
+36
View File
@@ -79,6 +79,42 @@ test("a 2xx resolves to the parsed payload", async () => {
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
});
test("a 204 with no body resolves to undefined instead of rejecting", async () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
(globalThis as Record<string, unknown>).location = win.location;
(globalThis as Record<string, unknown>).NodeFilter = (
win as unknown as { NodeFilter: unknown }
).NodeFilter;
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve({
ok: true,
status: 204,
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
});
(0, eval)(REACTIVE_RUNTIME);
const callApi = (win as unknown as { __wrnexusCallApi: (...args: any[]) => Promise<any> })
.__wrnexusCallApi;
await expect(callApi("/api/users", "DELETE", {})).resolves.toBeUndefined();
});
test("a 2xx with an empty/unparseable body resolves to undefined", async () => {
const { callApi } = harness({ status: 200, payload: undefined });
(globalThis as Record<string, unknown>).fetch = () =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError("Unexpected end of JSON input")),
});
await expect(callApi("/api/users", "GET", {})).resolves.toBeUndefined();
});
test("a non-2xx rejects with status, message and data", async () => {
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
+22 -1
View File
@@ -114,7 +114,28 @@ export function checkField(
}
if (desc.type === "boolean") {
const value = raw === true || raw === "true" || raw === "on";
const empty = raw === undefined || raw === null || raw === "";
let value: boolean;
if (typeof raw === "boolean") {
value = raw;
} else if (empty) {
value = false;
} else if (raw === 1) {
value = true;
} else if (raw === 0) {
value = false;
} else if (typeof raw === "string") {
const norm = raw.trim().toLowerCase();
if (norm === "true" || norm === "on" || norm === "1" || norm === "yes") {
value = true;
} else if (norm === "false" || norm === "off" || norm === "0" || norm === "no") {
value = false;
} else {
return { value: raw, error: desc.typeMessage ?? "Must be true or false" };
}
} else {
return { value: raw, error: desc.typeMessage ?? "Must be true or false" };
}
if (!desc.optional && !value) {
return { value, error: desc.requiredMessage || "Required" };
}
+22 -1
View File
@@ -45,7 +45,28 @@ export const VALIDATE_RUNTIME = String.raw`
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
}
if (desc.type === "boolean") {
var b = raw === true || raw === "true" || raw === "on";
var bEmpty = raw === undefined || raw === null || raw === "";
var b;
if (typeof raw === "boolean") {
b = raw;
} else if (bEmpty) {
b = false;
} else if (raw === 1) {
b = true;
} else if (raw === 0) {
b = false;
} else if (typeof raw === "string") {
var bNorm = raw.trim().toLowerCase();
if (bNorm === "true" || bNorm === "on" || bNorm === "1" || bNorm === "yes") {
b = true;
} else if (bNorm === "false" || bNorm === "off" || bNorm === "0" || bNorm === "no") {
b = false;
} else {
return desc.typeMessage || "Must be true or false";
}
} else {
return desc.typeMessage || "Must be true or false";
}
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
}
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
+134
View File
@@ -114,6 +114,140 @@ test("checkField coerces and applies rules", () => {
).toBe("Email is required");
});
test("checkField coerces recognised true/false boolean strings, case-insensitively and trimmed", () => {
const desc = { type: "boolean" as const, optional: true, rules: [] };
for (const raw of [
"true",
"TRUE",
" True ",
"on",
"ON",
" on ",
"1",
" 1 ",
"yes",
"YES",
" Yes ",
]) {
expect(checkField(desc, raw)).toEqual({ value: true, error: null });
}
for (const raw of [
"false",
"FALSE",
" False ",
"off",
"OFF",
" off ",
"0",
" 0 ",
"no",
"NO",
" No ",
]) {
expect(checkField(desc, raw)).toEqual({ value: false, error: null });
}
});
test("checkField coerces numeric 1/0 booleans (common in JSON payloads)", () => {
const desc = { type: "boolean" as const, optional: true, rules: [] };
expect(checkField(desc, 1)).toEqual({ value: true, error: null });
expect(checkField(desc, 0)).toEqual({ value: false, error: null });
expect(checkField(desc, true)).toEqual({ value: true, error: null });
expect(checkField(desc, false)).toEqual({ value: false, error: null });
});
test("checkField rejects unrecognised boolean strings/values as a type error, not a silent false", () => {
const desc = { type: "boolean" as const, optional: true, rules: [] };
for (const raw of ["yes please", "maybe", "treu", "TRUE!", "2", { a: 1 }, [1, 2]]) {
const result = checkField(desc, raw);
expect(result.error).toBe("Must be true or false");
}
const custom = checkField(
{ type: "boolean" as const, optional: true, typeMessage: "Pick yes or no", rules: [] },
"maybe",
);
expect(custom.error).toBe("Pick yes or no");
});
test("checkField treats absent/empty boolean input as false, erroring only when required", () => {
const optionalDesc = { type: "boolean" as const, optional: true, rules: [] };
expect(checkField(optionalDesc, undefined)).toEqual({ value: false, error: null });
expect(checkField(optionalDesc, null)).toEqual({ value: false, error: null });
expect(checkField(optionalDesc, "")).toEqual({ value: false, error: null });
const requiredDesc = {
type: "boolean" as const,
requiredMessage: "Required",
rules: [],
};
expect(checkField(requiredDesc, undefined).error).toBe("Required");
expect(checkField(requiredDesc, "").error).toBe("Required");
});
test("checkField still errors when a required boolean is explicitly false (checkbox semantics locked in)", () => {
expect(
checkField(
{ type: "boolean" as const, requiredMessage: "Accept the terms to continue", rules: [] },
false,
),
).toEqual({ value: false, error: "Accept the terms to continue" });
});
test("client/server boolean parity: checkField and the browser runtime agree on every case", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="parity">
<input name="field">
<span data-error="field"></span>
</form>`;
const schema = v.object({ field: v.boolean().optional() }).describe();
win.__wrnSchemas = { parity: schema };
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
try {
(0, eval)(VALIDATE_RUNTIME);
const runtime = win.__wrnValidate as { init(root: Document): void };
runtime.init(win.document as unknown as Document);
const input = win.document.querySelector("input") as HappyDOMHTMLInputElement;
const error = win.document.querySelector("[data-error=field]") as HappyDOMHTMLElement;
const cases = [
"true",
"TRUE",
" True ",
"on",
"ON",
"1",
" 1 ",
"yes",
"YES",
"false",
"FALSE",
"off",
"OFF",
"0",
"no",
"NO",
"",
"yes please",
"maybe",
"treu",
];
for (const raw of cases) {
const serverResult = checkField(schema.fields.field, raw);
input.value = raw;
input.dispatchEvent(windowEvent(win, "blur", { bubbles: true }));
const clientRejected = error.textContent !== "";
expect(clientRejected).toBe(serverResult.error !== null);
}
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});
test("invalid() returns a 400 with errors", async () => {
const res = invalid({ email: "bad" });
expect(res.status).toBe(400);