| 1 | //go:build browser |
| 2 | |
| 3 | // The enhanced fields in a browser: the select and date journeys, the |
| 4 | // Enter that must not submit, the field row's geometry, the calendar |
| 5 | // overlay, the native picker's button and the time field's clock. The |
| 6 | // B half of rastrillo's ui/browser_test.go at the seed commit, each page read |
| 7 | // through fixture so the page it drives is the page the rastrillo drive |
| 8 | // drove; the A half is menus_browser_test.go. |
| 9 | // |
| 10 | // These drives used to carry a KNOWN LIMITATION saying they were |
| 11 | // timing-sensitive under machine load — 1 failure in 4 on a busy box, |
| 12 | // 0-for-5 on GitHub's runner, and so excluded from CI (issue #86). It |
| 13 | // was not a limitation and it was not timing. It was a bug, and load |
| 14 | // only decided who noticed. |
| 15 | // |
| 16 | // The Enter that commits the select's choice was ALSO submitting the |
| 17 | // form, on every run, on every machine. select.js refused the keydown |
| 18 | // and that is enough for a key a person presses, but a key CDP |
| 19 | // synthesises arrives as two independent events and the second one |
| 20 | // carried the \r of implicit form submission with the refusal already |
| 21 | // forgotten. So every run raced: the mirror poll below against a |
| 22 | // navigation that had already started. An idle laptop won that race |
| 23 | // and called it green; a loaded runner lost it and called it a flake. |
| 24 | // select.js now refuses the keypress too, the way datetime.js always |
| 25 | // did, which is why the drives are back in the browser job whole. |
| 26 | // |
| 27 | // The drives still deliver keys through chromedp's three-event |
| 28 | // encoding rather than a friendlier one, on purpose: that shape is |
| 29 | // what broke, so it is the shape worth driving. |
| 30 | // TestEnterCommitsWithoutSubmittingTheForm asserts the invariant |
| 31 | // directly; a failure at a deadline here now means something else. |
| 32 | // |
| 33 | // One test per enhancement, deliberately: a browser drive is |
| 34 | // expensive, so each drives a whole journey — render, enhance, type, |
| 35 | // keyboard-commit, mirror, submit — and asserts the server received the |
| 36 | // value. Everything cheaper lives in the untagged select, datetime and |
| 37 | // scripts tests. |
| 38 | package check |
| 39 | |
| 40 | import ( |
| 41 | "context" |
| 42 | "fmt" |
| 43 | "net/http" |
| 44 | "regexp" |
| 45 | "strings" |
| 46 | "testing" |
| 47 | "time" |
| 48 | |
| 49 | "github.com/chromedp/cdproto/runtime" |
| 50 | "github.com/chromedp/chromedp" |
| 51 | "github.com/chromedp/chromedp/kb" |
| 52 | |
| 53 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 54 | ) |
| 55 | |
| 56 | // page builds the handler serving one form carrying an enhanced |
| 57 | // field-select of optionCount options, the real select.js, tokens.css |
| 58 | // and theme, and records what a submit delivers. The seeded page is |
| 59 | // the forty-option one; any other count is not seeded. |
| 60 | func page(t *testing.T, optionCount int) (http.Handler, chan string) { |
| 61 | t.Helper() |
| 62 | got := make(chan string, 1) |
| 63 | mux := sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "page", fmt.Sprint(optionCount))}) |
| 64 | received(mux, "/submit", "author", got, http.StatusOK) |
| 65 | return mux, got |
| 66 | } |
| 67 | |
| 68 | // TestEnhancedSelectDrivesTheWholeJourney is the one browser test. |
| 69 | // |
| 70 | // Bug classes it exists to catch — each renders perfectly and says |
| 71 | // nothing wrong: |
| 72 | // |
| 73 | // - the combobox never mirrors back, so the form submits the old value |
| 74 | // while the screen shows the new one; |
| 75 | // - the native select is removed rather than hidden, so the form |
| 76 | // submits nothing at all; |
| 77 | // - the label is left pointing at the hidden select, leaving the |
| 78 | // control the user types into with no accessible name; |
| 79 | // - a JS error takes the enhancement down and the page still looks fine. |
| 80 | func TestEnhancedSelectDrivesTheWholeJourney(t *testing.T) { |
| 81 | mux, submitted := page(t, 40) |
| 82 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 83 | |
| 84 | // A healthy run takes well under a second on an idle machine, but |
| 85 | // this is wall-clock against a real browser: on a loaded box it is |
| 86 | // slower by orders of magnitude, and a budget tuned to the idle case |
| 87 | // fails for no reason. 60s was that mistake, tuned on a quiet dev |
| 88 | // machine. The budget exists so a hang fails as itself, not to race |
| 89 | // a busy runner's clock — 180s still fails far faster than Go's |
| 90 | // default test timeout, so a genuine regression surfaces as a |
| 91 | // deadline rather than a hung suite. |
| 92 | ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) |
| 93 | defer cancelTimeout() |
| 94 | |
| 95 | var ( |
| 96 | comboCount, nativeCount, optionsShown int |
| 97 | labelFor, nativeValue string |
| 98 | filterText string |
| 99 | nativeHidden bool |
| 100 | ) |
| 101 | |
| 102 | // A bare "context deadline exceeded" tells whoever hits this in CI |
| 103 | // nothing at all. Record the last step that completed, and report it |
| 104 | // with the state gathered so far. |
| 105 | reached := "start" |
| 106 | at := func(name string) chromedp.Action { |
| 107 | return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) |
| 108 | } |
| 109 | if err := chromedp.Run(ctx, |
| 110 | chromedp.Navigate(rig.Origin+"/"), at("navigated"), |
| 111 | chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), |
| 112 | at("combobox-visible"), |
| 113 | // The enhancement happened; the native control survived it. |
| 114 | chromedp.Evaluate(`document.querySelectorAll('input[role="combobox"]').length`, &comboCount), |
| 115 | chromedp.Evaluate(`document.querySelectorAll('select[name="author"]').length`, &nativeCount), |
| 116 | // Hidden, not removed. sr-only leaves a ~1px box; anything wider |
| 117 | // means the select is still taking real layout space. |
| 118 | chromedp.Evaluate(`(document.querySelector('select[name="author"]')?.getBoundingClientRect().width ?? 999) < 4`, &nativeHidden), |
| 119 | // The label must name the control the user actually types into. |
| 120 | chromedp.Evaluate(`document.querySelector('label')?.getAttribute('for') ?? ''`, &labelFor), |
| 121 | |
| 122 | // Every probe above is null-safe on purpose: a missing node should |
| 123 | // reach the assertions as an empty value, so the failure names the |
| 124 | // broken invariant instead of surfacing a chromedp node error. |
| 125 | // |
| 126 | // Filter, then pick with the keyboard only: a mouse-only |
| 127 | // combobox is a broken one. |
| 128 | chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), |
| 129 | chromedp.SendKeys(`input[role="combobox"]`, "Option 12", chromedp.ByQuery), at("typed-filter"), |
| 130 | // A burst of keystrokes inside one frame is searched once more |
| 131 | // before that frame is drawn (select.js coalesces), so the count is |
| 132 | // read after a frame, as a person would see it. |
| 133 | chromedp.Evaluate(`new Promise(function (r) { requestAnimationFrame(function () { setTimeout(r, 0); }); })`, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), |
| 134 | chromedp.Evaluate(`document.querySelectorAll('[role="option"]:not([style*="none"])').length`, &optionsShown), |
| 135 | chromedp.Evaluate(`document.querySelector('input[role="combobox"]')?.value ?? ''`, &filterText), |
| 136 | // Synchronise on observable state rather than assuming a keystroke |
| 137 | // landed: under load the arrow key can arrive before the filtered |
| 138 | // list is drawn, and an ArrowDown with nothing to highlight is a |
| 139 | // silent no-op the next step would inherit. Waiting for the |
| 140 | // highlight turns that into a fast, legible failure at the exact |
| 141 | // step that did not happen. |
| 142 | chromedp.WaitVisible(`[role="option"]:not([style*="none"])`, chromedp.ByQuery), at("list-drawn"), |
| 143 | // SendKeys, not KeyEvent: KeyEvent trusts ambient focus, while |
| 144 | // SendKeys focuses its target first and then delivers the same |
| 145 | // CDP key events, so the key lands where the user's would. It |
| 146 | // was reached for while chasing issue #86 and kept afterwards — |
| 147 | // aiming a keystroke is right whether or not it was ever the |
| 148 | // problem, and it was not. |
| 149 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.ArrowDown), chromedp.ByQuery), at("arrow-down"), |
| 150 | chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), at("option-highlighted"), |
| 151 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("enter"), |
| 152 | |
| 153 | // The mirror: what the form will actually submit. Polled rather |
| 154 | // than sampled, and bounded at 10s, so a commit that never |
| 155 | // happens fails fast at this step with the widget's state in the |
| 156 | // error instead of burning the whole budget on a bare deadline. |
| 157 | // This poll is also what used to hide issue #86: it was racing a |
| 158 | // navigation the Enter had already started, and winning often |
| 159 | // enough to look green. |
| 160 | chromedp.ActionFunc(func(ctx context.Context) error { |
| 161 | deadline := time.Now().Add(10 * time.Second) |
| 162 | for { |
| 163 | var v string |
| 164 | if err := chromedp.Evaluate(`document.querySelector('select[name="author"]')?.value ?? ''`, &v).Do(ctx); err != nil { |
| 165 | return err |
| 166 | } |
| 167 | if v != "" { |
| 168 | nativeValue = v |
| 169 | return nil |
| 170 | } |
| 171 | if time.Now().After(deadline) { |
| 172 | var snap string |
| 173 | _ = chromedp.Evaluate(`JSON.stringify({ |
| 174 | activeEl: (document.activeElement && (document.activeElement.tagName + "/" + (document.activeElement.getAttribute("role") || ""))) || "none", |
| 175 | hasFocus: document.hasFocus(), |
| 176 | options: document.querySelectorAll('[role="option"]').length, |
| 177 | highlighted: document.querySelectorAll('[role="option"].is-active').length, |
| 178 | listHidden: (function(l){ return l ? l.hidden : "no-list" })(document.querySelector('[role="listbox"]')), |
| 179 | inputValue: (function(i){ return i ? i.value : "no-input" })(document.querySelector('input[role="combobox"]')), |
| 180 | })`, &snap).Do(ctx) |
| 181 | return fmt.Errorf("mirror never committed within 10s of Enter; widget state: %s", snap) |
| 182 | } |
| 183 | select { |
| 184 | case <-ctx.Done(): |
| 185 | return ctx.Err() |
| 186 | case <-time.After(100 * time.Millisecond): |
| 187 | } |
| 188 | } |
| 189 | }), at("read-mirrored-value"), |
| 190 | |
| 191 | // Submit rather than Click: what this test asserts is that the |
| 192 | // form posts the mirrored value, and chromedp.Click waits for the |
| 193 | // button to be actionable — a wait that intermittently never |
| 194 | // resolved here even with the value already correctly mirrored. |
| 195 | // Submitting the form exercises the thing under test without |
| 196 | // depending on hit-testing an overlay-adjacent button. |
| 197 | chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), |
| 198 | chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), |
| 199 | ); err != nil { |
| 200 | t.Fatalf("drive failed after %q: %v\n filterText=%q optionsShown=%d nativeValue=%q labelFor=%q", |
| 201 | reached, err, filterText, optionsShown, nativeValue, labelFor) |
| 202 | } |
| 203 | |
| 204 | if comboCount != 1 { |
| 205 | t.Errorf("expected exactly one combobox, found %d", comboCount) |
| 206 | } |
| 207 | if nativeCount != 1 { |
| 208 | t.Errorf("the native select must survive enhancement, found %d", nativeCount) |
| 209 | } |
| 210 | if !nativeHidden { |
| 211 | t.Error("the native select is still visible; it should be hidden, not removed") |
| 212 | } |
| 213 | if !strings.HasSuffix(labelFor, "-combo") { |
| 214 | t.Errorf("label still points at the hidden select (for=%q): the combobox has no accessible name", labelFor) |
| 215 | } |
| 216 | // Focus must select the committed label so typing replaces it. When |
| 217 | // it does not, the filter text becomes "Option 1Option 12", nothing |
| 218 | // matches, and Enter commits the select's pre-existing default — a |
| 219 | // green test that proves nothing. Assert the text we actually typed. |
| 220 | if filterText != "Option 12" { |
| 221 | t.Errorf("filter box holds %q, want %q: focus is not replacing the committed label", filterText, "Option 12") |
| 222 | } |
| 223 | if optionsShown != 1 { |
| 224 | t.Errorf("filtering for a unique label showed %d options, want 1", optionsShown) |
| 225 | } |
| 226 | // "Option 12" is the 12th option, so value "12". Crucially NOT "1", |
| 227 | // which is what the select holds by default — an assertion that |
| 228 | // merely checked non-empty would pass on the bug this test exists for. |
| 229 | if nativeValue != "12" { |
| 230 | t.Errorf("native select holds %q, want %q — the combobox did not mirror the keyboard pick back", nativeValue, "12") |
| 231 | } |
| 232 | |
| 233 | select { |
| 234 | case v := <-submitted: |
| 235 | if v != nativeValue { |
| 236 | t.Errorf("server received author=%q, the select held %q", v, nativeValue) |
| 237 | } |
| 238 | case <-time.After(5 * time.Second): |
| 239 | t.Fatal("the form never reached the server") |
| 240 | } |
| 241 | |
| 242 | // The console check and the junk scan are the rig's screen gate |
| 243 | // now — which also reads input values and aria-labels, and knows |
| 244 | // the junk set in full: the "null" this file's in-place scan was |
| 245 | // missing arrived with the move. |
| 246 | rig.Screen("body", "after the journey") |
| 247 | } |
| 248 | |
| 249 | // datePage serves one form carrying an enhanced field-date (or |
| 250 | // field-time), the real datetime.js and calendar.js, tokens.css and |
| 251 | // theme, and records what a submit delivers under field. The seeded |
| 252 | // pages are keyed by partial; ?nocal serves the field-date page with |
| 253 | // calendar.js left off, the app that never adopted the overlay, whose |
| 254 | // date button opens the browser's own panel instead. |
| 255 | func datePage(t *testing.T, partial, field string) (http.Handler, chan string) { |
| 256 | t.Helper() |
| 257 | got := make(chan string, 1) |
| 258 | with := fixture(t, "browser_test.go", "datePage", partial) |
| 259 | without := "" |
| 260 | if partial == "field-date" { |
| 261 | without = fixture(t, "browser_test.go", "datePage", partial+"-nocal") |
| 262 | } |
| 263 | mux := sizingMux(t, map[string]string{}) |
| 264 | received(mux, "/submit", field, got, http.StatusOK) |
| 265 | mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 266 | doc := with |
| 267 | if r.URL.Query().Has("nocal") { |
| 268 | if without == "" { |
| 269 | http.NotFound(w, r) |
| 270 | return |
| 271 | } |
| 272 | doc = without |
| 273 | } |
| 274 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 275 | fmt.Fprint(w, doc) |
| 276 | }) |
| 277 | return mux, got |
| 278 | } |
| 279 | |
| 280 | // TestEnhancedDateDrivesTheWholeJourney is the date field's one browser |
| 281 | // test, on the same terms as the select drive above: a real engine, real |
| 282 | // focus, real keys. |
| 283 | // |
| 284 | // Bug classes it exists to catch — each renders perfectly and says |
| 285 | // nothing wrong: |
| 286 | // |
| 287 | // - the parser reads "tomorrow" but nothing is written back, so the |
| 288 | // form submits an empty date while the screen shows one; |
| 289 | // - the native input is removed rather than hidden, so the form |
| 290 | // submits nothing at all; |
| 291 | // - the label is left pointing at the hidden native, leaving the |
| 292 | // control the user types into with no accessible name; |
| 293 | // - a JS error takes the enhancement down and the page still looks |
| 294 | // fine (the rig's screen gate catches this one). |
| 295 | // |
| 296 | // It is the drive that was already right about Enter: datetime.js has |
| 297 | // refused the synthesised keypress from the start, which is why this |
| 298 | // one never submitted the page out from under itself the way the |
| 299 | // select drive did. See the file header, and issue #86. |
| 300 | func TestEnhancedDateDrivesTheWholeJourney(t *testing.T) { |
| 301 | mux, submitted := datePage(t, "field-date", "due") |
| 302 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 303 | |
| 304 | ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) |
| 305 | defer cancelTimeout() |
| 306 | |
| 307 | // The browser and this test share one clock, so "tomorrow" is the |
| 308 | // same day for both — except across the one midnight a run could |
| 309 | // straddle. Both readings are accepted rather than pinning the |
| 310 | // clock, which a real browser will not let a Go test do anyway. |
| 311 | before := time.Now().AddDate(0, 0, 1).Format("2006-01-02") |
| 312 | |
| 313 | var ( |
| 314 | comboCount, nativeCount int |
| 315 | labelFor, nativeValue string |
| 316 | typed string |
| 317 | nativeHidden bool |
| 318 | ) |
| 319 | |
| 320 | reached := "start" |
| 321 | at := func(name string) chromedp.Action { |
| 322 | return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) |
| 323 | } |
| 324 | if err := chromedp.Run(ctx, |
| 325 | chromedp.Navigate(rig.Origin+"/"), at("navigated"), |
| 326 | chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), |
| 327 | chromedp.Evaluate(`document.querySelectorAll('input[role="combobox"]').length`, &comboCount), |
| 328 | chromedp.Evaluate(`document.querySelectorAll('input[name="due"]').length`, &nativeCount), |
| 329 | // Hidden, not removed — and "hidden" measured the way the |
| 330 | // utility actually hides it. A replaced element keeps an |
| 331 | // intrinsic minimum size whatever inline-size says (the date |
| 332 | // input measures 6x5 in Chromium, not the select's ~1x1), so |
| 333 | // what proves it invisible is the clip and the absolute |
| 334 | // position, not a width threshold. A native left in flow fails |
| 335 | // all three. |
| 336 | chromedp.Evaluate(`(function () { |
| 337 | var el = document.querySelector('input[name="due"]'); |
| 338 | if (!el) return false; |
| 339 | var s = getComputedStyle(el); |
| 340 | return s.position === "absolute" && s.clipPath !== "none" && |
| 341 | el.getBoundingClientRect().width < 24; |
| 342 | })()`, &nativeHidden), |
| 343 | chromedp.Evaluate(`document.querySelector('label')?.getAttribute('for') ?? ''`, &labelFor), |
| 344 | |
| 345 | chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), |
| 346 | chromedp.SendKeys(`input[role="combobox"]`, "tomorrow", chromedp.ByQuery), at("typed-phrase"), |
| 347 | chromedp.Evaluate(`document.querySelector('input[role="combobox"]')?.value ?? ''`, &typed), |
| 348 | // Synchronise on the reading being armed rather than assuming |
| 349 | // the keystrokes landed: the highlighted row IS the parse, and |
| 350 | // waiting for it turns a drifted focus into a fast, legible |
| 351 | // failure at this step instead of a deadline three steps later. |
| 352 | chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), at("reading-armed"), |
| 353 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("enter"), |
| 354 | |
| 355 | chromedp.ActionFunc(func(ctx context.Context) error { |
| 356 | deadline := time.Now().Add(10 * time.Second) |
| 357 | for { |
| 358 | var v string |
| 359 | if err := chromedp.Evaluate(`document.querySelector('input[name="due"]')?.value ?? ''`, &v).Do(ctx); err != nil { |
| 360 | return err |
| 361 | } |
| 362 | if v != "" { |
| 363 | nativeValue = v |
| 364 | return nil |
| 365 | } |
| 366 | if time.Now().After(deadline) { |
| 367 | var snap string |
| 368 | _ = chromedp.Evaluate(`JSON.stringify({ |
| 369 | activeEl: (document.activeElement && (document.activeElement.tagName + "/" + (document.activeElement.getAttribute("role") || ""))) || "none", |
| 370 | options: document.querySelectorAll('[role="option"]').length, |
| 371 | highlighted: document.querySelectorAll('[role="option"].is-active').length, |
| 372 | inputValue: (function(i){ return i ? i.value : "no-input" })(document.querySelector('input[role="combobox"]')), |
| 373 | })`, &snap).Do(ctx) |
| 374 | return fmt.Errorf("the carrier never took a value within 10s of Enter; widget state: %s", snap) |
| 375 | } |
| 376 | select { |
| 377 | case <-ctx.Done(): |
| 378 | return ctx.Err() |
| 379 | case <-time.After(100 * time.Millisecond): |
| 380 | } |
| 381 | } |
| 382 | }), at("read-carrier-value"), |
| 383 | |
| 384 | chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), |
| 385 | chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), |
| 386 | ); err != nil { |
| 387 | t.Fatalf("drive failed after %q: %v\n typed=%q nativeValue=%q labelFor=%q", |
| 388 | reached, err, typed, nativeValue, labelFor) |
| 389 | } |
| 390 | |
| 391 | after := time.Now().AddDate(0, 0, 1).Format("2006-01-02") |
| 392 | |
| 393 | if comboCount != 1 { |
| 394 | t.Errorf("expected exactly one combobox, found %d", comboCount) |
| 395 | } |
| 396 | if nativeCount != 1 { |
| 397 | t.Errorf("the native date input must survive enhancement, found %d", nativeCount) |
| 398 | } |
| 399 | if !nativeHidden { |
| 400 | t.Error("the native date input is still visible; it should be clipped out of sight, not removed") |
| 401 | } |
| 402 | if !strings.HasSuffix(labelFor, "-combo") { |
| 403 | t.Errorf("label still points at the hidden native (for=%q): the combobox has no accessible name", labelFor) |
| 404 | } |
| 405 | if typed != "tomorrow" { |
| 406 | t.Errorf("the combobox holds %q, want %q: the keystrokes did not land where they were aimed", typed, "tomorrow") |
| 407 | } |
| 408 | // The whole point: a phrase typed in English became the wire value |
| 409 | // the server parses. Crucially NOT today's date, which is what an |
| 410 | // unparsed reading committing the first quick pick would leave. |
| 411 | if nativeValue != before && nativeValue != after { |
| 412 | t.Errorf("the carrier holds %q, want tomorrow (%q or %q)", nativeValue, before, after) |
| 413 | } |
| 414 | |
| 415 | select { |
| 416 | case v := <-submitted: |
| 417 | if v != nativeValue { |
| 418 | t.Errorf("server received due=%q, the carrier held %q", v, nativeValue) |
| 419 | } |
| 420 | case <-time.After(5 * time.Second): |
| 421 | t.Fatal("the form never reached the server") |
| 422 | } |
| 423 | |
| 424 | rig.Screen("body", "after the date journey") |
| 425 | } |
| 426 | |
| 427 | // TestEnterCommitsWithoutSubmittingTheForm is the regression test for |
| 428 | // issue #86, and it is the only test in this file that asserts what |
| 429 | // must NOT happen. |
| 430 | // |
| 431 | // Both comboboxes make the same promise: Enter picks the highlighted |
| 432 | // row and stops there. It must never also submit the form, because a |
| 433 | // submit racing a commit posts whatever the control held BEFORE the |
| 434 | // user chose — the choice is thrown away and the page moves on as if |
| 435 | // it had been made. |
| 436 | // |
| 437 | // Keeping that promise takes two refusals, not one. The keydown |
| 438 | // handler's preventDefault is enough for a key a person presses: the |
| 439 | // engine derives the keypress from the keydown it just cancelled, so |
| 440 | // there is no keypress left to submit anything. It is NOT enough for a |
| 441 | // synthesised key. CDP — and so chromedp, playwright's raw protocol, |
| 442 | // and every browser drive built on either — can deliver the keydown |
| 443 | // and the character as two independent events, and the second arrives |
| 444 | // with no memory of the first one's refusal: an uncancelled keypress |
| 445 | // carrying \r, which is exactly what implicit form submission listens |
| 446 | // for. Both widgets therefore refuse the keypress as well. |
| 447 | // |
| 448 | // This test drives Enter through chromedp's three-event encoding on |
| 449 | // purpose, because that is the shape that breaks. It is a real |
| 450 | // assertion and not a test-harness quirk: before the guard went into |
| 451 | // select.js the drive above fired a submit on EVERY run, on every |
| 452 | // machine, and passed only because reading the mirror usually beat the |
| 453 | // navigation — a race it lost five times out of five on GitHub's |
| 454 | // runner and about one run in seven on a box under load. |
| 455 | // |
| 456 | // The submit spy cancels what it counts, so a regression fails here |
| 457 | // with a count instead of taking the page out from under the next |
| 458 | // step. |
| 459 | func TestEnterCommitsWithoutSubmittingTheForm(t *testing.T) { |
| 460 | const spy = `window.__submits = 0; |
| 461 | document.addEventListener('submit', function (e) { e.preventDefault(); window.__submits++; }, true); |
| 462 | true` |
| 463 | |
| 464 | for _, tc := range []struct { |
| 465 | name string |
| 466 | page func(*testing.T) (http.Handler, chan string) |
| 467 | typed string |
| 468 | mirror string |
| 469 | wantVal func(string) bool |
| 470 | }{ |
| 471 | { |
| 472 | name: "select", |
| 473 | page: func(t *testing.T) (http.Handler, chan string) { return page(t, 40) }, |
| 474 | typed: "Option 12", |
| 475 | mirror: `document.querySelector('select[name="author"]')?.value ?? ''`, |
| 476 | // "Option 12" is the 12th option, so value "12" — not "1", |
| 477 | // which is what the select holds by default. |
| 478 | wantVal: func(v string) bool { return v == "12" }, |
| 479 | }, |
| 480 | { |
| 481 | name: "date", |
| 482 | page: func(t *testing.T) (http.Handler, chan string) { return datePage(t, "field-date", "due") }, |
| 483 | typed: "tomorrow", |
| 484 | mirror: `document.querySelector('input[name="due"]')?.value ?? ''`, |
| 485 | wantVal: func(v string) bool { return v != "" }, |
| 486 | }, |
| 487 | } { |
| 488 | t.Run(tc.name, func(t *testing.T) { |
| 489 | mux, _ := tc.page(t) |
| 490 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 491 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 492 | defer cancel() |
| 493 | |
| 494 | var ( |
| 495 | submits int |
| 496 | value string |
| 497 | ) |
| 498 | if err := chromedp.Run(ctx, |
| 499 | chromedp.Navigate(rig.Origin+"/"), |
| 500 | chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), |
| 501 | chromedp.Evaluate(spy, nil), |
| 502 | chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), |
| 503 | chromedp.SendKeys(`input[role="combobox"]`, tc.typed, chromedp.ByQuery), |
| 504 | chromedp.WaitVisible(`[role="option"]:not([style*="none"])`, chromedp.ByQuery), |
| 505 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.ArrowDown), chromedp.ByQuery), |
| 506 | chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), |
| 507 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), |
| 508 | chromedp.Evaluate(`window.__submits`, &submits), |
| 509 | chromedp.Evaluate(tc.mirror, &value), |
| 510 | ); err != nil { |
| 511 | t.Fatalf("drive failed: %v", err) |
| 512 | } |
| 513 | |
| 514 | if submits != 0 { |
| 515 | t.Errorf("Enter submitted the form %d time(s); it must commit the highlighted row and stop", submits) |
| 516 | } |
| 517 | // Without this the test would pass on a widget that ignores |
| 518 | // Enter entirely, which keeps the letter of the promise and |
| 519 | // breaks the point of it. |
| 520 | if !tc.wantVal(value) { |
| 521 | t.Errorf("Enter did not commit: the native control holds %q", value) |
| 522 | } |
| 523 | }) |
| 524 | } |
| 525 | } |
| 526 | |
| 527 | // ── The field row ──────────────────────────────────────────────────── |
| 528 | |
| 529 | // fieldRowPage serves the seeded field-row page: a date range with an |
| 530 | // error on its end, a grow/short pair, a pair with an error on both |
| 531 | // halves (one message far longer than the other), and a labelled field |
| 532 | // beside one with no label, with datetime.js and calendar.js. Every |
| 533 | // bug this page exists for was in the interaction between the real |
| 534 | // partials, the real stylesheet and the real enhancement. |
| 535 | // |
| 536 | // dir rides the query string rather than a second handler: the two |
| 537 | // passes must differ in exactly one attribute, or an RTL failure is not |
| 538 | // evidence about direction. |
| 539 | func fieldRowPage(t *testing.T) http.Handler { |
| 540 | t.Helper() |
| 541 | pages := map[string]string{ |
| 542 | "ltr": fixture(t, "browser_test.go", "fieldRowPage", "ltr"), |
| 543 | "rtl": fixture(t, "browser_test.go", "fieldRowPage", "rtl"), |
| 544 | } |
| 545 | mux := sizingMux(t, map[string]string{}) |
| 546 | mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { |
| 547 | dir := "ltr" |
| 548 | if r.URL.Query().Get("dir") == "rtl" { |
| 549 | dir = "rtl" |
| 550 | } |
| 551 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 552 | fmt.Fprint(w, pages[dir]) |
| 553 | }) |
| 554 | return mux |
| 555 | } |
| 556 | |
| 557 | // geometry is one element's border box, as the browser measured it. |
| 558 | type geometry struct { |
| 559 | Top float64 `json:"top"` |
| 560 | Left float64 `json:"left"` |
| 561 | Right float64 `json:"right"` |
| 562 | Bottom float64 `json:"bottom"` |
| 563 | Width float64 `json:"width"` |
| 564 | } |
| 565 | |
| 566 | // rowGeometry is everything the assertions below need, read in one |
| 567 | // evaluation so every rect comes from the same layout. |
| 568 | type rowGeometry struct { |
| 569 | FromControl geometry `json:"fromControl"` |
| 570 | ToControl geometry `json:"toControl"` |
| 571 | FromField geometry `json:"fromField"` |
| 572 | ToField geometry `json:"toField"` |
| 573 | Error geometry `json:"error"` |
| 574 | FromPick geometry `json:"fromPick"` |
| 575 | City geometry `json:"city"` |
| 576 | CityField geometry `json:"cityField"` |
| 577 | Zip geometry `json:"zip"` |
| 578 | ZipField geometry `json:"zipField"` |
| 579 | BothOne geometry `json:"bothOne"` |
| 580 | BothTwo geometry `json:"bothTwo"` |
| 581 | BothOneMsg geometry `json:"bothOneMsg"` |
| 582 | BothTwoMsg geometry `json:"bothTwoMsg"` |
| 583 | BothOneFld geometry `json:"bothOneFld"` |
| 584 | BothTwoFld geometry `json:"bothTwoFld"` |
| 585 | Labelled geometry `json:"labelled"` |
| 586 | Unlabelled geometry `json:"unlabelled"` |
| 587 | Dir string `json:"dir"` |
| 588 | RootFont float64 `json:"rootFont"` |
| 589 | } |
| 590 | |
| 591 | const rowGeometryJS = `(function () { |
| 592 | function box(el) { |
| 593 | var r = el.getBoundingClientRect(); |
| 594 | return {top: r.top, left: r.left, right: r.right, bottom: r.bottom, width: r.width}; |
| 595 | } |
| 596 | function byId(id) { return box(document.getElementById(id)); } |
| 597 | // The control a person sees: the enhancement's combobox where it ran, |
| 598 | // the native input where it did not. |
| 599 | function control(name) { |
| 600 | var native = document.getElementsByName(name)[0]; |
| 601 | var wrap = native.closest("[rst-dtp]"); |
| 602 | return wrap ? wrap.querySelector("[rst-dtp-input]") : native; |
| 603 | } |
| 604 | function field(name) { return document.getElementsByName(name)[0].closest("[rst-field]"); } |
| 605 | var from = control("dr_from"), to = control("dr_to"); |
| 606 | return { |
| 607 | fromControl: box(from), toControl: box(to), |
| 608 | fromField: box(field("dr_from")), toField: box(field("dr_to")), |
| 609 | error: byId("dr_to-error"), |
| 610 | fromPick: box(from.closest("[rst-dtp]").querySelector("[rst-dtp-pick]")), |
| 611 | city: byId("city"), cityField: box(field("city")), |
| 612 | zip: byId("zip"), zipField: box(field("zip")), |
| 613 | bothOne: byId("b_one"), bothTwo: byId("b_two"), |
| 614 | bothOneMsg: byId("b_one-error"), bothTwoMsg: byId("b_two-error"), |
| 615 | bothOneFld: box(field("b_one")), bothTwoFld: box(field("b_two")), |
| 616 | labelled: byId("n_one"), unlabelled: byId("n_two"), |
| 617 | dir: document.documentElement.getAttribute("dir") || "ltr", |
| 618 | rootFont: parseFloat(getComputedStyle(document.documentElement).fontSize) |
| 619 | }; |
| 620 | })()` |
| 621 | |
| 622 | // One device pixel of slack: sub-pixel layout is legitimate, a |
| 623 | // misaligned row is not. |
| 624 | const geometrySlack = 1.0 |
| 625 | |
| 626 | func absf(f float64) float64 { |
| 627 | if f < 0 { |
| 628 | return -f |
| 629 | } |
| 630 | return f |
| 631 | } |
| 632 | |
| 633 | // overlaps reports whether two border boxes intersect by more than the |
| 634 | // slack — the literal shape of the bug from the screenshot, a message |
| 635 | // printed across the control beside it. |
| 636 | func overlaps(a, b geometry) bool { |
| 637 | return a.Left < b.Right-geometrySlack && a.Right > b.Left+geometrySlack && |
| 638 | a.Top < b.Bottom-geometrySlack && a.Bottom > b.Top+geometrySlack |
| 639 | } |
| 640 | |
| 641 | // within reports whether the inner box stays inside the outer one on the |
| 642 | // inline axis. |
| 643 | func within(inner, outer geometry) bool { |
| 644 | return inner.Left >= outer.Left-geometrySlack && inner.Right <= outer.Right+geometrySlack |
| 645 | } |
| 646 | |
| 647 | // assertRowGeometry is every invariant [rst-field-row] now owes, checked |
| 648 | // against one layout. It is called once per writing mode, because the |
| 649 | // picker button's placement and the row's own inline axis are the two |
| 650 | // things a logical-property mistake gets wrong in exactly one of them. |
| 651 | func assertRowGeometry(t *testing.T, g rowGeometry) { |
| 652 | t.Helper() |
| 653 | rtl := g.Dir == "rtl" |
| 654 | |
| 655 | // 1. The row aligns by the control, whatever else the fields carry. |
| 656 | for _, pair := range []struct { |
| 657 | what string |
| 658 | a, b geometry |
| 659 | }{ |
| 660 | {"the date range (error on the end)", g.FromControl, g.ToControl}, |
| 661 | {"the pair with an error on both halves", g.BothOne, g.BothTwo}, |
| 662 | {"a labelled field beside an unlabelled one", g.Labelled, g.Unlabelled}, |
| 663 | } { |
| 664 | if d := absf(pair.a.Top - pair.b.Top); d > geometrySlack { |
| 665 | 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", |
| 666 | pair.what, d, pair.a.Top, pair.b.Top) |
| 667 | } |
| 668 | } |
| 669 | |
| 670 | // 2. A message stays in its own column and off its neighbour. |
| 671 | for _, m := range []struct { |
| 672 | what string |
| 673 | msg, neighbour, field geometry |
| 674 | }{ |
| 675 | {"the To field's error", g.Error, g.FromControl, g.ToField}, |
| 676 | {"the long error on the first half", g.BothOneMsg, g.BothTwo, g.BothOneFld}, |
| 677 | {"the short error on the second half", g.BothTwoMsg, g.BothOne, g.BothTwoFld}, |
| 678 | } { |
| 679 | if overlaps(m.msg, m.neighbour) { |
| 680 | t.Errorf("%s overlaps the control beside it: message %+v, control %+v", m.what, m.msg, m.neighbour) |
| 681 | } |
| 682 | if !within(m.msg, m.field) { |
| 683 | t.Errorf("%s escapes its own column: message [%.1f, %.1f], field [%.1f, %.1f]", |
| 684 | m.what, m.msg.Left, m.msg.Right, m.field.Left, m.field.Right) |
| 685 | } |
| 686 | } |
| 687 | // A long message must not have bought its column extra width at its |
| 688 | // neighbour's expense — that is what contain:inline-size is for. |
| 689 | if d := absf(g.BothOneFld.Width - g.BothTwoFld.Width); d > geometrySlack { |
| 690 | 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", |
| 691 | g.BothOneFld.Width, g.BothTwoFld.Width, d) |
| 692 | } |
| 693 | |
| 694 | // 3. No control outgrows the field that holds it — the box-sizing |
| 695 | // bug, stated as the invariant it broke. |
| 696 | for _, c := range []struct { |
| 697 | name string |
| 698 | control, field geometry |
| 699 | }{ |
| 700 | {"From", g.FromControl, g.FromField}, |
| 701 | {"To", g.ToControl, g.ToField}, |
| 702 | {"City", g.City, g.CityField}, |
| 703 | {"ZIP", g.Zip, g.ZipField}, |
| 704 | } { |
| 705 | if !within(c.control, c.field) { |
| 706 | t.Errorf("the %s control overflows its field: control [%.1f, %.1f], field [%.1f, %.1f] — width:100%% is being measured as a content box", |
| 707 | c.name, c.control.Left, c.control.Right, c.field.Left, c.field.Right) |
| 708 | } |
| 709 | } |
| 710 | if overlaps(g.City, g.ZipField) { |
| 711 | t.Errorf("the grown City control runs into the ZIP field: City [%.1f, %.1f], ZIP field [%.1f, %.1f]", |
| 712 | g.City.Left, g.City.Right, g.ZipField.Left, g.ZipField.Right) |
| 713 | } |
| 714 | |
| 715 | // 4. The picker sits at the control's INLINE end, inside it, centred |
| 716 | // on it. Which physical edge that is flips with the writing mode, |
| 717 | // which is the whole point of running this twice. |
| 718 | inset := 0.35 * g.RootFont |
| 719 | gotEdge, wantEdge, edge := g.FromPick.Right, g.FromControl.Right-inset, "right" |
| 720 | if rtl { |
| 721 | gotEdge, wantEdge, edge = g.FromPick.Left, g.FromControl.Left+inset, "left" |
| 722 | } |
| 723 | if absf(gotEdge-wantEdge) > 2 { |
| 724 | 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])", |
| 725 | g.Dir, edge, gotEdge, wantEdge, g.FromControl.Left, g.FromControl.Right) |
| 726 | } |
| 727 | if !within(g.FromPick, g.FromControl) || g.FromPick.Top < g.FromControl.Top || g.FromPick.Bottom > g.FromControl.Bottom { |
| 728 | 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) |
| 729 | } |
| 730 | if top, bottom := g.FromPick.Top-g.FromControl.Top, g.FromControl.Bottom-g.FromPick.Bottom; absf(top-bottom) > geometrySlack { |
| 731 | t.Errorf("dir=%s: the picker button is not centred on the control: %.1f above, %.1f below", g.Dir, top, bottom) |
| 732 | } |
| 733 | |
| 734 | // 5. Short is compact, not crushed: 8rem is what tokens.css declares, |
| 735 | // and the point of declaring it is that a row cannot take it away. |
| 736 | if want := 8 * g.RootFont; g.Zip.Width < want-geometrySlack { |
| 737 | t.Errorf("the short field lost its width in the row: %.1f, wanted at least %.1f", g.Zip.Width, want) |
| 738 | } |
| 739 | if g.City.Width <= g.Zip.Width { |
| 740 | t.Errorf("rst-grow did not grow: City %.1f, ZIP %.1f", g.City.Width, g.Zip.Width) |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | // TestFieldRowGeometryHoldsUnderAnError pins the layout contract of |
| 745 | // [rst-field-row] with a real engine doing the layout, because none of |
| 746 | // these bugs is visible in the markup — every one of them is a used |
| 747 | // value the browser computed. |
| 748 | // |
| 749 | // The bug classes it exists to catch, all four shipped at once and all |
| 750 | // four looked fine in the template: |
| 751 | // |
| 752 | // - the row bottom-aligned its fields, so a field carrying an error |
| 753 | // lifted its control clear of its neighbour's and dropped the error |
| 754 | // line across the field beside it; |
| 755 | // - [rst-input] sized width:100% as a content box, so every input |
| 756 | // overflowed its own column by its padding and borders and ran |
| 757 | // under the field next to it; |
| 758 | // - the picker button, anchored to a wrapper the input had outgrown, |
| 759 | // landed short of the control's visible end — inside the text, not |
| 760 | // at the edge; |
| 761 | // - the short field shrank to whatever was left over. |
| 762 | // |
| 763 | // And the three mechanisms the fix introduced, which are load-bearing |
| 764 | // and would otherwise be proven by nothing: the reserved label line |
| 765 | // under a field with no label, contain:inline-size keeping a long |
| 766 | // message out of its column's width, and the picker's placement in a |
| 767 | // right-to-left page — the one thing a logical-property mistake gets |
| 768 | // wrong in exactly one writing mode. |
| 769 | // |
| 770 | // Cheap by browser-drive standards: two navigations, one evaluation |
| 771 | // each, no keyboard input, so it does not share the keystroke |
| 772 | // flakiness the two journey drives above document. |
| 773 | func TestFieldRowGeometryHoldsUnderAnError(t *testing.T) { |
| 774 | rig := harness.New(t, func(string) http.Handler { return fieldRowPage(t) }) |
| 775 | |
| 776 | ctx, cancelTimeout := context.WithTimeout(rig.Context(), 120*time.Second) |
| 777 | defer cancelTimeout() |
| 778 | |
| 779 | for _, dir := range []string{"ltr", "rtl"} { |
| 780 | t.Run(dir, func(t *testing.T) { |
| 781 | var g rowGeometry |
| 782 | if err := chromedp.Run(ctx, |
| 783 | chromedp.Navigate(rig.Origin+"/?dir="+dir), |
| 784 | chromedp.WaitVisible("[rst-dtp-pick]", chromedp.ByQuery), |
| 785 | chromedp.Evaluate(rowGeometryJS, &g), |
| 786 | ); err != nil { |
| 787 | t.Fatalf("dir=%s: reading the row's geometry: %v", dir, err) |
| 788 | } |
| 789 | if g.Dir != dir { |
| 790 | t.Fatalf("asked for dir=%s, the document reports %q — the two passes are not differing in what they claim to", dir, g.Dir) |
| 791 | } |
| 792 | assertRowGeometry(t, g) |
| 793 | }) |
| 794 | } |
| 795 | |
| 796 | rig.Screen("body", "the field rows") |
| 797 | } |
| 798 | |
| 799 | // ── The calendar overlay, the native picker and the clock ──────────── |
| 800 | |
| 801 | // TestCalendarOverlayDrivesTheWholeJourney is the overlay's browser |
| 802 | // test, on the same terms as the journeys above: a real engine, real |
| 803 | // focus, real keys, one drive covering a whole journey. |
| 804 | // |
| 805 | // It exists because every bug class below renders perfectly and says |
| 806 | // nothing wrong — which is exactly how the thing it replaced shipped. |
| 807 | // The old button called native.showPicker(): the panel it opened |
| 808 | // belonged to the browser, and because that panel does not move focus, |
| 809 | // the guard on the change listener swallowed the pick. The field looked |
| 810 | // like it had simply ignored the date somebody had just chosen. |
| 811 | // |
| 812 | // - the button opens nothing, or opens the browser's panel again, |
| 813 | // because window.fiestaCalendar was not found; |
| 814 | // - a day is clicked and the grid closes, but the carrier never takes |
| 815 | // the value, so the form posts an empty date over a filled-in |
| 816 | // screen; |
| 817 | // - the grid draws the right days under the wrong headings, or off by |
| 818 | // a week, so every date is a weekday out; |
| 819 | // - the words and the grid drift apart: typing moves neither, or |
| 820 | // moves the grid without moving what Enter would commit; |
| 821 | // - the grid takes no keyboard at all, which is the difference |
| 822 | // between a date picker and a date picker for people with mice. |
| 823 | // |
| 824 | // The month is pinned by typing a date rather than by pinning a clock, |
| 825 | // which a real browser will not let a Go test do: "25 dec 2027" is a |
| 826 | // day the grid can be asked for by name and asserted on exactly. |
| 827 | func TestCalendarOverlayDrivesTheWholeJourney(t *testing.T) { |
| 828 | mux, submitted := datePage(t, "field-date", "due") |
| 829 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 830 | |
| 831 | ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) |
| 832 | defer cancelTimeout() |
| 833 | |
| 834 | var ( |
| 835 | hasPanel bool |
| 836 | calRole, calLabel string |
| 837 | headCount, dayCount, rowCount int |
| 838 | headings, previewDay, titleOne string |
| 839 | titleTwo, afterKeys string |
| 840 | selectedDay, ariaExpanded string |
| 841 | nativeValue, comboText string |
| 842 | gridClosed, disabledCount int |
| 843 | firstColumnAgrees bool |
| 844 | ) |
| 845 | |
| 846 | reached := "start" |
| 847 | at := func(name string) chromedp.Action { |
| 848 | return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) |
| 849 | } |
| 850 | |
| 851 | if err := chromedp.Run(ctx, |
| 852 | chromedp.Navigate(rig.Origin+"/"), at("navigated"), |
| 853 | chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), |
| 854 | |
| 855 | // The button is there, and it is the overlay's button rather |
| 856 | // than a fallback to the browser's own panel. |
| 857 | chromedp.Evaluate(`!!document.querySelector('[rst-dtp-pick]')`, &hasPanel), |
| 858 | chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-haspopup') ?? ''`, &calRole), |
| 859 | |
| 860 | // Type a date first, so the month on screen is one this test |
| 861 | // can name. The grid must open on what the words already say, |
| 862 | // not on this month. |
| 863 | chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), |
| 864 | chromedp.SendKeys(`input[role="combobox"]`, "25 dec 2027", chromedp.ByQuery), at("typed-date"), |
| 865 | chromedp.WaitVisible(`[role="option"].is-active`, chromedp.ByQuery), at("reading-armed"), |
| 866 | |
| 867 | chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), at("pressed-calendar-button"), |
| 868 | chromedp.WaitVisible(`[rst-cal] [role="gridcell"]`, chromedp.ByQuery), at("grid-drawn"), |
| 869 | |
| 870 | chromedp.Evaluate(`document.querySelector('[rst-cal]')?.getAttribute('role') ?? ''`, &calLabel), |
| 871 | chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-expanded') ?? ''`, &ariaExpanded), |
| 872 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal-grid] th[role="columnheader"]').length`, &headCount), |
| 873 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal] [role="gridcell"]').length`, &dayCount), |
| 874 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal] tbody tr[role="row"]').length`, &rowCount), |
| 875 | chromedp.Evaluate(`document.querySelector('[rst-cal-title]')?.textContent ?? ''`, &titleOne), |
| 876 | // Every heading has a spoken name as well as the two letters |
| 877 | // the eye reads: "Mo" alone is not a weekday. |
| 878 | chromedp.Evaluate(`Array.from(document.querySelectorAll('[rst-cal-grid] th')) |
| 879 | .map(th => (th.querySelector('.rst-sr-only')?.textContent ?? '')).join('|')`, &headings), |
| 880 | // The headings describe the columns they sit over. This is the |
| 881 | // off-by-one that would look completely normal and put every |
| 882 | // date on the wrong weekday: the first cell's own weekday, |
| 883 | // asked of the browser, against the first column's name. |
| 884 | chromedp.Evaluate(`(function () { |
| 885 | var td = document.querySelector('[rst-cal] tbody [role="gridcell"]'); |
| 886 | var th = document.querySelector('[rst-cal-grid] th .rst-sr-only'); |
| 887 | if (!td || !th) return false; |
| 888 | var d = new Date(td.getAttribute('data-rst-day') + 'T00:00'); |
| 889 | var lang = document.documentElement.getAttribute('lang') || undefined; |
| 890 | return new Intl.DateTimeFormat(lang, { weekday: 'long' }).format(d) === th.textContent; |
| 891 | })()`, &firstColumnAgrees), |
| 892 | // Opened on the typed date, and marked as the preview rather |
| 893 | // than as a selection: nothing has been committed yet. |
| 894 | chromedp.Evaluate(`document.querySelector('[rst-cal-day].is-preview')?.getAttribute('data-rst-day') ?? ''`, &previewDay), |
| 895 | |
| 896 | // The live half of the brief. The grid is open; changing the |
| 897 | // words moves it, with no commit and no focus change. |
| 898 | chromedp.ActionFunc(func(ctx context.Context) error { |
| 899 | return chromedp.Evaluate(`(function () { |
| 900 | var i = document.querySelector('input[role="combobox"]'); |
| 901 | i.value = '3 mar 2028'; |
| 902 | i.dispatchEvent(new Event('input', { bubbles: true })); |
| 903 | return true; |
| 904 | })()`, new(bool)).Do(ctx) |
| 905 | }), at("retyped-while-open"), |
| 906 | chromedp.Evaluate(`document.querySelector('[rst-cal-title]')?.textContent ?? ''`, &titleTwo), |
| 907 | |
| 908 | // The keyboard. Down out of the box lands on the grid; a week |
| 909 | // back from 3 March 2028 is 25 February 2028. |
| 910 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.ArrowDown), chromedp.ByQuery), at("arrowed-into-grid"), |
| 911 | // To whatever has focus, deliberately: the claim under test is |
| 912 | // that ArrowDown MOVED focus onto the grid, and re-selecting |
| 913 | // the cell first would prove it by assuming it. |
| 914 | chromedp.KeyEvent(kb.ArrowUp), at("arrowed-a-week-back"), |
| 915 | chromedp.Evaluate(`document.activeElement?.getAttribute('data-rst-day') ?? ''`, &afterKeys), |
| 916 | |
| 917 | // Enter on the focused day commits it, and the grid closes. |
| 918 | chromedp.KeyEvent(kb.Enter), at("entered-on-a-day"), |
| 919 | chromedp.ActionFunc(func(ctx context.Context) error { |
| 920 | deadline := time.Now().Add(10 * time.Second) |
| 921 | for { |
| 922 | var v string |
| 923 | if err := chromedp.Evaluate(`document.querySelector('input[name="due"]')?.value ?? ''`, &v).Do(ctx); err != nil { |
| 924 | return err |
| 925 | } |
| 926 | if v != "" { |
| 927 | nativeValue = v |
| 928 | return nil |
| 929 | } |
| 930 | if time.Now().After(deadline) { |
| 931 | return fmt.Errorf("the carrier never took a value within 10s of Enter on the grid") |
| 932 | } |
| 933 | select { |
| 934 | case <-ctx.Done(): |
| 935 | return ctx.Err() |
| 936 | case <-time.After(100 * time.Millisecond): |
| 937 | } |
| 938 | } |
| 939 | }), at("read-carrier-value"), |
| 940 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal]:not([hidden])').length`, &gridClosed), |
| 941 | chromedp.Evaluate(`document.querySelector('input[role="combobox"]')?.value ?? ''`, &comboText), |
| 942 | |
| 943 | // Reopened, the day it now holds is the SELECTED one — the |
| 944 | // committed value, said in the accessibility tree and not only |
| 945 | // in a class. |
| 946 | chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), at("reopened-calendar"), |
| 947 | chromedp.WaitVisible(`[rst-cal] [role="gridcell"]`, chromedp.ByQuery), at("grid-redrawn"), |
| 948 | chromedp.Evaluate(`document.querySelector('[rst-cal-day][aria-selected="true"]')?.getAttribute('data-rst-day') ?? ''`, &selectedDay), |
| 949 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal-day][aria-disabled="true"]').length`, &disabledCount), |
| 950 | |
| 951 | chromedp.KeyEvent(kb.Escape), at("escaped"), |
| 952 | chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), |
| 953 | chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), |
| 954 | ); err != nil { |
| 955 | t.Fatalf("drive failed after %q: %v\n title=%q preview=%q afterKeys=%q nativeValue=%q", |
| 956 | reached, err, titleOne, previewDay, afterKeys, nativeValue) |
| 957 | } |
| 958 | |
| 959 | if !hasPanel { |
| 960 | t.Fatal("no calendar button was rendered at all") |
| 961 | } |
| 962 | if calRole != "dialog" { |
| 963 | t.Errorf("the button says aria-haspopup=%q; with calendar.js on the page it opens a dialog, not %q", calRole, calRole) |
| 964 | } |
| 965 | if calLabel != "dialog" { |
| 966 | t.Errorf("the overlay carries role=%q, want dialog", calLabel) |
| 967 | } |
| 968 | if ariaExpanded != "true" { |
| 969 | t.Errorf("the button says aria-expanded=%q while its panel is open", ariaExpanded) |
| 970 | } |
| 971 | if headCount != 7 { |
| 972 | t.Errorf("the grid has %d column headers, want 7", headCount) |
| 973 | } |
| 974 | if dayCount != 42 { |
| 975 | t.Errorf("the grid has %d cells, want 42 — six weeks, so the panel keeps one height all year", dayCount) |
| 976 | } |
| 977 | if rowCount != 6 { |
| 978 | t.Errorf("the grid has %d rows, want 6", rowCount) |
| 979 | } |
| 980 | if n := len(strings.Split(headings, "|")); n != 7 || strings.Contains(headings, "||") { |
| 981 | t.Errorf("the column headings have no spoken names (%q): two letters read aloud is not a weekday", headings) |
| 982 | } |
| 983 | if !firstColumnAgrees { |
| 984 | 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") |
| 985 | } |
| 986 | if previewDay != "2027-12-25" { |
| 987 | t.Errorf("the grid opened previewing %q, want 2027-12-25 — it should open on what the words already say", previewDay) |
| 988 | } |
| 989 | if titleOne == titleTwo { |
| 990 | t.Errorf("the title stayed %q when the text changed under it: the words are not driving the grid", titleOne) |
| 991 | } |
| 992 | // A week up from 3 March 2028. Pinned exactly, because "the grid |
| 993 | // moved" and "the grid moved by a week" are different claims. |
| 994 | if afterKeys != "2028-02-25" { |
| 995 | t.Errorf("ArrowDown then ArrowUp left focus on %q, want 2028-02-25 (a week back from the previewed 3 March)", afterKeys) |
| 996 | } |
| 997 | if nativeValue != "2028-02-25" { |
| 998 | t.Errorf("the carrier holds %q, want 2028-02-25: Enter on the grid must write the focused day back", nativeValue) |
| 999 | } |
| 1000 | if gridClosed != 0 { |
| 1001 | t.Error("the grid is still open after the date was set; committing a value closes it") |
| 1002 | } |
| 1003 | if comboText == "" || strings.Contains(comboText, "2028-02-25") { |
| 1004 | t.Errorf("the box reads %q; it should show the date in the page's own words, not the wire format", comboText) |
| 1005 | } |
| 1006 | if selectedDay != "2028-02-25" { |
| 1007 | t.Errorf("reopened, the grid marks %q as selected, want 2028-02-25", selectedDay) |
| 1008 | } |
| 1009 | if disabledCount != 0 { |
| 1010 | t.Errorf("%d days are disabled on a field with no min or max", disabledCount) |
| 1011 | } |
| 1012 | |
| 1013 | select { |
| 1014 | case got := <-submitted: |
| 1015 | if got != "2028-02-25" { |
| 1016 | t.Errorf("the server received due=%q, want 2028-02-25", got) |
| 1017 | } |
| 1018 | default: |
| 1019 | t.Fatal("the form never reached the server") |
| 1020 | } |
| 1021 | |
| 1022 | rig.Screen("body", "after the calendar journey") |
| 1023 | } |
| 1024 | |
| 1025 | // Without calendar.js a date field's button hands over to the browser's |
| 1026 | // own panel, which is not ours to describe: it is not a listbox, and |
| 1027 | // nothing on the page ever learns whether it is open. The button used to |
| 1028 | // say aria-haspopup="listbox" and aria-expanded="false" there — a popup |
| 1029 | // type that was wrong, and a state that was permanently "closed" even |
| 1030 | // while the browser's panel was up. A screen reader announced a |
| 1031 | // collapsed list box that never expanded. The button there is a plain |
| 1032 | // button; only the popups datetime.js draws (the grid, the clock) carry |
| 1033 | // the popup attributes. |
| 1034 | func TestNativePickerButtonClaimsNoPopup(t *testing.T) { |
| 1035 | mux, _ := datePage(t, "field-date", "due") |
| 1036 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 1037 | |
| 1038 | ctx, cancelTimeout := context.WithTimeout(rig.Context(), 60*time.Second) |
| 1039 | defer cancelTimeout() |
| 1040 | |
| 1041 | attrs := `(function () { |
| 1042 | var b = document.querySelector('[rst-dtp-pick]'); |
| 1043 | if (!b) return 'no button'; |
| 1044 | return 'haspopup=' + b.hasAttribute('aria-haspopup') + ' expanded=' + b.hasAttribute('aria-expanded'); |
| 1045 | })()` |
| 1046 | var calendars int |
| 1047 | var before, listOpen, pressed string |
| 1048 | if err := chromedp.Run(ctx, |
| 1049 | chromedp.Navigate(rig.Origin+"/?nocal"), |
| 1050 | chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), |
| 1051 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal]').length`, &calendars), |
| 1052 | chromedp.Evaluate(attrs, &before), |
| 1053 | // The suggestions opening is the combobox's popup, not the |
| 1054 | // button's: it must not hand the button a state either. |
| 1055 | chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), |
| 1056 | chromedp.WaitVisible(`[role="option"]`, chromedp.ByQuery), |
| 1057 | chromedp.Evaluate(attrs, &listOpen), |
| 1058 | chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), |
| 1059 | chromedp.Evaluate(attrs, &pressed), |
| 1060 | ); err != nil { |
| 1061 | t.Fatal(err) |
| 1062 | } |
| 1063 | if calendars != 0 { |
| 1064 | t.Fatalf("the page built %d calendars; this test needs calendar.js off the page", calendars) |
| 1065 | } |
| 1066 | want := "haspopup=false expanded=false" |
| 1067 | for _, c := range []struct{ when, got string }{ |
| 1068 | {"on load", before}, {"with the suggestions open", listOpen}, {"after the press", pressed}, |
| 1069 | } { |
| 1070 | if c.got != want { |
| 1071 | 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) |
| 1072 | } |
| 1073 | } |
| 1074 | } |
| 1075 | |
| 1076 | // A time field has no calendar, so its button opens the clock instead: |
| 1077 | // every half hour of the day, in the field's own locale, scrolled to the |
| 1078 | // one it is already showing. Its own drive because it is its own popup — |
| 1079 | // and because the failure it guards against is the one the whole branch |
| 1080 | // began with. datetime.js used to hand a time field to showPicker() too, |
| 1081 | // and on the engines where a time input has no panel, that button was a |
| 1082 | // control that did nothing at all. |
| 1083 | func TestTimeFieldOpensAClockRatherThanACalendar(t *testing.T) { |
| 1084 | mux, submitted := datePage(t, "field-time", "at") |
| 1085 | rig := harness.New(t, func(string) http.Handler { return mux }) |
| 1086 | |
| 1087 | ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) |
| 1088 | defer cancelTimeout() |
| 1089 | |
| 1090 | var ( |
| 1091 | calendars, slots int |
| 1092 | haspopup, label string |
| 1093 | firstSlot, last string |
| 1094 | activeText string |
| 1095 | nativeValue string |
| 1096 | ) |
| 1097 | |
| 1098 | reached := "start" |
| 1099 | at := func(name string) chromedp.Action { |
| 1100 | return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) |
| 1101 | } |
| 1102 | |
| 1103 | if err := chromedp.Run(ctx, |
| 1104 | chromedp.Navigate(rig.Origin+"/"), at("navigated"), |
| 1105 | chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), |
| 1106 | |
| 1107 | chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-haspopup') ?? ''`, &haspopup), |
| 1108 | chromedp.Evaluate(`document.querySelector('[rst-dtp-pick]')?.getAttribute('aria-label') ?? ''`, &label), |
| 1109 | |
| 1110 | chromedp.Click(`[rst-dtp-pick]`, chromedp.ByQuery), at("pressed-the-button"), |
| 1111 | chromedp.WaitVisible(`[role="option"]`, chromedp.ByQuery), at("slots-drawn"), |
| 1112 | |
| 1113 | // No calendar was built for it, and the popup is the day's half |
| 1114 | // hours: twenty-four hours at two slots each. |
| 1115 | chromedp.Evaluate(`document.querySelectorAll('[rst-cal]').length`, &calendars), |
| 1116 | chromedp.Evaluate(`document.querySelectorAll('[role="option"]').length`, &slots), |
| 1117 | chromedp.Evaluate(`document.querySelector('[role="option"] [rst-dtp-label]')?.textContent ?? ''`, &firstSlot), |
| 1118 | chromedp.Evaluate(`(function () { |
| 1119 | var all = document.querySelectorAll('[role="option"] [rst-dtp-label]'); |
| 1120 | return all.length ? all[all.length - 1].textContent : ''; |
| 1121 | })()`, &last), |
| 1122 | chromedp.Evaluate(`document.querySelector('[role="option"].is-active [rst-dtp-label]')?.textContent ?? ''`, &activeText), |
| 1123 | |
| 1124 | chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("committed-a-slot"), |
| 1125 | chromedp.ActionFunc(func(ctx context.Context) error { |
| 1126 | deadline := time.Now().Add(10 * time.Second) |
| 1127 | for { |
| 1128 | var v string |
| 1129 | if err := chromedp.Evaluate(`document.querySelector('input[name="at"]')?.value ?? ''`, &v).Do(ctx); err != nil { |
| 1130 | return err |
| 1131 | } |
| 1132 | if v != "" { |
| 1133 | nativeValue = v |
| 1134 | return nil |
| 1135 | } |
| 1136 | if time.Now().After(deadline) { |
| 1137 | return fmt.Errorf("the carrier never took a value within 10s of Enter on a slot") |
| 1138 | } |
| 1139 | select { |
| 1140 | case <-ctx.Done(): |
| 1141 | return ctx.Err() |
| 1142 | case <-time.After(100 * time.Millisecond): |
| 1143 | } |
| 1144 | } |
| 1145 | }), at("read-carrier-value"), |
| 1146 | |
| 1147 | chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), |
| 1148 | chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), |
| 1149 | ); err != nil { |
| 1150 | t.Fatalf("drive failed after %q: %v\n slots=%d first=%q active=%q", reached, err, slots, firstSlot, activeText) |
| 1151 | } |
| 1152 | |
| 1153 | if haspopup != "listbox" { |
| 1154 | t.Errorf("the time field's button says aria-haspopup=%q, want listbox: it opens a list of times, not a dialog", haspopup) |
| 1155 | } |
| 1156 | if label == "" || strings.Contains(strings.ToLower(label), "calendar") { |
| 1157 | t.Errorf("the time field's button is labelled %q; a time field opens a clock", label) |
| 1158 | } |
| 1159 | if calendars != 0 { |
| 1160 | t.Errorf("%d calendar panels were built for a time field", calendars) |
| 1161 | } |
| 1162 | if slots != 48 { |
| 1163 | t.Errorf("the clock offers %d slots, want 48 — every half hour of the day", slots) |
| 1164 | } |
| 1165 | if firstSlot == "" || last == "" || firstSlot == last { |
| 1166 | t.Errorf("the slots do not span a day: first=%q last=%q", firstSlot, last) |
| 1167 | } |
| 1168 | // Something is armed, so Enter commits a time rather than nothing. |
| 1169 | if activeText == "" { |
| 1170 | t.Error("no slot is highlighted, so Enter would commit nothing") |
| 1171 | } |
| 1172 | // The carrier holds a wire clock, and one that lands on a half hour. |
| 1173 | if !regexp.MustCompile(`^\d{2}:(00|30)$`).MatchString(nativeValue) { |
| 1174 | t.Errorf("the carrier holds %q, want a HH:00 or HH:30 wire time", nativeValue) |
| 1175 | } |
| 1176 | |
| 1177 | select { |
| 1178 | case got := <-submitted: |
| 1179 | if got != nativeValue { |
| 1180 | t.Errorf("the server received at=%q, but the carrier held %q", got, nativeValue) |
| 1181 | } |
| 1182 | default: |
| 1183 | t.Fatal("the form never reached the server") |
| 1184 | } |
| 1185 | } |
| 1186 | |