rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

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.
38package check
39
40import (
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.
60func 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.
80func 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.
255func 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.
300func 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.
459func 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.
539func 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.
558type 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.
568type 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
591const 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.
624const geometrySlack = 1.0
625
626func 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.
636func 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.
643func 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.
651func 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.
773func 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.
827func 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.
1034func 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.
1083func 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