Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
281615a4b0 | ||
|
|
3ae5d7cf97 | ||
|
|
b5029889a5 | ||
|
|
e9db4ca24d | ||
|
|
5318320c70 |
@@ -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
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user