//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, `
okreceived
`)
})
}
// 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