| 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. |
| 8 | package check |
| 9 | |
| 10 | import ( |
| 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. |
| 36 | func 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. |
| 65 | func 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. |
| 89 | func 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. |
| 294 | func 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. |
| 323 | func 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. |
| 358 | const 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. |
| 382 | func 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. |
| 554 | func 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". |
| 573 | func 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 | })()`, ¢red), |
| 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. |
| 663 | const 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. |
| 688 | const 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. |
| 716 | const 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". |
| 729 | const 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. |
| 742 | const 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". |
| 757 | const 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. |
| 771 | const 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. |
| 791 | func 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. |
| 810 | const 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. |
| 842 | func 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. |
| 869 | func 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. |
| 882 | func 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. |
| 912 | func 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¬e=hello" { |
| 1107 | t.Errorf("the server received %q, want %q — the clicked button's name/value was dropped from the payload", got, "action=save¬e=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¬e=hello" { |
| 1415 | t.Errorf("with scripts off the server received %q, want %q — the shim changed the scriptless submit", got, "action=save¬e=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. |
| 1486 | func 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. |
| 1502 | const 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. |
| 1549 | func 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. |
| 1596 | func 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. |
| 1620 | func 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 | |