//go:build browser // The menu idioms, the modal panel, the busy rule and the name cell in // a browser: the A half of rastrillo's ui/browser_test.go at the seed commit, // each drive reading its page through fixture so the page it drives // is the page the rastrillo drive drove. The select and date journeys // that made up the file's other half are fields_browser_test.go. package check import ( "context" "encoding/json" "fmt" "math" "net/http" "strings" "testing" "time" "github.com/chromedp/cdproto/accessibility" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // received answers a POST on path with status, and hands the form's // one field (or, with field empty, the whole encoded form) to a // buffered channel, so a drive can check what the server got. A 204 // goes out with no body, as the busy drive's server answered; any // other status carries a page that says the post was received. func received(mux *http.ServeMux, path, field string, got chan string, status int) { mux.HandleFunc("POST "+path, func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() v := r.PostForm.Encode() if field != "" { v = r.PostFormValue(field) } select { case got <- v: default: } if status == http.StatusNoContent { w.WriteHeader(status) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) fmt.Fprint(w, `ok

received

`) }) } // groupPage serves one form carrying two hand-written selects: a // grouped one that should enhance, and a large one that says no, and // records what a submit delivers. // // Hand-written rather than rendered through field-select, because // field-select's Options are flat — the partial has no optgroup to // emit. Grouped selects are exactly the case an app writes by hand, so // that is what the drive drives. func groupPage(t *testing.T) (http.Handler, chan string) { t.Helper() got := make(chan string, 1) mux := sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "groupPage", "")}) received(mux, "/submit", "city", got, http.StatusOK) return mux, got } // TestGroupedSelectRendersItsGroups drives the two things a Go test // cannot see: that a grouped select keeps its groups on the way into // the combobox, and that a select saying data-rst-select="false" is // left alone. // // Bug classes it exists to catch — each renders perfectly and says // nothing wrong: // // - the mirror flattens native.options, so the headings the author // wrote to make a long list readable silently vanish; // - a search leaves the headings up over rows it has re-ranked, or // clearing it leaves a lifted row outside its own group; // - the headings join the keyboard order, so arrowing down lands on a // heading and Enter commits the wrong option — or nothing; // - the opt-out is read as a truthy attribute (it is present, after // all) and the select enhances anyway. func TestGroupedSelectRendersItsGroups(t *testing.T) { mux, submitted := groupPage(t) rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancelTimeout := context.WithTimeout(rig.Context(), 180*time.Second) defer cancelTimeout() var ( comboCount, optOutEnhanced int optOutVisible bool groupLabels, headings string headingsAreOptions int openGroups, openOptions int narrowedGroups, narrowedOptions int narrowedHeadings string activeText, nativeValue, carrierShape string restored string ) reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } // Poll rather than sample: the list is redrawn on every keystroke, // and under load a probe fired straight after SendKeys reads the // list as it was. Waiting on the observable state turns a drifted // keystroke into a fast failure at the step that did not happen. until := func(name, js string) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { deadline := time.Now().Add(10 * time.Second) for { var ok bool if err := chromedp.Evaluate(js, &ok).Do(ctx); err != nil { return err } if ok { return nil } if time.Now().After(deadline) { var snap string _ = chromedp.Evaluate(`JSON.stringify({ options: document.querySelectorAll('[role="option"]').length, groups: document.querySelectorAll('[role="group"]').length, headings: Array.from(document.querySelectorAll('[rst-select-group]')).map(function (h) { return h.textContent }), inputValue: (function (i) { return i ? i.value : "no-input" })(document.querySelector('input[role="combobox"]')), })`, &snap).Do(ctx) return fmt.Errorf("%s never became true within 10s; widget state: %s", name, snap) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(100 * time.Millisecond): } } }) } if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`input[role="combobox"]`, chromedp.ByQuery), at("combobox-visible"), // Exactly one enhancement on a page holding two selects, both of // which carry the attribute. chromedp.Evaluate(`document.querySelectorAll('input[role="combobox"]').length`, &comboCount), chromedp.Evaluate(`document.querySelectorAll('select[name="team"][data-rst-enhanced]').length`, &optOutEnhanced), // Untouched means untouched: still a real, visible native select. chromedp.Evaluate(`(document.querySelector('select[name="team"]')?.getBoundingClientRect().width ?? 0) > 40`, &optOutVisible), // The grouped native survives as the carrier, groups and all. chromedp.Evaluate(`(function () { var s = document.querySelector('select[name="city"]'); if (!s) return "no-select"; return s.querySelectorAll('optgroup').length + "/" + s.options.length; })()`, &carrierShape), // Open it: focus draws the unfiltered list. The select is required, // so its blank ("Choose a city") is a prompt and never a row. chromedp.Click(`input[role="combobox"]`, chromedp.ByQuery), at("clicked-combobox"), until("the grouped list is drawn", `document.querySelectorAll('[role="option"]:not([style*="none"])').length === 5`), at("list-drawn"), chromedp.Evaluate(`document.querySelectorAll('[role="group"]:not([style*="none"])').length`, &openGroups), chromedp.Evaluate(`document.querySelectorAll('[role="option"]:not([style*="none"])').length`, &openOptions), chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]')).map(function (g) { return g.getAttribute('aria-label') }).join(',')`, &groupLabels), chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]:not([style*="none"]) [rst-select-group]')).map(function (h) { return h.textContent }).join(',')`, &headings), // A heading is furniture, never a pick. chromedp.Evaluate(`document.querySelectorAll('[rst-select-group][role="option"]').length`, &headingsAreOptions), // Four arrows from nothing highlighted (an unanswered question // opens with nothing): Dublin, Cork, Galway, then Madrid, the first // row of the SECOND group. Were the headings in the keyboard order, // the fourth would land on the "Spain" heading instead. chromedp.SendKeys(`input[role="combobox"]`, strings.Repeat(string(kb.ArrowDown), 4), chromedp.ByQuery), at("arrow-down-4"), until("a row is highlighted", `document.querySelectorAll('[role="option"].is-active').length === 1`), at("row-highlighted"), chromedp.Evaluate(`document.querySelector('[role="option"].is-active')?.textContent ?? ''`, &activeText), // Filter to "ad": only Madrid matches. A search ranks across groups, // so the headings step aside and the one row stands alone. chromedp.SendKeys(`input[role="combobox"]`, "ad", chromedp.ByQuery), at("typed-ad"), until("the list narrows to one", `document.querySelectorAll('[role="option"]:not([style*="none"])').length === 1`), at("narrowed-to-one"), chromedp.Evaluate(`document.querySelectorAll('[role="group"]:not([style*="none"])').length`, &narrowedGroups), chromedp.Evaluate(`document.querySelectorAll('[role="option"]:not([style*="none"])').length`, &narrowedOptions), chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]:not([style*="none"]) [rst-select-group]')).map(function (h) { return h.textContent }).join(',')`, &narrowedHeadings), // Clear it: every row is back in its own group, in its own place, // under its own heading. chromedp.Evaluate(`(function () { var input = document.querySelector('input[role="combobox"]'); input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`, nil), at("cleared"), until("the list is whole again", `document.querySelectorAll('[role="option"]:not([style*="none"])').length === 5`), chromedp.Evaluate(`Array.from(document.querySelectorAll('[role="group"]:not([style*="none"])')).map(function (g) { return g.getAttribute('aria-label') + ':' + Array.from(g.querySelectorAll('[role="option"]')).map(function (o) { return o.textContent }).join('|'); }).join(';')`, &restored), // Filter again, to the one row, and take it. chromedp.SendKeys(`input[role="combobox"]`, "ad", chromedp.ByQuery), at("typed-ad-again"), until("the one row is highlighted", `(document.querySelector('[role="option"].is-active')?.textContent ?? '') === 'Madrid'`), // The sole match commits on Enter, and mirrors onto the carrier. chromedp.SendKeys(`input[role="combobox"]`, string(kb.Enter), chromedp.ByQuery), at("enter"), until("the mirror commits", `(document.querySelector('select[name="city"]')?.value ?? '') !== ''`), at("read-mirrored-value"), chromedp.Evaluate(`document.querySelector('select[name="city"]')?.value ?? ''`, &nativeValue), chromedp.Submit(`#go`, chromedp.ByQuery), at("submitted-form"), chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("server-responded"), ); err != nil { t.Fatalf("drive failed after %q: %v\n groupLabels=%q headings=%q activeText=%q nativeValue=%q", reached, err, groupLabels, headings, activeText, nativeValue) } if comboCount != 1 { t.Errorf("expected exactly one combobox, found %d", comboCount) } if optOutEnhanced != 0 { t.Errorf(`data-rst-select="false" enhanced anyway (%d enhanced selects): the opt-out is being read as a present attribute`, optOutEnhanced) } if !optOutVisible { t.Error(`the opted-out select is no longer a visible native select; "false" must leave it entirely alone`) } if carrierShape != "2/6" { t.Errorf("the native carrier reads %q, want %q: enhancement must not touch the select", carrierShape, "2/6") } if openGroups != 2 { t.Errorf("the open list holds %d ARIA groups, want 2: the optgroups were flattened away", openGroups) } if openOptions != 5 { t.Errorf("the open list holds %d options, want 5", openOptions) } if groupLabels != "Ireland,Spain" { t.Errorf("group aria-labels are %q, want %q: the optgroup labels did not carry over", groupLabels, "Ireland,Spain") } if headings != "Ireland,Spain" { t.Errorf("visible group headings are %q, want %q", headings, "Ireland,Spain") } if headingsAreOptions != 0 { t.Errorf("%d group headings carry role=option; a heading is furniture, not a pick", headingsAreOptions) } // Four arrows from nothing highlighted land on the first row of the // second group. A heading in the keyboard order would leave the // highlight on "Spain" (or on "Galway", a step short). if activeText != "Madrid" { t.Errorf("four ArrowDowns highlighted %q, want %q: the keyboard order is not skipping the group headings", activeText, "Madrid") } if narrowedGroups != 0 || narrowedOptions != 1 { 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) } if narrowedHeadings != "" { 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) } if want := "Ireland:Dublin|Cork|Galway;Spain:Madrid|Barcelona"; restored != want { 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) } if nativeValue != "mad" { t.Errorf("the carrier holds %q, want %q — the pick inside a group did not mirror back", nativeValue, "mad") } select { case v := <-submitted: if v != nativeValue { t.Errorf("server received city=%q, the select held %q", v, nativeValue) } case <-time.After(5 * time.Second): t.Fatal("the form never reached the server") } rig.Screen("body", "after the grouped journey") } // ── The menu idioms: exclusivity, light dismiss, and the dialog panel ── // menuPage serves one page carrying every menu shape at once, plus the // two
that must stay OUT of the exclusivity group, plus the // real shim. Everything on it is the library's own markup: the shell // account dropdown from the topbar layout's sample, a row menu from the // list grid, a nested rst-menu-group inside the dropdown, the sidebar's // chrome strip and a toggle-block. // // Hand-assembled at the seed commit rather than rendered from the // samples because the two shell samples are whole page frames that // cannot both be on one page, and because the drive needs stable ids // to click. func menuPage(t *testing.T) http.Handler { t.Helper() // The mid-upgrade window, in the one spelling this repository no // longer writes anywhere: an app that has taken the new module and // the new fiesta.js and has not run `rastrillo markup` yet. Its // markup is still classes; its shim is this one. If MENUS only // matched attributes, this page's menu would stop light-dismissing // and nothing in this repository would ever have noticed. // markup-spelling: old-spelling begin — the /old-spelling fixture is // markup in the spelling this repository retired, on purpose. It is // what an app looks like mid-upgrade, and nothing else in the suite // is written that way. return sizingMux(t, map[string]string{ "/": fixture(t, "browser_test.go", "menuPage", ""), "/old-spelling": fixture(t, "browser_test.go", "menuPage", "old-spelling"), }) // markup-spelling: old-spelling end } // TestLightDismissWorksInBothSpellings is the mid-upgrade window, made // a test. The runbook is `rastrillo doctor --fix` and then // `rastrillo markup --fix`; between them an app runs this shim against // markup that is still classes. tokens.css pairs both spellings for // exactly that window, and the shim has to as well, or the upgrade path // we hand people breaks under them: menus that open and never close on // an outside click or Escape. // // The page is deliberately written in the spelling this repository no // longer emits anywhere, so nothing else in the suite covers it. func TestLightDismissWorksInBothSpellings(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return menuPage(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() const openState = `["account","rowmenu"].` + `filter(function (id) { return document.getElementById(id).open }).join(",")` var opened, afterOutside, afterEsc string if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/old-spelling"), chromedp.WaitVisible(`#account-summary`, chromedp.ByQuery), chromedp.Click(`#account-summary`, chromedp.ByQuery), chromedp.Evaluate(openState, &opened), chromedp.Click(`#elsewhere`, chromedp.ByQuery), chromedp.Evaluate(openState, &afterOutside), chromedp.Click(`#rowmenu-summary`, chromedp.ByQuery), chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(openState, &afterEsc), ); err != nil { t.Fatalf("driving the class-spelled menus: %v", err) } if opened != "account" { t.Fatalf("the class-spelled dropdown did not open at all (open = %q); the rest of this test proves nothing", opened) } if afterOutside != "" { t.Errorf("after a click outside, open = %q, want none — a class-spelled menu does not light-dismiss, "+ "so an app between `doctor --fix` and `markup --fix` has menus that never close", afterOutside) } if afterEsc != "" { t.Errorf("after Escape, open = %q, want none — same window, same break, by keyboard", afterEsc) } } // openState reads which of the page's
are open, as one string, // so a failure names the whole state rather than one boolean. const openStateJS = `["account","filter","submenu","rowmenu","chrome"].` + `filter(function (id) { return document.getElementById(id).open }).join(",")` // TestMenuExclusivityAndDropdownDismissDrive is the browser drive for // the two halves of the menu contract that no Go test can reach: the // native
group, which needs a real engine implementing // it, and the shim's light dismiss, which needs real clicks and real // focus. // // Bug classes it exists to catch — every one of them renders perfectly // and reports nothing wrong: // // - a menu emitted with no name attribute at all, so two menus sit // open on top of each other; // - the nested rst-menu-group sharing its parent's group, so opening a // submenu closes the menu around it and the submenu vanishes with // it — the exact trap the partial docs warn about; // - light dismiss closing the menu the user is clicking inside, so // every menu item click misses; // - light dismiss bound per element, so a menu that arrived after load // never dismisses; // - the old sidebar chrome strip or the toggle-block swept into the // group or the dismiss, so the narrow-screen nav rail closes when a // filter opens, or the shell menu card left out of the dismiss. func TestMenuExclusivityAndDropdownDismissDrive(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return menuPage(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() // The same last-step breadcrumb the select drive keeps: a bare // deadline names nothing. reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } var ( afterAccount, afterRow, afterSubmenu string afterOutside, afterReopen string afterInside, afterEsc string focusAfterEsc string focusInSubmenu string afterSubEsc, focusAfterSubEsc string tblockOpenAfter bool ) if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`#account-summary`, chromedp.ByQuery), at("page-visible"), // 1. Open the header dropdown. chromedp.Click(`#account-summary`, chromedp.ByQuery), at("opened-account"), chromedp.Evaluate(openStateJS, &afterAccount), // 2. Open a row menu. The header dropdown must close, natively: // same name, one group, no script involved. chromedp.Click(`#rowmenu-summary`, chromedp.ByQuery), at("opened-rowmenu"), chromedp.Evaluate(openStateJS, &afterRow), // 3. Open the filter dropdown, then the submenu inside it. The // parent MUST still be open:
exclusivity is // document-wide, so a shared name here would have closed it. chromedp.Click(`#filter-summary`, chromedp.ByQuery), at("opened-filter"), chromedp.Click(`#submenu-summary`, chromedp.ByQuery), at("opened-submenu"), chromedp.Evaluate(openStateJS, &afterSubmenu), // 4. Click somewhere that is not a menu. Every menu closes — the // nested submenu included, which the
group // cannot do for it and must not: dismissal is not exclusivity. // The chrome strip is left alone. chromedp.Click(`#elsewhere`, chromedp.ByQuery), at("clicked-outside"), chromedp.Evaluate(openStateJS, &afterOutside), // 4b. Reopen the parent. The submenu must NOT come back open: a // menu that remembers a state the user cannot see is the bug // that made submenus join the dismiss selector in the first // place. chromedp.Click(`#filter-summary`, chromedp.ByQuery), at("reopened-filter"), chromedp.Evaluate(openStateJS, &afterReopen), chromedp.Click(`#elsewhere`, chromedp.ByQuery), at("cleared-again"), // 5. Reopen, then click an item INSIDE the open menu. It must // survive: a light dismiss that fires on its own menu's items // makes every one of them unclickable. chromedp.Click(`#account-summary`, chromedp.ByQuery), at("reopened-account"), chromedp.Click(`#account-item`, chromedp.ByQuery), at("clicked-inside"), chromedp.Evaluate(openStateJS, &afterInside), // 6. Escape closes it and hands focus back to the summary, rather // than stranding the keyboard user at the top of the document. chromedp.KeyEvent(kb.Escape), at("pressed-escape"), chromedp.Evaluate(openStateJS, &afterEsc), chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &focusAfterEsc), // 7. Escape with focus INSIDE an open submenu. Everything closes, // and focus must land on the PARENT dropdown's summary — not // the submenu's, which by then sits inside a closed parent and // is not rendered, so focusing it would drop focus to // and strand the keyboard user at the top of the document. // This is the leg that exercises the outermost-menu climb; // without it that code is only ever proven by reading it. chromedp.Click(`#filter-summary`, chromedp.ByQuery), at("reopened-filter-for-escape"), chromedp.Click(`#submenu-summary`, chromedp.ByQuery), at("reopened-submenu"), // Focus rather than click: what this leg is about is where focus // goes, so it has to start from a focus this test put there on // purpose rather than one a click happened to leave behind. chromedp.Focus(`#submenu-item`, chromedp.ByQuery), at("focused-in-submenu"), chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &focusInSubmenu), chromedp.KeyEvent(kb.Escape), at("escape-from-submenu"), chromedp.Evaluate(openStateJS, &afterSubEsc), chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &focusAfterSubEsc), // The toggle-block is not a
at all, so nothing above can // have touched it — but it is the other thing the brief holds // outside the group, so read it rather than assume it. chromedp.Evaluate(`document.getElementById("tblock-input").checked`, &tblockOpenAfter), ); err != nil { t.Fatalf("drive failed after %q: %v", reached, err) } if afterAccount != "account,chrome" { t.Errorf("after opening the account menu, open = %q, want %q", afterAccount, "account,chrome") } if afterRow != "rowmenu,chrome" { t.Errorf("after opening a row menu, open = %q, want %q — the shared
group did not close the header dropdown", afterRow, "rowmenu,chrome") } if afterSubmenu != "filter,submenu,chrome" { 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") } if afterOutside != "chrome" { 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") } if afterReopen != "filter,chrome" { t.Errorf("reopening the parent gave open = %q, want %q — the submenu was left open behind its closing parent", afterReopen, "filter,chrome") } if afterInside != "account,chrome" { t.Errorf("after clicking an item inside the open menu, open = %q, want %q — light dismiss closed the menu being used", afterInside, "account,chrome") } if afterEsc != "chrome" { t.Errorf("after Escape, open = %q, want %q", afterEsc, "chrome") } if focusAfterEsc != "account-summary" { t.Errorf("focus after Escape is %q, want the summary that opened the menu", focusAfterEsc) } // The premise first: if focus never reached the submenu, the two // assertions below would pass for the wrong reason. if focusInSubmenu != "submenu-item" { t.Fatalf("focus before Escape is %q, want %q — this leg proves nothing unless focus starts inside the submenu", focusInSubmenu, "submenu-item") } if afterSubEsc != "chrome" { t.Errorf("after Escape from inside the submenu, open = %q, want %q", afterSubEsc, "chrome") } if focusAfterSubEsc != "filter-summary" { 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 ", focusAfterSubEsc, "filter-summary") } if !tblockOpenAfter { t.Error("the toggle-block's switch was flipped by the menu handling; it is not a menu") } // The shell menu joined light dismiss when it became a card: an // outside click closes it, and Escape from a link in its tail (its content, a sibling) // closes it and hands focus to its summary. The legacy chrome strip // is still left alone. At 390, where the topbar has its Menu: at the // default width tokens.css hides it. var shellOpened, shellAfterOutside, shellAfterEsc bool var shellFocus string if err := chromedp.Run(ctx, chromedp.EmulateViewport(390, 844), chromedp.WaitVisible(`#shellmenu-summary`, chromedp.ByQuery), chromedp.Click(`#shellmenu-summary`, chromedp.ByQuery), chromedp.Evaluate(`document.getElementById("shellmenu").open`, &shellOpened), chromedp.Click(`#elsewhere`, chromedp.ByQuery), chromedp.Evaluate(`document.getElementById("shellmenu").open`, &shellAfterOutside), chromedp.Click(`#shellmenu-summary`, chromedp.ByQuery), chromedp.Focus(`#tail-link`, chromedp.ByQuery), chromedp.KeyEvent(kb.Escape), chromedp.Evaluate(`document.getElementById("shellmenu").open`, &shellAfterEsc), chromedp.Evaluate(`document.activeElement ? (document.activeElement.id || document.activeElement.tagName) : "none"`, &shellFocus), ); err != nil { t.Fatalf("driving the shell menu: %v", err) } // The premise: a menu that never opened is closed after anything. if !shellOpened { t.Fatal("the shell menu did not open on its summary; the dismiss leg below proves nothing") } if shellAfterOutside || shellAfterEsc || shellFocus != "shellmenu-summary" { t.Errorf("shell menu: open after an outside click %v, after Escape %v, focus %q; want closed, closed, shellmenu-summary", shellAfterOutside, shellAfterEsc, shellFocus) } rig.Screen("body", "after the menu journey") } // modalPage serves the modal sample as a real page, exactly as // rastrillo's Styleguide()["modal"] wrote it at the seed commit // (the same markup is the modal example's default state), so // the drive reads the shipped markup rather than a copy of it. func modalPage(t *testing.T) http.Handler { t.Helper() return sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "modalPage", "")}) } // TestModalDialogPanelDrive proves the panel is a rendered // dialog and not a modal one, and that the scoped resets in tokens.css // actually undo the UA dialog block. // // Bug classes it exists to catch — all of which look fine in the markup: // // - the UA dialog block left in place, so the panel is absolutely // positioned with 1em of padding and sits wherever the viewport puts // it instead of centred in the overlay; // - the panel reaching the top layer (a showModal() creeping in), which // would put it above the scrim's stacking context, paint a ::backdrop // nobody styled, and trap Escape; // - the dialog rendering with Canvas colours instead of the theme's, // which reads as "the panel ignores the theme". func TestModalDialogPanelDrive(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return modalPage(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() var ( tag, position, padding string isOpen, inTopLayer bool centred, insideOverlay bool backdropInert bool labelledBy, labelText string ) if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-modal-panel]`, chromedp.ByQuery), chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").tagName`, &tag), chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").open === true`, &isOpen), // matches(":modal") is true only for a dialog in the top layer, // which is what showModal() does and a rendered-open one never // does. This is the assertion that the zero-JS promise holds. chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").matches(":modal")`, &inTopLayer), chromedp.Evaluate(`getComputedStyle(document.querySelector("[rst-modal-panel]")).position`, &position), chromedp.Evaluate(`getComputedStyle(document.querySelector("[rst-modal-panel]")).paddingTop`, &padding), chromedp.Evaluate(`document.querySelector("[rst-modal-overlay]").contains(document.querySelector("[rst-modal-panel]"))`, &insideOverlay), // Centred inside the overlay's flex box, within a pixel: the UA // block's absolute positioning and auto margins would put it // somewhere else entirely. chromedp.Evaluate(`(function () { var o = document.querySelector("[rst-modal-overlay]").getBoundingClientRect(); var p = document.querySelector("[rst-modal-panel]").getBoundingClientRect(); return Math.abs((o.left + o.right) / 2 - (p.left + p.right) / 2) < 2; })()`, ¢red), chromedp.Evaluate(`document.querySelector("[rst-backdrop]").hasAttribute("inert")`, &backdropInert), // The dialog role has to be named. Resolve aria-labelledby the way // an AT would — follow the id to the element and read its text — // rather than trusting the attribute is pointing at anything. chromedp.Evaluate(`document.querySelector("[rst-modal-panel]").getAttribute("aria-labelledby") ?? ""`, &labelledBy), chromedp.Evaluate(`(function () { var id = document.querySelector("[rst-modal-panel]").getAttribute("aria-labelledby"); var el = id ? document.getElementById(id) : null; return el ? el.textContent.trim() : ""; })()`, &labelText), ); err != nil { t.Fatalf("modal drive failed: %v", err) } if tag != "DIALOG" { t.Errorf("the modal panel is a <%s>, want ", strings.ToLower(tag)) } if !isOpen { t.Error("the dialog is not open; a modal route renders it already open, since nothing here can open it") } if inTopLayer { t.Error("the panel is in the top layer: something called showModal(), and the idiom is zero-JS") } if position != "static" { t.Errorf("the panel's position is %q, want %q — the UA dialog block was not reset", position, "static") } if padding != "0px" { t.Errorf("the panel's padding-top is %q, want %q — the UA dialog block's 1em survived", padding, "0px") } if !insideOverlay { t.Error("the panel is no longer inside the overlay div, which is the scrim") } if !centred { t.Error("the panel is not centred in the overlay; the UA dialog block is still positioning it") } if !backdropInert { t.Error("the page behind the panel lost its inert attribute") } if labelledBy == "" { t.Error("the dialog has no aria-labelledby: a dialog role with no name is announced as \"dialog\" and nothing else") } // Resolved through the id, not merely present: an aria-labelledby // pointing at an id that is not in the document names nothing at all, // and looks entirely correct in the markup. if labelText == "" { t.Errorf("aria-labelledby=%q resolves to no text; the dialog's accessible name is empty", labelledBy) } rig.Screen("[rst-modal-panel]", "the open modal") } // ── The busy rule ──────────────────────────────────────────────────── // swapStateJS reads the busy button whose spinner replaces its label. // "Ink" is what paints: a transparent text fill, a zero opacity, or a // transparent ring all read as none. Width is read on its own, because // only the default leg expects it unchanged. const swapStateJS = `(function () { var b = document.getElementById("swapgo"); var icon = document.getElementById("swapicon"); var spin = b.querySelector("[rst-spin]"); var cs = getComputedStyle(b); var clear = "rgba(0, 0, 0, 0)"; var sc = spin && getComputedStyle(spin); var br = b.getBoundingClientRect(); var sr = spin && spin.getBoundingClientRect(); var centred = !!spin && Math.abs((sr.left + sr.width / 2) - (br.left + br.width / 2)) < 1 && Math.abs((sr.top + sr.height / 2) - (br.top + br.height / 2)) < 1; return [ "label-ink:" + (cs.webkitTextFillColor === clear ? "none" : "shown"), "icon-ink:" + (parseFloat(getComputedStyle(icon).opacity) === 0 ? "none" : "shown"), "spin-ink:" + (sc && sc.borderTopColor !== clear && spin.offsetWidth > 6 ? "shown" : "none"), "spin-centred:" + centred, ].join(" "); })()` // busyStateJS reads the whole busy state of the two-button form in one // round trip: the attributes, the swapped label, the untouched sibling, // and — the part an attribute check would miss — whether the spinner is // really on screen and really turning, out of the computed style rather // than out of the markup. const busyStateJS = `(function () { var f = document.getElementById("two"); var s = document.getElementById("save"); var d = document.getElementById("draft"); var spin = s.querySelector("[rst-spin]"); var cs = spin && getComputedStyle(spin); return [ "form:" + (f.getAttribute("aria-busy") || "-"), "save:" + (s.getAttribute("aria-busy") || "-"), "save-off:" + s.disabled, "save-text:" + s.textContent.trim(), "spin-first:" + (spin ? s.firstElementChild === spin : false), "spin-hidden:" + (spin ? spin.getAttribute("aria-hidden") : "-"), "spin-anim:" + (cs ? cs.animationName : "-"), // offsetWidth, not getBoundingClientRect: a rect is the rotated // box, so a 4px ring that only LOOKS bigger because it is spinning // would pass a rect check and fail the reduced-motion one. "spin-shown:" + (spin ? (spin.offsetWidth > 6 && spin.offsetHeight > 6 && cs.display !== "none" && cs.visibility === "visible" && parseFloat(cs.opacity) > 0) : false), "draft:" + (d.getAttribute("aria-busy") || "-"), "draft-off:" + d.disabled, "draft-value:" + d.value, ].join(" "); })()` // invalidStateJS is the same reading for the form the browser refuses: // nothing about it may look as though it is on its way anywhere. const invalidStateJS = `(function () { var f = document.getElementById("needs-title"); var b = document.getElementById("try"); return [ "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "btn-off:" + b.disabled, "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), "valid:" + f.checkValidity(), ].join(" "); })()` // optOutStateJS reads the form that said data-busy="false". const optOutStateJS = `(function () { var f = document.getElementById("out"); var b = document.getElementById("optout"); return [ "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "btn-off:" + b.disabled, "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), ].join(" "); })()` // quietStateJS reads the form whose BUTTON said data-busy="false": the // form is still guarded, the button just declines the loading state. const quietStateJS = `(function () { var f = document.getElementById("quiet"); var b = document.getElementById("quietbtn"); return [ "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "btn-off:" + b.disabled, "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), ].join(" "); })()` // shadowStateJS proves the premise of the shadowing leg before the leg // asserts anything: a control named "target" really has replaced // form.target with itself, so the property read the shim must not make // really would be truthy and really would not be "_self". const shadowStateJS = `(function () { var f = document.getElementById("shadow"); var b = document.getElementById("shadowgo"); return [ "shadowed:" + (f.target && f.target.tagName === "INPUT"), "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), ].join(" "); })()` // backStateJS reads the form the visitor navigated away from and came // back to. Everything the busy state wrote has to be gone — including // the guard, or the form is a dead end. const backStateJS = `(function () { var f = document.getElementById("nav"); var b = document.getElementById("navgo"); return [ "persisted:" + (window.__persisted === true), "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "btn-off:" + b.disabled, "btn-text:" + b.textContent.trim(), "idle-label:" + (b.getAttribute("data-idle-label") || "-"), "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), ].join(" "); })()` // dialogState reads a dialog close pattern, by ids: the , the // form inside it, and the button that closes it. Two shapes wear this // pattern —
, and an ordinary form whose button // carries formmethod="dialog" — and neither may end up busy, because // neither is on its way anywhere. The dialog's open state is the // assertion that says so in behaviour rather than in flags. func dialogState(dlg, form, btn string) string { return `(function () { var d = document.getElementById("` + dlg + `"); var f = document.getElementById("` + form + `"); var b = document.getElementById("` + btn + `"); return [ "open:" + d.open, "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "btn-off:" + b.disabled, "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), ].join(" "); })()` } // extBackStateJS is backStateJS for the form whose submit button lives // OUTSIDE it and belongs to it through form="ext" — the toolbar Save // the form attribute exists for. The button is not a descendant of the // form, so a reset that sweeps the form's own subtree never reaches it. const extBackStateJS = `(function () { var f = document.getElementById("ext"); var b = document.getElementById("extgo"); return [ "persisted:" + (window.__persisted === true), "inside:" + f.contains(b), "form:" + (f.getAttribute("aria-busy") || "-"), "btn:" + (b.getAttribute("aria-busy") || "-"), "btn-off:" + b.disabled, "btn-text:" + b.textContent.trim(), "idle-label:" + (b.getAttribute("data-idle-label") || "-"), "spin:" + (b.querySelector("[rst-spin]") ? "yes" : "no"), ].join(" "); })()` // busyPage serves the forms the busy drive needs and a submit endpoint // that answers 204. // // The 204 is the whole trick. A form whose response navigates leaves // nothing on the screen to look at — the busy state is gone with the // document before an assertion can reach it, and a drive that sometimes // reads the old page and sometimes the new one is worse than no drive. // A form submission answered with 204 No Content is defined not to // navigate at all: the request is really made, the payload is really // delivered, and the page stays exactly as the submit left it, busy // button and all, for as long as the assertions need. Which is also, // near enough, the situation the rule exists for — a submit that is // under way and has not come back yet. // // The endpoint reports what it received on a buffered channel, which is // how the drive checks the clicked button's name and value survived: // the trap the deferred `disabled` exists to avoid. func busyPage(t *testing.T) (http.Handler, chan string) { t.Helper() payloads := make(chan string, 8) // rastrillo's handler answered every GET path with the busy page, and // the hold leg leaves for /elsewhere and comes back; the shared mux // answers one path per page, so both are the busy page here. page := fixture(t, "browser_test.go", "busyPage", "") mux := sizingMux(t, map[string]string{"/": page, "/elsewhere": page}) received(mux, "/submit", "", payloads, http.StatusNoContent) // The one endpoint that really navigates, for the back-button leg: // 204 keeps the visitor where they are, and a page you never left is // a page you cannot come back to. mux.HandleFunc("POST /go", func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() select { case payloads <- r.PostForm.Encode(): default: } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, ``+ `sent

sent

`) }) return mux, payloads } // took reports what the endpoint received, or fails: the drive is // meaningless if the submission never arrived. func took(t *testing.T, payloads chan string, leg string) string { t.Helper() select { case p := <-payloads: return p case <-time.After(20 * time.Second): t.Fatalf("%s: the server never received a submission", leg) return "" } } // tookNothing is the double-submit assertion: after the guard is armed, // no second request may reach the server. func tookNothing(t *testing.T, payloads chan string, leg string) { t.Helper() select { case p := <-payloads: t.Fatalf("%s: the server received a SECOND submission (%q); the guard is the substance of the rule", leg, p) case <-time.After(1500 * time.Millisecond): } } // TestBusyButtonDrive is the busy rule in a real engine. // // Bug classes it exists to catch — every one of which renders perfectly // and says nothing wrong: // // - the button is disabled while the payload is being read, so the // clicked button's name/value never reaches the server and the // handler cannot tell Save from Save-draft; // - the whole form goes busy, so a second submit button loses its // value too, or reads as unavailable when it is not; // - the guard is missing, so a double click posts twice — which for a // payment or an invitation is the bug the rule is FOR; // - the busy class is set but the spinner never paints (no rule for // it, zero size, display:none), so the page claims to be working // and shows nothing; // - the spinner keeps rotating for a reader who asked for reduced // motion; // - a form the browser REFUSED is left stuck busy, so the visitor // fixes the field and finds a dead button; // - the rule turns out to depend on the script, breaking the // scriptless submit it is only ever an enhancement to. func TestBusyButtonDrive(t *testing.T) { mux, payloads := busyPage(t) rig := harness.New(t, func(string) http.Handler { return mux }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() reached := "start" at := func(name string) chromedp.Action { return chromedp.ActionFunc(func(context.Context) error { reached = name; return nil }) } fail := func(err error) { if err != nil { t.Fatalf("drive failed after %q: %v", reached, err) } } // ── 1. The form the browser refuses ─────────────────────────────── // // #title is required and empty, so the click never produces a submit // event at all. Nothing may be busy afterwards, and nothing may have // been sent. var invalidBefore, invalidAfter string fail(chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), at("navigated"), chromedp.WaitVisible(`#save`, chromedp.ByQuery), at("page-visible"), chromedp.Evaluate(invalidStateJS, &invalidBefore), chromedp.Click(`#try`, chromedp.ByQuery), at("clicked-invalid-submit"), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(invalidStateJS, &invalidAfter), )) const wantInvalid = "form:- btn:- btn-off:false spin:no valid:false" if invalidBefore != wantInvalid { 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) } if invalidAfter != wantInvalid { 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) } tookNothing(t, payloads, "invalid form") // ── 2. The opt-out ──────────────────────────────────────────────── // // data-busy="false" on the form. It still submits — that is the // point, it is opting out of the loading state and nothing else. var optOut string fail(chromedp.Run(ctx, chromedp.Click(`#optout`, chromedp.ByQuery), at("clicked-optout"), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(optOutStateJS, &optOut), )) if got := took(t, payloads, "opt-out"); got != "action=optout" { t.Errorf("the opted-out form sent %q, want %q", got, "action=optout") } const wantOptOut = "form:- btn:- btn-off:false spin:no" if optOut != wantOptOut { t.Errorf(`data-busy="false" form reads %q, want %q — the opt-out is not being honoured`, optOut, wantOptOut) } // ── 2b. The same opt-out, on the button ────────────────────────── // // The button declines the loading state; the FORM is still guarded, // because the guard is the substance of the rule and is not what // was opted out of. var quiet string quietAt := time.Now() fail(chromedp.Run(ctx, chromedp.Click(`#quietbtn`, chromedp.ByQuery), at("clicked-quiet"), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(quietStateJS, &quiet), )) if got := took(t, payloads, "button opt-out"); got != "action=quiet" { t.Errorf("the form with the opted-out button sent %q, want %q", got, "action=quiet") } // No spinner, no hold: the 650ms minimum is for a spinner on screen, // and an opted-out button shows none. The clock covers the 300ms // sleep and the CDP round trips as well as the submit, so the line is // drawn under the hold rather than over the sleep: at 450ms a busy // CI box (31 shards and another repo's suite on one worker) read // 496ms for a submit that was not held at all. if waited := time.Since(quietAt); waited > 600*time.Millisecond { t.Errorf("the opted-out button's submit took %v to arrive; with no spinner to show it must not be held", waited) } const wantQuiet = "form:true btn:- btn-off:false spin:no" if quiet != wantQuiet { t.Errorf(`data-busy="false" on the button reads %q, want %q`, quiet, wantQuiet) } // The guard is not what was opted out of, and the way to prove that // is behaviour rather than a flag: ask the form to submit again and // nothing may reach the server. fail(chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("quiet").requestSubmit()`, nil), at("re-submitted-quiet"), )) tookNothing(t, payloads, "button opt-out, second submit") // ── 2c. A control named "target" ────────────────────────────────── // // HTMLFormElement is [LegacyOverrideBuiltIns], so replaces form.target with the input element: // truthy, and not "_self". A shim that reads the property instead of // the attribute bails out here, before it arms the guard, and hands // the visitor back the double submit the rule exists to prevent. var shadow string fail(chromedp.Run(ctx, chromedp.Click(`#shadowgo`, chromedp.ByQuery), at("clicked-shadow"), chromedp.Poll(`document.getElementById("shadowgo").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("shadow-hardened"), chromedp.Evaluate(shadowStateJS, &shadow), chromedp.Evaluate(`document.getElementById("shadow").requestSubmit()`, nil), at("re-submitted-shadow"), )) if got := took(t, payloads, "shadowed target"); got != "action=shadow&target=42" { t.Errorf("the shadowing form sent %q, want %q", got, "action=shadow&target=42") } const wantShadow = "shadowed:true form:true btn:true spin:yes" if shadow != wantShadow { 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) } tookNothing(t, payloads, "shadowed target, second submit") // ── 2d. A dialog's own close form ───────────────────────────────── // //
is the // standard way to close a , and it is the one submit that // goes nowhere at all: the dialog closes and the page stays exactly // where it stood. So nothing ever navigates or reloads to clear a // busy state armed here — the close button ends up disabled with the // guard still up, and the next time the dialog opens it cannot be // closed through its form at all. A modal still takes Esc; a // non-modal one has no way out. // // The second close is the assertion. A wedged dialog closes once, // looks fine, and never closes again. const wantDialog = "open:false form:- btn:- btn-off:false spin:no" var dialogFirst, dialogSecond string fail(chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("dlg").showModal()`, nil), at("opened-dialog"), chromedp.Evaluate(`document.getElementById("dlgclose").click()`, nil), at("clicked-dialog-close"), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(dialogState("dlg", "dlgform", "dlgclose"), &dialogFirst), chromedp.Evaluate(`document.getElementById("dlg").showModal()`, nil), at("reopened-dialog"), chromedp.Evaluate(`document.getElementById("dlgclose").click()`, nil), at("clicked-dialog-close-again"), chromedp.Sleep(300*time.Millisecond), chromedp.Evaluate(dialogState("dlg", "dlgform", "dlgclose"), &dialogSecond), )) if dialogFirst != wantDialog { 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) } if dialogSecond != wantDialog { 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) } // ── 2e. The same close, through the submitter ───────────────────── // //