Step 3 — Constraint Validation, and a Trap That Breaks Test Suites
Goal
Establish how much validation the platform does for free, exactly where it stops — and meet a spec behaviour that makes tests pass while production fails.
Predict first
A form with type=email, min/max, pattern, minlength, type=url and zero lines of
validation JavaScript.
- Which constraints are enforced?
- Is submission blocked while invalid?
- Does
el.validity.tooShortbecome true when aminlength=10field contains"short"?
Question 3 is the one that matters. Commit to an answer.
Run
npm run forms
Expected output (abridged):
field | constraint | value | valid | validity flag
email | type=email | "not-an-email" | NO | typeMismatch
age | min=18 | "9" | NO | rangeUnderflow
code | pattern | "abc-12" | NO | patternMismatch
site | type=url | "nope" | NO | typeMismatch
submission blocked while invalid : yes
:user-invalid supported : yes
THE DIRTY-VALUE TRAP (minlength):
el.value = 'short' (programmatic) -> valid=true tooShort=false
user types 'short' (real keys) -> valid=false tooShort=true
What just happened
The platform enforced every constraint and blocked submission, with no validation code. And it
gave precise reason codes through ValidityState — typeMismatch, rangeUnderflow,
patternMismatch — not a boolean.
Then the trap. minlength and maxlength apply only to values the user edited — the HTML
spec's dirty value flag. Setting .value programmatically does not set it.
Consequence, stated plainly:
A test that sets
.valuedirectly never seestooShort, and passes while the real form rejects the same input.
This is the same class of finding as fe-01's .click() result: synthetic interaction and real
interaction have different semantics, and the harness is the thing that is wrong. Verify you
understand it by breaking it — swap page.type() for page.fill() and watch the trap vanish.
What native validation does not do
- inline error text in your layout (the bubble is unstyleable)
aria-describedbywiring between input and error- localisation — messages follow browser locale, not application locale
- cross-field rules (confirm password, end date after start date)
- server-side validation, the only one that is a security control
The architecture that survives
Native constraints as the source of truth, read through ValidityState, with your own
presentation layer:
form.noValidate = true; // set from JS: suppresses the bubble, keeps the constraints
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) { e.preventDefault(); showErrors(form); }
});
Setting noValidate from JavaScript is the load-bearing detail: if the bundle fails to load,
the attribute is never set and native validation still protects the form. That is progressive
enhancement as a failure-mode strategy, not an ideology (fe-20).
Reimplementing the constraints is the mistake — that is the part you get free. Building the presentation is the actual work.
Also prefer :user-invalid to :invalid: the latter matches an untouched empty required field, so
your form renders as an error on first paint.
Checkpoint
docs/verification.md Checkpoints 4 and 5.