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; win.document.body.innerHTML = `
`; win.__wrnSchemas = { login: schema.describe() }; (globalThis as Record).window = win; (globalThis as Record).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).window; delete (globalThis as Record).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("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; win.document.body.innerHTML = `
`; win.__wrnSchemas = {}; (globalThis as Record).window = win; (globalThis as Record).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).window; delete (globalThis as Record).document; } }); test("schema-backed forms validate on input, change, and blur and synchronize field state", () => { const win = new Window() as unknown as Window & Record; win.document.body.innerHTML = `
`; win.__wrnSchemas = { profile: v .object({ email: v.string().required("Enter an email").email("Use a valid email") }) .describe(), }; (globalThis as Record).window = win; (globalThis as Record).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).window; delete (globalThis as Record).document; } }); test("submit guards run only after schema fields are valid", () => { const win = new Window() as unknown as Window & Record; win.document.body.innerHTML = `
`; win.__wrnSchemas = { guarded: v.object({ email: v.string().required("Enter an email") }).describe(), }; (globalThis as Record).window = win; (globalThis as Record).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).window; delete (globalThis as Record).document; } });