Files
ClintchizandClaude Opus 5 281615a4b0
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(validation): stop unrecognised boolean strings coercing to a silent false
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

495 lines
17 KiB
TypeScript

import { test, expect } from "bun:test";
import {
Window,
type Event as HappyDOMEvent,
type HTMLFormElement as HappyDOMHTMLFormElement,
type HTMLInputElement as HappyDOMHTMLInputElement,
type HTMLElement as HappyDOMHTMLElement,
type IEventInit,
} from "happy-dom";
import { v, invalid, parseBody, checkField, parseEnv, VALIDATE_RUNTIME } from "../src/index.ts";
type HappyDOMEventConstructor = new (type: string, init?: IEventInit) => HappyDOMEvent;
function windowEvent(win: Window, type: string, init?: IEventInit): HappyDOMEvent {
const EventConstructor = (win as unknown as { Event: HappyDOMEventConstructor }).Event;
return new EventConstructor(type, init);
}
const login = v.object({
email: v.string().email(),
password: v.string().min(8, "too short"),
age: v.number().min(18).optional(),
});
test("parse coerces valid input and reports no errors", () => {
const r = login.parse({ email: "a@b.com", password: "secretpw", age: "25" });
expect(r.ok).toBe(true);
expect(r.value).toEqual({ email: "a@b.com", password: "secretpw", age: 25 });
expect(r.errors).toEqual({});
});
test("parse reports per-field errors with custom messages", () => {
const r = login.parse({ email: "nope", password: "x" });
expect(r.ok).toBe(false);
expect(r.errors.email).toBe("Must be a valid email");
expect(r.errors.password).toBe("too short");
});
test("optional fields may be omitted", () => {
const r = login.parse({ email: "a@b.com", password: "longenough" });
expect(r.ok).toBe(true);
expect(r.value.age).toBeUndefined();
});
test("required (non-optional) empty fields fail", () => {
const r = login.parse({});
expect(r.errors.email).toBe("Required");
expect(r.errors.password).toBe("Required");
});
test("required(message) customizes empty-field errors and overrides optional", () => {
const schema = v.object({
email: v.string().required("Enter your email or username"),
password: v.string().optional().required("Enter your password"),
accepted: v.boolean().required("Accept the terms to continue"),
});
const result = schema.parse({ accepted: false });
expect(result.errors).toEqual({
email: "Enter your email or username",
password: "Enter your password",
accepted: "Accept the terms to continue",
});
expect(schema.describe().fields.email.requiredMessage).toBe("Enter your email or username");
});
test("browser validation displays the serialized custom required message", () => {
const schema = v.object({
email: v.string().required("Enter your email or username"),
});
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="login">
<input name="email">
<span data-error="email"></span>
</form>`;
win.__wrnSchemas = { login: schema.describe() };
(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 form = win.document.querySelector("form")!;
win.document.querySelector("input")!.dispatchEvent(windowEvent(win, "blur", { bubbles: true }));
expect(form.noValidate).toBe(true);
expect(form.hasAttribute("novalidate")).toBe(true);
expect(win.document.querySelector("[data-error=email]")!.textContent).toBe(
"Enter your email or username",
);
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});
test("describe emits a JSON descriptor", () => {
const d = login.describe();
expect(d.type).toBe("object");
expect(d.fields.email.rules).toContainEqual({ kind: "email" });
expect(d.fields.age.optional).toBe(true);
});
test("checkField coerces and applies rules", () => {
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "5").error).toBe(
"Must be at least 18",
);
expect(checkField({ type: "number", rules: [{ kind: "min", n: 18 }] }, "20").value).toBe(20);
expect(checkField({ type: "string", optional: true, rules: [] }, "").error).toBeNull();
expect(
checkField({ type: "string", requiredMessage: "Email is required", rules: [] }, "").error,
).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);
expect(await res.json()).toEqual({ ok: false, errors: { email: "bad" } });
});
test("parseBody reads JSON and form-encoded bodies", async () => {
const jsonReq = new Request("http://x", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "a@b.com", password: "longenough" }),
});
const j = await parseBody(login, jsonReq);
expect(j.ok).toBe(true);
const formReq = new Request("http://x", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "email=a@b.com&password=longenough",
});
const f = await parseBody(login, formReq);
expect(f.ok).toBe(true);
});
test("new string rules: url, uuid, date, length, oneOf, trim", () => {
const s = v.object({
site: v.string().url(),
id: v.string().uuid(),
when: v.string().date(),
code: v.string().length(4),
role: v.string().oneOf(["admin", "user"]),
name: v.string().trim().min(2),
});
const ok = s.parse({
site: "https://x.com/a",
id: "123e4567-e89b-12d3-a456-426614174000",
when: "2026-01-01",
code: "ABCD",
role: "admin",
name: " Ada ",
});
expect(ok.ok).toBe(true);
expect(ok.value.name).toBe("Ada"); // trimmed
const bad = s.parse({
site: "not-a-url",
id: "nope",
when: "not-a-date",
code: "AB",
role: "root",
name: " a ",
});
expect(bad.ok).toBe(false);
expect(Object.keys(bad.errors).sort()).toEqual(["code", "id", "name", "role", "site", "when"]);
});
test("number rules: positive, oneOf", () => {
const s = v.object({ qty: v.number().positive(), size: v.number().oneOf([1, 2, 3]) });
expect(s.parse({ qty: "5", size: "2" }).ok).toBe(true);
expect(s.parse({ qty: "-1", size: "9" }).ok).toBe(false);
});
test("default() fills absent fields; refine() runs server-side", () => {
const s = v.object({
role: v.string().default("user"),
even: v.number().refine((n) => (n as number) % 2 === 0, "Must be even"),
});
const r = s.parse({ even: 4 });
expect(r.ok).toBe(true);
expect(r.value.role).toBe("user"); // default applied
const bad = s.parse({ even: 3 });
expect(bad.ok).toBe(false);
expect(bad.errors.even).toBe("Must be even");
});
const envSchema = v.object({
DATABASE_URL: v.string().min(1),
PORT: v.number(),
DEBUG: v.boolean().optional(),
});
test("parseEnv coerces and returns typed values", () => {
const env = parseEnv<{ DATABASE_URL: string; PORT: number; DEBUG?: boolean }>(envSchema, {
DATABASE_URL: "sqlite://dev.db",
PORT: "3000",
DEBUG: "true",
});
expect(env.DATABASE_URL).toBe("sqlite://dev.db");
expect(env.PORT).toBe(3000);
expect(env.DEBUG).toBe(true);
});
test("parseEnv throws one readable error listing every missing/invalid var", () => {
let message = "";
try {
parseEnv(envSchema, { PORT: "not-a-number" });
} catch (err) {
message = (err as Error).message;
}
expect(message).toContain("Invalid environment variables");
expect(message).toContain("DATABASE_URL");
expect(message).toContain("PORT");
});
test("parseEnv defaults to reading the ambient environment", () => {
process.env.WRN_TEST_ENV_VAR = "present";
const env = parseEnv<{ WRN_TEST_ENV_VAR: string }>(
v.object({ WRN_TEST_ENV_VAR: v.string().min(1) }),
);
expect(env.WRN_TEST_ENV_VAR).toBe("present");
delete process.env.WRN_TEST_ENV_VAR;
});
test("object schemas can be extended without mutating package defaults", () => {
const base = v.object({ identifier: v.string().required("Enter an identifier") });
const extended = base.extend({
identifier: v.string().email("Use an email address"),
remember: v.boolean().optional(),
});
expect(base.parse({ identifier: "username" }).ok).toBe(true);
expect(extended.parse({ identifier: "username" }).errors.identifier).toBe("Use an email address");
expect(extended.parse({ identifier: "person@example.com" }).ok).toBe(true);
});
test("unknown schemas preserve structured request payloads", () => {
const schema = v.object({ response: v.unknown().required("Response is required") });
const payload = { id: "credential", nested: { ok: true } };
const result = schema.parse({ response: payload });
expect(result.ok).toBe(true);
expect(result.value.response).toEqual(payload);
expect(schema.parse({}).errors.response).toBe("Response is required");
});
test("validation can bind after a package registers a missing schema", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="late-schema">
<input name="identifier">
<span data-error="identifier"></span>
</form>`;
win.__wrnSchemas = {};
(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);
win.__wrnSchemas = {
"late-schema": v
.object({ identifier: v.string().required("Enter an identifier") })
.describe(),
};
runtime.init(win.document as unknown as Document);
win.document
.querySelector("form")!
.dispatchEvent(windowEvent(win, "submit", { bubbles: true, cancelable: true }));
expect(win.document.querySelector("[data-error=identifier]")!.textContent).toBe(
"Enter an identifier",
);
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});
test("schema-backed forms validate on input, change, and blur and synchronize field state", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="profile">
<div class="wrn-next--field" data-invalid="false">
<input name="email">
<span data-error="email"></span>
</div>
</form>`;
win.__wrnSchemas = {
profile: v
.object({ email: v.string().required("Enter an email").email("Use a valid email") })
.describe(),
};
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
try {
(0, eval)(VALIDATE_RUNTIME);
const input = win.document.querySelector("input") as HappyDOMHTMLInputElement;
const field = win.document.querySelector(".wrn-next--field") as HappyDOMHTMLElement;
const error = win.document.querySelector("[data-error=email]") as HappyDOMHTMLElement;
input.dispatchEvent(windowEvent(win, "input", { bubbles: true }));
expect(error.textContent).toBe("Enter an email");
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(field.dataset.invalid).toBe("true");
input.value = "person@example.com";
input.dispatchEvent(windowEvent(win, "change", { bubbles: true }));
expect(error.textContent).toBe("");
expect(input.hasAttribute("aria-invalid")).toBe(false);
expect(field.dataset.invalid).toBe("false");
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});
test("submit guards run only after schema fields are valid", () => {
const win = new Window() as unknown as Window & Record<string, unknown>;
win.document.body.innerHTML = `
<form data-schema="guarded">
<input name="email">
<span data-error="email"></span>
</form>`;
win.__wrnSchemas = {
guarded: v.object({ email: v.string().required("Enter an email") }).describe(),
};
(globalThis as Record<string, unknown>).window = win;
(globalThis as Record<string, unknown>).document = win.document;
try {
(0, eval)(VALIDATE_RUNTIME);
const form = win.document.querySelector("form")! as HappyDOMHTMLFormElement & {
__wrnSubmitGuards?: Array<() => boolean>;
};
let guardCalls = 0;
form.__wrnSubmitGuards = [
() => {
guardCalls += 1;
return false;
},
];
form.dispatchEvent(windowEvent(win, "submit", { bubbles: true, cancelable: true }));
expect(guardCalls).toBe(0);
expect(win.document.querySelector("[data-error=email]")!.textContent).toBe("Enter an email");
(form.elements.namedItem("email") as HappyDOMHTMLInputElement).value = "person@example.com";
form.dispatchEvent(windowEvent(win, "submit", { bubbles: true, cancelable: true }));
expect(guardCalls).toBe(1);
} finally {
delete (globalThis as Record<string, unknown>).window;
delete (globalThis as Record<string, unknown>).document;
}
});