//go:build browser // The enhanced fields in a browser: the select and date journeys, the // Enter that must not submit, the field row's geometry, the calendar // overlay, the native picker's button and the time field's clock. The // B half of rastrillo's ui/browser_test.go at the seed commit, each page read // through fixture so the page it drives is the page the rastrillo drive // drove; the A half is menus_browser_test.go. // // These drives used to carry a KNOWN LIMITATION saying they were // timing-sensitive under machine load — 1 failure in 4 on a busy box, // 0-for-5 on GitHub's runner, and so excluded from CI (issue #86). It // was not a limitation and it was not timing. It was a bug, and load // only decided who noticed. // // The Enter that commits the select's choice was ALSO submitting the // form, on every run, on every machine. select.js refused the keydown // and that is enough for a key a person presses, but a key CDP // synthesises arrives as two independent events and the second one // carried the \r of implicit form submission with the refusal already // forgotten. So every run raced: the mirror poll below against a // navigation that had already started. An idle laptop won that race // and called it green; a loaded runner lost it and called it a flake. // select.js now refuses the keypress too, the way datetime.js always // did, which is why the drives are back in the browser job whole. // // The drives still deliver keys through chromedp's three-event // encoding rather than a friendlier one, on purpose: that shape is // what broke, so it is the shape worth driving. // TestEnterCommitsWithoutSubmittingTheForm asserts the invariant // directly; a failure at a deadline here now means something else. // // One test per enhancement, deliberately: a browser drive is // expensive, so each drives a whole journey — render, enhance, type, // keyboard-commit, mirror, submit — and asserts the server received the // value. Everything cheaper lives in the untagged select, datetime and // scripts tests. package check import ( "context" "fmt" "net/http" "regexp" "strings" "testing" "time" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta/internal/harness" ) // page builds the handler serving one form carrying an enhanced // field-select of optionCount options, the real select.js, tokens.css // and theme, and records what a submit delivers. The seeded page is // the forty-option one; any other count is not seeded. func page(t *testing.T, optionCount int) (http.Handler, chan string) { t.Helper() got := make(chan string, 1) mux := sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "page", fmt.Sprint(optionCount))}) received(mux, "/submit", "author", got, http.StatusOK) return mux, got } // TestEnhancedSelectDrivesTheWholeJourney is the one browser test. // // Bug classes it exists to catch — each renders perfectly and says // nothing wrong: // // - the combobox never mirrors back, so the form submits the old value // while the screen shows the new one; // - the native select is removed rather than hidden, so the form // submits nothing at all; // - the label is left pointing at the hidden select, leaving the // control the user types into with no accessible name; // - a JS error takes the enhancement down and the page still looks fine. func TestEnhancedSelectDrivesTheWholeJourney(t *testing.T) { mux, submitted := page(t, 40) rig := harness.New(t, func(string) http.Handler { return mux }) // A healthy run takes well under a second on an idle machine, but // this is wall-clock against a real browser: on a loaded box it is // slower by orders of magnitude, and a budget tuned to the idle case // fails for no reason. 60s was that mistake, tuned on a quiet dev // machine. The budget exists so a hang fails as itself, not to race // a busy runner's clock — 180s still fails far faster than Go's // default test timeout, so a genuine regression surfaces as a // deadline rather than a hung suite. ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) defer cancelTimeout() var ( comboCount, nativeCount, optionsShown int labelFor, nativeValue string filterText string nativeHidden bool ) // A bare "context deadline exceeded" tells whoever hits this in CI // nothing at all. Record the last step that completed, and report it // with the state gathered so far. reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), // The enhancement happened; the native control survived it. chromedp.Evaluate(`document.querySelectorAll('input[role="combobox"]').length`, &comboCount), chromedp.Evaluate(`document.querySelectorAll('select[name="author"]').length`, &nativeCount), // Hidden, not removed. sr-only leaves a ~1px box; anything wider // means the select is still taking real layout space. chromedp.Evaluate(`(document.querySelector('select[name="author"]')?.getBoundingClientRect().width ?? 999) < 4`, &nativeHidden), // The label must name the control the user actually types into. chromedp.Evaluate(`document.querySelector('label')?.getAttribute('for') ?? ''`, &labelFor), // Every probe above is null-safe on purpose: a missing node should // reach the assertions as an empty value, so the failure names the // broken invariant instead of surfacing a chromedp node error. // // Filter, then pick with the keyboard only: a mouse-only // combobox is a broken one. chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), chromedp.SendKeys(`input[role="combobox"]`, "Option 12", chromedp.ByQuery), at("typed-filter"), // A burst of keystrokes inside one frame is searched once more // before that frame is drawn (select.js coalesces), so the count is // read after a frame, as a person would see it. chromedp.Evaluate(`new Promise(function (r) { requestAnimationFrame(function () { setTimeout(r, 0); }); })`, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), chromedp.Evaluate(`document.querySelectorAll('[role="option"]:not([style*="none"])').length`, &optionsShown), chromedp.Evaluate(`document.querySelector('input[role="combobox"]')?.value ?? ''`, &filterText), // Synchronise on observable state rather than assuming a keystroke // landed: under load the arrow key can arrive before the filtered // list is drawn, and an ArrowDown with nothing to highlight is a // silent no-op the next step would inherit. Waiting for the // highlight turns that into a fast, legible failure at the exact // step that did not happen. chromedp.WaitVisible(`[role="option"]:not([style*="none"])`, chromedp.ByQuery), at("list-drawn"), // SendKeys, not KeyEvent: KeyEvent trusts ambient focus, while // SendKeys focuses its target first and then delivers the same // CDP key events, so the key lands where the user's would. It // was reached for while chasing issue #86 and kept afterwards — // aiming a keystroke is right whether or not it was ever the // problem, and it was not. chromedp.SendKeys(`input[role="combobox"]`, string(kb.ArrowDown), chromedp.ByQuery), at("arrow-down"), chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), at("option-highlighted"), chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("enter"), // The mirror: what the form will actually submit. Polled rather // than sampled, and bounded at 10s, so a commit that never // happens fails fast at this step with the widget's state in the // error instead of burning the whole budget on a bare deadline. // This poll is also what used to hide issue #86: it was racing a // navigation the Enter had already started, and winning often // enough to look green. chromedp.ActionFunc(func(ctx context.Context) error { deadline := time.Now().Add(10 * time.Second) for { var v string if err := chromedp.Evaluate(`document.querySelector('select[name="author"]')?.value ?? ''`, &v).Do(ctx); err != nil { return err } if v != "" { nativeValue = v return nil } if time.Now().After(deadline) { var snap string _ = chromedp.Evaluate(`JSON.stringify({ activeEl: (document.activeElement && (document.activeElement.tagName + "/" + (document.activeElement.getAttribute("role") || ""))) || "none", hasFocus: document.hasFocus(), options: document.querySelectorAll('[role="option"]').length, highlighted: document.querySelectorAll('[role="option"].is-active').length, listHidden: (function(l){ return l ? l.hidden : "no-list" })(document.querySelector('[role="listbox"]')), inputValue: (function(i){ return i ? i.value : "no-input" })(document.querySelector('input[role="combobox"]')), })`, &snap).Do(ctx) return fmt.Errorf("mirror never committed within 10s of Enter; widget state: %s", snap) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } }), at("read-mirrored-value"), // Submit rather than Click: what this test asserts is that the // form posts the mirrored value, and chromedp.Click waits for the // button to be actionable — a wait that intermittently never // resolved here even with the value already correctly mirrored. // Submitting the form exercises the thing under test without // depending on hit-testing an overlay-adjacent button. chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), ); err != nil { t.Fatalf("drive failed after %q: %v\n filterText=%q optionsShown=%d nativeValue=%q labelFor=%q", reached, err, filterText, optionsShown, nativeValue, labelFor) } if comboCount != 1 { t.Errorf("expected exactly one combobox, found %d", comboCount) } if nativeCount != 1 { t.Errorf("the native select must survive enhancement, found %d", nativeCount) } if !nativeHidden { t.Error("the native select is still visible; it should be hidden, not removed") } if !strings.HasSuffix(labelFor, "-combo") { t.Errorf("label still points at the hidden select (for=%q): the combobox has no accessible name", labelFor) } // Focus must select the committed label so typing replaces it. When // it does not, the filter text becomes "Option 1Option 12", nothing // matches, and Enter commits the select's pre-existing default — a // green test that proves nothing. Assert the text we actually typed. if filterText != "Option 12" { t.Errorf("filter box holds %q, want %q: focus is not replacing the committed label", filterText, "Option 12") } if optionsShown != 1 { t.Errorf("filtering for a unique label showed %d options, want 1", optionsShown) } // "Option 12" is the 12th option, so value "12". Crucially NOT "1", // which is what the select holds by default — an assertion that // merely checked non-empty would pass on the bug this test exists for. if nativeValue != "12" { t.Errorf("native select holds %q, want %q — the combobox did not mirror the keyboard pick back", nativeValue, "12") } select { case v := <-submitted: if v != nativeValue { t.Errorf("server received author=%q, the select held %q", v, nativeValue) } case <-time.After(5 * time.Second): t.Fatal("the form never reached the server") } // The console check and the junk scan are the rig's screen gate // now — which also reads input values and aria-labels, and knows // the junk set in full: the "null" this file's in-place scan was // missing arrived with the move. rig.Screen("body", "after the journey") } // datePage serves one form carrying an enhanced field-date (or // field-time), the real datetime.js and calendar.js, tokens.css and // theme, and records what a submit delivers under field. The seeded // pages are keyed by partial; ?nocal serves the field-date page with // calendar.js left off, the app that never adopted the overlay, whose // date button opens the browser's own panel instead. func datePage(t *testing.T, partial, field string) (http.Handler, chan string) { t.Helper() got := make(chan string, 1) with := fixture(t, "browser_test.go", "datePage", partial) without := "" if partial == "field-date" { without = fixture(t, "browser_test.go", "datePage", partial+"-nocal") } mux := sizingMux(t, map[string]string{}) received(mux, "/submit", field, got, http.StatusOK) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { doc := with if r.URL.Query().Has("nocal") { if without == "" { http.NotFound(w, r) return } doc = without } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, doc) }) return mux, got } // TestEnhancedDateDrivesTheWholeJourney is the date field's one browser // test, on the same terms as the select drive above: a real engine, real // focus, real keys. // // Bug classes it exists to catch — each renders perfectly and says // nothing wrong: // // - the parser reads "tomorrow" but nothing is written back, so the // form submits an empty date while the screen shows one; // - the native input is removed rather than hidden, so the form // submits nothing at all; // - the label is left pointing at the hidden native, leaving the // control the user types into with no accessible name; // - a JS error takes the enhancement down and the page still looks // fine (the rig's screen gate catches this one). // // It is the drive that was already right about Enter: datetime.js has // refused the synthesised keypress from the start, which is why this // one never submitted the page out from under itself the way the // select drive did. See the file header, and issue #86. func TestEnhancedDateDrivesTheWholeJourney(t *testing.T) { mux, submitted := datePage(t, "field-date", "due") rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) defer cancelTimeout() // The browser and this test share one clock, so "tomorrow" is the // same day for both — except across the one midnight a run could // straddle. Both readings are accepted rather than pinning the // clock, which a real browser will not let a Go test do anyway. before := time.Now().AddDate(0, 0, 1).Format("2006-01-02") var ( comboCount, nativeCount int labelFor, nativeValue string typed string nativeHidden bool ) reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), chromedp.Evaluate(`document.querySelectorAll('input[role="combobox"]').length`, &comboCount), chromedp.Evaluate(`document.querySelectorAll('input[name="due"]').length`, &nativeCount), // Hidden, not removed — and "hidden" measured the way the // utility actually hides it. A replaced element keeps an // intrinsic minimum size whatever inline-size says (the date // input measures 6x5 in Chromium, not the select's ~1x1), so // what proves it invisible is the clip and the absolute // position, not a width threshold. A native left in flow fails // all three. chromedp.Evaluate(`(function () { var el = document.querySelector('input[name="due"]'); if (!el) return false; var s = getComputedStyle(el); return s.position === "absolute" && s.clipPath !== "none" && el.getBoundingClientRect().width < 24; })()`, &nativeHidden), chromedp.Evaluate(`document.querySelector('label')?.getAttribute('for') ?? ''`, &labelFor), chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), chromedp.SendKeys(`input[role="combobox"]`, "tomorrow", chromedp.ByQuery), at("typed-phrase"), chromedp.Evaluate(`document.querySelector('input[role="combobox"]')?.value ?? ''`, &typed), // Synchronise on the reading being armed rather than assuming // the keystrokes landed: the highlighted row IS the parse, and // waiting for it turns a drifted focus into a fast, legible // failure at this step instead of a deadline three steps later. chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), at("reading-armed"), chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("enter"), chromedp.ActionFunc(func(ctx context.Context) error { deadline := time.Now().Add(10 * time.Second) for { var v string if err := chromedp.Evaluate(`document.querySelector('input[name="due"]')?.value ?? ''`, &v).Do(ctx); err != nil { return err } if v != "" { nativeValue = v return nil } if time.Now().After(deadline) { var snap string _ = chromedp.Evaluate(`JSON.stringify({ activeEl: (document.activeElement && (document.activeElement.tagName + "/" + (document.activeElement.getAttribute("role") || ""))) || "none", options: document.querySelectorAll('[role="option"]').length, highlighted: document.querySelectorAll('[role="option"].is-active').length, inputValue: (function(i){ return i ? i.value : "no-input" })(document.querySelector('input[role="combobox"]')), })`, &snap).Do(ctx) return fmt.Errorf("the carrier never took a value within 10s of Enter; widget state: %s", snap) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } }), at("read-carrier-value"), chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), ); err != nil { t.Fatalf("drive failed after %q: %v\n typed=%q nativeValue=%q labelFor=%q", reached, err, typed, nativeValue, labelFor) } after := time.Now().AddDate(0, 0, 1).Format("2006-01-02") if comboCount != 1 { t.Errorf("expected exactly one combobox, found %d", comboCount) } if nativeCount != 1 { t.Errorf("the native date input must survive enhancement, found %d", nativeCount) } if !nativeHidden { t.Error("the native date input is still visible; it should be clipped out of sight, not removed") } if !strings.HasSuffix(labelFor, "-combo") { t.Errorf("label still points at the hidden native (for=%q): the combobox has no accessible name", labelFor) } if typed != "tomorrow" { t.Errorf("the combobox holds %q, want %q: the keystrokes did not land where they were aimed", typed, "tomorrow") } // The whole point: a phrase typed in English became the wire value // the server parses. Crucially NOT today's date, which is what an // unparsed reading committing the first quick pick would leave. if nativeValue != before && nativeValue != after { t.Errorf("the carrier holds %q, want tomorrow (%q or %q)", nativeValue, before, after) } select { case v := <-submitted: if v != nativeValue { t.Errorf("server received due=%q, the carrier held %q", v, nativeValue) } case <-time.After(5 * time.Second): t.Fatal("the form never reached the server") } rig.Screen("body", "after the date journey") } // TestEnterCommitsWithoutSubmittingTheForm is the regression test for // issue #86, and it is the only test in this file that asserts what // must NOT happen. // // Both comboboxes make the same promise: Enter picks the highlighted // row and stops there. It must never also submit the form, because a // submit racing a commit posts whatever the control held BEFORE the // user chose — the choice is thrown away and the page moves on as if // it had been made. // // Keeping that promise takes two refusals, not one. The keydown // handler's preventDefault is enough for a key a person presses: the // engine derives the keypress from the keydown it just cancelled, so // there is no keypress left to submit anything. It is NOT enough for a // synthesised key. CDP — and so chromedp, playwright's raw protocol, // and every browser drive built on either — can deliver the keydown // and the character as two independent events, and the second arrives // with no memory of the first one's refusal: an uncancelled keypress // carrying \r, which is exactly what implicit form submission listens // for. Both widgets therefore refuse the keypress as well. // // This test drives Enter through chromedp's three-event encoding on // purpose, because that is the shape that breaks. It is a real // assertion and not a test-harness quirk: before the guard went into // select.js the drive above fired a submit on EVERY run, on every // machine, and passed only because reading the mirror usually beat the // navigation — a race it lost five times out of five on GitHub's // runner and about one run in seven on a box under load. // // The submit spy cancels what it counts, so a regression fails here // with a count instead of taking the page out from under the next // step. func TestEnterCommitsWithoutSubmittingTheForm(t *testing.T) { const spy = `window.__submits = 0; document.addEventListener('submit', function (e) { e.preventDefault(); window.__submits++; }, true); true` for _, tc := range []struct { name string page func(*testing.T) (http.Handler, chan string) typed string mirror string wantVal func(string) bool }{ { name: "select", page: func(t *testing.T) (http.Handler, chan string) { return page(t, 40) }, typed: "Option 12", mirror: `document.querySelector('select[name="author"]')?.value ?? ''`, // "Option 12" is the 12th option, so value "12" — not "1", // which is what the select holds by default. wantVal: func(v string) bool { return v == "12" }, }, { name: "date", page: func(t *testing.T) (http.Handler, chan string) { return datePage(t, "field-date", "due") }, typed: "tomorrow", mirror: `document.querySelector('input[name="due"]')?.value ?? ''`, wantVal: func(v string) bool { return v != "" }, }, } { t.Run(tc.name, func(t *testing.T) { mux, _ := tc.page(t) rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() var ( submits int value string ) if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), chromedp.Evaluate(spy, nil), chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), chromedp.SendKeys(`input[role="combobox"]`, tc.typed, chromedp.ByQuery), chromedp.WaitVisible(`[role="option"]:not([style*="none"])`, chromedp.ByQuery), chromedp.SendKeys(`input[role="combobox"]`, string(kb.ArrowDown), chromedp.ByQuery), chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), chromedp.Evaluate(`window.__submits`, &submits), chromedp.Evaluate(tc.mirror, &value), ); err != nil { t.Fatalf("drive failed: %v", err) } if submits != 0 { t.Errorf("Enter submitted the form %d time(s); it must commit the highlighted row and stop", submits) } // Without this the test would pass on a widget that ignores // Enter entirely, which keeps the letter of the promise and // breaks the point of it. if !tc.wantVal(value) { t.Errorf("Enter did not commit: the native control holds %q", value) } }) } } // ── The field row ──────────────────────────────────────────────────── // fieldRowPage serves the seeded field-row page: a date range with an // error on its end, a grow/short pair, a pair with an error on both // halves (one message far longer than the other), and a labelled field // beside one with no label, with datetime.js and calendar.js. Every // bug this page exists for was in the interaction between the real // partials, the real stylesheet and the real enhancement. // // dir rides the query string rather than a second handler: the two // passes must differ in exactly one attribute, or an RTL failure is not // evidence about direction. func fieldRowPage(t *testing.T) http.Handler { t.Helper() pages := map[string]string{ "ltr": fixture(t, "browser_test.go", "fieldRowPage", "ltr"), "rtl": fixture(t, "browser_test.go", "fieldRowPage", "rtl"), } mux := sizingMux(t, map[string]string{}) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { dir := "ltr" if r.URL.Query().Get("dir") == "rtl" { dir = "rtl" } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, pages[dir]) }) return mux } // geometry is one element's border box, as the browser measured it. type geometry struct { Top float64 `json:"top"` Left float64 `json:"left"` Right float64 `json:"right"` Bottom float64 `json:"bottom"` Width float64 `json:"width"` } // rowGeometry is everything the assertions below need, read in one // evaluation so every rect comes from the same layout. type rowGeometry struct { FromControl geometry `json:"fromControl"` ToControl geometry `json:"toControl"` FromField geometry `json:"fromField"` ToField geometry `json:"toField"` Error geometry `json:"error"` FromPick geometry `json:"fromPick"` City geometry `json:"city"` CityField geometry `json:"cityField"` Zip geometry `json:"zip"` ZipField geometry `json:"zipField"` BothOne geometry `json:"bothOne"` BothTwo geometry `json:"bothTwo"` BothOneMsg geometry `json:"bothOneMsg"` BothTwoMsg geometry `json:"bothTwoMsg"` BothOneFld geometry `json:"bothOneFld"` BothTwoFld geometry `json:"bothTwoFld"` Labelled geometry `json:"labelled"` Unlabelled geometry `json:"unlabelled"` Dir string `json:"dir"` RootFont float64 `json:"rootFont"` } const rowGeometryJS = `(function () { function box(el) { var r = el.getBoundingClientRect(); return {top: r.top, left: r.left, right: r.right, bottom: r.bottom, width: r.width}; } function byId(id) { return box(document.getElementById(id)); } // The control a person sees: the enhancement's combobox where it ran, // the native input where it did not. function control(name) { var native = document.getElementsByName(name)[0]; var wrap = native.closest("[rst-dtp]"); return wrap ? wrap.querySelector("[rst-dtp-input]") : native; } function field(name) { return document.getElementsByName(name)[0].closest("[rst-field]"); } var from = control("dr_from"), to = control("dr_to"); return { fromControl: box(from), toControl: box(to), fromField: box(field("dr_from")), toField: box(field("dr_to")), error: byId("dr_to-error"), fromPick: box(from.closest("[rst-dtp]").querySelector("[rst-dtp-pick]")), city: byId("city"), cityField: box(field("city")), zip: byId("zip"), zipField: box(field("zip")), bothOne: byId("b_one"), bothTwo: byId("b_two"), bothOneMsg: byId("b_one-error"), bothTwoMsg: byId("b_two-error"), bothOneFld: box(field("b_one")), bothTwoFld: box(field("b_two")), labelled: byId("n_one"), unlabelled: byId("n_two"), dir: document.documentElement.getAttribute("dir") || "ltr", rootFont: parseFloat(getComputedStyle(document.documentElement).fontSize) }; })()` // One device pixel of slack: sub-pixel layout is legitimate, a // misaligned row is not. const geometrySlack = 1.0 func absf(f float64) float64 { if f < 0 { return -f } return f } // overlaps reports whether two border boxes intersect by more than the // slack — the literal shape of the bug from the screenshot, a message // printed across the control beside it. func overlaps(a, b geometry) bool { return a.Left < b.Right-geometrySlack && a.Right > b.Left+geometrySlack && a.Top < b.Bottom-geometrySlack && a.Bottom > b.Top+geometrySlack } // within reports whether the inner box stays inside the outer one on the // inline axis. func within(inner, outer geometry) bool { return inner.Left >= outer.Left-geometrySlack && inner.Right <= outer.Right+geometrySlack } // assertRowGeometry is every invariant [rst-field-row] now owes, checked // against one layout. It is called once per writing mode, because the // picker button's placement and the row's own inline axis are the two // things a logical-property mistake gets wrong in exactly one of them. func assertRowGeometry(t *testing.T, g rowGeometry) { t.Helper() rtl := g.Dir == "rtl" // 1. The row aligns by the control, whatever else the fields carry. for _, pair := range []struct { what string a, b geometry }{ {"the date range (error on the end)", g.FromControl, g.ToControl}, {"the pair with an error on both halves", g.BothOne, g.BothTwo}, {"a labelled field beside an unlabelled one", g.Labelled, g.Unlabelled}, } { if d := absf(pair.a.Top - pair.b.Top); d > geometrySlack { t.Errorf("%s: the controls do not share a top edge, %.1f apart (%.1f and %.1f) — the row is aligning by something other than the control", pair.what, d, pair.a.Top, pair.b.Top) } } // 2. A message stays in its own column and off its neighbour. for _, m := range []struct { what string msg, neighbour, field geometry }{ {"the To field's error", g.Error, g.FromControl, g.ToField}, {"the long error on the first half", g.BothOneMsg, g.BothTwo, g.BothOneFld}, {"the short error on the second half", g.BothTwoMsg, g.BothOne, g.BothTwoFld}, } { if overlaps(m.msg, m.neighbour) { t.Errorf("%s overlaps the control beside it: message %+v, control %+v", m.what, m.msg, m.neighbour) } if !within(m.msg, m.field) { t.Errorf("%s escapes its own column: message [%.1f, %.1f], field [%.1f, %.1f]", m.what, m.msg.Left, m.msg.Right, m.field.Left, m.field.Right) } } // A long message must not have bought its column extra width at its // neighbour's expense — that is what contain:inline-size is for. if d := absf(g.BothOneFld.Width - g.BothTwoFld.Width); d > geometrySlack { t.Errorf("a long error stretched its own column: fields are %.1f and %.1f wide, %.1f apart — the message is being counted in the field's max-content width", g.BothOneFld.Width, g.BothTwoFld.Width, d) } // 3. No control outgrows the field that holds it — the box-sizing // bug, stated as the invariant it broke. for _, c := range []struct { name string control, field geometry }{ {"From", g.FromControl, g.FromField}, {"To", g.ToControl, g.ToField}, {"City", g.City, g.CityField}, {"ZIP", g.Zip, g.ZipField}, } { if !within(c.control, c.field) { t.Errorf("the %s control overflows its field: control [%.1f, %.1f], field [%.1f, %.1f] — width:100%% is being measured as a content box", c.name, c.control.Left, c.control.Right, c.field.Left, c.field.Right) } } if overlaps(g.City, g.ZipField) { t.Errorf("the grown City control runs into the ZIP field: City [%.1f, %.1f], ZIP field [%.1f, %.1f]", g.City.Left, g.City.Right, g.ZipField.Left, g.ZipField.Right) } // 4. The picker sits at the control's INLINE end, inside it, centred // on it. Which physical edge that is flips with the writing mode, // which is the whole point of running this twice. inset := 0.35 * g.RootFont gotEdge, wantEdge, edge := g.FromPick.Right, g.FromControl.Right-inset, "right" if rtl { gotEdge, wantEdge, edge = g.FromPick.Left, g.FromControl.Left+inset, "left" } if absf(gotEdge-wantEdge) > 2 { t.Errorf("dir=%s: the picker button is not at the control's inline end: button %s edge %.1f, wanted about %.1f (control spans [%.1f, %.1f])", g.Dir, edge, gotEdge, wantEdge, g.FromControl.Left, g.FromControl.Right) } if !within(g.FromPick, g.FromControl) || g.FromPick.Top < g.FromControl.Top || g.FromPick.Bottom > g.FromControl.Bottom { t.Errorf("dir=%s: the picker button is not inside the control it belongs to: button %+v, control %+v", g.Dir, g.FromPick, g.FromControl) } if top, bottom := g.FromPick.Top-g.FromControl.Top, g.FromControl.Bottom-g.FromPick.Bottom; absf(top-bottom) > geometrySlack { t.Errorf("dir=%s: the picker button is not centred on the control: %.1f above, %.1f below", g.Dir, top, bottom) } // 5. Short is compact, not crushed: 8rem is what tokens.css declares, // and the point of declaring it is that a row cannot take it away. if want := 8 * g.RootFont; g.Zip.Width < want-geometrySlack { t.Errorf("the short field lost its width in the row: %.1f, wanted at least %.1f", g.Zip.Width, want) } if g.City.Width <= g.Zip.Width { t.Errorf("rst-grow did not grow: City %.1f, ZIP %.1f", g.City.Width, g.Zip.Width) } } // TestFieldRowGeometryHoldsUnderAnError pins the layout contract of // [rst-field-row] with a real engine doing the layout, because none of // these bugs is visible in the markup — every one of them is a used // value the browser computed. // // The bug classes it exists to catch, all four shipped at once and all // four looked fine in the template: // // - the row bottom-aligned its fields, so a field carrying an error // lifted its control clear of its neighbour's and dropped the error // line across the field beside it; // - [rst-input] sized width:100% as a content box, so every input // overflowed its own column by its padding and borders and ran // under the field next to it; // - the picker button, anchored to a wrapper the input had outgrown, // landed short of the control's visible end — inside the text, not // at the edge; // - the short field shrank to whatever was left over. // // And the three mechanisms the fix introduced, which are load-bearing // and would otherwise be proven by nothing: the reserved label line // under a field with no label, contain:inline-size keeping a long // message out of its column's width, and the picker's placement in a // right-to-left page — the one thing a logical-property mistake gets // wrong in exactly one writing mode. // // Cheap by browser-drive standards: two navigations, one evaluation // each, no keyboard input, so it does not share the keystroke // flakiness the two journey drives above document. func TestFieldRowGeometryHoldsUnderAnError(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return fieldRowPage(t) }) ctx, cancelTimeout := context.WithTimeout(rig.Context(), 120*time.Second) defer cancelTimeout() for _, dir := range []string{"ltr", "rtl"} { t.Run(dir, func(t *testing.T) { var g rowGeometry if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/?dir="+dir), chromedp.WaitVisible("[rst-dtp-pick]", chromedp.ByQuery), chromedp.Evaluate(rowGeometryJS, &g), ); err != nil { t.Fatalf("dir=%s: reading the row's geometry: %v", dir, err) } if g.Dir != dir { t.Fatalf("asked for dir=%s, the document reports %q — the two passes are not differing in what they claim to", dir, g.Dir) } assertRowGeometry(t, g) }) } rig.Screen("body", "the field rows") } // ── The calendar overlay, the native picker and the clock ──────────── // TestCalendarOverlayDrivesTheWholeJourney is the overlay's browser // test, on the same terms as the journeys above: a real engine, real // focus, real keys, one drive covering a whole journey. // // It exists because every bug class below renders perfectly and says // nothing wrong — which is exactly how the thing it replaced shipped. // The old button called native.showPicker(): the panel it opened // belonged to the browser, and because that panel does not move focus, // the guard on the change listener swallowed the pick. The field looked // like it had simply ignored the date somebody had just chosen. // // - the button opens nothing, or opens the browser's panel again, // because window.fiestaCalendar was not found; // - a day is clicked and the grid closes, but the carrier never takes // the value, so the form posts an empty date over a filled-in // screen; // - the grid draws the right days under the wrong headings, or off by // a week, so every date is a weekday out; // - the words and the grid drift apart: typing moves neither, or // moves the grid without moving what Enter would commit; // - the grid takes no keyboard at all, which is the difference // between a date picker and a date picker for people with mice. // // The month is pinned by typing a date rather than by pinning a clock, // which a real browser will not let a Go test do: "25 dec 2027" is a // day the grid can be asked for by name and asserted on exactly. func TestCalendarOverlayDrivesTheWholeJourney(t *testing.T) { mux, submitted := datePage(t, "field-date", "due") rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) defer cancelTimeout() var ( hasPanel bool calRole, calLabel string headCount, dayCount, rowCount int headings, previewDay, titleOne string titleTwo, afterKeys string selectedDay, ariaExpanded string nativeValue, comboText string gridClosed, disabledCount int firstColumnAgrees bool ) reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), // The button is there, and it is the overlay's button rather // than a fallback to the browser's own panel. chromedp.Evaluate(`!!document.querySelector('[rst-dtp-pick]')`, &hasPanel), chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-haspopup') ?? ''`, &calRole), // Type a date first, so the month on screen is one this test // can name. The grid must open on what the words already say, // not on this month. chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), chromedp.SendKeys(`input[role="combobox"]`, "25 dec 2027", chromedp.ByQuery), at("typed-date"), chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), at("reading-armed"), chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), at("pressed-calendar-button"), chromedp.WaitVisible(`[rst-cal] [role="gridcell"]`, chromedp.ByQuery), at("grid-drawn"), chromedp.Evaluate(`document.querySelector('[rst-cal]')?.getAttribute('role') ?? ''`, &calLabel), chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-expanded') ?? ''`, &ariaExpanded), chromedp.Evaluate(`document.querySelectorAll('[rst-cal-grid] th[role="columnheader"]').length`, &headCount), chromedp.Evaluate(`document.querySelectorAll('[rst-cal] [role="gridcell"]').length`, &dayCount), chromedp.Evaluate(`document.querySelectorAll('[rst-cal] tbody tr[role="row"]').length`, &rowCount), chromedp.Evaluate(`document.querySelector('[rst-cal-title]')?.textContent ?? ''`, &titleOne), // Every heading has a spoken name as well as the two letters // the eye reads: "Mo" alone is not a weekday. chromedp.Evaluate(`Array.from(document.querySelectorAll('[rst-cal-grid] th')) .map(th => (th.querySelector('.rst-sr-only')?.textContent ?? '')).join('|')`, &headings), // The headings describe the columns they sit over. This is the // off-by-one that would look completely normal and put every // date on the wrong weekday: the first cell's own weekday, // asked of the browser, against the first column's name. chromedp.Evaluate(`(function () { var td = document.querySelector('[rst-cal] tbody [role="gridcell"]'); var th = document.querySelector('[rst-cal-grid] th .rst-sr-only'); if (!td || !th) return false; var d = new Date(td.getAttribute('data-rst-day') + 'T00:00'); var lang = document.documentElement.getAttribute('lang') || undefined; return new Intl.DateTimeFormat(lang, { weekday: 'long' }).format(d) === th.textContent; })()`, &firstColumnAgrees), // Opened on the typed date, and marked as the preview rather // than as a selection: nothing has been committed yet. chromedp.Evaluate(`document.querySelector('[rst-cal-day].is-preview')?.getAttribute('data-rst-day') ?? ''`, &previewDay), // The live half of the brief. The grid is open; changing the // words moves it, with no commit and no focus change. chromedp.ActionFunc(func(ctx context.Context) error { return chromedp.Evaluate(`(function () { var i = document.querySelector('input[role="combobox"]'); i.value = '3 mar 2028'; i.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, new(bool)).Do(ctx) }), at("retyped-while-open"), chromedp.Evaluate(`document.querySelector('[rst-cal-title]')?.textContent ?? ''`, &titleTwo), // The keyboard. Down out of the box lands on the grid; a week // back from 3 March 2028 is 25 February 2028. chromedp.SendKeys(`input[role="combobox"]`, string(kb.ArrowDown), chromedp.ByQuery), at("arrowed-into-grid"), // To whatever has focus, deliberately: the claim under test is // that ArrowDown MOVED focus onto the grid, and re-selecting // the cell first would prove it by assuming it. chromedp.KeyEvent(kb.ArrowUp), at("arrowed-a-week-back"), chromedp.Evaluate(`document.activeElement?.getAttribute('data-rst-day') ?? ''`, &afterKeys), // Enter on the focused day commits it, and the grid closes. chromedp.KeyEvent(kb.Enter), at("entered-on-a-day"), chromedp.ActionFunc(func(ctx context.Context) error { deadline := time.Now().Add(10 * time.Second) for { var v string if err := chromedp.Evaluate(`document.querySelector('input[name="due"]')?.value ?? ''`, &v).Do(ctx); err != nil { return err } if v != "" { nativeValue = v return nil } if time.Now().After(deadline) { return fmt.Errorf("the carrier never took a value within 10s of Enter on the grid") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } }), at("read-carrier-value"), chromedp.Evaluate(`document.querySelectorAll('[rst-cal]:not([hidden])').length`, &gridClosed), chromedp.Evaluate(`document.querySelector('input[role="combobox"]')?.value ?? ''`, &comboText), // Reopened, the day it now holds is the SELECTED one — the // committed value, said in the accessibility tree and not only // in a class. chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), at("reopened-calendar"), chromedp.WaitVisible(`[rst-cal] [role="gridcell"]`, chromedp.ByQuery), at("grid-redrawn"), chromedp.Evaluate(`document.querySelector('[rst-cal-day][aria-selected="true"]')?.getAttribute('data-rst-day') ?? ''`, &selectedDay), chromedp.Evaluate(`document.querySelectorAll('[rst-cal-day][aria-disabled="true"]').length`, &disabledCount), chromedp.KeyEvent(kb.Escape), at("escaped"), chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), ); err != nil { t.Fatalf("drive failed after %q: %v\n title=%q preview=%q afterKeys=%q nativeValue=%q", reached, err, titleOne, previewDay, afterKeys, nativeValue) } if !hasPanel { t.Fatal("no calendar button was rendered at all") } if calRole != "dialog" { t.Errorf("the button says aria-haspopup=%q; with calendar.js on the page it opens a dialog, not %q", calRole, calRole) } if calLabel != "dialog" { t.Errorf("the overlay carries role=%q, want dialog", calLabel) } if ariaExpanded != "true" { t.Errorf("the button says aria-expanded=%q while its panel is open", ariaExpanded) } if headCount != 7 { t.Errorf("the grid has %d column headers, want 7", headCount) } if dayCount != 42 { t.Errorf("the grid has %d cells, want 42 — six weeks, so the panel keeps one height all year", dayCount) } if rowCount != 6 { t.Errorf("the grid has %d rows, want 6", rowCount) } if n := len(strings.Split(headings, "|")); n != 7 || strings.Contains(headings, "||") { t.Errorf("the column headings have no spoken names (%q): two letters read aloud is not a weekday", headings) } if !firstColumnAgrees { t.Error("the first column's heading does not name the first cell's own weekday: the grid is off by a column, and every date in it is on the wrong day") } if previewDay != "2027-12-25" { t.Errorf("the grid opened previewing %q, want 2027-12-25 — it should open on what the words already say", previewDay) } if titleOne == titleTwo { t.Errorf("the title stayed %q when the text changed under it: the words are not driving the grid", titleOne) } // A week up from 3 March 2028. Pinned exactly, because "the grid // moved" and "the grid moved by a week" are different claims. if afterKeys != "2028-02-25" { t.Errorf("ArrowDown then ArrowUp left focus on %q, want 2028-02-25 (a week back from the previewed 3 March)", afterKeys) } if nativeValue != "2028-02-25" { t.Errorf("the carrier holds %q, want 2028-02-25: Enter on the grid must write the focused day back", nativeValue) } if gridClosed != 0 { t.Error("the grid is still open after the date was set; committing a value closes it") } if comboText == "" || strings.Contains(comboText, "2028-02-25") { t.Errorf("the box reads %q; it should show the date in the page's own words, not the wire format", comboText) } if selectedDay != "2028-02-25" { t.Errorf("reopened, the grid marks %q as selected, want 2028-02-25", selectedDay) } if disabledCount != 0 { t.Errorf("%d days are disabled on a field with no min or max", disabledCount) } select { case got := <-submitted: if got != "2028-02-25" { t.Errorf("the server received due=%q, want 2028-02-25", got) } default: t.Fatal("the form never reached the server") } rig.Screen("body", "after the calendar journey") } // Without calendar.js a date field's button hands over to the browser's // own panel, which is not ours to describe: it is not a listbox, and // nothing on the page ever learns whether it is open. The button used to // say aria-haspopup="listbox" and aria-expanded="false" there — a popup // type that was wrong, and a state that was permanently "closed" even // while the browser's panel was up. A screen reader announced a // collapsed list box that never expanded. The button there is a plain // button; only the popups datetime.js draws (the grid, the clock) carry // the popup attributes. func TestNativePickerButtonClaimsNoPopup(t *testing.T) { mux, _ := datePage(t, "field-date", "due") rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancelTimeout := context.WithTimeout(rig.Context(), 60*time.Second) defer cancelTimeout() attrs := `(function () { var b = document.querySelector('[rst-dtp-pick]'); if (!b) return 'no button'; return 'haspopup=' + b.hasAttribute('aria-haspopup') + ' expanded=' + b.hasAttribute('aria-expanded'); })()` var calendars int var before, listOpen, pressed string if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/?nocal"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), chromedp.Evaluate(`document.querySelectorAll('[rst-cal]').length`, &calendars), chromedp.Evaluate(attrs, &before), // The suggestions opening is the combobox's popup, not the // button's: it must not hand the button a state either. chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), chromedp.WaitVisible(`[role="option"]`, chromedp.ByQuery), chromedp.Evaluate(attrs, &listOpen), chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), chromedp.Evaluate(attrs, &pressed), ); err != nil { t.Fatal(err) } if calendars != 0 { t.Fatalf("the page built %d calendars; this test needs calendar.js off the page", calendars) } want := "haspopup=false expanded=false" for _, c := range []struct{ when, got string }{ {"on load", before}, {"with the suggestions open", listOpen}, {"after the press", pressed}, } { if c.got != want { t.Errorf("%s the native-picker button says %s, want %s: the browser's panel is neither a listbox nor a state this page can report", c.when, c.got, want) } } } // A time field has no calendar, so its button opens the clock instead: // every half hour of the day, in the field's own locale, scrolled to the // one it is already showing. Its own drive because it is its own popup — // and because the failure it guards against is the one the whole branch // began with. datetime.js used to hand a time field to showPicker() too, // and on the engines where a time input has no panel, that button was a // control that did nothing at all. func TestTimeFieldOpensAClockRatherThanACalendar(t *testing.T) { mux, submitted := datePage(t, "field-time", "at") rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) defer cancelTimeout() var ( calendars, slots int haspopup, label string firstSlot, last string activeText string nativeValue string ) reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-haspopup') ?? ''`, &haspopup), chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-label') ?? ''`, &label), chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), at("pressed-the-button"), chromedp.WaitVisible(`[role="option"]`, chromedp.ByQuery), at("slots-drawn"), // No calendar was built for it, and the popup is the day's half // hours: twenty-four hours at two slots each. chromedp.Evaluate(`document.querySelectorAll('[rst-cal]').length`, &calendars), chromedp.Evaluate(`document.querySelectorAll('[role="option"]').length`, &slots), chromedp.Evaluate(`document.querySelector('[role="option"] [rst-dtp-label]')?.textContent ?? ''`, &firstSlot), chromedp.Evaluate(`(function () { var all = document.querySelectorAll('[role="option"] [rst-dtp-label]'); return all.length ? all[all.length - 1].textContent : ''; })()`, &last), chromedp.Evaluate(`document.querySelector('[role="option"].is-active [rst-dtp-label]')?.textContent ?? ''`, &activeText), chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("committed-a-slot"), chromedp.ActionFunc(func(ctx context.Context) error { deadline := time.Now().Add(10 * time.Second) for { var v string if err := chromedp.Evaluate(`document.querySelector('input[name="at"]')?.value ?? ''`, &v).Do(ctx); err != nil { return err } if v != "" { nativeValue = v return nil } if time.Now().After(deadline) { return fmt.Errorf("the carrier never took a value within 10s of Enter on a slot") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } }), at("read-carrier-value"), chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), ); err != nil { t.Fatalf("drive failed after %q: %v\n slots=%d first=%q active=%q", reached, err, slots, firstSlot, activeText) } if haspopup != "listbox" { t.Errorf("the time field's button says aria-haspopup=%q, want listbox: it opens a list of times, not a dialog", haspopup) } if label == "" || strings.Contains(strings.ToLower(label), "calendar") { t.Errorf("the time field's button is labelled %q; a time field opens a clock", label) } if calendars != 0 { t.Errorf("%d calendar panels were built for a time field", calendars) } if slots != 48 { t.Errorf("the clock offers %d slots, want 48 — every half hour of the day", slots) } if firstSlot == "" || last == "" || firstSlot == last { t.Errorf("the slots do not span a day: first=%q last=%q", firstSlot, last) } // Something is armed, so Enter commits a time rather than nothing. if activeText == "" { t.Error("no slot is highlighted, so Enter would commit nothing") } // The carrier holds a wire clock, and one that lands on a half hour. if !regexp.MustCompile(`^\d{2}:(00|30)$`).MatchString(nativeValue) { t.Errorf("the carrier holds %q, want a HH:00 or HH:30 wire time", nativeValue) } select { case got := <-submitted: if got != nativeValue { t.Errorf("the server received at=%q, but the carrier held %q", got, nativeValue) } default: t.Fatal("the form never reached the server") } }