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 menu idioms, the modal panel, the busy rule and the name cell in
4// a browser: the A half of rastrillo's ui/browser_test.go at the seed commit,
5// each drive reading its page through fixture so the page it drives
6// is the page the rastrillo drive drove. The select and date journeys
7// that made up the file's other half are fields_browser_test.go.
8package check
9
10import (
11 "context"
12 "encoding/json"
13 "fmt"
14 "math"
15 "net/http"
16 "strings"
17 "testing"
18 "time"
19
20 "github.com/chromedp/cdproto/accessibility"
21 "github.com/chromedp/cdproto/cdp"
22 "github.com/chromedp/cdproto/emulation"
23 "github.com/chromedp/chromedp"
24 "github.com/chromedp/chromedp/kb"
25
26 "amadan.net/rastrillo/fiesta"
27 "amadan.net/rastrillo/fiesta/example"
28 "amadan.net/rastrillo/fiesta/internal/harness"
29)
30
31// received answers a POST on path with status, and hands the form's
32// one field (or, with field empty, the whole encoded form) to a
33// buffered channel, so a drive can check what the server got. A 204
34// goes out with no body, as the busy drive's server answered; any
35// other status carries a page that says the post was received.
36func received(mux *http.ServeMux, path, field string, got chan string, status int) {
37 mux.HandleFunc("POST "+path, func(w http.ResponseWriter, r *http.Request) {
38 _ = r.ParseForm()
39 v := r.PostForm.Encode()
40 if field != "" {
41 v = r.PostFormValue(field)
42 }
43 select {
44 case got <- v:
45 default:
46 }
47 if status == http.StatusNoContent {
48 w.WriteHeader(status)
49 return
50 }
51 w.Header().Set("Content-Type", "text/html; charset=utf-8")
52 w.WriteHeader(status)
53 fmt.Fprint(w, `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>ok</title></head><body><p id="done">received</p></body></html>`)
54 })
55}
56
57// groupPage serves one form carrying two hand-written selects: a
58// grouped one that should enhance, and a large one that says no, and
59// records what a submit delivers.
60//
61// Hand-written rather than rendered through field-select, because
62// field-select's Options are flat — the partial has no optgroup to
63// emit. Grouped selects are exactly the case an app writes by hand, so
64// that is what the drive drives.
65func groupPage(t *testing.T) (http.Handler, chan string) {
66 t.Helper()
67 got := make(chan string, 1)
68 mux := sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "groupPage", "")})
69 received(mux, "/submit", "city", got, http.StatusOK)
70 return mux, got
71}
72
73// TestGroupedSelectRendersItsGroups drives the two things a Go test
74// cannot see: that a grouped select keeps its groups on the way into
75// the combobox, and that a select saying data-rst-select="false" is
76// left alone.
77//
78// Bug classes it exists to catch — each renders perfectly and says
79// nothing wrong:
80//
81// - the mirror flattens native.options, so the headings the author
82// wrote to make a long list readable silently vanish;
83// - a search leaves the headings up over rows it has re-ranked, or
84// clearing it leaves a lifted row outside its own group;
85// - the headings join the keyboard order, so arrowing down lands on a
86// heading and Enter commits the wrong option — or nothing;
87// - the opt-out is read as a truthy attribute (it is present, after
88// all) and the select enhances anyway.
89func TestGroupedSelectRendersItsGroups(t *testing.T) {
90 mux, submitted := groupPage(t)
91 rig := harness.New(t, func(string) http.Handler { return mux })
92
93 ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second)
94 defer cancelTimeout()
95
96 var (
97 comboCount, optOutEnhanced int
98 optOutVisible bool
99 groupLabels, headings string
100 headingsAreOptions int
101 openGroups, openOptions int
102 narrowedGroups, narrowedOptions int
103 narrowedHeadings string
104 activeText, nativeValue, carrierShape string
105 restored string
106 )
107
108 reached := "start"
109 at := func(name string) chromedp.Action {
110 return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil })
111 }
112 // Poll rather than sample: the list is redrawn on every keystroke,
113 // and under load a probe fired straight after SendKeys reads the
114 // list as it was. Waiting on the observable state turns a drifted
115 // keystroke into a fast failure at the step that did not happen.
116 until := func(name, js string) chromedp.Action {
117 return chromedp.ActionFunc(func(ctx context.Context) error {
118 deadline := time.Now().Add(10 * time.Second)
119 for {
120 var ok bool
121 if err := chromedp.Evaluate(js, &ok).Do(ctx); err != nil {
122 return err
123 }
124 if ok {
125 return nil
126 }
127 if time.Now().After(deadline) {
128 var snap string
129 _ = chromedp.Evaluate(`JSON.stringify({
130 options: document.querySelectorAll('[role="option"]').length,
131 groups: document.querySelectorAll('[role="group"]').length,
132 headings: Array.from(document.querySelectorAll('[rst-select-group]')).map(function (h) { return h.textContent }),
133 inputValue: (function (i) { return i ? i.value : "no-input" })(document.querySelector('input[role="combobox"]')),
134 })`, &snap).Do(ctx)
135 return fmt.Errorf("%s never became true within 10s; widget state: %s", name, snap)
136 }
137 select {
138 case <-ctx.Done():
139 return ctx.Err()
140 case <-time.After(100 * time.Millisecond):
141 }
142 }
143 })
144 }
145
146 if err := chromedp.Run(ctx,
147 chromedp.Navigate(rig.Origin+"/"), at("navigated"),
148 chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"),
149
150 // Exactly one enhancement on a page holding two selects, both of
151 // which carry the attribute.
152 chromedp.Evaluate(`document.querySelectorAll('input[role="combobox"]').length`, &comboCount),
153 chromedp.Evaluate(`document.querySelectorAll('select[name="team"][data-rst-enhanced]').length`, &optOutEnhanced),
154 // Untouched means untouched: still a real, visible native select.
155 chromedp.Evaluate(`(document.querySelector('select[name="team"]')?.getBoundingClientRect().width ?? 0) > 40`, &optOutVisible),
156 // The grouped native survives as the carrier, groups and all.
157 chromedp.Evaluate(`(function () {
158 var s = document.querySelector('select[name="city"]');
159 if (!s) return "no-select";
160 return s.querySelectorAll('optgroup').length + "/" + s.options.length;
161 })()`, &carrierShape),
162
163 // Open it: focus draws the unfiltered list. The select is required,
164 // so its blank ("Choose a city") is a prompt and never a row.
165 chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"),
166 until("the grouped list is drawn", `document.querySelectorAll('[role="option"]:not([style*="none"])').length === 5`),
167 at("list-drawn"),
168 chromedp.Evaluate(`document.querySelectorAll('[role="group"]:not([style*="none"])').length`, &openGroups),
169 chromedp.Evaluate(`document.querySelectorAll('[role="option"]:not([style*="none"])').length`, &openOptions),
170 chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]')).map(function (g) { return g.getAttribute('aria-label') }).join(',')`, &groupLabels),
171 chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]:not([style*="none"]) [rst-select-group]')).map(function (h) { return h.textContent }).join(',')`, &headings),
172 // A heading is furniture, never a pick.
173 chromedp.Evaluate(`document.querySelectorAll('[rst-select-group][role="option"]').length`, &headingsAreOptions),
174
175 // Four arrows from nothing highlighted (an unanswered question
176 // opens with nothing): Dublin, Cork, Galway, then Madrid, the first
177 // row of the SECOND group. Were the headings in the keyboard order,
178 // the fourth would land on the "Spain" heading instead.
179 chromedp.SendKeys(`input[role="combobox"]`, strings.Repeat(string(kb.ArrowDown), 4), chromedp.ByQuery), at("arrow-down-4"),
180 until("a row is highlighted", `document.querySelectorAll('[role="option"].is-active').length === 1`),
181 at("row-highlighted"),
182 chromedp.Evaluate(`document.querySelector('[role="option"].is-active')?.textContent ?? ''`, &activeText),
183
184 // Filter to "ad": only Madrid matches. A search ranks across groups,
185 // so the headings step aside and the one row stands alone.
186 chromedp.SendKeys(`input[role="combobox"]`, "ad", chromedp.ByQuery), at("typed-ad"),
187 until("the list narrows to one", `document.querySelectorAll('[role="option"]:not([style*="none"])').length === 1`),
188 at("narrowed-to-one"),
189 chromedp.Evaluate(`document.querySelectorAll('[role="group"]:not([style*="none"])').length`, &narrowedGroups),
190 chromedp.Evaluate(`document.querySelectorAll('[role="option"]:not([style*="none"])').length`, &narrowedOptions),
191 chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]:not([style*="none"]) [rst-select-group]')).map(function (h) { return h.textContent }).join(',')`, &narrowedHeadings),
192
193 // Clear it: every row is back in its own group, in its own place,
194 // under its own heading.
195 chromedp.Evaluate(`(function () {
196 var input = document.querySelector('input[role="combobox"]');
197 input.value = '';
198 input.dispatchEvent(new Event('input', { bubbles: true }));
199 return true;
200 })()`, nil), at("cleared"),
201 until("the list is whole again", `document.querySelectorAll('[role="option"]:not([style*="none"])').length === 5`),
202 chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]:not([style*="none"])')).map(function (g) {
203 return g.getAttribute('aria-label') + ':' + Array.from(g.querySelectorAll('[role="option"]')).map(function (o) { return o.textContent }).join('|');
204 }).join(';')`, &restored),
205
206 // Filter again, to the one row, and take it.
207 chromedp.SendKeys(`input[role="combobox"]`, "ad", chromedp.ByQuery), at("typed-ad-again"),
208 until("the one row is highlighted", `(document.querySelector('[role="option"].is-active')?.textContent ?? '') === 'Madrid'`),
209
210 // The sole match commits on Enter, and mirrors onto the carrier.
211 chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("enter"),
212 until("the mirror commits", `(document.querySelector('select[name="city"]')?.value ?? '') !== ''`),
213 at("read-mirrored-value"),
214 chromedp.Evaluate(`document.querySelector('select[name="city"]')?.value ?? ''`, &nativeValue),
215
216 chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"),
217 chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"),
218 ); err != nil {
219 t.Fatalf("drive failed after %q: %v\n groupLabels=%q headings=%q activeText=%q nativeValue=%q",
220 reached, err, groupLabels, headings, activeText, nativeValue)
221 }
222
223 if comboCount != 1 {
224 t.Errorf("expected exactly one combobox, found %d", comboCount)
225 }
226 if optOutEnhanced != 0 {
227 t.Errorf(`data-rst-select="false" enhanced anyway (%d enhanced selects): the opt-out is being read as a present attribute`, optOutEnhanced)
228 }
229 if !optOutVisible {
230 t.Error(`the opted-out select is no longer a visible native select; "false" must leave it entirely alone`)
231 }
232 if carrierShape != "2/6" {
233 t.Errorf("the native carrier reads %q, want %q: enhancement must not touch the select", carrierShape, "2/6")
234 }
235 if openGroups != 2 {
236 t.Errorf("the open list holds %d ARIA groups, want 2: the optgroups were flattened away", openGroups)
237 }
238 if openOptions != 5 {
239 t.Errorf("the open list holds %d options, want 5", openOptions)
240 }
241 if groupLabels != "Ireland,Spain" {
242 t.Errorf("group aria-labels are %q, want %q: the optgroup labels did not carry over", groupLabels, "Ireland,Spain")
243 }
244 if headings != "Ireland,Spain" {
245 t.Errorf("visible group headings are %q, want %q", headings, "Ireland,Spain")
246 }
247 if headingsAreOptions != 0 {
248 t.Errorf("%d group headings carry role=option; a heading is furniture, not a pick", headingsAreOptions)
249 }
250 // Four arrows from nothing highlighted land on the first row of the
251 // second group. A heading in the keyboard order would leave the
252 // highlight on "Spain" (or on "Galway", a step short).
253 if activeText != "Madrid" {
254 t.Errorf("four ArrowDowns highlighted %q, want %q: the keyboard order is not skipping the group headings", activeText, "Madrid")
255 }
256 if narrowedGroups != 0 || narrowedOptions != 1 {
257 t.Errorf("filtering to one match left %d groups and %d options showing, want 0 and 1: a search steps the headings aside", narrowedGroups, narrowedOptions)
258 }
259 if narrowedHeadings != "" {
260 t.Errorf("while searching the visible headings read %q, want none: a heading over re-ranked rows is a lie about what is under it", narrowedHeadings)
261 }
262 if want := "Ireland:Dublin|Cork|Galway;Spain:Madrid|Barcelona"; restored != want {
263 t.Errorf("after clearing the search the groups read %q, want %q: a row a search lifted did not go back into its own group", restored, want)
264 }
265 if nativeValue != "mad" {
266 t.Errorf("the carrier holds %q, want %q — the pick inside a group did not mirror back", nativeValue, "mad")
267 }
268
269 select {
270 case v := <-submitted:
271 if v != nativeValue {
272 t.Errorf("server received city=%q, the select held %q", v, nativeValue)
273 }
274 case <-time.After(5 * time.Second):
275 t.Fatal("the form never reached the server")
276 }
277
278 rig.Screen("body", "after the grouped journey")
279}
280
281// ── The menu idioms: exclusivity, light dismiss, and the dialog panel ──
282
283// menuPage serves one page carrying every menu shape at once, plus the
284// two <details> that must stay OUT of the exclusivity group, plus the
285// real shim. Everything on it is the library's own markup: the shell
286// account dropdown from the topbar layout's sample, a row menu from the
287// list grid, a nested rst-menu-group inside the dropdown, the sidebar's
288// chrome strip and a toggle-block.
289//
290// Hand-assembled at the seed commit rather than rendered from the
291// samples because the two shell samples are whole page frames that
292// cannot both be on one page, and because the drive needs stable ids
293// to click.
294func menuPage(t *testing.T) http.Handler {
295 t.Helper()
296 // The mid-upgrade window, in the one spelling this repository no
297 // longer writes anywhere: an app that has taken the new module and
298 // the new fiesta.js and has not run `rastrillo markup` yet. Its
299 // markup is still classes; its shim is this one. If MENUS only
300 // matched attributes, this page's menu would stop light-dismissing
301 // and nothing in this repository would ever have noticed.
302 // markup-spelling: old-spelling begin — the /old-spelling fixture is
303 // markup in the spelling this repository retired, on purpose. It is
304 // what an app looks like mid-upgrade, and nothing else in the suite
305 // is written that way.
306 return sizingMux(t, map[string]string{
307 "/": fixture(t, "browser_test.go", "menuPage", ""),
308 "/old-spelling": fixture(t, "browser_test.go", "menuPage", "old-spelling"),
309 })
310 // markup-spelling: old-spelling end
311}
312
313// TestLightDismissWorksInBothSpellings is the mid-upgrade window, made
314// a test. The runbook is `rastrillo doctor --fix` and then
315// `rastrillo markup --fix`; between them an app runs this shim against
316// markup that is still classes. tokens.css pairs both spellings for
317// exactly that window, and the shim has to as well, or the upgrade path
318// we hand people breaks under them: menus that open and never close on
319// an outside click or Escape.
320//
321// The page is deliberately written in the spelling this repository no
322// longer emits anywhere, so nothing else in the suite covers it.
323func TestLightDismissWorksInBothSpellings(t *testing.T) {
324 rig := harness.New(t, func(string) http.Handler { return menuPage(t) })
325 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
326 defer cancel()
327
328 const openState = `["account","rowmenu"].` +
329 `filter(function (id) { return document.getElementById(id).open }).join(",")`
330 var opened, afterOutside, afterEsc string
331 if err := chromedp.Run(ctx,
332 chromedp.Navigate(rig.Origin+"/old-spelling"),
333 chromedp.WaitVisible(`#account-summary`, chromedp.ByQuery),
334 chromedp.Click(`#account-summary`, chromedp.ByQuery),
335 chromedp.Evaluate(openState, &opened),
336 chromedp.Click(`#elsewhere`, chromedp.ByQuery),
337 chromedp.Evaluate(openState, &afterOutside),
338 chromedp.Click(`#rowmenu-summary`, chromedp.ByQuery),
339 chromedp.KeyEvent(kb.Escape),
340 chromedp.Evaluate(openState, &afterEsc),
341 ); err != nil {
342 t.Fatalf("driving the class-spelled menus: %v", err)
343 }
344 if opened != "account" {
345 t.Fatalf("the class-spelled dropdown did not open at all (open = %q); the rest of this test proves nothing", opened)
346 }
347 if afterOutside != "" {
348 t.Errorf("after a click outside, open = %q, want none — a class-spelled menu does not light-dismiss, "+
349 "so an app between `doctor --fix` and `markup --fix` has menus that never close", afterOutside)
350 }
351 if afterEsc != "" {
352 t.Errorf("after Escape, open = %q, want none — same window, same break, by keyboard", afterEsc)
353 }
354}
355
356// openState reads which of the page's <details> are open, as one string,
357// so a failure names the whole state rather than one boolean.
358const openStateJS = `["account","filter","submenu","rowmenu","chrome"].` +
359 `filter(function (id) { return document.getElementById(id).open }).join(",")`
360
361// TestMenuExclusivityAndDropdownDismissDrive is the browser drive for
362// the two halves of the menu contract that no Go test can reach: the
363// native <details name> group, which needs a real engine implementing
364// it, and the shim's light dismiss, which needs real clicks and real
365// focus.
366//
367// Bug classes it exists to catch — every one of them renders perfectly
368// and reports nothing wrong:
369//
370// - a menu emitted with no name attribute at all, so two menus sit
371// open on top of each other;
372// - the nested rst-menu-group sharing its parent's group, so opening a
373// submenu closes the menu around it and the submenu vanishes with
374// it — the exact trap the partial docs warn about;
375// - light dismiss closing the menu the user is clicking inside, so
376// every menu item click misses;
377// - light dismiss bound per element, so a menu that arrived after load
378// never dismisses;
379// - the old sidebar chrome strip or the toggle-block swept into the
380// group or the dismiss, so the narrow-screen nav rail closes when a
381// filter opens, or the shell menu card left out of the dismiss.
382func TestMenuExclusivityAndDropdownDismissDrive(t *testing.T) {
383 rig := harness.New(t, func(string) http.Handler { return menuPage(t) })
384
385 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
386 defer cancel()
387
388 // The same last-step breadcrumb the select drive keeps: a bare
389 // deadline names nothing.
390 reached := "start"
391 at := func(name string) chromedp.Action {
392 return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil })
393 }
394 var (
395 afterAccount, afterRow, afterSubmenu string
396 afterOutside, afterReopen string
397 afterInside, afterEsc string
398 focusAfterEsc string
399 focusInSubmenu string
400 afterSubEsc, focusAfterSubEsc string
401 tblockOpenAfter bool
402 )
403
404 if err := chromedp.Run(ctx,
405 chromedp.Navigate(rig.Origin+"/"), at("navigated"),
406 chromedp.WaitVisible(`#account-summary`, chromedp.ByQuery), at("page-visible"),
407
408 // 1. Open the header dropdown.
409 chromedp.Click(`#account-summary`, chromedp.ByQuery), at("opened-account"),
410 chromedp.Evaluate(openStateJS, &afterAccount),
411
412 // 2. Open a row menu. The header dropdown must close, natively:
413 // same name, one group, no script involved.
414 chromedp.Click(`#rowmenu-summary`, chromedp.ByQuery), at("opened-rowmenu"),
415 chromedp.Evaluate(openStateJS, &afterRow),
416
417 // 3. Open the filter dropdown, then the submenu inside it. The
418 // parent MUST still be open: <details name> exclusivity is
419 // document-wide, so a shared name here would have closed it.
420 chromedp.Click(`#filter-summary`, chromedp.ByQuery), at("opened-filter"),
421 chromedp.Click(`#submenu-summary`, chromedp.ByQuery), at("opened-submenu"),
422 chromedp.Evaluate(openStateJS, &afterSubmenu),
423
424 // 4. Click somewhere that is not a menu. Every menu closes — the
425 // nested submenu included, which the <details name> group
426 // cannot do for it and must not: dismissal is not exclusivity.
427 // The chrome strip is left alone.
428 chromedp.Click(`#elsewhere`, chromedp.ByQuery), at("clicked-outside"),
429 chromedp.Evaluate(openStateJS, &afterOutside),
430
431 // 4b. Reopen the parent. The submenu must NOT come back open: a
432 // menu that remembers a state the user cannot see is the bug
433 // that made submenus join the dismiss selector in the first
434 // place.
435 chromedp.Click(`#filter-summary`, chromedp.ByQuery), at("reopened-filter"),
436 chromedp.Evaluate(openStateJS, &afterReopen),
437 chromedp.Click(`#elsewhere`, chromedp.ByQuery), at("cleared-again"),
438
439 // 5. Reopen, then click an item INSIDE the open menu. It must
440 // survive: a light dismiss that fires on its own menu's items
441 // makes every one of them unclickable.
442 chromedp.Click(`#account-summary`, chromedp.ByQuery), at("reopened-account"),
443 chromedp.Click(`#account-item`, chromedp.ByQuery), at("clicked-inside"),
444 chromedp.Evaluate(openStateJS, &afterInside),
445
446 // 6. Escape closes it and hands focus back to the summary, rather
447 // than stranding the keyboard user at the top of the document.
448 chromedp.KeyEvent(kb.Escape), at("pressed-escape"),
449 chromedp.Evaluate(openStateJS, &afterEsc),
450 chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &focusAfterEsc),
451
452 // 7. Escape with focus INSIDE an open submenu. Everything closes,
453 // and focus must land on the PARENT dropdown's summary — not
454 // the submenu's, which by then sits inside a closed parent and
455 // is not rendered, so focusing it would drop focus to <body>
456 // and strand the keyboard user at the top of the document.
457 // This is the leg that exercises the outermost-menu climb;
458 // without it that code is only ever proven by reading it.
459 chromedp.Click(`#filter-summary`, chromedp.ByQuery), at("reopened-filter-for-escape"),
460 chromedp.Click(`#submenu-summary`, chromedp.ByQuery), at("reopened-submenu"),
461 // Focus rather than click: what this leg is about is where focus
462 // goes, so it has to start from a focus this test put there on
463 // purpose rather than one a click happened to leave behind.
464 chromedp.Focus(`#submenu-item`, chromedp.ByQuery), at("focused-in-submenu"),
465 chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &focusInSubmenu),
466 chromedp.KeyEvent(kb.Escape), at("escape-from-submenu"),
467 chromedp.Evaluate(openStateJS, &afterSubEsc),
468 chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &focusAfterSubEsc),
469
470 // The toggle-block is not a <details> at all, so nothing above can
471 // have touched it — but it is the other thing the brief holds
472 // outside the group, so read it rather than assume it.
473 chromedp.Evaluate(`document.getElementById("tblock-input").checked`, &tblockOpenAfter),
474 ); err != nil {
475 t.Fatalf("drive failed after %q: %v", reached, err)
476 }
477
478 if afterAccount != "account,chrome" {
479 t.Errorf("after opening the account menu, open = %q, want %q", afterAccount, "account,chrome")
480 }
481 if afterRow != "rowmenu,chrome" {
482 t.Errorf("after opening a row menu, open = %q, want %q — the shared <details name> group did not close the header dropdown", afterRow, "rowmenu,chrome")
483 }
484 if afterSubmenu != "filter,submenu,chrome" {
485 t.Errorf("after opening the submenu, open = %q, want %q — a nested rst-menu-group sharing its parent's group closes the parent", afterSubmenu, "filter,submenu,chrome")
486 }
487 if afterOutside != "chrome" {
488 t.Errorf("after a click outside, open = %q, want %q — light dismiss must close every menu, submenus included, and leave shell chrome alone", afterOutside, "chrome")
489 }
490 if afterReopen != "filter,chrome" {
491 t.Errorf("reopening the parent gave open = %q, want %q — the submenu was left open behind its closing parent", afterReopen, "filter,chrome")
492 }
493 if afterInside != "account,chrome" {
494 t.Errorf("after clicking an item inside the open menu, open = %q, want %q — light dismiss closed the menu being used", afterInside, "account,chrome")
495 }
496 if afterEsc != "chrome" {
497 t.Errorf("after Escape, open = %q, want %q", afterEsc, "chrome")
498 }
499 if focusAfterEsc != "account-summary" {
500 t.Errorf("focus after Escape is %q, want the summary that opened the menu", focusAfterEsc)
501 }
502 // The premise first: if focus never reached the submenu, the two
503 // assertions below would pass for the wrong reason.
504 if focusInSubmenu != "submenu-item" {
505 t.Fatalf("focus before Escape is %q, want %q — this leg proves nothing unless focus starts inside the submenu", focusInSubmenu, "submenu-item")
506 }
507 if afterSubEsc != "chrome" {
508 t.Errorf("after Escape from inside the submenu, open = %q, want %q", afterSubEsc, "chrome")
509 }
510 if focusAfterSubEsc != "filter-summary" {
511 t.Errorf("focus after Escape from inside the submenu is %q, want %q — the hand-back must climb to the OUTERMOST open menu; the submenu's own summary is inside the parent that just closed, so focusing it drops focus to <body>", focusAfterSubEsc, "filter-summary")
512 }
513 if !tblockOpenAfter {
514 t.Error("the toggle-block's switch was flipped by the menu handling; it is not a menu")
515 }
516
517 // The shell menu joined light dismiss when it became a card: an
518 // outside click closes it, and Escape from a link in its tail (its content, a sibling)
519 // closes it and hands focus to its summary. The legacy chrome strip
520 // is still left alone. At 390, where the topbar has its Menu: at the
521 // default width tokens.css hides it.
522 var shellOpened, shellAfterOutside, shellAfterEsc bool
523 var shellFocus string
524 if err := chromedp.Run(ctx,
525 chromedp.EmulateViewport(390, 844),
526 chromedp.WaitVisible(`#shellmenu-summary`, chromedp.ByQuery),
527 chromedp.Click(`#shellmenu-summary`, chromedp.ByQuery),
528 chromedp.Evaluate(`document.getElementById("shellmenu").open`, &shellOpened),
529 chromedp.Click(`#elsewhere`, chromedp.ByQuery),
530 chromedp.Evaluate(`document.getElementById("shellmenu").open`, &shellAfterOutside),
531 chromedp.Click(`#shellmenu-summary`, chromedp.ByQuery),
532 chromedp.Focus(`#tail-link`, chromedp.ByQuery),
533 chromedp.KeyEvent(kb.Escape),
534 chromedp.Evaluate(`document.getElementById("shellmenu").open`, &shellAfterEsc),
535 chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &shellFocus),
536 ); err != nil {
537 t.Fatalf("driving the shell menu: %v", err)
538 }
539 // The premise: a menu that never opened is closed after anything.
540 if !shellOpened {
541 t.Fatal("the shell menu did not open on its summary; the dismiss leg below proves nothing")
542 }
543 if shellAfterOutside || shellAfterEsc || shellFocus != "shellmenu-summary" {
544 t.Errorf("shell menu: open after an outside click %v, after Escape %v, focus %q; want closed, closed, shellmenu-summary", shellAfterOutside, shellAfterEsc, shellFocus)
545 }
546
547 rig.Screen("body", "after the menu journey")
548}
549
550// modalPage serves the modal sample as a real page, exactly as
551// rastrillo's Styleguide()["modal"] wrote it at the seed commit
552// (the same markup is the modal example's default state), so
553// the drive reads the shipped markup rather than a copy of it.
554func modalPage(t *testing.T) http.Handler {
555 t.Helper()
556 return sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "modalPage", "")})
557}
558
559// TestModalDialogPanelDrive proves the <dialog open> panel is a rendered
560// dialog and not a modal one, and that the scoped resets in tokens.css
561// actually undo the UA dialog block.
562//
563// Bug classes it exists to catch — all of which look fine in the markup:
564//
565// - the UA dialog block left in place, so the panel is absolutely
566// positioned with 1em of padding and sits wherever the viewport puts
567// it instead of centred in the overlay;
568// - the panel reaching the top layer (a showModal() creeping in), which
569// would put it above the scrim's stacking context, paint a ::backdrop
570// nobody styled, and trap Escape;
571// - the dialog rendering with Canvas colours instead of the theme's,
572// which reads as "the panel ignores the theme".
573func TestModalDialogPanelDrive(t *testing.T) {
574 rig := harness.New(t, func(string) http.Handler { return modalPage(t) })
575
576 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
577 defer cancel()
578
579 var (
580 tag, position, padding string
581 isOpen, inTopLayer bool
582 centred, insideOverlay bool
583 backdropInert bool
584 labelledBy, labelText string
585 )
586 if err := chromedp.Run(ctx,
587 chromedp.Navigate(rig.Origin+"/"),
588 chromedp.WaitVisible(`[rst-modal-panel]`, chromedp.ByQuery),
589 chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").tagName`, &tag),
590 chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").open === true`, &isOpen),
591 // matches(":modal") is true only for a dialog in the top layer,
592 // which is what showModal() does and a rendered-open one never
593 // does. This is the assertion that the zero-JS promise holds.
594 chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").matches(":modal")`, &inTopLayer),
595 chromedp.Evaluate(`getComputedStyle(document.querySelector("[rst-modal-panel]")).position`, &position),
596 chromedp.Evaluate(`getComputedStyle(document.querySelector("[rst-modal-panel]")).paddingTop`, &padding),
597 chromedp.Evaluate(`document.querySelector("[rst-modal-overlay]").contains(document.querySelector("[rst-modal-panel]"))`, &insideOverlay),
598 // Centred inside the overlay's flex box, within a pixel: the UA
599 // block's absolute positioning and auto margins would put it
600 // somewhere else entirely.
601 chromedp.Evaluate(`(function () {
602 var o = document.querySelector("[rst-modal-overlay]").getBoundingClientRect();
603 var p = document.querySelector("[rst-modal-panel]").getBoundingClientRect();
604 return Math.abs((o.left + o.right) / 2 - (p.left + p.right) / 2) < 2;
605 })()`, &centred),
606 chromedp.Evaluate(`document.querySelector("[rst-backdrop]").hasAttribute("inert")`, &backdropInert),
607 // The dialog role has to be named. Resolve aria-labelledby the way
608 // an AT would — follow the id to the element and read its text —
609 // rather than trusting the attribute is pointing at anything.
610 chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").getAttribute("aria-labelledby") ?? ""`, &labelledBy),
611 chromedp.Evaluate(`(function () {
612 var id = document.querySelector("[rst-modal-panel]").getAttribute("aria-labelledby");
613 var el = id ? document.getElementById(id) : null;
614 return el ? el.textContent.trim() : "";
615 })()`, &labelText),
616 ); err != nil {
617 t.Fatalf("modal drive failed: %v", err)
618 }
619
620 if tag != "DIALOG" {
621 t.Errorf("the modal panel is a <%s>, want <dialog>", strings.ToLower(tag))
622 }
623 if !isOpen {
624 t.Error("the dialog is not open; a modal route renders it already open, since nothing here can open it")
625 }
626 if inTopLayer {
627 t.Error("the panel is in the top layer: something called showModal(), and the idiom is zero-JS")
628 }
629 if position != "static" {
630 t.Errorf("the panel's position is %q, want %q — the UA dialog block was not reset", position, "static")
631 }
632 if padding != "0px" {
633 t.Errorf("the panel's padding-top is %q, want %q — the UA dialog block's 1em survived", padding, "0px")
634 }
635 if !insideOverlay {
636 t.Error("the panel is no longer inside the overlay div, which is the scrim")
637 }
638 if !centred {
639 t.Error("the panel is not centred in the overlay; the UA dialog block is still positioning it")
640 }
641 if !backdropInert {
642 t.Error("the page behind the panel lost its inert attribute")
643 }
644 if labelledBy == "" {
645 t.Error("the dialog has no aria-labelledby: a dialog role with no name is announced as \"dialog\" and nothing else")
646 }
647 // Resolved through the id, not merely present: an aria-labelledby
648 // pointing at an id that is not in the document names nothing at all,
649 // and looks entirely correct in the markup.
650 if labelText == "" {
651 t.Errorf("aria-labelledby=%q resolves to no text; the dialog's accessible name is empty", labelledBy)
652 }
653
654 rig.Screen("[rst-modal-panel]", "the open modal")
655}
656
657// ── The busy rule ────────────────────────────────────────────────────
658
659// swapStateJS reads the busy button whose spinner replaces its label.
660// "Ink" is what paints: a transparent text fill, a zero opacity, or a
661// transparent ring all read as none. Width is read on its own, because
662// only the default leg expects it unchanged.
663const swapStateJS = `(function () {
664 var b = document.getElementById("swapgo");
665 var icon = document.getElementById("swapicon");
666 var spin = b.querySelector("[rst-spin]");
667 var cs = getComputedStyle(b);
668 var clear = "rgba(0, 0, 0, 0)";
669 var sc = spin && getComputedStyle(spin);
670 var br = b.getBoundingClientRect();
671 var sr = spin && spin.getBoundingClientRect();
672 var centred = !!spin &&
673 Math.abs((sr.left + sr.width / 2) - (br.left + br.width / 2)) < 1 &&
674 Math.abs((sr.top + sr.height / 2) - (br.top + br.height / 2)) < 1;
675 return [
676 "label-ink:" + (cs.webkitTextFillColor === clear ? "none" : "shown"),
677 "icon-ink:" + (parseFloat(getComputedStyle(icon).opacity) === 0 ? "none" : "shown"),
678 "spin-ink:" + (sc && sc.borderTopColor !== clear && spin.offsetWidth > 6 ? "shown" : "none"),
679 "spin-centred:" + centred,
680 ].join(" ");
681})()`
682
683// busyStateJS reads the whole busy state of the two-button form in one
684// round trip: the attributes, the swapped label, the untouched sibling,
685// and — the part an attribute check would miss — whether the spinner is
686// really on screen and really turning, out of the computed style rather
687// than out of the markup.
688const busyStateJS = `(function () {
689 var f = document.getElementById("two");
690 var s = document.getElementById("save");
691 var d = document.getElementById("draft");
692 var spin = s.querySelector("[rst-spin]");
693 var cs = spin && getComputedStyle(spin);
694 return [
695 "form:" + (f.getAttribute("aria-busy") || "-"),
696 "save:" + (s.getAttribute("aria-busy") || "-"),
697 "save-off:" + s.disabled,
698 "save-text:" + s.textContent.trim(),
699 "spin-first:" + (spin ? s.firstElementChild === spin : false),
700 "spin-hidden:" + (spin ? spin.getAttribute("aria-hidden") : "-"),
701 "spin-anim:" + (cs ? cs.animationName : "-"),
702 // offsetWidth, not getBoundingClientRect: a rect is the rotated
703 // box, so a 4px ring that only LOOKS bigger because it is spinning
704 // would pass a rect check and fail the reduced-motion one.
705 "spin-shown:" + (spin ? (spin.offsetWidth > 6 && spin.offsetHeight > 6 &&
706 cs.display !== "none" && cs.visibility === "visible" &&
707 parseFloat(cs.opacity) > 0) : false),
708 "draft:" + (d.getAttribute("aria-busy") || "-"),
709 "draft-off:" + d.disabled,
710 "draft-value:" + d.value,
711 ].join(" ");
712})()`
713
714// invalidStateJS is the same reading for the form the browser refuses:
715// nothing about it may look as though it is on its way anywhere.
716const invalidStateJS = `(function () {
717 var f = document.getElementById("needs-title");
718 var b = document.getElementById("try");
719 return [
720 "form:" + (f.getAttribute("aria-busy") || "-"),
721 "btn:" + (b.getAttribute("aria-busy") || "-"),
722 "btn-off:" + b.disabled,
723 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
724 "valid:" + f.checkValidity(),
725 ].join(" ");
726})()`
727
728// optOutStateJS reads the form that said data-busy="false".
729const optOutStateJS = `(function () {
730 var f = document.getElementById("out");
731 var b = document.getElementById("optout");
732 return [
733 "form:" + (f.getAttribute("aria-busy") || "-"),
734 "btn:" + (b.getAttribute("aria-busy") || "-"),
735 "btn-off:" + b.disabled,
736 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
737 ].join(" ");
738})()`
739
740// quietStateJS reads the form whose BUTTON said data-busy="false": the
741// form is still guarded, the button just declines the loading state.
742const quietStateJS = `(function () {
743 var f = document.getElementById("quiet");
744 var b = document.getElementById("quietbtn");
745 return [
746 "form:" + (f.getAttribute("aria-busy") || "-"),
747 "btn:" + (b.getAttribute("aria-busy") || "-"),
748 "btn-off:" + b.disabled,
749 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
750 ].join(" ");
751})()`
752
753// shadowStateJS proves the premise of the shadowing leg before the leg
754// asserts anything: a control named "target" really has replaced
755// form.target with itself, so the property read the shim must not make
756// really would be truthy and really would not be "_self".
757const shadowStateJS = `(function () {
758 var f = document.getElementById("shadow");
759 var b = document.getElementById("shadowgo");
760 return [
761 "shadowed:" + (f.target && f.target.tagName === "INPUT"),
762 "form:" + (f.getAttribute("aria-busy") || "-"),
763 "btn:" + (b.getAttribute("aria-busy") || "-"),
764 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
765 ].join(" ");
766})()`
767
768// backStateJS reads the form the visitor navigated away from and came
769// back to. Everything the busy state wrote has to be gone — including
770// the guard, or the form is a dead end.
771const backStateJS = `(function () {
772 var f = document.getElementById("nav");
773 var b = document.getElementById("navgo");
774 return [
775 "persisted:" + (window.__persisted === true),
776 "form:" + (f.getAttribute("aria-busy") || "-"),
777 "btn:" + (b.getAttribute("aria-busy") || "-"),
778 "btn-off:" + b.disabled,
779 "btn-text:" + b.textContent.trim(),
780 "idle-label:" + (b.getAttribute("data-idle-label") || "-"),
781 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
782 ].join(" ");
783})()`
784
785// dialogState reads a dialog close pattern, by ids: the <dialog>, the
786// form inside it, and the button that closes it. Two shapes wear this
787// pattern — <form method="dialog">, and an ordinary form whose button
788// carries formmethod="dialog" — and neither may end up busy, because
789// neither is on its way anywhere. The dialog's open state is the
790// assertion that says so in behaviour rather than in flags.
791func dialogState(dlg, form, btn string) string {
792 return `(function () {
793 var d = document.getElementById("` + dlg + `");
794 var f = document.getElementById("` + form + `");
795 var b = document.getElementById("` + btn + `");
796 return [
797 "open:" + d.open,
798 "form:" + (f.getAttribute("aria-busy") || "-"),
799 "btn:" + (b.getAttribute("aria-busy") || "-"),
800 "btn-off:" + b.disabled,
801 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
802 ].join(" ");
803})()`
804}
805
806// extBackStateJS is backStateJS for the form whose submit button lives
807// OUTSIDE it and belongs to it through form="ext" — the toolbar Save
808// the form attribute exists for. The button is not a descendant of the
809// form, so a reset that sweeps the form's own subtree never reaches it.
810const extBackStateJS = `(function () {
811 var f = document.getElementById("ext");
812 var b = document.getElementById("extgo");
813 return [
814 "persisted:" + (window.__persisted === true),
815 "inside:" + f.contains(b),
816 "form:" + (f.getAttribute("aria-busy") || "-"),
817 "btn:" + (b.getAttribute("aria-busy") || "-"),
818 "btn-off:" + b.disabled,
819 "btn-text:" + b.textContent.trim(),
820 "idle-label:" + (b.getAttribute("data-idle-label") || "-"),
821 "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"),
822 ].join(" ");
823})()`
824
825// busyPage serves the forms the busy drive needs and a submit endpoint
826// that answers 204.
827//
828// The 204 is the whole trick. A form whose response navigates leaves
829// nothing on the screen to look at — the busy state is gone with the
830// document before an assertion can reach it, and a drive that sometimes
831// reads the old page and sometimes the new one is worse than no drive.
832// A form submission answered with 204 No Content is defined not to
833// navigate at all: the request is really made, the payload is really
834// delivered, and the page stays exactly as the submit left it, busy
835// button and all, for as long as the assertions need. Which is also,
836// near enough, the situation the rule exists for — a submit that is
837// under way and has not come back yet.
838//
839// The endpoint reports what it received on a buffered channel, which is
840// how the drive checks the clicked button's name and value survived:
841// the trap the deferred `disabled` exists to avoid.
842func busyPage(t *testing.T) (http.Handler, chan string) {
843 t.Helper()
844 payloads := make(chan string, 8)
845 // rastrillo's handler answered every GET path with the busy page, and
846 // the hold leg leaves for /elsewhere and comes back; the shared mux
847 // answers one path per page, so both are the busy page here.
848 page := fixture(t, "browser_test.go", "busyPage", "")
849 mux := sizingMux(t, map[string]string{"/": page, "/elsewhere": page})
850 received(mux, "/submit", "", payloads, http.StatusNoContent)
851 // The one endpoint that really navigates, for the back-button leg:
852 // 204 keeps the visitor where they are, and a page you never left is
853 // a page you cannot come back to.
854 mux.HandleFunc("POST /go", func(w http.ResponseWriter, r *http.Request) {
855 _ = r.ParseForm()
856 select {
857 case payloads <- r.PostForm.Encode():
858 default:
859 }
860 w.Header().Set("Content-Type", "text/html; charset=utf-8")
861 fmt.Fprint(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+
862 `<title>sent</title></head><body><p id="done">sent</p></body></html>`)
863 })
864 return mux, payloads
865}
866
867// took reports what the endpoint received, or fails: the drive is
868// meaningless if the submission never arrived.
869func took(t *testing.T, payloads chan string, leg string) string {
870 t.Helper()
871 select {
872 case p := <-payloads:
873 return p
874 case <-time.After(20 * time.Second):
875 t.Fatalf("%s: the server never received a submission", leg)
876 return ""
877 }
878}
879
880// tookNothing is the double-submit assertion: after the guard is armed,
881// no second request may reach the server.
882func tookNothing(t *testing.T, payloads chan string, leg string) {
883 t.Helper()
884 select {
885 case p := <-payloads:
886 t.Fatalf("%s: the server received a SECOND submission (%q); the guard is the substance of the rule", leg, p)
887 case <-time.After(1500 * time.Millisecond):
888 }
889}
890
891// TestBusyButtonDrive is the busy rule in a real engine.
892//
893// Bug classes it exists to catch — every one of which renders perfectly
894// and says nothing wrong:
895//
896// - the button is disabled while the payload is being read, so the
897// clicked button's name/value never reaches the server and the
898// handler cannot tell Save from Save-draft;
899// - the whole form goes busy, so a second submit button loses its
900// value too, or reads as unavailable when it is not;
901// - the guard is missing, so a double click posts twice — which for a
902// payment or an invitation is the bug the rule is FOR;
903// - the busy class is set but the spinner never paints (no rule for
904// it, zero size, display:none), so the page claims to be working
905// and shows nothing;
906// - the spinner keeps rotating for a reader who asked for reduced
907// motion;
908// - a form the browser REFUSED is left stuck busy, so the visitor
909// fixes the field and finds a dead button;
910// - the rule turns out to depend on the script, breaking the
911// scriptless submit it is only ever an enhancement to.
912func TestBusyButtonDrive(t *testing.T) {
913 mux, payloads := busyPage(t)
914 rig := harness.New(t, func(string) http.Handler { return mux })
915
916 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
917 defer cancel()
918
919 reached := "start"
920 at := func(name string) chromedp.Action {
921 return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil })
922 }
923 fail := func(err error) {
924 if err != nil {
925 t.Fatalf("drive failed after %q: %v", reached, err)
926 }
927 }
928
929 // ── 1. The form the browser refuses ───────────────────────────────
930 //
931 // #title is required and empty, so the click never produces a submit
932 // event at all. Nothing may be busy afterwards, and nothing may have
933 // been sent.
934 var invalidBefore, invalidAfter string
935 fail(chromedp.Run(ctx,
936 chromedp.Navigate(rig.Origin+"/"), at("navigated"),
937 chromedp.WaitVisible(`#save`, chromedp.ByQuery), at("page-visible"),
938 chromedp.Evaluate(invalidStateJS, &invalidBefore),
939 chromedp.Click(`#try`, chromedp.ByQuery), at("clicked-invalid-submit"),
940 chromedp.Sleep(300*time.Millisecond),
941 chromedp.Evaluate(invalidStateJS, &invalidAfter),
942 ))
943 const wantInvalid = "form:- btn:- btn-off:false spin:no valid:false"
944 if invalidBefore != wantInvalid {
945 t.Fatalf("before the click the invalid form reads %q, want %q — this leg proves nothing unless the form really is invalid to begin with", invalidBefore, wantInvalid)
946 }
947 if invalidAfter != wantInvalid {
948 t.Errorf("after submitting an invalid form the state is %q, want %q — the browser refused the submit, so the button must not be left stuck busy", invalidAfter, wantInvalid)
949 }
950 tookNothing(t, payloads, "invalid form")
951
952 // ── 2. The opt-out ────────────────────────────────────────────────
953 //
954 // data-busy="false" on the form. It still submits — that is the
955 // point, it is opting out of the loading state and nothing else.
956 var optOut string
957 fail(chromedp.Run(ctx,
958 chromedp.Click(`#optout`, chromedp.ByQuery), at("clicked-optout"),
959 chromedp.Sleep(300*time.Millisecond),
960 chromedp.Evaluate(optOutStateJS, &optOut),
961 ))
962 if got := took(t, payloads, "opt-out"); got != "action=optout" {
963 t.Errorf("the opted-out form sent %q, want %q", got, "action=optout")
964 }
965 const wantOptOut = "form:- btn:- btn-off:false spin:no"
966 if optOut != wantOptOut {
967 t.Errorf(`data-busy="false" form reads %q, want %q — the opt-out is not being honoured`, optOut, wantOptOut)
968 }
969
970 // ── 2b. The same opt-out, on the button ──────────────────────────
971 //
972 // The button declines the loading state; the FORM is still guarded,
973 // because the guard is the substance of the rule and is not what
974 // was opted out of.
975 var quiet string
976 quietAt := time.Now()
977 fail(chromedp.Run(ctx,
978 chromedp.Click(`#quietbtn`, chromedp.ByQuery), at("clicked-quiet"),
979 chromedp.Sleep(300*time.Millisecond),
980 chromedp.Evaluate(quietStateJS, &quiet),
981 ))
982 if got := took(t, payloads, "button opt-out"); got != "action=quiet" {
983 t.Errorf("the form with the opted-out button sent %q, want %q", got, "action=quiet")
984 }
985 // No spinner, no hold: the 650ms minimum is for a spinner on screen,
986 // and an opted-out button shows none. The clock covers the 300ms
987 // sleep and the CDP round trips as well as the submit, so the line is
988 // drawn under the hold rather than over the sleep: at 450ms a busy
989 // CI box (31 shards and another repo's suite on one worker) read
990 // 496ms for a submit that was not held at all.
991 if waited := time.Since(quietAt); waited > 600*time.Millisecond {
992 t.Errorf("the opted-out button's submit took %v to arrive; with no spinner to show it must not be held", waited)
993 }
994 const wantQuiet = "form:true btn:- btn-off:false spin:no"
995 if quiet != wantQuiet {
996 t.Errorf(`data-busy="false" on the button reads %q, want %q`, quiet, wantQuiet)
997 }
998 // The guard is not what was opted out of, and the way to prove that
999 // is behaviour rather than a flag: ask the form to submit again and
1000 // nothing may reach the server.
1001 fail(chromedp.Run(ctx,
1002 chromedp.Evaluate(`document.getElementById("quiet").requestSubmit()`, nil), at("re-submitted-quiet"),
1003 ))
1004 tookNothing(t, payloads, "button opt-out, second submit")
1005
1006 // ── 2c. A control named "target" ──────────────────────────────────
1007 //
1008 // HTMLFormElement is [LegacyOverrideBuiltIns], so <input
1009 // name="target"> replaces form.target with the input element:
1010 // truthy, and not "_self". A shim that reads the property instead of
1011 // the attribute bails out here, before it arms the guard, and hands
1012 // the visitor back the double submit the rule exists to prevent.
1013 var shadow string
1014 fail(chromedp.Run(ctx,
1015 chromedp.Click(`#shadowgo`, chromedp.ByQuery), at("clicked-shadow"),
1016 chromedp.Poll(`document.getElementById("shadowgo").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("shadow-hardened"),
1017 chromedp.Evaluate(shadowStateJS, &shadow),
1018 chromedp.Evaluate(`document.getElementById("shadow").requestSubmit()`, nil), at("re-submitted-shadow"),
1019 ))
1020 if got := took(t, payloads, "shadowed target"); got != "action=shadow&target=42" {
1021 t.Errorf("the shadowing form sent %q, want %q", got, "action=shadow&target=42")
1022 }
1023 const wantShadow = "shadowed:true form:true btn:true spin:yes"
1024 if shadow != wantShadow {
1025 t.Errorf("with a control named \"target\" the state is %q, want %q — reading form.target as a property switches the rule off", shadow, wantShadow)
1026 }
1027 tookNothing(t, payloads, "shadowed target, second submit")
1028
1029 // ── 2d. A dialog's own close form ─────────────────────────────────
1030 //
1031 // <form method="dialog"><button>Close</button></form> is the
1032 // standard way to close a <dialog>, and it is the one submit that
1033 // goes nowhere at all: the dialog closes and the page stays exactly
1034 // where it stood. So nothing ever navigates or reloads to clear a
1035 // busy state armed here — the close button ends up disabled with the
1036 // guard still up, and the next time the dialog opens it cannot be
1037 // closed through its form at all. A modal still takes Esc; a
1038 // non-modal one has no way out.
1039 //
1040 // The second close is the assertion. A wedged dialog closes once,
1041 // looks fine, and never closes again.
1042 const wantDialog = "open:false form:- btn:- btn-off:false spin:no"
1043 var dialogFirst, dialogSecond string
1044 fail(chromedp.Run(ctx,
1045 chromedp.Evaluate(`document.getElementById("dlg").showModal()`, nil), at("opened-dialog"),
1046 chromedp.Evaluate(`document.getElementById("dlgclose").click()`, nil), at("clicked-dialog-close"),
1047 chromedp.Sleep(300*time.Millisecond),
1048 chromedp.Evaluate(dialogState("dlg", "dlgform", "dlgclose"), &dialogFirst),
1049 chromedp.Evaluate(`document.getElementById("dlg").showModal()`, nil), at("reopened-dialog"),
1050 chromedp.Evaluate(`document.getElementById("dlgclose").click()`, nil), at("clicked-dialog-close-again"),
1051 chromedp.Sleep(300*time.Millisecond),
1052 chromedp.Evaluate(dialogState("dlg", "dlgform", "dlgclose"), &dialogSecond),
1053 ))
1054 if dialogFirst != wantDialog {
1055 t.Errorf("after closing the dialog once the state is\n %q\nwant\n %q\n— method=\"dialog\" submits nowhere, so nothing may be left looking busy", dialogFirst, wantDialog)
1056 }
1057 if dialogSecond != wantDialog {
1058 t.Errorf("after reopening and closing the dialog again the state is\n %q\nwant\n %q\n— the close button is dead and the dialog is wedged open", dialogSecond, wantDialog)
1059 }
1060
1061 // ── 2e. The same close, through the submitter ─────────────────────
1062 //
1063 // <form method="post"><button formmethod="dialog"> is 2d reached
1064 // through the button's own attribute, which HTML says beats the
1065 // form's method. The form looks like an ordinary POST — a rule that
1066 // reads only form.method sees "post", arms the guard, and wedges the
1067 // dialog exactly as 2d does, in the shape a real app is likelier to
1068 // write: one form that both saves and closes.
1069 //
1070 // Same assertion, and for the same reason: the second close is the
1071 // one that catches it.
1072 var fmFirst, fmSecond string
1073 fail(chromedp.Run(ctx,
1074 chromedp.Evaluate(`document.getElementById("fmdlg").showModal()`, nil), at("opened-formmethod-dialog"),
1075 chromedp.Evaluate(`document.getElementById("fmclose").click()`, nil), at("clicked-formmethod-close"),
1076 chromedp.Sleep(300*time.Millisecond),
1077 chromedp.Evaluate(dialogState("fmdlg", "fmform", "fmclose"), &fmFirst),
1078 chromedp.Evaluate(`document.getElementById("fmdlg").showModal()`, nil), at("reopened-formmethod-dialog"),
1079 chromedp.Evaluate(`document.getElementById("fmclose").click()`, nil), at("clicked-formmethod-close-again"),
1080 chromedp.Sleep(300*time.Millisecond),
1081 chromedp.Evaluate(dialogState("fmdlg", "fmform", "fmclose"), &fmSecond),
1082 ))
1083 if fmFirst != wantDialog {
1084 t.Errorf("after closing the dialog once through formmethod=\"dialog\" the state is\n %q\nwant\n %q\n— the submitter's formmethod beats the form's method, so this submits nowhere either", fmFirst, wantDialog)
1085 }
1086 if fmSecond != wantDialog {
1087 t.Errorf("after reopening and closing it again through formmethod=\"dialog\" the state is\n %q\nwant\n %q\n— the close button is dead and the dialog is wedged open", fmSecond, wantDialog)
1088 }
1089 // The form's action is real: if the effective method were ever read
1090 // as the form's "post", this leg would also have posted to it.
1091 tookNothing(t, payloads, "formmethod=\"dialog\" close")
1092
1093 // ── 3. The rule itself, and the payload ───────────────────────────
1094 var busy, afterSecond string
1095 var clickedAt time.Time
1096 fail(chromedp.Run(ctx,
1097 chromedp.ActionFunc(func(context.Context) error { clickedAt = time.Now(); return nil }),
1098 chromedp.Click(`#save`, chromedp.ByQuery), at("clicked-save"),
1099 // The hardening to disabled is deferred by a tick, so wait for
1100 // it rather than for a guess at how long a tick takes.
1101 chromedp.Poll(`document.getElementById("save").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("save-hardened"),
1102 chromedp.Evaluate(busyStateJS, &busy),
1103 ))
1104 // The payload check: the trap this whole shape exists to avoid is a
1105 // disabled button whose name/value never reaches the server.
1106 if got := took(t, payloads, "save"); got != "action=save&note=hello" {
1107 t.Errorf("the server received %q, want %q — the clicked button's name/value was dropped from the payload", got, "action=save&note=hello")
1108 }
1109 // The hold: the spinner shows for at least 650ms, which it can only
1110 // do if the submit leaves no sooner than that. A local server answers
1111 // in a millisecond, so without the hold the payload lands at once.
1112 // (A little under 650: the click is timed from here, not from inside
1113 // the page, and a timer may fire a hair early.)
1114 if waited := time.Since(clickedAt); waited < 620*time.Millisecond {
1115 t.Errorf("the submit reached the server %v after the click; the busy rule holds it for at least 650ms", waited)
1116 }
1117 const wantBusy = "form:true save:true save-off:true save-text:Saving… " +
1118 "spin-first:true spin-hidden:true spin-anim:rst-spin spin-shown:true " +
1119 "draft:- draft-off:false draft-value:draft"
1120 if busy != wantBusy {
1121 t.Errorf("busy state is\n %q\nwant\n %q", busy, wantBusy)
1122 }
1123
1124 rig.Screen("body", "a submit button while it works")
1125
1126 // ── 4. Re-entrancy ────────────────────────────────────────────────
1127 //
1128 // Three ways back in, one guard: a second click on the button that
1129 // started it, a click on the OTHER submit button in the same form,
1130 // and a programmatic requestSubmit(). None may reach the server.
1131 fail(chromedp.Run(ctx,
1132 chromedp.Evaluate(`document.getElementById("save").click()`, nil), at("clicked-save-again"),
1133 chromedp.Evaluate(`document.getElementById("draft").click()`, nil), at("clicked-draft"),
1134 chromedp.Evaluate(`document.getElementById("two").requestSubmit()`, nil), at("requested-submit"),
1135 chromedp.Sleep(300*time.Millisecond),
1136 chromedp.Evaluate(busyStateJS, &afterSecond),
1137 ))
1138 tookNothing(t, payloads, "second submit")
1139 if afterSecond != wantBusy {
1140 t.Errorf("after the re-entrancy attempts the state is\n %q\nwant it unchanged:\n %q", afterSecond, wantBusy)
1141 }
1142
1143 // ── 4a. The spinner takes the label's place ─────────────────────
1144 //
1145 // With no data-busy-label there are no words to show while it works,
1146 // so the label gives way to a centred spinner. Bug classes, each of
1147 // which still "works": the spinner joins the label and the button
1148 // grows, reflowing everything beside it mid-click; the label is hidden
1149 // with the spinner, so it inherits the transparent ink and the button
1150 // shows nothing at all; the label is hidden from the accessibility
1151 // tree too, so a screen reader announces an unnamed busy button.
1152 var idleWidth, busyWidth float64
1153 var swap string
1154 fail(chromedp.Run(ctx,
1155 chromedp.Evaluate(`document.getElementById("swapgo").getBoundingClientRect().width`, &idleWidth), at("measured-swap"),
1156 chromedp.Click(`#swapgo`, chromedp.ByQuery), at("clicked-swap"),
1157 chromedp.Poll(`document.getElementById("swapgo").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("swap-hardened"),
1158 chromedp.Evaluate(swapStateJS, &swap),
1159 chromedp.Evaluate(`document.getElementById("swapgo").getBoundingClientRect().width`, &busyWidth),
1160 ))
1161 if got := took(t, payloads, "swap"); got != "action=swap" {
1162 t.Errorf("the swapping form sent %q, want %q", got, "action=swap")
1163 }
1164 const wantSwap = "label-ink:none icon-ink:none spin-ink:shown spin-centred:true"
1165 if swap != wantSwap {
1166 t.Errorf("a busy button with no busy label reads\n %q\nwant\n %q", swap, wantSwap)
1167 }
1168 if math.Abs(busyWidth-idleWidth) > 0.5 {
1169 t.Errorf("the busy button is %.1fpx wide, %.1fpx idle — the spinner must take the label's place, not add to it", busyWidth, idleWidth)
1170 }
1171 // The name is read from the accessibility tree, not textContent:
1172 // textContent ignores CSS and aria-hidden, so it would still say
1173 // Publish with the label hidden from a screen reader.
1174 if name := axName(ctx, t, "#swapgo"); name != "Publish" {
1175 t.Errorf("the busy button's accessible name is %q, want %q — unpainting the label must not take it away from a screen reader", name, "Publish")
1176 }
1177 var authoredInk string
1178 fail(chromedp.Run(ctx,
1179 chromedp.Evaluate(`getComputedStyle(document.getElementById("authored")).webkitTextFillColor`, &authoredInk),
1180 ))
1181 if authoredInk == "rgba(0, 0, 0, 0)" {
1182 t.Errorf("a button an app marked aria-busy, with no spinner in it, has transparent label ink — it renders blank")
1183 }
1184 // And the other side of the same selector: a busy label is words
1185 // the author asked to be read, so it stays inked.
1186 var saveInk string
1187 fail(chromedp.Run(ctx,
1188 chromedp.Evaluate(`getComputedStyle(document.getElementById("save")).webkitTextFillColor`, &saveInk),
1189 ))
1190 if saveInk == "rgba(0, 0, 0, 0)" {
1191 t.Errorf("the button with data-busy-label has transparent label ink — its busy label must stay visible")
1192 }
1193 rig.Screen("body", "a busy button whose spinner replaces the label")
1194
1195 // ── 4b. Back ──────────────────────────────────────────────────────
1196 //
1197 // The back/forward cache restores a document exactly as it was left,
1198 // busy button and all, so a visitor who submits and then comes back
1199 // finds a dead form: disabled, wearing the busy label, refusing a
1200 // second submit because the guard is still armed. The shim clears it
1201 // on pageshow.
1202 //
1203 // This needs a response that really navigates, which is why /go is
1204 // not a 204 like everything else here.
1205 //
1206 // chromedp.NavigateBack() cannot be used here, and the reason is the
1207 // same fact the leg is about: a page restored from the back/forward
1208 // cache fires no load event, because it was never re-loaded — so the
1209 // action waits for one until the deadline. Ask the PAGE to go back
1210 // and then poll for the element, tolerating the evaluations that
1211 // error while the document is being swapped.
1212 var back string
1213 // settle polls one page expression until it is true, tolerating the
1214 // evaluations that error while a document is being swapped in.
1215 // Whether it times out is deliberately not an error: the assertions
1216 // after it are the report. A shim that never clears the busy state
1217 // would make this wait ten seconds and then fail on what it reads,
1218 // which says what went wrong; failing here would only say "timeout".
1219 settle := func(expr string, hard bool) chromedp.Action {
1220 return chromedp.ActionFunc(func(c context.Context) error {
1221 deadline := time.Now().Add(10 * time.Second)
1222 for time.Now().Before(deadline) {
1223 var yes bool
1224 if err := chromedp.Evaluate(expr, &yes).Do(c); err == nil && yes {
1225 return nil
1226 }
1227 time.Sleep(100 * time.Millisecond)
1228 }
1229 if hard {
1230 return fmt.Errorf("never became true within 10s: %s", expr)
1231 }
1232 return nil
1233 })
1234 }
1235 waitForID := func(id string) chromedp.Action {
1236 return settle(`!!document.getElementById("`+id+`")`, true)
1237 }
1238 fail(chromedp.Run(ctx,
1239 chromedp.Navigate(rig.Origin+"/"), at("navigated-for-back"),
1240 chromedp.WaitVisible(`#navgo`, chromedp.ByQuery), at("page-visible-for-back"),
1241 chromedp.Click(`#navgo`, chromedp.ByQuery), at("clicked-nav"),
1242 chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("landed-on-response"),
1243 // Deferred a tick so the evaluation returns before the
1244 // navigation it starts.
1245 chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0)`, nil), at("asked-to-go-back"),
1246 waitForID("navgo"), at("back-on-the-form"),
1247 // The element exists the instant the document is swapped in,
1248 // which is BEFORE pageshow's listeners run — so reading here
1249 // races the very handler the leg is about. Give the reset its
1250 // dispatch, then read.
1251 settle(`document.getElementById("nav").getAttribute("aria-busy") === null`, false), at("reset-settled"),
1252 chromedp.Evaluate(backStateJS, &back),
1253 ))
1254 if got := took(t, payloads, "back: first submit"); got != "action=nav" {
1255 t.Errorf("the navigating form sent %q, want %q", got, "action=nav")
1256 }
1257 // The premise, first and fatally: if the browser re-fetched the page
1258 // instead of restoring it, everything below would be clean for a
1259 // reason that has nothing to do with the shim, and the leg would
1260 // pass while pinning nothing at all.
1261 if !strings.HasPrefix(back, "persisted:true ") {
1262 t.Fatalf("after going back the page reads %q — it was not restored from the back/forward cache, so this leg proves nothing about the pageshow reset", back)
1263 }
1264 const wantBack = "persisted:true form:- btn:- btn-off:false btn-text:Send idle-label:- spin:no"
1265 if back != wantBack {
1266 t.Errorf("after going back the form reads\n %q\nwant\n %q\n— a restored page must not still be wearing the busy state", back, wantBack)
1267 }
1268 // Enabled and clean is not the same as usable: the guard has to be
1269 // cleared too, or the form looks fine and silently refuses.
1270 //
1271 // Clicked through the page rather than with chromedp.Click, for the
1272 // same reason NavigateBack could not be used above: a restored
1273 // document raises no DOM.documentUpdated, so chromedp's node cache
1274 // still describes the response page and a selector query retries
1275 // until the deadline. What this leg is about is the guard, not mouse
1276 // input, and element.click() submits the form exactly the same way.
1277 fail(chromedp.Run(ctx,
1278 chromedp.Evaluate(`document.getElementById("navgo").click()`, nil), at("re-submitted-after-back"),
1279 waitForID("done"), at("landed-again"),
1280 ))
1281 if got := took(t, payloads, "back: re-submit"); got != "action=nav" {
1282 t.Errorf("re-submitting after going back sent %q, want %q — the guard survived the restore and the form is a dead end", got, "action=nav")
1283 }
1284
1285 // ── 4c. Back, with the submit button OUTSIDE its form ─────────────
1286 //
1287 // <button form="ext"> belongs to a form it does not live in, which
1288 // is the whole reason the form attribute exists: a sticky header's
1289 // Save, a toolbar's Publish. The submit event's submitter is that
1290 // button, so the rule busies it exactly as it busies any other — but
1291 // a reset that sweeps the FORM's own subtree never finds it again.
1292 // The visitor comes back from the bfcache to a button that is still
1293 // disabled, still wearing the busy label and still spinning, with no
1294 // way out short of a reload. That is precisely the state the
1295 // back-button requirement forbids.
1296 var extBack string
1297 fail(chromedp.Run(ctx,
1298 chromedp.Navigate(rig.Origin+"/"), at("navigated-for-ext-back"),
1299 chromedp.WaitVisible(`#extgo`, chromedp.ByQuery), at("page-visible-for-ext-back"),
1300 chromedp.Click(`#extgo`, chromedp.ByQuery), at("clicked-ext"),
1301 chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("landed-on-response-ext"),
1302 chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0)`, nil), at("asked-to-go-back-ext"),
1303 waitForID("extgo"), at("back-on-the-ext-form"),
1304 settle(`document.getElementById("extgo").disabled === false`, false), at("ext-reset-settled"),
1305 chromedp.Evaluate(extBackStateJS, &extBack),
1306 ))
1307 if got := took(t, payloads, "external button: first submit"); got != "action=ext" {
1308 t.Errorf("the form with an external submit button sent %q, want %q", got, "action=ext")
1309 }
1310 // Two premises, both fatal: the page really came out of the
1311 // back/forward cache, and the button really does live outside the
1312 // form. Without either, this leg pins nothing.
1313 if !strings.HasPrefix(extBack, "persisted:true inside:false ") {
1314 t.Fatalf("after going back the page reads %q — it either was not restored from the back/forward cache or the button is inside its form after all, so this leg proves nothing", extBack)
1315 }
1316 const wantExtBack = "persisted:true inside:false form:- btn:- btn-off:false " +
1317 "btn-text:Save idle-label:- spin:no"
1318 if extBack != wantExtBack {
1319 t.Errorf("after going back the external submit button reads\n %q\nwant\n %q\n— a button that went busy must never come back disabled and spinning", extBack, wantExtBack)
1320 }
1321 // Clean is not the same as usable: the guard has to be gone too.
1322 fail(chromedp.Run(ctx,
1323 chromedp.Evaluate(`document.getElementById("extgo").click()`, nil), at("re-submitted-ext-after-back"),
1324 waitForID("done"), at("landed-again-ext"),
1325 ))
1326 if got := took(t, payloads, "external button: re-submit"); got != "action=ext" {
1327 t.Errorf("re-submitting the external button after going back sent %q, want %q — the guard survived the restore and the form is a dead end", got, "action=ext")
1328 }
1329
1330 // ── 5. Reduced motion ─────────────────────────────────────────────
1331 //
1332 // The spinner is still there and still says "working"; it just stops
1333 // turning. A computed animationName of "none" is the assertion —
1334 // the media query is in tokens.css, so a spinner built with an
1335 // inline animation would pass every markup check and fail here.
1336 var reduced string
1337 fail(chromedp.Run(ctx,
1338 chromedp.ActionFunc(func(c context.Context) error {
1339 return emulation.SetEmulatedMedia().
1340 WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}}).
1341 Do(c)
1342 }), at("emulated-reduced-motion"),
1343 chromedp.Navigate(rig.Origin+"/"), at("navigated-reduced"),
1344 chromedp.WaitVisible(`#save`, chromedp.ByQuery), at("page-visible-reduced"),
1345 chromedp.Click(`#save`, chromedp.ByQuery), at("clicked-save-reduced"),
1346 chromedp.Poll(`document.getElementById("save").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("save-hardened-reduced"),
1347 chromedp.Evaluate(busyStateJS, &reduced),
1348 ))
1349 took(t, payloads, "reduced motion")
1350 const wantReduced = "form:true save:true save-off:true save-text:Saving… " +
1351 "spin-first:true spin-hidden:true spin-anim:none spin-shown:true " +
1352 "draft:- draft-off:false draft-value:draft"
1353 if reduced != wantReduced {
1354 t.Errorf("under prefers-reduced-motion the busy state is\n %q\nwant\n %q", reduced, wantReduced)
1355 }
1356
1357 // ── 5b. The swap stands down where a lone ring fails ──────────────
1358 //
1359 // Two readers for whom a spinner alone is the wrong trade, so the
1360 // label stays and the ring sits beside it as it used to:
1361 //
1362 // - reduced motion: the ring stops turning and is dimmed, and a
1363 // pale, still, open ring on its own reads as a stray "C";
1364 // - forced colours: the engine repaints the text fill, so the
1365 // "hidden" label paints anyway and a centred ring lands on top
1366 // of it. The computed fill still reads transparent there, which
1367 // is why this leg asserts position and not only ink.
1368 for _, media := range []struct {
1369 name, value string
1370 }{{"prefers-reduced-motion", "reduce"}, {"forced-colors", "active"}} {
1371 var state, pos string
1372 fail(chromedp.Run(ctx,
1373 chromedp.ActionFunc(func(c context.Context) error {
1374 return emulation.SetEmulatedMedia().
1375 WithFeatures([]*emulation.MediaFeature{{Name: media.name, Value: media.value}}).
1376 Do(c)
1377 }), at("emulated-"+media.name),
1378 chromedp.Navigate(rig.Origin+"/"), at("navigated-"+media.name),
1379 chromedp.WaitVisible(`#swapgo`, chromedp.ByQuery), at("page-visible-"+media.name),
1380 chromedp.Click(`#swapgo`, chromedp.ByQuery), at("clicked-swap-"+media.name),
1381 chromedp.Poll(`document.getElementById("swapgo").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("swap-hardened-"+media.name),
1382 chromedp.Evaluate(swapStateJS, &state),
1383 chromedp.Evaluate(`getComputedStyle(document.querySelector("#swapgo > [rst-spin]")).position`, &pos),
1384 ))
1385 took(t, payloads, media.name)
1386 const want = "label-ink:shown icon-ink:shown spin-ink:shown spin-centred:false"
1387 if state != want || pos != "static" {
1388 t.Errorf("under %s: %s the busy button reads %q with the spinner %s, want %q with it static beside the label", media.name, media.value, state, pos, want)
1389 }
1390 }
1391
1392 // ── 6. Scriptless ─────────────────────────────────────────────────
1393 //
1394 // The rule is an enhancement, not a correctness claim. With script
1395 // execution off the form submits exactly as it always did, carrying
1396 // exactly the same payload, and nothing on the page is marked busy.
1397 var scriptless string
1398 fail(chromedp.Run(ctx,
1399 chromedp.ActionFunc(func(c context.Context) error {
1400 if err := emulation.SetEmulatedMedia().Do(c); err != nil {
1401 return err
1402 }
1403 return emulation.SetScriptExecutionDisabled(true).Do(c)
1404 }), at("disabled-scripts"),
1405 chromedp.Navigate(rig.Origin+"/"), at("navigated-scriptless"),
1406 chromedp.WaitVisible(`#save`, chromedp.ByQuery), at("page-visible-scriptless"),
1407 chromedp.Click(`#save`, chromedp.ByQuery), at("clicked-save-scriptless"),
1408 chromedp.Sleep(500*time.Millisecond),
1409 chromedp.Evaluate(busyStateJS, &scriptless),
1410 chromedp.ActionFunc(func(c context.Context) error {
1411 return emulation.SetScriptExecutionDisabled(false).Do(c)
1412 }),
1413 ))
1414 if got := took(t, payloads, "scriptless"); got != "action=save&note=hello" {
1415 t.Errorf("with scripts off the server received %q, want %q — the shim changed the scriptless submit", got, "action=save&note=hello")
1416 }
1417 const wantScriptless = "form:- save:- save-off:false save-text:Save post " +
1418 "spin-first:false spin-hidden:- spin-anim:- spin-shown:false " +
1419 "draft:- draft-off:false draft-value:draft"
1420 if scriptless != wantScriptless {
1421 t.Errorf("with scripts off the page reads\n %q\nwant\n %q", scriptless, wantScriptless)
1422 }
1423
1424 // ── 7. Leaving during the hold ───────────────────────────────────
1425 //
1426 // A submit still being held when the visitor leaves the page is
1427 // dropped. The trap is the back/forward cache: a timer frozen with
1428 // the page resumes when the visitor comes Back, and sends a submit
1429 // they walked away from. Click, leave inside the hold, come back the
1430 // way leg 5's Back does, and wait out more than the hold: nothing may
1431 // reach the server, and the restored form must be clean.
1432 var heldBack string
1433 fail(chromedp.Run(ctx,
1434 chromedp.Navigate(rig.Origin+"/"), at("navigated-for-hold"),
1435 chromedp.WaitVisible(`#navgo`, chromedp.ByQuery),
1436 chromedp.Click(`#navgo`, chromedp.ByQuery), at("clicked-nav-then-leave"),
1437 chromedp.Evaluate(`setTimeout(function () { location.href = "/elsewhere"; }, 150)`, nil), at("leaving-during-hold"),
1438 settle(`location.pathname === "/elsewhere" && document.readyState === "complete"`, true), at("left-during-hold"),
1439 chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0)`, nil), at("asked-to-come-back"),
1440 waitForID("navgo"), at("came-back"),
1441 chromedp.Sleep(900*time.Millisecond), // more than the hold
1442 chromedp.Evaluate(backStateJS, &heldBack),
1443 ))
1444 tookNothing(t, payloads, "left during the hold")
1445 if !strings.HasPrefix(heldBack, "persisted:true ") {
1446 t.Fatalf("after coming back the page reads %q — it was not restored from the back/forward cache, so this leg proves nothing about a held submit surviving in it", heldBack)
1447 }
1448 if heldBack != wantBack {
1449 t.Errorf("back on the page after leaving during the hold it reads\n %q\nwant\n %q — the form must be handed back", heldBack, wantBack)
1450 }
1451
1452 // ── 8. A script's submit, with no button ─────────────────────────
1453 //
1454 // requestSubmit() from a script (Enter in a quick-add field, a file
1455 // dropped on a page) has no submitter, so nothing shows a spinner and
1456 // nothing is held: it goes at once. The form is still guarded.
1457 scriptAt := time.Now()
1458 fail(chromedp.Run(ctx,
1459 chromedp.Navigate(rig.Origin+"/"), at("navigated-for-script-submit"),
1460 chromedp.WaitVisible(`#save`, chromedp.ByQuery),
1461 chromedp.ActionFunc(func(context.Context) error { scriptAt = time.Now(); return nil }),
1462 chromedp.Evaluate(`document.getElementById("two").requestSubmit()`, nil), at("script-submitted"),
1463 ))
1464 if got := took(t, payloads, "script submit"); got != "note=hello" {
1465 t.Errorf("the script's submit sent %q, want %q", got, "note=hello")
1466 }
1467 if waited := time.Since(scriptAt); waited > 450*time.Millisecond {
1468 t.Errorf("a script's submit with no button took %v to arrive; with no spinner it must not be held", waited)
1469 }
1470 fail(chromedp.Run(ctx,
1471 chromedp.Evaluate(`document.getElementById("two").requestSubmit()`, nil), at("script-resubmitted"),
1472 ))
1473 tookNothing(t, payloads, "script submit, second")
1474}
1475
1476// ── Bidirectional text: the name cell ────────────────────────────────
1477
1478// bidiRowPage renders the documented list-row name cell twice with the
1479// same right-to-left name in it: once wrapped in <bdi>, as the library's
1480// own sample now writes it, and once bare, as it was written before.
1481//
1482// The bare one is the control, and this drive is worthless without it.
1483// The whole claim is that <bdi> changes where the browser puts things,
1484// so a run that cannot show the unwrapped version going wrong is a run
1485// that proves the wrapper does nothing.
1486func bidiRowPage(t *testing.T) http.Handler {
1487 t.Helper()
1488 // "Ali Muhammad" — a real name in a script the framework ships a
1489 // locale for. The row shape is [rst-nm] with a <small> under it, and
1490 // tokens.css makes that <small> a block, so the name, the separator
1491 // and the time are one bidi paragraph. That is the whole setup.
1492 return sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "bidiRowPage", "")})
1493}
1494
1495// bidiOrderJS reports, for each row, whether the name is drawn to the
1496// LEFT of the time — which is the order an English reader of an
1497// left-to-right table expects, whatever script the name is in.
1498//
1499// Measured with a Range over the text rather than off elements, because
1500// the bare row has no element around its name to measure and the point
1501// is to compare the two.
1502const bidiOrderJS = `(() => {
1503 function rectOf(root, needle) {
1504 const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1505 let n;
1506 while ((n = w.nextNode())) {
1507 const i = n.textContent.indexOf(needle);
1508 if (i >= 0) {
1509 const r = document.createRange();
1510 r.setStart(n, i); r.setEnd(n, i + needle.length);
1511 const b = r.getBoundingClientRect();
1512 return Math.round(b.x);
1513 }
1514 }
1515 return null;
1516 }
1517 const out = {};
1518 for (const id of ["wrapped", "bare"]) {
1519 const el = document.getElementById(id);
1520 out[id] = { name: rectOf(el, "علي"), time: rectOf(el, "09:12") };
1521 }
1522 return JSON.stringify(out);
1523})()`
1524
1525// TestABidiIsolatedNameKeepsItsRowInOrder is the gate for the one
1526// genuine bidirectional-text defect in this library's markup, and for
1527// the shape of the fix.
1528//
1529// A person's name is user-supplied and can be in any script. Put one
1530// inline before a separator and a time — which is exactly what the name
1531// cell's <small> does — and the browser's bidi algorithm treats the
1532// digits as part of the right-to-left run: European numbers after an
1533// Arabic letter become Arabic numbers, and Arabic numbers order
1534// right-to-left. The time is then drawn to the LEFT of the name it
1535// belongs to, in a table every other row of which reads left to right.
1536//
1537// Nothing about that is visible to a Go test, to a screenshot of the
1538// English page, or to a reader who does not use the language. It is
1539// measured here or it is not caught.
1540//
1541// What was measured before writing this, and what it changed: the
1542// PARTIALS turned out not to have the defect. The obvious suspect was
1543// job-status, which puts a user-supplied job name inline —
1544// "<strong>NAME</strong> is running — 42…" — and it renders identically
1545// with and without isolation, because the Latin words "is running" sit
1546// between the name and the number and insulate them. Wrapping it would
1547// have been a change with no defect under it. The defect is in the name
1548// cell, where nothing insulates.
1549func TestABidiIsolatedNameKeepsItsRowInOrder(t *testing.T) {
1550 rig := harness.New(t, func(string) http.Handler { return bidiRowPage(t) })
1551 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
1552 defer cancel()
1553
1554 var raw string
1555 if err := chromedp.Run(ctx,
1556 chromedp.Navigate(rig.Origin+"/"),
1557 chromedp.WaitVisible("#wrapped", chromedp.ByQuery),
1558 chromedp.Sleep(300*time.Millisecond),
1559 chromedp.Evaluate(bidiOrderJS, &raw),
1560 ); err != nil {
1561 t.Fatalf("measuring the rows: %v", err)
1562 }
1563 var got map[string]struct{ Name, Time *int }
1564 if err := json.Unmarshal([]byte(raw), &got); err != nil {
1565 t.Fatalf("decoding %q: %v", raw, err)
1566 }
1567 for _, id := range []string{"wrapped", "bare"} {
1568 if got[id].Name == nil || got[id].Time == nil {
1569 t.Fatalf("%s: could not find the name and the time in the rendered row (%q)", id, raw)
1570 }
1571 }
1572
1573 // THE CONTROL, first. Without it a green run means nothing: if the
1574 // engine ever stopped reordering the bare row, the assertion below
1575 // would pass on a page where <bdi> was doing no work at all, and
1576 // somebody would later delete it as decoration.
1577 bare := got["bare"]
1578 if *bare.Name < *bare.Time {
1579 t.Fatalf("the unwrapped row draws the name at x=%d and the time at x=%d, already in order — this engine is not reordering, so the wrapped row below proves nothing about <bdi>",
1580 *bare.Name, *bare.Time)
1581 }
1582 t.Logf("control: unwrapped, the time is drawn at x=%d and the name at x=%d — the time has jumped to the left of the name it belongs to",
1583 *bare.Time, *bare.Name)
1584
1585 // And the fix.
1586 w := got["wrapped"]
1587 if *w.Name >= *w.Time {
1588 t.Errorf("with <bdi> the name is at x=%d and the time at x=%d; the name has to come first in a left-to-right row. The isolation is not working",
1589 *w.Name, *w.Time)
1590 }
1591}
1592
1593// TestTheLibrarysOwnSamplesIsolateAName holds the samples to the rule
1594// the drive above establishes. A gate on the mechanism with no gate on
1595// the markup leaves the library shipping an example of the defect.
1596func TestTheLibrarysOwnSamplesIsolateAName(t *testing.T) {
1597 pieces, err := example.All(fiesta.FS())
1598 if err != nil {
1599 t.Fatal(err)
1600 }
1601 found := false
1602 for _, p := range pieces {
1603 if p.Name != "list-grid" {
1604 continue
1605 }
1606 found = true
1607 if !strings.Contains(p.Markup, "<bdi>Grace Hopper</bdi>") {
1608 t.Errorf("the list-grid sample (%s) does not isolate the person's name. It is the markup readers copy for a row, and an unwrapped name reorders the row it is in:\n%s", p.State, p.Markup)
1609 }
1610 }
1611 if !found {
1612 t.Fatal("no list-grid sample to check")
1613 }
1614}
1615
1616// axName is an element's accessible name as the browser computes it for
1617// assistive technology — the only reading that sees CSS and aria-hidden.
1618// GetPartialAXTree rather than GetFullAXTree: the full tree carries an
1619// ignored-reason the vendored cdproto cannot unmarshal.
1620func axName(ctx context.Context, t *testing.T, sel string) string {
1621 t.Helper()
1622 var nodes []*cdp.Node
1623 if err := chromedp.Run(ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)); err != nil || len(nodes) == 0 {
1624 t.Fatalf("axName: no node for %s: %v", sel, err)
1625 }
1626 var name string
1627 err := chromedp.Run(ctx, chromedp.ActionFunc(func(c context.Context) error {
1628 ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c)
1629 if err != nil {
1630 return err
1631 }
1632 if len(ax) > 0 && ax[0].Name != nil {
1633 var v string
1634 if json.Unmarshal(ax[0].Name.Value, &v) == nil {
1635 name = v
1636 }
1637 }
1638 return nil
1639 }))
1640 if err != nil {
1641 t.Fatalf("axName(%s): %v", sel, err)
1642 }
1643 return name
1644}
1645