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.

  1. Which constraints are enforced?
  2. Is submission blocked while invalid?
  3. Does el.validity.tooShort become true when a minlength=10 field 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 ValidityStatetypeMismatch, 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 .value directly never sees tooShort, 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-describedby wiring 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.