Port the menu, select, busy and shell-script drives
The drives that hold the scripts to their behaviour, ported from rastrillo e14b745's ui/ with mechanical substitutions: package and import paths, the script and stylesheet readers, and the page builders reading their pages through fixture instead of building a mux by hand. Every drive and Node test passed on its first run against the tree. fixture(t, file, builder, case) is new, in an untagged file, with the reader for testdata/fixtures/index.json: it returns the seeded file for one row and fails on a missing row, a row that names an example rather than a path, or an absent file. TestEveryFixtureRowResolves holds the index to files that exist and are not empty. TestTheSchemeMenuPassesAxeInBothSchemes is the one new test: the scheme-menu example's standalone state served whole through example.Document, its root checked for data-rst-scheme-store="local", scanned with the menu open before and after Dark is chosen in place, with no navigation. Already passing; control is a mutation: with the buttons' text removed from a scratch copy of the state it fails naming button-name in both schemes. driveSelectFilter is TestSelectKeepsPaceWithTyping's body against any rig built from selectFilterPages, for the release tests to call. The preservation control, dismissMenus returning early on Escape in a scratch copy of fiesta.js, fails TestMenuExclusivityAndDropdownDismissDrive and TestLightDismissWorksInBothSpellings; the text gates stay green, as they only catch forbidden entries in the menu list. One cap moved: TestSelectContract's 52 KiB, which select.js has been over since Task 6 added the string lookup to it (53,128 at SEED, 54,601 now); 54 KiB with the arithmetic written down, as the file's own ledger does.
Human
Prompt
Task 9: port the behaviour drives (menus, select, busy, shell.js, the scheme flip) from rastrillo e14b745 ui/ into check/, define fixture, add TestTheSchemeMenuPassesAxeInBothSchemes, pull out driveSelectFilter, run the shard pass and make ci, commit.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
New in check/: fixture and its index reader (fixture_test.go, with TestEveryFixtureRowResolves); the select Node tests and select_node.mjs; the shell.js Node test and shell_node.mjs; the select, select-focus and shell.js browser drives; menus_browser_test.go, the A half of SEED browser_test.go (menus, modal, busy, bidi, grouped select) reading its pages through fixture; TestTheSchemeMenuPassesAxeInBothSchemes and the three scheme-flip drives appended to scheme_browser_test.go; the six shim contract tests appended to scripts_test.go. driveSelectFilter and selectFilterPages are standalone for Task 12.
Agent notes
TestSelectContract failed its verbatim first run: select.js is 54,601 bytes against SEED 52 KiB cap, because Task 6 added fiestaPickLang/fiestaWord (+1,473) and raised only datetime.js cap. Raised to 54 KiB with a ledger paragraph; one revert if the controller wants the red. busyPage also serves /elsewhere (SEED handler was a catch-all). scriptErrors/skippedTransition from index_browser_test.go live in shelljs_browser_test.go for Task 10 to find. Report: .superpowers/sdd/2026-10-27-fiesta-1-package/task-9-report.md in the fiesta-design worktree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
11 files changed,
+5242
−0
check/fixture_test.go+105 −0check/menus_browser_test.go+1644 −0check/node/select_node.mjs+36 −0check/node/shell_node.mjs+87 −0check/scheme_browser_test.go+323 −0check/scripts_test.go+336 −0check/select_browser_test.go+1723 −0check/select_focus_browser_test.go+132 −0check/select_test.go+293 −0check/shell_test.go+28 −0check/shelljs_browser_test.go+535 −0
diff --git a/check/fixture_test.go b/check/fixture_test.go| new file mode 100644 |
| index 0000000..960e7c2 |
| --- /dev/null |
| +++ b/check/fixture_test.go |
| @@ -0,0 +1,105 @@ |
| +package check |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// The seeded fixtures: the pages rastrillo's browser drives built by |
| +// hand at SEED, written out once under testdata/fixtures/ and listed |
| +// in index.json by the file, builder and case they came from. A drive |
| +// ported here reads its page through fixture rather than retyping it, |
| +// so the page it drives is the page the SEED drive drove. |
| + |
| +// fixtureRow is one entry of testdata/fixtures/index.json. A row |
| +// names either a path under testdata/fixtures/ or an example state |
| +// that carries the same markup; the two reference rows have no path. |
| +type fixtureRow struct { |
| + File string `json:"file"` |
| + Builder string `json:"builder"` |
| + Case string `json:"case"` |
| + Path string `json:"path"` |
| + Kind string `json:"kind"` |
| + Request string `json:"request"` |
| + Example string `json:"example"` |
| + Note string `json:"note"` |
| +} |
| + |
| +// fixtureDir is where the index and the files live, under check/. |
| +const fixtureDir = "testdata/fixtures" |
| + |
| +// fixtureIndex reads index.json. |
| +func fixtureIndex(t *testing.T) []fixtureRow { |
| + t.Helper() |
| + b, err := os.ReadFile(filepath.Join(fixtureDir, "index.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var rows []fixtureRow |
| + if err := json.Unmarshal(b, &rows); err != nil { |
| + t.Fatalf("%s/index.json: %v", fixtureDir, err) |
| + } |
| + return rows |
| +} |
| + |
| +// fixture returns the contents of the fixture file the index lists for |
| +// the {file, builder, case} row: the body or the whole document, as |
| +// the row's kind says, exactly as the SEED builder wrote it. It fails |
| +// the test when the row is missing, when the row names an example |
| +// rather than a path, or when the file is absent. |
| +func fixture(t *testing.T, file, builder, kase string) string { |
| + t.Helper() |
| + for _, r := range fixtureIndex(t) { |
| + if r.File != file || r.Builder != builder || r.Case != kase { |
| + continue |
| + } |
| + if r.Path == "" { |
| + t.Fatalf("fixture %s %s %q has no path (it names example %q); read it through the example package", file, builder, kase, r.Example) |
| + } |
| + b, err := os.ReadFile(filepath.Join(fixtureDir, filepath.FromSlash(r.Path))) |
| + if err != nil { |
| + t.Fatalf("fixture %s %s %q: %v", file, builder, kase, err) |
| + } |
| + return string(b) |
| + } |
| + t.Fatalf("fixture %s %s %q is not in %s/index.json", file, builder, kase, fixtureDir) |
| + return "" |
| +} |
| + |
| +// TestEveryFixtureRowResolves: every row that names a path names a |
| +// file that exists and is not empty, and the rows without a path are |
| +// the example references, which name an example. The count is pinned |
| +// so an index that lost its rows could not pass by listing nothing. |
| +func TestEveryFixtureRowResolves(t *testing.T) { |
| + rows := fixtureIndex(t) |
| + if len(rows) < 100 { |
| + t.Fatalf("%s/index.json lists %d rows; the seed wrote 137", fixtureDir, len(rows)) |
| + } |
| + seen := map[[3]string]bool{} |
| + for _, r := range rows { |
| + key := [3]string{r.File, r.Builder, r.Case} |
| + if seen[key] { |
| + t.Errorf("%s %s %q is listed twice", r.File, r.Builder, r.Case) |
| + } |
| + seen[key] = true |
| + if r.Path == "" { |
| + if r.Example == "" { |
| + t.Errorf("%s %s %q has neither a path nor an example", r.File, r.Builder, r.Case) |
| + } |
| + continue |
| + } |
| + info, err := os.Stat(filepath.Join(fixtureDir, filepath.FromSlash(r.Path))) |
| + if err != nil { |
| + t.Errorf("%s %s %q: %v", r.File, r.Builder, r.Case, err) |
| + continue |
| + } |
| + if info.Size() == 0 { |
| + t.Errorf("%s %s %q: %s is empty", r.File, r.Builder, r.Case, r.Path) |
| + } |
| + if got := fixture(t, r.File, r.Builder, r.Case); int64(len(got)) != info.Size() { |
| + t.Errorf("%s %s %q: fixture returned %d bytes of a %d-byte file", r.File, r.Builder, r.Case, len(got), info.Size()) |
| + } |
| + } |
| +} |
diff --git a/check/menus_browser_test.go b/check/menus_browser_test.go| new file mode 100644 |
| index 0000000..6c648a9 |
| --- /dev/null |
| +++ b/check/menus_browser_test.go |
| @@ -0,0 +1,1644 @@ |
| +//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 SEED, |
| +// each drive reading its page through fixture so the page it drives |
| +// is the page the SEED drive drove. The select and date journeys that |
| +// made up the file's other half are ported beside the scripts they |
| +// drive. |
| +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 a page that says so, 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. |
| +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") |
| + 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>`) |
| + }) |
| +} |
| + |
| +// 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. |
| +// |
| +// It runs on the same terms as the drives above; see the file header |
| +// for what issue #86 turned out to be. |
| +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 <details> 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 SEED 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 <details> 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 <details name> 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: <details name> 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 <details name> 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 <body> |
| + // 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 <details> 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 <details name> 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 <body>", 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 SEED (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 <dialog open> 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 <dialog>", 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 <dialog>, the |
| +// form inside it, and the button that closes it. Two shapes wear this |
| +// pattern — <form method="dialog">, 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) |
| + // SEED'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, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+ |
| + `<title>sent</title></head><body><p id="done">sent</p></body></html>`) |
| + }) |
| + 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 <input |
| + // name="target"> 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 ───────────────────────────────── |
| + // |
| + // <form method="dialog"><button>Close</button></form> is the |
| + // standard way to close a <dialog>, 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 ───────────────────── |
| + // |
| + // <form method="post"><button formmethod="dialog"> is 2d reached |
| + // through the button's own attribute, which HTML says beats the |
| + // form's method. The form looks like an ordinary POST — a rule that |
| + // reads only form.method sees "post", arms the guard, and wedges the |
| + // dialog exactly as 2d does, in the shape a real app is likelier to |
| + // write: one form that both saves and closes. |
| + // |
| + // Same assertion, and for the same reason: the second close is the |
| + // one that catches it. |
| + var fmFirst, fmSecond string |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`document.getElementById("fmdlg").showModal()`, nil), at("opened-formmethod-dialog"), |
| + chromedp.Evaluate(`document.getElementById("fmclose").click()`, nil), at("clicked-formmethod-close"), |
| + chromedp.Sleep(300*time.Millisecond), |
| + chromedp.Evaluate(dialogState("fmdlg", "fmform", "fmclose"), &fmFirst), |
| + chromedp.Evaluate(`document.getElementById("fmdlg").showModal()`, nil), at("reopened-formmethod-dialog"), |
| + chromedp.Evaluate(`document.getElementById("fmclose").click()`, nil), at("clicked-formmethod-close-again"), |
| + chromedp.Sleep(300*time.Millisecond), |
| + chromedp.Evaluate(dialogState("fmdlg", "fmform", "fmclose"), &fmSecond), |
| + )) |
| + if fmFirst != wantDialog { |
| + 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) |
| + } |
| + if fmSecond != wantDialog { |
| + 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) |
| + } |
| + // The form's action is real: if the effective method were ever read |
| + // as the form's "post", this leg would also have posted to it. |
| + tookNothing(t, payloads, "formmethod=\"dialog\" close") |
| + |
| + // ── 3. The rule itself, and the payload ─────────────────────────── |
| + var busy, afterSecond string |
| + var clickedAt time.Time |
| + fail(chromedp.Run(ctx, |
| + chromedp.ActionFunc(func(context.Context) error { clickedAt = time.Now(); return nil }), |
| + chromedp.Click(`#save`, chromedp.ByQuery), at("clicked-save"), |
| + // The hardening to disabled is deferred by a tick, so wait for |
| + // it rather than for a guess at how long a tick takes. |
| + chromedp.Poll(`document.getElementById("save").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("save-hardened"), |
| + chromedp.Evaluate(busyStateJS, &busy), |
| + )) |
| + // The payload check: the trap this whole shape exists to avoid is a |
| + // disabled button whose name/value never reaches the server. |
| + if got := took(t, payloads, "save"); got != "action=save¬e=hello" { |
| + t.Errorf("the server received %q, want %q — the clicked button's name/value was dropped from the payload", got, "action=save¬e=hello") |
| + } |
| + // The hold: the spinner shows for at least 650ms, which it can only |
| + // do if the submit leaves no sooner than that. A local server answers |
| + // in a millisecond, so without the hold the payload lands at once. |
| + // (A little under 650: the click is timed from here, not from inside |
| + // the page, and a timer may fire a hair early.) |
| + if waited := time.Since(clickedAt); waited < 620*time.Millisecond { |
| + t.Errorf("the submit reached the server %v after the click; the busy rule holds it for at least 650ms", waited) |
| + } |
| + const wantBusy = "form:true save:true save-off:true save-text:Saving… " + |
| + "spin-first:true spin-hidden:true spin-anim:rst-spin spin-shown:true " + |
| + "draft:- draft-off:false draft-value:draft" |
| + if busy != wantBusy { |
| + t.Errorf("busy state is\n %q\nwant\n %q", busy, wantBusy) |
| + } |
| + |
| + rig.Screen("body", "a submit button while it works") |
| + |
| + // ── 4. Re-entrancy ──────────────────────────────────────────────── |
| + // |
| + // Three ways back in, one guard: a second click on the button that |
| + // started it, a click on the OTHER submit button in the same form, |
| + // and a programmatic requestSubmit(). None may reach the server. |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`document.getElementById("save").click()`, nil), at("clicked-save-again"), |
| + chromedp.Evaluate(`document.getElementById("draft").click()`, nil), at("clicked-draft"), |
| + chromedp.Evaluate(`document.getElementById("two").requestSubmit()`, nil), at("requested-submit"), |
| + chromedp.Sleep(300*time.Millisecond), |
| + chromedp.Evaluate(busyStateJS, &afterSecond), |
| + )) |
| + tookNothing(t, payloads, "second submit") |
| + if afterSecond != wantBusy { |
| + t.Errorf("after the re-entrancy attempts the state is\n %q\nwant it unchanged:\n %q", afterSecond, wantBusy) |
| + } |
| + |
| + // ── 4a. The spinner takes the label's place ───────────────────── |
| + // |
| + // With no data-busy-label there are no words to show while it works, |
| + // so the label gives way to a centred spinner. Bug classes, each of |
| + // which still "works": the spinner joins the label and the button |
| + // grows, reflowing everything beside it mid-click; the label is hidden |
| + // with the spinner, so it inherits the transparent ink and the button |
| + // shows nothing at all; the label is hidden from the accessibility |
| + // tree too, so a screen reader announces an unnamed busy button. |
| + var idleWidth, busyWidth float64 |
| + var swap string |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`document.getElementById("swapgo").getBoundingClientRect().width`, &idleWidth), at("measured-swap"), |
| + chromedp.Click(`#swapgo`, chromedp.ByQuery), at("clicked-swap"), |
| + chromedp.Poll(`document.getElementById("swapgo").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("swap-hardened"), |
| + chromedp.Evaluate(swapStateJS, &swap), |
| + chromedp.Evaluate(`document.getElementById("swapgo").getBoundingClientRect().width`, &busyWidth), |
| + )) |
| + if got := took(t, payloads, "swap"); got != "action=swap" { |
| + t.Errorf("the swapping form sent %q, want %q", got, "action=swap") |
| + } |
| + const wantSwap = "label-ink:none icon-ink:none spin-ink:shown spin-centred:true" |
| + if swap != wantSwap { |
| + t.Errorf("a busy button with no busy label reads\n %q\nwant\n %q", swap, wantSwap) |
| + } |
| + if math.Abs(busyWidth-idleWidth) > 0.5 { |
| + t.Errorf("the busy button is %.1fpx wide, %.1fpx idle — the spinner must take the label's place, not add to it", busyWidth, idleWidth) |
| + } |
| + // The name is read from the accessibility tree, not textContent: |
| + // textContent ignores CSS and aria-hidden, so it would still say |
| + // Publish with the label hidden from a screen reader. |
| + if name := axName(ctx, t, "#swapgo"); name != "Publish" { |
| + 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") |
| + } |
| + var authoredInk string |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`getComputedStyle(document.getElementById("authored")).webkitTextFillColor`, &authoredInk), |
| + )) |
| + if authoredInk == "rgba(0, 0, 0, 0)" { |
| + t.Errorf("a button an app marked aria-busy, with no spinner in it, has transparent label ink — it renders blank") |
| + } |
| + // And the other side of the same selector: a busy label is words |
| + // the author asked to be read, so it stays inked. |
| + var saveInk string |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`getComputedStyle(document.getElementById("save")).webkitTextFillColor`, &saveInk), |
| + )) |
| + if saveInk == "rgba(0, 0, 0, 0)" { |
| + t.Errorf("the button with data-busy-label has transparent label ink — its busy label must stay visible") |
| + } |
| + rig.Screen("body", "a busy button whose spinner replaces the label") |
| + |
| + // ── 4b. Back ────────────────────────────────────────────────────── |
| + // |
| + // The back/forward cache restores a document exactly as it was left, |
| + // busy button and all, so a visitor who submits and then comes back |
| + // finds a dead form: disabled, wearing the busy label, refusing a |
| + // second submit because the guard is still armed. The shim clears it |
| + // on pageshow. |
| + // |
| + // This needs a response that really navigates, which is why /go is |
| + // not a 204 like everything else here. |
| + // |
| + // chromedp.NavigateBack() cannot be used here, and the reason is the |
| + // same fact the leg is about: a page restored from the back/forward |
| + // cache fires no load event, because it was never re-loaded — so the |
| + // action waits for one until the deadline. Ask the PAGE to go back |
| + // and then poll for the element, tolerating the evaluations that |
| + // error while the document is being swapped. |
| + var back string |
| + // settle polls one page expression until it is true, tolerating the |
| + // evaluations that error while a document is being swapped in. |
| + // Whether it times out is deliberately not an error: the assertions |
| + // after it are the report. A shim that never clears the busy state |
| + // would make this wait ten seconds and then fail on what it reads, |
| + // which says what went wrong; failing here would only say "timeout". |
| + settle := func(expr string, hard bool) chromedp.Action { |
| + return chromedp.ActionFunc(func(c context.Context) error { |
| + deadline := time.Now().Add(10 * time.Second) |
| + for time.Now().Before(deadline) { |
| + var yes bool |
| + if err := chromedp.Evaluate(expr, &yes).Do(c); err == nil && yes { |
| + return nil |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + if hard { |
| + return fmt.Errorf("never became true within 10s: %s", expr) |
| + } |
| + return nil |
| + }) |
| + } |
| + waitForID := func(id string) chromedp.Action { |
| + return settle(`!!document.getElementById("`+id+`")`, true) |
| + } |
| + fail(chromedp.Run(ctx, |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-for-back"), |
| + chromedp.WaitVisible(`#navgo`, chromedp.ByQuery), at("page-visible-for-back"), |
| + chromedp.Click(`#navgo`, chromedp.ByQuery), at("clicked-nav"), |
| + chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("landed-on-response"), |
| + // Deferred a tick so the evaluation returns before the |
| + // navigation it starts. |
| + chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0)`, nil), at("asked-to-go-back"), |
| + waitForID("navgo"), at("back-on-the-form"), |
| + // The element exists the instant the document is swapped in, |
| + // which is BEFORE pageshow's listeners run — so reading here |
| + // races the very handler the leg is about. Give the reset its |
| + // dispatch, then read. |
| + settle(`document.getElementById("nav").getAttribute("aria-busy") === null`, false), at("reset-settled"), |
| + chromedp.Evaluate(backStateJS, &back), |
| + )) |
| + if got := took(t, payloads, "back: first submit"); got != "action=nav" { |
| + t.Errorf("the navigating form sent %q, want %q", got, "action=nav") |
| + } |
| + // The premise, first and fatally: if the browser re-fetched the page |
| + // instead of restoring it, everything below would be clean for a |
| + // reason that has nothing to do with the shim, and the leg would |
| + // pass while pinning nothing at all. |
| + if !strings.HasPrefix(back, "persisted:true ") { |
| + 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) |
| + } |
| + const wantBack = "persisted:true form:- btn:- btn-off:false btn-text:Send idle-label:- spin:no" |
| + if back != wantBack { |
| + 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) |
| + } |
| + // Enabled and clean is not the same as usable: the guard has to be |
| + // cleared too, or the form looks fine and silently refuses. |
| + // |
| + // Clicked through the page rather than with chromedp.Click, for the |
| + // same reason NavigateBack could not be used above: a restored |
| + // document raises no DOM.documentUpdated, so chromedp's node cache |
| + // still describes the response page and a selector query retries |
| + // until the deadline. What this leg is about is the guard, not mouse |
| + // input, and element.click() submits the form exactly the same way. |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`document.getElementById("navgo").click()`, nil), at("re-submitted-after-back"), |
| + waitForID("done"), at("landed-again"), |
| + )) |
| + if got := took(t, payloads, "back: re-submit"); got != "action=nav" { |
| + 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") |
| + } |
| + |
| + // ── 4c. Back, with the submit button OUTSIDE its form ───────────── |
| + // |
| + // <button form="ext"> belongs to a form it does not live in, which |
| + // is the whole reason the form attribute exists: a sticky header's |
| + // Save, a toolbar's Publish. The submit event's submitter is that |
| + // button, so the rule busies it exactly as it busies any other — but |
| + // a reset that sweeps the FORM's own subtree never finds it again. |
| + // The visitor comes back from the bfcache to a button that is still |
| + // disabled, still wearing the busy label and still spinning, with no |
| + // way out short of a reload. That is precisely the state the |
| + // back-button requirement forbids. |
| + var extBack string |
| + fail(chromedp.Run(ctx, |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-for-ext-back"), |
| + chromedp.WaitVisible(`#extgo`, chromedp.ByQuery), at("page-visible-for-ext-back"), |
| + chromedp.Click(`#extgo`, chromedp.ByQuery), at("clicked-ext"), |
| + chromedp.WaitVisible(`#done`, chromedp.ByQuery), at("landed-on-response-ext"), |
| + chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0)`, nil), at("asked-to-go-back-ext"), |
| + waitForID("extgo"), at("back-on-the-ext-form"), |
| + settle(`document.getElementById("extgo").disabled === false`, false), at("ext-reset-settled"), |
| + chromedp.Evaluate(extBackStateJS, &extBack), |
| + )) |
| + if got := took(t, payloads, "external button: first submit"); got != "action=ext" { |
| + t.Errorf("the form with an external submit button sent %q, want %q", got, "action=ext") |
| + } |
| + // Two premises, both fatal: the page really came out of the |
| + // back/forward cache, and the button really does live outside the |
| + // form. Without either, this leg pins nothing. |
| + if !strings.HasPrefix(extBack, "persisted:true inside:false ") { |
| + 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) |
| + } |
| + const wantExtBack = "persisted:true inside:false form:- btn:- btn-off:false " + |
| + "btn-text:Save idle-label:- spin:no" |
| + if extBack != wantExtBack { |
| + 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) |
| + } |
| + // Clean is not the same as usable: the guard has to be gone too. |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`document.getElementById("extgo").click()`, nil), at("re-submitted-ext-after-back"), |
| + waitForID("done"), at("landed-again-ext"), |
| + )) |
| + if got := took(t, payloads, "external button: re-submit"); got != "action=ext" { |
| + 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") |
| + } |
| + |
| + // ── 5. Reduced motion ───────────────────────────────────────────── |
| + // |
| + // The spinner is still there and still says "working"; it just stops |
| + // turning. A computed animationName of "none" is the assertion — |
| + // the media query is in tokens.css, so a spinner built with an |
| + // inline animation would pass every markup check and fail here. |
| + var reduced string |
| + fail(chromedp.Run(ctx, |
| + chromedp.ActionFunc(func(c context.Context) error { |
| + return emulation.SetEmulatedMedia(). |
| + WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}}). |
| + Do(c) |
| + }), at("emulated-reduced-motion"), |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-reduced"), |
| + chromedp.WaitVisible(`#save`, chromedp.ByQuery), at("page-visible-reduced"), |
| + chromedp.Click(`#save`, chromedp.ByQuery), at("clicked-save-reduced"), |
| + chromedp.Poll(`document.getElementById("save").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("save-hardened-reduced"), |
| + chromedp.Evaluate(busyStateJS, &reduced), |
| + )) |
| + took(t, payloads, "reduced motion") |
| + const wantReduced = "form:true save:true save-off:true save-text:Saving… " + |
| + "spin-first:true spin-hidden:true spin-anim:none spin-shown:true " + |
| + "draft:- draft-off:false draft-value:draft" |
| + if reduced != wantReduced { |
| + t.Errorf("under prefers-reduced-motion the busy state is\n %q\nwant\n %q", reduced, wantReduced) |
| + } |
| + |
| + // ── 5b. The swap stands down where a lone ring fails ────────────── |
| + // |
| + // Two readers for whom a spinner alone is the wrong trade, so the |
| + // label stays and the ring sits beside it as it used to: |
| + // |
| + // - reduced motion: the ring stops turning and is dimmed, and a |
| + // pale, still, open ring on its own reads as a stray "C"; |
| + // - forced colours: the engine repaints the text fill, so the |
| + // "hidden" label paints anyway and a centred ring lands on top |
| + // of it. The computed fill still reads transparent there, which |
| + // is why this leg asserts position and not only ink. |
| + for _, media := range []struct { |
| + name, value string |
| + }{{"prefers-reduced-motion", "reduce"}, {"forced-colors", "active"}} { |
| + var state, pos string |
| + fail(chromedp.Run(ctx, |
| + chromedp.ActionFunc(func(c context.Context) error { |
| + return emulation.SetEmulatedMedia(). |
| + WithFeatures([]*emulation.MediaFeature{{Name: media.name, Value: media.value}}). |
| + Do(c) |
| + }), at("emulated-"+media.name), |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-"+media.name), |
| + chromedp.WaitVisible(`#swapgo`, chromedp.ByQuery), at("page-visible-"+media.name), |
| + chromedp.Click(`#swapgo`, chromedp.ByQuery), at("clicked-swap-"+media.name), |
| + chromedp.Poll(`document.getElementById("swapgo").disabled`, nil, chromedp.WithPollingTimeout(10*time.Second)), at("swap-hardened-"+media.name), |
| + chromedp.Evaluate(swapStateJS, &state), |
| + chromedp.Evaluate(`getComputedStyle(document.querySelector("#swapgo > [rst-spin]")).position`, &pos), |
| + )) |
| + took(t, payloads, media.name) |
| + const want = "label-ink:shown icon-ink:shown spin-ink:shown spin-centred:false" |
| + if state != want || pos != "static" { |
| + 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) |
| + } |
| + } |
| + |
| + // ── 6. Scriptless ───────────────────────────────────────────────── |
| + // |
| + // The rule is an enhancement, not a correctness claim. With script |
| + // execution off the form submits exactly as it always did, carrying |
| + // exactly the same payload, and nothing on the page is marked busy. |
| + var scriptless string |
| + fail(chromedp.Run(ctx, |
| + chromedp.ActionFunc(func(c context.Context) error { |
| + if err := emulation.SetEmulatedMedia().Do(c); err != nil { |
| + return err |
| + } |
| + return emulation.SetScriptExecutionDisabled(true).Do(c) |
| + }), at("disabled-scripts"), |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-scriptless"), |
| + chromedp.WaitVisible(`#save`, chromedp.ByQuery), at("page-visible-scriptless"), |
| + chromedp.Click(`#save`, chromedp.ByQuery), at("clicked-save-scriptless"), |
| + chromedp.Sleep(500*time.Millisecond), |
| + chromedp.Evaluate(busyStateJS, &scriptless), |
| + chromedp.ActionFunc(func(c context.Context) error { |
| + return emulation.SetScriptExecutionDisabled(false).Do(c) |
| + }), |
| + )) |
| + if got := took(t, payloads, "scriptless"); got != "action=save¬e=hello" { |
| + t.Errorf("with scripts off the server received %q, want %q — the shim changed the scriptless submit", got, "action=save¬e=hello") |
| + } |
| + const wantScriptless = "form:- save:- save-off:false save-text:Save post " + |
| + "spin-first:false spin-hidden:- spin-anim:- spin-shown:false " + |
| + "draft:- draft-off:false draft-value:draft" |
| + if scriptless != wantScriptless { |
| + t.Errorf("with scripts off the page reads\n %q\nwant\n %q", scriptless, wantScriptless) |
| + } |
| + |
| + // ── 7. Leaving during the hold ─────────────────────────────────── |
| + // |
| + // A submit still being held when the visitor leaves the page is |
| + // dropped. The trap is the back/forward cache: a timer frozen with |
| + // the page resumes when the visitor comes Back, and sends a submit |
| + // they walked away from. Click, leave inside the hold, come back the |
| + // way leg 5's Back does, and wait out more than the hold: nothing may |
| + // reach the server, and the restored form must be clean. |
| + var heldBack string |
| + fail(chromedp.Run(ctx, |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-for-hold"), |
| + chromedp.WaitVisible(`#navgo`, chromedp.ByQuery), |
| + chromedp.Click(`#navgo`, chromedp.ByQuery), at("clicked-nav-then-leave"), |
| + chromedp.Evaluate(`setTimeout(function () { location.href = "/elsewhere"; }, 150)`, nil), at("leaving-during-hold"), |
| + settle(`location.pathname === "/elsewhere" && document.readyState === "complete"`, true), at("left-during-hold"), |
| + chromedp.Evaluate(`setTimeout(function () { history.back(); }, 0)`, nil), at("asked-to-come-back"), |
| + waitForID("navgo"), at("came-back"), |
| + chromedp.Sleep(900*time.Millisecond), // more than the hold |
| + chromedp.Evaluate(backStateJS, &heldBack), |
| + )) |
| + tookNothing(t, payloads, "left during the hold") |
| + if !strings.HasPrefix(heldBack, "persisted:true ") { |
| + 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) |
| + } |
| + if heldBack != wantBack { |
| + 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) |
| + } |
| + |
| + // ── 8. A script's submit, with no button ───────────────────────── |
| + // |
| + // requestSubmit() from a script (Enter in a quick-add field, a file |
| + // dropped on a page) has no submitter, so nothing shows a spinner and |
| + // nothing is held: it goes at once. The form is still guarded. |
| + scriptAt := time.Now() |
| + fail(chromedp.Run(ctx, |
| + chromedp.Navigate(rig.Origin+"/"), at("navigated-for-script-submit"), |
| + chromedp.WaitVisible(`#save`, chromedp.ByQuery), |
| + chromedp.ActionFunc(func(context.Context) error { scriptAt = time.Now(); return nil }), |
| + chromedp.Evaluate(`document.getElementById("two").requestSubmit()`, nil), at("script-submitted"), |
| + )) |
| + if got := took(t, payloads, "script submit"); got != "note=hello" { |
| + t.Errorf("the script's submit sent %q, want %q", got, "note=hello") |
| + } |
| + if waited := time.Since(scriptAt); waited > 450*time.Millisecond { |
| + t.Errorf("a script's submit with no button took %v to arrive; with no spinner it must not be held", waited) |
| + } |
| + fail(chromedp.Run(ctx, |
| + chromedp.Evaluate(`document.getElementById("two").requestSubmit()`, nil), at("script-resubmitted"), |
| + )) |
| + tookNothing(t, payloads, "script submit, second") |
| +} |
| + |
| +// ── Bidirectional text: the name cell ──────────────────────────────── |
| + |
| +// bidiRowPage renders the documented list-row name cell twice with the |
| +// same right-to-left name in it: once wrapped in <bdi>, as the library's |
| +// own sample now writes it, and once bare, as it was written before. |
| +// |
| +// The bare one is the control, and this drive is worthless without it. |
| +// The whole claim is that <bdi> changes where the browser puts things, |
| +// so a run that cannot show the unwrapped version going wrong is a run |
| +// that proves the wrapper does nothing. |
| +func bidiRowPage(t *testing.T) http.Handler { |
| + t.Helper() |
| + // "Ali Muhammad" — a real name in a script the framework ships a |
| + // locale for. The row shape is [rst-nm] with a <small> under it, and |
| + // tokens.css makes that <small> a block, so the name, the separator |
| + // and the time are one bidi paragraph. That is the whole setup. |
| + return sizingMux(t, map[string]string{"/": fixture(t, "browser_test.go", "bidiRowPage", "")}) |
| +} |
| + |
| +// bidiOrderJS reports, for each row, whether the name is drawn to the |
| +// LEFT of the time — which is the order an English reader of an |
| +// left-to-right table expects, whatever script the name is in. |
| +// |
| +// Measured with a Range over the text rather than off elements, because |
| +// the bare row has no element around its name to measure and the point |
| +// is to compare the two. |
| +const bidiOrderJS = `(() => { |
| + function rectOf(root, needle) { |
| + const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); |
| + let n; |
| + while ((n = w.nextNode())) { |
| + const i = n.textContent.indexOf(needle); |
| + if (i >= 0) { |
| + const r = document.createRange(); |
| + r.setStart(n, i); r.setEnd(n, i + needle.length); |
| + const b = r.getBoundingClientRect(); |
| + return Math.round(b.x); |
| + } |
| + } |
| + return null; |
| + } |
| + const out = {}; |
| + for (const id of ["wrapped", "bare"]) { |
| + const el = document.getElementById(id); |
| + out[id] = { name: rectOf(el, "علي"), time: rectOf(el, "09:12") }; |
| + } |
| + return JSON.stringify(out); |
| +})()` |
| + |
| +// TestABidiIsolatedNameKeepsItsRowInOrder is the gate for the one |
| +// genuine bidirectional-text defect in this library's markup, and for |
| +// the shape of the fix. |
| +// |
| +// A person's name is user-supplied and can be in any script. Put one |
| +// inline before a separator and a time — which is exactly what the name |
| +// cell's <small> does — and the browser's bidi algorithm treats the |
| +// digits as part of the right-to-left run: European numbers after an |
| +// Arabic letter become Arabic numbers, and Arabic numbers order |
| +// right-to-left. The time is then drawn to the LEFT of the name it |
| +// belongs to, in a table every other row of which reads left to right. |
| +// |
| +// Nothing about that is visible to a Go test, to a screenshot of the |
| +// English page, or to a reader who does not use the language. It is |
| +// measured here or it is not caught. |
| +// |
| +// What was measured before writing this, and what it changed: the |
| +// PARTIALS turned out not to have the defect. The obvious suspect was |
| +// job-status, which puts a user-supplied job name inline — |
| +// "<strong>NAME</strong> is running — 42…" — and it renders identically |
| +// with and without isolation, because the Latin words "is running" sit |
| +// between the name and the number and insulate them. Wrapping it would |
| +// have been a change with no defect under it. The defect is in the name |
| +// cell, where nothing insulates. |
| +func TestABidiIsolatedNameKeepsItsRowInOrder(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return bidiRowPage(t) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("#wrapped", chromedp.ByQuery), |
| + chromedp.Sleep(300*time.Millisecond), |
| + chromedp.Evaluate(bidiOrderJS, &raw), |
| + ); err != nil { |
| + t.Fatalf("measuring the rows: %v", err) |
| + } |
| + var got map[string]struct{ Name, Time *int } |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("decoding %q: %v", raw, err) |
| + } |
| + for _, id := range []string{"wrapped", "bare"} { |
| + if got[id].Name == nil || got[id].Time == nil { |
| + t.Fatalf("%s: could not find the name and the time in the rendered row (%q)", id, raw) |
| + } |
| + } |
| + |
| + // THE CONTROL, first. Without it a green run means nothing: if the |
| + // engine ever stopped reordering the bare row, the assertion below |
| + // would pass on a page where <bdi> was doing no work at all, and |
| + // somebody would later delete it as decoration. |
| + bare := got["bare"] |
| + if *bare.Name < *bare.Time { |
| + 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>", |
| + *bare.Name, *bare.Time) |
| + } |
| + 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", |
| + *bare.Time, *bare.Name) |
| + |
| + // And the fix. |
| + w := got["wrapped"] |
| + if *w.Name >= *w.Time { |
| + 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", |
| + *w.Name, *w.Time) |
| + } |
| +} |
| + |
| +// TestTheLibrarysOwnSamplesIsolateAName holds the samples to the rule |
| +// the drive above establishes. A gate on the mechanism with no gate on |
| +// the markup leaves the library shipping an example of the defect. |
| +func TestTheLibrarysOwnSamplesIsolateAName(t *testing.T) { |
| + pieces, err := example.All(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, p := range pieces { |
| + if p.Name != "list-grid" { |
| + continue |
| + } |
| + found = true |
| + if !strings.Contains(p.Markup, "<bdi>Grace Hopper</bdi>") { |
| + 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) |
| + } |
| + } |
| + if !found { |
| + t.Fatal("no list-grid sample to check") |
| + } |
| +} |
| + |
| +// axName is an element's accessible name as the browser computes it for |
| +// assistive technology — the only reading that sees CSS and aria-hidden. |
| +// GetPartialAXTree rather than GetFullAXTree: the full tree carries an |
| +// ignored-reason the vendored cdproto cannot unmarshal. |
| +func axName(ctx context.Context, t *testing.T, sel string) string { |
| + t.Helper() |
| + var nodes []*cdp.Node |
| + if err := chromedp.Run(ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)); err != nil || len(nodes) == 0 { |
| + t.Fatalf("axName: no node for %s: %v", sel, err) |
| + } |
| + var name string |
| + err := chromedp.Run(ctx, chromedp.ActionFunc(func(c context.Context) error { |
| + ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c) |
| + if err != nil { |
| + return err |
| + } |
| + if len(ax) > 0 && ax[0].Name != nil { |
| + var v string |
| + if json.Unmarshal(ax[0].Name.Value, &v) == nil { |
| + name = v |
| + } |
| + } |
| + return nil |
| + })) |
| + if err != nil { |
| + t.Fatalf("axName(%s): %v", sel, err) |
| + } |
| + return name |
| +} |
diff --git a/check/node/select_node.mjs b/check/node/select_node.mjs| new file mode 100644 |
| index 0000000..fa541e0 |
| --- /dev/null |
| +++ b/check/node/select_node.mjs |
| @@ -0,0 +1,36 @@ |
| +// select_node — drives select.js's pure half in plain Node: how a query |
| +// ranks a select's options, what leaving the box after a search commits, |
| +// and which blanks are prompts. Everything that touches a page stays |
| +// behind select.js's `document` guard, which Node never takes. |
| +// |
| +// check/select_test.go is the only caller. It pipes {options, queries} |
| +// on stdin and reads back each query's values, best first, and the |
| +// value leaving the box would settle on; or {blanks}, and reads back |
| +// what the box shows and lists for each blank option. |
| +// |
| +// This file lives under check/node/ beside the other Node drivers, so |
| +// `go test` in check/ finds it by name; it is never embedded, and |
| +// nothing in fiesta.go reaches for *.mjs. |
| +import sel from "../../js/select.js"; |
| + |
| +const { order, settle, isPrompt, offered } = sel; |
| + |
| +let raw = ""; |
| +for await (const chunk of process.stdin) raw += chunk; |
| +const { options, queries, blanks } = JSON.parse(raw); |
| +if (blanks) { |
| + process.stdout.write(JSON.stringify(blanks.map((b) => ({ |
| + prompt: isPrompt(b.value, b.marked, b.required, b.disabled), |
| + offered: offered(b.value, b.marked, b.required, b.searching, b.disabled), |
| + })))); |
| +} else { |
| + const out = {}; |
| + const settled = {}; |
| + for (const q of queries) { |
| + const hits = order(options, q); |
| + out[q] = hits.map((o) => o.value); |
| + const s = settle(hits, q); |
| + settled[q] = s ? s.value : ""; |
| + } |
| + process.stdout.write(JSON.stringify({ order: out, settled })); |
| +} |
diff --git a/check/node/shell_node.mjs b/check/node/shell_node.mjs| new file mode 100644 |
| index 0000000..1de585d |
| --- /dev/null |
| +++ b/check/node/shell_node.mjs |
| @@ -0,0 +1,87 @@ |
| +// shell_node — runs shell.js against a stub page in plain Node, one |
| +// fresh script per page and one sessionStorage for the tab, so a record |
| +// one page writes is what the next page reads, as in a browser. Only |
| +// the direction is driven: what the back control's click leaves behind |
| +// and which way the next page's transition is typed. |
| +// |
| +// check/shell_test.go is the only caller. It reads back one JSON |
| +// object: each scenario's name and the transition type the last page |
| +// got. |
| +// |
| +// This file lives under check/node/ beside the other Node drivers, so |
| +// `go test` in check/ finds it by name; it is never embedded, and |
| +// nothing in fiesta.go reaches for *.mjs. |
| +import { readFileSync } from "node:fs"; |
| +import vm from "node:vm"; |
| + |
| +const src = readFileSync(new URL("../../js/shell.js", import.meta.url), "utf8"); |
| + |
| +// A page: its URL, its view ("page" or "index"), and the href of its |
| +// back control, if it has one. No Navigation API, so the click never |
| +// takes the history.back() path and always leaves its record behind. |
| +function open(storage, url, view, backHref) { |
| + const on = { window: {}, document: {} }; |
| + const location = new URL(url); |
| + const root = { matches: (sel) => sel.includes(`~="${view}"`) }; |
| + const back = backHref && { |
| + href: new URL(backHref, url).href, |
| + closest: (sel) => (sel.includes("[rst-shell-back]") ? back : null), |
| + }; |
| + const document = { |
| + prerendering: false, |
| + querySelector: (sel) => (sel.includes("[rst-shell-sidebar]") ? root : null), |
| + querySelectorAll: () => [], |
| + getElementById: () => null, |
| + addEventListener: (type, fn) => { on.document[type] = fn; }, |
| + }; |
| + const window = { |
| + sessionStorage: storage, |
| + onpagereveal: null, |
| + onpageswap: null, |
| + addEventListener: (type, fn) => { on.window[type] = fn; }, |
| + }; |
| + vm.runInNewContext(src, { window, document, location, URL, matchMedia: () => ({ matches: true }) }); |
| + return { |
| + tapBack() { |
| + on.document.click({ target: back, button: 0, defaultPrevented: false, preventDefault() {} }); |
| + }, |
| + leave() { on.window.pageswap({}); }, |
| + reveal() { |
| + const types = new Set(); |
| + on.window.pagereveal({ viewTransition: { types } }); |
| + return [...types].join(","); |
| + }, |
| + }; |
| +} |
| + |
| +function tab() { |
| + const m = new Map(); |
| + return { |
| + getItem: (k) => (m.has(k) ? m.get(k) : null), |
| + setItem: (k, v) => { m.set(k, String(v)); }, |
| + removeItem: (k) => { m.delete(k); }, |
| + }; |
| +} |
| + |
| +const out = {}; |
| + |
| +// Back is tapped and the page really goes back to its up page. |
| +{ |
| + const s = tab(); |
| + const p = open(s, "https://app.test/invoices", "page", "/#nav-invoices"); |
| + p.tapBack(); |
| + p.leave(); |
| + out.taken = open(s, "https://app.test/#nav-invoices", "index").reveal(); |
| +} |
| + |
| +// Back is tapped, the reader cancels the leave-page prompt, then follows |
| +// a link to another content page: that is a forward navigation. |
| +{ |
| + const s = tab(); |
| + const p = open(s, "https://app.test/invoices", "page", "/"); |
| + p.tapBack(); |
| + p.leave(); |
| + out.cancelled = open(s, "https://app.test/orders", "page", "/").reveal(); |
| +} |
| + |
| +process.stdout.write(JSON.stringify(out)); |
diff --git a/check/scheme_browser_test.go b/check/scheme_browser_test.go| index daefa89..2865986 100644 |
| --- a/check/scheme_browser_test.go |
| +++ b/check/scheme_browser_test.go |
| @@ -9,11 +9,17 @@ package check |
| import ( |
| "context" |
| "fmt" |
| + "strings" |
| "testing" |
| "time" |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/cdproto/input" |
| "github.com/chromedp/chromedp" |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| + "amadan.net/rastrillo/fiesta/internal/axe" |
| "amadan.net/rastrillo/fiesta/internal/harness" |
| ) |
| @@ -218,3 +224,320 @@ func TestAServerOwnedSchemeSurvivesAStandalonePage(t *testing.T) { |
| } |
| rig.Screen("body", "the server-owned page after Light") |
| } |
| + |
| +// ── The scheme menu under axe ──────────────────────────────────────── |
| + |
| +// schemeMenuPages serves the scheme-menu example's standalone state |
| +// whole, through example.Document, so the <html> that carries |
| +// data-rst-scheme-store="local" comes along with the menu. Its |
| +// relative asset links are rewritten to the flat names the fixture |
| +// server answers (parityLinks), and nothing else in the document is |
| +// touched. It takes over from rastrillo's server-backed schemeApp, |
| +// which stays in rastrillo with the tests that need a server. |
| +func schemeMenuPages(t *testing.T) map[string]string { |
| + t.Helper() |
| + state, ok := manifest(t).Default("scheme-menu") |
| + if !ok || state == "" { |
| + t.Fatal("examples.json names no default state for scheme-menu") |
| + } |
| + doc, err := example.Document(fiesta.FS(), "scheme-menu", state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(doc, `data-rst-scheme-store="local"`) { |
| + t.Fatalf("scheme-menu/%s is not the standalone state: its root carries no data-rst-scheme-store", state) |
| + } |
| + return map[string]string{"/": parityLinks(doc)} |
| +} |
| + |
| +// axeClean scans the whole page and reports every violation, each |
| +// with the nodes it names; what axe could not decide is logged, so |
| +// the seam between the scanned half of the standard and the reviewed |
| +// half is never silent. |
| +func axeClean(t *testing.T, ctx context.Context, where string) { |
| + t.Helper() |
| + res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| + if err != nil { |
| + t.Fatalf("%s: axe: %v", where, err) |
| + } |
| + if len(res.Incomplete) > 0 { |
| + t.Logf("%s: needs review, axe could not decide: %v", where, res.Incomplete) |
| + } |
| + for _, v := range res.Violations { |
| + t.Errorf("%s: %s [%s] %s: %s", where, v.ID, v.Impact, v.Help, strings.Join(v.Nodes, "; ")) |
| + } |
| +} |
| + |
| +// TestTheSchemeMenuPassesAxeInBothSchemes: the standalone scheme menu, |
| +// served with fiesta.js as the example links it, passes axe with the |
| +// menu open before a choice and after Dark is chosen in place. The |
| +// choice sets data-theme="dark" on the root and navigates nowhere (the |
| +// mark chooseScheme leaves on the window survives it). The root is |
| +// read first: without data-rst-scheme-store="local" this would be a |
| +// server-owned page, and the drive could not tell the two apart. |
| +func TestTheSchemeMenuPassesAxeInBothSchemes(t *testing.T) { |
| + rig := sizingRig(t, false, schemeMenuPages(t)) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) |
| + |
| + var store string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.getAttribute("data-rst-scheme-store") || ""`, &store)) |
| + if store != "local" { |
| + t.Fatalf("the served document's root carries data-rst-scheme-store=%q, want local: this is not the standalone page", store) |
| + } |
| + var got schemeState |
| + at(t, ctx, schemeStateJS, &got) |
| + if got.Theme != "" || got.Stored != "" { |
| + t.Fatalf("first visit: %+v", got) |
| + } |
| + |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery)) |
| + axeClean(t, ctx, "light, menu open") |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = false`, nil)) |
| + |
| + chooseScheme(t, ctx, "dark") |
| + at(t, ctx, schemeStateJS, &got) |
| + if got.Theme != "dark" || got.Current != "dark" || got.Open || !got.Mark { |
| + t.Fatalf("after choosing Dark in place: %+v", got) |
| + } |
| + mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery)) |
| + axeClean(t, ctx, "dark, menu open") |
| + rig.Screen("body", "the standalone scheme menu open in dark") |
| +} |
| + |
| +// ── A scheme flip lands (ui/scheme_browser_test.go) ────────────────── |
| + |
| +// schemeFlipBody is the page a scheme flip is read on: a sidebar's |
| +// current nav item and a linked media tile, the two things whose |
| +// background fades between schemes (0.2s and 0.25s, for the press |
| +// and the hover) while their text changes at once, and the switch's |
| +// own forms as an app renders them. |
| +const schemeFlipBody = `<div rst-shell-sidebar><nav rst-shell-rail><div rst-shell-nav><a href="#x">Overview</a><a href="#y" aria-current="page" id="nav-cur">Requests</a></div></nav></div> |
| +<div rst-list><div rst-media><div rst-media-tile id="tile"><div rst-media-frame><img src="/images/a.svg" alt=""></div><div rst-media-caption><div rst-media-name><a href="#t">Picture</a></div></div></div></div></div> |
| +<details rst-dropdown rst-scheme><summary>Theme</summary><div rst-dropdown-menu> |
| +<form method="post" action="/_theme"><input type="hidden" name="scheme" value="light"><button>Light</button></form> |
| +<form method="post" action="/_theme"><input type="hidden" name="scheme" value="dark"><button>Dark</button></form> |
| +<form method="post" action="/_theme"><input type="hidden" name="scheme" value="system"><button>System</button></form> |
| +</div></details>` |
| + |
| +// schemeFlipState is the two backgrounds and the scheme as read on |
| +// one frame. |
| +type schemeFlipState struct { |
| + Theme string // data-theme on <html>, "" for none |
| + Nav string // the current nav item's computed background |
| + Tile string // the linked media tile's computed background |
| +} |
| + |
| +// schemeFlipReadJS reads the state on the next animation frame. On |
| +// the next frame rather than now: a transition's first frame is its |
| +// start value, which is the same number the test would read without |
| +// a flip at all. |
| +const schemeFlipReadJS = `new Promise(r => requestAnimationFrame(() => r(JSON.stringify({ |
| + Theme: document.documentElement.getAttribute("data-theme") || "", |
| + Nav: getComputedStyle(document.getElementById("nav-cur")).backgroundColor, |
| + Tile: getComputedStyle(document.getElementById("tile")).backgroundColor |
| +}))))` |
| + |
| +// schemeFlipLandedJS waits out every finite animation, then reads the |
| +// same state: the value a reader sees once the page has settled. |
| +const schemeFlipLandedJS = `(async () => { |
| + await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); |
| + return await ` + schemeFlipReadJS + `; |
| +})()` |
| + |
| +// A scheme flip lands in the frame it happens in. The current nav |
| +// item and a linked tile fade their background for the press and the |
| +// hover, and a change of data-theme started the same fade while the |
| +// text snapped: for about a tenth of a second after the click, light |
| +// text sat on a still-light ground. Read on the very next frame, both |
| +// backgrounds already equal what they settle to. The same through the |
| +// OS flipping while the page is on System, where no form is involved. |
| +func TestASchemeFlipLandsOnTheNextFrame(t *testing.T) { |
| + rig := sizingRig(t, false, map[string]string{"/": sizingDoc("scheme flip", schemeFlipBody)}) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + os := func(scheme string) chromedp.Action { |
| + return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: scheme}}) |
| + } |
| + mustRun(t, ctx, os("light"), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) |
| + // choose submits one of the switch's forms the way a click does, |
| + // and reads the state on the next frame, in one script so no |
| + // round trip sits between the two. |
| + choose := func(scheme string) schemeFlipState { |
| + t.Helper() |
| + var got schemeFlipState |
| + at(t, ctx, fmt.Sprintf(`(() => { document.querySelector('[rst-scheme] input[value=%q] ~ button').click(); return %s; })()`, scheme, schemeFlipReadJS), &got, awaitPromise) |
| + return got |
| + } |
| + landed := func(where string) schemeFlipState { |
| + t.Helper() |
| + var got schemeFlipState |
| + at(t, ctx, schemeFlipLandedJS, &got, awaitPromise) |
| + t.Logf("%s, landed: %+v", where, got) |
| + return got |
| + } |
| + check := func(where string, got, want schemeFlipState) { |
| + t.Helper() |
| + if got.Theme != want.Theme { |
| + t.Fatalf("%s: data-theme %q, want %q", where, got.Theme, want.Theme) |
| + } |
| + if got.Nav != want.Nav || got.Tile != want.Tile { |
| + t.Errorf("%s: on the next frame the nav item is %s and the tile %s; settled they are %s and %s. The flip faded instead of landing.", where, got.Nav, got.Tile, want.Nav, want.Tile) |
| + } |
| + } |
| + |
| + // Learn the settled values of each scheme first, so the test holds |
| + // the theme's own numbers rather than a copy of them. |
| + choose("dark") |
| + dark := landed("dark chosen") |
| + choose("light") |
| + light := landed("light chosen") |
| + if dark.Nav == light.Nav || dark.Tile == light.Tile { |
| + t.Fatalf("the page does not change with the scheme: dark %+v, light %+v", dark, light) |
| + } |
| + |
| + check("Light to Dark by the switch", choose("dark"), dark) |
| + landed("after Dark") |
| + check("Dark to Light by the switch", choose("light"), light) |
| + landed("after Light") |
| + |
| + // System, with the OS flipping underneath: the attribute is gone, |
| + // light-dark() repaints on its own, and no form is submitted. Read |
| + // on the second frame, not the next: Chromium finds the media |
| + // change in the style update that starts the fade, and reports it |
| + // to listeners at the start of the frame after, so the shim can |
| + // land an OS flip one frame late at best. Without it the second |
| + // frame is a tenth of the way into the fade, and still light. |
| + got := choose("system") |
| + if got.Theme != "" { |
| + t.Fatalf("after choosing System data-theme is %q", got.Theme) |
| + } |
| + landed("system, OS light") |
| + mustRun(t, ctx, os("dark")) |
| + at(t, ctx, schemeFlipSecondFrameJS, &got, awaitPromise) |
| + check("System, OS light to dark", got, schemeFlipState{Nav: dark.Nav, Tile: dark.Tile}) |
| + landed("system, OS dark") |
| + mustRun(t, ctx, os("light")) |
| + at(t, ctx, schemeFlipSecondFrameJS, &got, awaitPromise) |
| + check("System, OS dark to light", got, schemeFlipState{Nav: light.Nav, Tile: light.Tile}) |
| +} |
| + |
| +// TestASchemeFlipLandsWhileHovered is Astra A3's residual (#104 item |
| +// 2): a tile and a rail item under the pointer, with their hover tint |
| +// settled, when an app's own script flips data-theme the way an app's |
| +// switch does (no shipped switch, so nothing cancels a transition). |
| +// The hover tint used to be a background-color transition, and a flip |
| +// retargeted it: the one item under the pointer faded for 0.2 to |
| +// 0.25s while its text changed at once. The tint now rides a |
| +// registered percentage and is re-resolved from the scheme every |
| +// frame, so the very next frame's background is the value the item |
| +// settles to. Light to dark on the tile, dark to light on the rail |
| +// item, each with the mouse really over it. |
| +func TestASchemeFlipLandsWhileHovered(t *testing.T) { |
| + body := strings.Replace(schemeFlipBody, `<a href="#x">`, `<a href="#x" id="nav-x">`, 1) |
| + rig := sizingRig(t, false, map[string]string{"/": sizingDoc("hovered scheme flip", body)}, harness.WithHover()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}), |
| + chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) |
| + bg := func(id string) string { |
| + t.Helper() |
| + var got string |
| + mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.getElementById(`+jsStr(id)+`)).backgroundColor`, &got)) |
| + return got |
| + } |
| + settled := func(id string) string { |
| + t.Helper() |
| + var got string |
| + mustRun(t, ctx, chromedp.Evaluate(`(async () => { await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); return getComputedStyle(document.getElementById(`+jsStr(id)+`)).backgroundColor; })()`, &got, awaitPromise)) |
| + return got |
| + } |
| + for _, c := range []struct{ id, from, to string }{{"tile", "light", "dark"}, {"nav-x", "dark", "light"}} { |
| + mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", `+jsStr(c.from)+`), true`, nil)) |
| + settled(c.id) |
| + rest := bg(c.id) |
| + // The shell fills the viewport and the tile sits below it. |
| + mustRun(t, ctx, chromedp.Evaluate(`document.getElementById(`+jsStr(c.id)+`).scrollIntoView({block: "center"}), true`, nil)) |
| + x, y, w, h := rect(t, ctx, "#"+c.id) |
| + // Two frames after the move, so the hover's transition exists |
| + // before settled waits on it. |
| + mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, x+w/2, y+h/2), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
| + hovered := settled(c.id) |
| + if hovered == rest { |
| + t.Fatalf("#%s in %s: hovered it is %s, as at rest; the hover did not take, so the drive would prove nothing", c.id, c.from, rest) |
| + } |
| + // The app's flip and the next frame's read in one script, so no |
| + // round trip sits between the write and the read. |
| + var next string |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { document.documentElement.setAttribute("data-theme", `+jsStr(c.to)+`); return new Promise(r => requestAnimationFrame(() => r(getComputedStyle(document.getElementById(`+jsStr(c.id)+`)).backgroundColor))); })()`, &next, awaitPromise)) |
| + want := settled(c.id) |
| + t.Logf("#%s hovered: %s in %s, %s a frame after the app set %s, settled %s", c.id, hovered, c.from, next, c.to, want) |
| + if want == hovered { |
| + t.Fatalf("#%s: the hover tint is %s in %s and in %s alike; the drive would prove nothing", c.id, hovered, c.from, c.to) |
| + } |
| + if next != want { |
| + t.Errorf("#%s hovered, app script sets data-theme=%s: on the next frame it is %s; settled it is %s. The flip faded instead of landing.", c.id, c.to, next, want) |
| + } |
| + mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, 5, 5), chromedp.Sleep(300*time.Millisecond)) |
| + } |
| +} |
| + |
| +// schemeFlipSecondFrameJS reads the state one frame later than |
| +// schemeFlipReadJS, for the OS flip (see the test). |
| +const schemeFlipSecondFrameJS = `new Promise(r => requestAnimationFrame(() => r(` + schemeFlipReadJS + `)))` |
| + |
| +// TestAnAppsOwnSchemeSwitchLandsAtRest is Astra A3: the shipped switch |
| +// lands a flip by cancelling the running transitions (land(), above), |
| +// but an app that sets data-theme from its own script, or that took |
| +// the new stylesheets with an old fiesta.js, never calls it. The |
| +// nav items', linked rows' and tiles' background transitions sit on |
| +// their :hover and :active rules only, not at rest, so at rest a |
| +// scheme change has no transition to start and lands on the next |
| +// frame with no script's help. The page is the scheme-flip fixture; |
| +// the flip is an attribute write from page script, the way an app's |
| +// switch does it, read on the very next frame against the value the |
| +// page settles to. |
| +func TestAnAppsOwnSchemeSwitchLandsAtRest(t *testing.T) { |
| + rig := sizingRig(t, false, map[string]string{"/": sizingDoc("app scheme flip", schemeFlipBody)}) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}), |
| + chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) |
| + // set writes the attribute the way an app's own switch does and |
| + // reads the next frame, in one script so no round trip sits |
| + // between the write and the read. |
| + set := func(scheme string) schemeFlipState { |
| + t.Helper() |
| + var got schemeFlipState |
| + at(t, ctx, fmt.Sprintf(`(() => { document.documentElement.setAttribute("data-theme", %q); return %s; })()`, scheme, schemeFlipReadJS), &got, awaitPromise) |
| + return got |
| + } |
| + settled := func() schemeFlipState { |
| + t.Helper() |
| + var got schemeFlipState |
| + at(t, ctx, schemeFlipLandedJS, &got, awaitPromise) |
| + return got |
| + } |
| + set("dark") |
| + dark := settled() |
| + set("light") |
| + light := settled() |
| + if dark.Nav == light.Nav || dark.Tile == light.Tile { |
| + t.Fatalf("the page does not change with the scheme: dark %+v, light %+v", dark, light) |
| + } |
| + for _, c := range []struct { |
| + to string |
| + want schemeFlipState |
| + }{{"dark", dark}, {"light", light}} { |
| + got := set(c.to) |
| + if got.Theme != c.to { |
| + t.Fatalf("data-theme is %q after the app set %q", got.Theme, c.to) |
| + } |
| + if got.Nav != c.want.Nav || got.Tile != c.want.Tile { |
| + t.Errorf("app script sets data-theme=%s: on the next frame the nav item is %s and the tile %s; settled they are %s and %s. The flip faded instead of landing, with no shipped switch to cancel it.", c.to, got.Nav, got.Tile, c.want.Nav, c.want.Tile) |
| + } |
| + settled() |
| + } |
| +} |
diff --git a/check/scripts_test.go b/check/scripts_test.go| index fc3c8cb..2f1fd66 100644 |
| --- a/check/scripts_test.go |
| +++ b/check/scripts_test.go |
| @@ -316,3 +316,339 @@ func TestAServerOwnedPageIgnoresTheStoredScheme(t *testing.T) { |
| t.Errorf("a server-owned page read storage %d times on load; want never", got.StorageCalls) |
| } |
| } |
| + |
| +// ── The other scripts' contracts (ui/shim_test.go) ────────────────── |
| + |
| +// The busy rule is a DEFAULT, and this is the assertion that says so in |
| +// Go rather than in a browser: one delegated submit listener on the |
| +// document, no scan over forms carrying an attribute, and the only |
| +// thing data-busy can now say is "false". |
| +// |
| +// The bug this catches is a quiet regression, not a crash: someone |
| +// restores the old `document.querySelectorAll("form[data-busy]")` scan, |
| +// every drive that plants data-busy on its form keeps passing, and every |
| +// app that never wrote the attribute silently loses the rule. |
| +func TestBusyRuleIsTheDefault(t *testing.T) { |
| + js := string(shippedScript(t, "busy.js")) |
| + if !strings.Contains(js, `document.addEventListener("submit", busySubmit, true)`) { |
| + t.Error("the busy rule is not one delegated capture-phase submit listener on the document") |
| + } |
| + // Two mentions and no more: the definition and the one listener. A |
| + // third would mean someone started binding it per form again. |
| + if n := strings.Count(js, "busySubmit"); n != 2 { |
| + t.Errorf("busySubmit appears %d times, want 2 (one definition, one document listener)", n) |
| + } |
| + // The opt-in scan is gone. Any querySelectorAll over a data-busy |
| + // selector is the old shape coming back. |
| + for _, bad := range []string{`"form[data-busy]"`, `"[data-busy]"`, "busyForm"} { |
| + if strings.Contains(js, bad) { |
| + t.Errorf("busy.js scans for %s; the busy rule is on by default, not opted into", bad) |
| + } |
| + } |
| + // data-busy survives only as an opt-out, on the form and on the |
| + // button. Both readings have to be there: a rule you can only turn |
| + // off for a whole form is not the rule the docs describe. |
| + if n := strings.Count(js, `getAttribute("data-busy") === "false"`); n != 2 { |
| + t.Errorf(`data-busy=="false" is tested %d times, want 2 (the form's opt-out and the button's)`, n) |
| + } |
| + // A form is [LegacyOverrideBuiltIns], so a control named "target" |
| + // shadows form.target with the input element — truthy, not "_self", |
| + // and the property form would silently switch the rule off for that |
| + // form. Every attribute the handler reads goes through getAttribute, |
| + // and the guard is the form's own aria-busy rather than an expando a |
| + // control could shadow (or, under strict mode, throw on). |
| + // Comments stripped first, or this gate would fire on the very |
| + // sentence in the shim that explains why the property read is wrong. |
| + code := js[strings.Index(js, "(function () {"):] |
| + var b strings.Builder |
| + for _, line := range strings.Split(code, "\n") { |
| + if strings.HasPrefix(strings.TrimSpace(line), "//") { |
| + continue |
| + } |
| + b.WriteString(line) |
| + b.WriteByte('\n') |
| + } |
| + code = b.String() |
| + for _, bad := range []string{"form.target", "form.method", ".formMethod", "form.rstBusy", ".rstBusy ="} { |
| + if strings.Contains(code, bad) { |
| + t.Errorf("the shim reads %s as a property; a control of that name shadows it — switching the rule off, or, for an assignment under strict mode, throwing", bad) |
| + } |
| + } |
| + if !strings.Contains(js, `form.getAttribute("target")`) { |
| + t.Error("the shim does not read the form's target through getAttribute") |
| + } |
| + if !strings.Contains(js, `if (form.getAttribute("aria-busy") === "true") { e.preventDefault(); return; }`) { |
| + t.Error("the double-submit guard is not the form's own aria-busy attribute") |
| + } |
| + // A submit that goes NOWHERE must not be guarded, and there are |
| + // three shapes of it. target="_blank" leaves this page where it is; |
| + // so does <form method="dialog">, the standard close button inside a |
| + // <dialog>; and so does <button formmethod="dialog"> in an ordinary |
| + // form, the same close reached through the submitter's own |
| + // attribute. The dialog shapes are the worse: nothing ever navigates |
| + // or reloads afterwards, so a guard armed there is never cleared — |
| + // the close button ends up disabled and the dialog cannot be closed |
| + // through that form again, ever. |
| + // |
| + // One effective method, computed once, with the submitter's |
| + // formmethod beating the form's method the way HTML says it does. |
| + // Both read through getAttribute — never form.method or |
| + // btn.formMethod — for the [LegacyOverrideBuiltIns] reason above, |
| + // and matched case-insensitively because method is an enumerated |
| + // attribute. |
| + for _, want := range []string{ |
| + `if (/^dialog$/i.test(btn && btn.getAttribute("formmethod") ||`, |
| + ` form.getAttribute("method"))) return;`, |
| + } { |
| + if !strings.Contains(js, want) { |
| + t.Errorf(`the busy rule does not bail out of an effective method of "dialog" (%s missing); a dialog's close form — or a button's formmethod="dialog" — arms a guard nothing will ever clear`, want) |
| + } |
| + } |
| + // The submitter has to be in hand BEFORE the guard runs, or the |
| + // effective method cannot see it and the bail reads the form's |
| + // method alone. |
| + if strings.Index(js, " var btn = e.submitter;") > strings.Index(js, ` if (/^dialog$/i.test(`) { |
| + t.Error("the submitter is read after the dialog bail; the bail cannot see formmethod") |
| + } |
| + // The reset has to reach a submit button that belongs to the form |
| + // through form="id" while living somewhere else in the document — a |
| + // sticky header's Save. busySubmit busies it (it is the submit |
| + // event's submitter, wherever it sits), so a reset that swept the |
| + // form's own subtree left it disabled and spinning after every |
| + // bfcache restore, for good. Sweep the document and test ownership. |
| + if strings.Contains(js, `form.querySelectorAll('[aria-busy="true"]')`) { |
| + t.Error(`busyOff sweeps the form's descendants; a submit button associated through form="id" is not one, and never comes back`) |
| + } |
| + if !strings.Contains(js, `if (b.form !== form && !form.contains(b)) return;`) { |
| + t.Error("busyOff does not test ownership, so it would clear busy state belonging to another form") |
| + } |
| + // The payload trap, pinned where a reader will trip over it: the |
| + // two mutations that can change what the server receives — disabled |
| + // and an <input type="submit">'s value — happen inside the deferred |
| + // callback, never in the synchronous part of the handler. |
| + sync, deferred, ok := strings.Cut(js, " setTimeout(function () {") |
| + if !ok { |
| + t.Fatal("the busy handler no longer defers anything; disabled must not be set during the submit event") |
| + } |
| + sync = sync[strings.Index(sync, "function busySubmit"):] |
| + for _, bad := range []string{"btn.disabled", "btn.value ="} { |
| + if strings.Contains(sync, bad) { |
| + t.Errorf("%s runs synchronously in the submit handler; it drops the button's name/value from the payload", bad) |
| + } |
| + if !strings.Contains(deferred, bad) { |
| + t.Errorf("%s is not in the deferred callback; the busy state never hardens", bad) |
| + } |
| + } |
| +} |
| + |
| +// select.js holds to the same contract as the shim, and to the same |
| +// reason for existing: small enough that the app owner who now owns it |
| +// can read the whole thing. |
| +func TestSelectContract(t *testing.T) { |
| + js := string(shippedScript(t, "select.js")) |
| + for _, want := range []string{ |
| + "data-rst-select", "data-rst-select-filter", |
| + "data-rst-select-results", "data-rst-select-result-one", |
| + // The convention, pinned: an ARIA combobox that mirrors rather |
| + // than replaces, announced to assistive tech. |
| + "combobox", "aria-activedescendant", "aria-expanded", "aria-live", |
| + "rst-sr-only", |
| + } { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("select.js does not mention %q", want) |
| + } |
| + } |
| + // The whole point: the native control survives enhancement, because |
| + // it is what the form submits. The one thing select.js does remove is |
| + // a row it drew itself (a page's extra suggestions, replaced), so the |
| + // fence is on the native select and the markup around it. |
| + for _, bad := range []string{"native.remove()", "removeChild(native", "outerHTML =", "innerHTML ="} { |
| + if strings.Contains(js, bad) { |
| + t.Errorf("select.js destroys DOM (%q); the native select must survive", bad) |
| + } |
| + } |
| + // Inert by default, and re-scannable. |
| + if !strings.Contains(js, "rstEnhanced") { |
| + t.Error("select.js is not idempotent; re-scanning would double-enhance") |
| + } |
| + // The markup-side opt-out, and the groups. |
| + if !strings.Contains(js, `!== "false"`) { |
| + t.Error(`select.js does not honour data-rst-select="false"; a hand-written select cannot opt out`) |
| + } |
| + for _, want := range []string{"OPTGROUP", `"group"`, "rst-select-group"} { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("select.js does not mention %q; a grouped select would be flattened", want) |
| + } |
| + } |
| + // Raised from 8KB to 12KB, once, by optgroups and the markup |
| + // opt-out. Flattening native.options was a line shorter and silently |
| + // threw away the headings an author wrote to make a long list |
| + // readable; rendering the groups costs a nested list, an ARIA group |
| + // per optgroup, and a filter that hides a heading when its rows all |
| + // go. That is the trade, and it is a decision, not a drift. |
| + // |
| + // Raised again, to 37KB (measured 36,296 bytes), by the convergence |
| + // with Tito Go's searchselect (2026-09-26): ranking that puts the row |
| + // someone meant first (accents folded, ISO and calling codes as |
| + // terms), prompts that are never picks, a list that keeps pace with |
| + // fast typing by moving only what a search shows, settle-on-leave, |
| + // dividers, compact boxes and borrowed validity. Splitting the pure |
| + // ranking into its own file was tried and rejected: importing it makes |
| + // this an ES module, which every app's classic <script defer> tag and |
| + // the design system's preview files would have to change for, and an |
| + // app that re-vendored without editing its layout would lose the |
| + // enhancement silently. Most of the growth is the why-comments, which |
| + // are what let the app owner who now owns it read it in one sitting. |
| + // The headroom is about what the old 12KB ceiling had: room for a fix |
| + // and its why, not for a feature. |
| + // |
| + // Then to 47KB by the alignment with Tito's final searchselect (titogo |
| + // 9d21ce1): the box steps aside when a page replaces the select |
| + // outright, and rebuilds itself when a page changes the options — |
| + // without both it showed one pick while the form posted another — and, |
| + // from Paul's iPhone, the list opens upward when the keyboard leaves no |
| + // room below, with layout read once a frame. Measured 48,035 bytes after |
| + // rows moved from the hidden attribute to inline display (titogo |
| + // #3145); comments were trimmed to stay inside rather than raise it. |
| + // |
| + // Then to 48KB (measured 48,573 bytes) by rst:select-scan, the one way |
| + // a page that adds content after load gets its selects enhanced (Tito's |
| + // modal forms need it). The header was trimmed first; the ranking |
| + // comments were not, because they are kept in step with Tito's. |
| + // |
| + // Then to 50KB (measured 51,096 bytes) by placement in frames: a band a |
| + // host tells the frame (rst:select-viewport), floors under sticky bars |
| + // (data-rst-select-floor), the list's padding and borders counted |
| + // against the room, and the gap re-read — Tito Go #3146's rule, which |
| + // the checkout widget's iPhone keyboard needed. |
| + // |
| + // Then to 51KB (measured 52,098 bytes) by data-rst-class, a row and the |
| + // closed box styled per option: Tito Go's font menus set each name in |
| + // its own face. Comments were trimmed to the one that says why typing |
| + // takes the classes off. |
| + // |
| + // Then to 52KB (measured 53,128 bytes) by selecting the pick on a |
| + // focusing click as well as on focus: WebKit put the caret where a |
| + // click landed after focus, so clearing "Dublin" took a double-click. |
| + // The comment is the one that says why each pointer selects when. |
| + // |
| + // Then to 54KB (measured 54,601 bytes) by the string tables: the |
| + // file sat 120 bytes under the cap, and fiestaPickLang and |
| + // fiestaWord, the lookup that reads a label off the attribute, then |
| + // the page's table, then the fallback, added 1,473 net. The same |
| + // bytes went into datetime.js, whose cap moved with them; see |
| + // TestTheTwoLookupsAreTheSame. 53KB does not hold it; 54KB does, |
| + // with 695 to spare. |
| + if n := len(js); n > 54*1024 { |
| + t.Fatalf("select.js is %d bytes; it is split out of the shim precisely to stay readable — trim it", n) |
| + } |
| + if strings.Contains(js, "\t") { |
| + t.Error("select.js uses two-space indentation, not tabs") |
| + } |
| +} |
| + |
| +// No shipped script may reach off-origin: every one fiesta.ScriptNames |
| +// lists is vendored, first-party and dependency-free. The count is |
| +// pinned so a list that lost its names could not pass by naming none. |
| +func TestScriptsAreSelfContained(t *testing.T) { |
| + names := fiesta.ScriptNames() |
| + if len(names) != 8 { |
| + t.Fatalf("fiesta.ScriptNames() lists %d scripts, want 8: %v", len(names), names) |
| + } |
| + for _, name := range names { |
| + js := string(shippedScript(t, name)) |
| + for _, bad := range []string{"http://", "https://", "import ", "require(", "//cdn"} { |
| + if strings.Contains(js, bad) { |
| + t.Errorf("%s reaches outside the page (%q)", name, bad) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// anchor.js is the one script an app is told to delete in time, so its |
| +// contract is that it is inert where it is not needed: the feature test |
| +// returns before any listener is added, and the class it writes is the |
| +// one tokens.css opens a menu from. Small, like the others. |
| +func TestAnchorContract(t *testing.T) { |
| + js := string(shippedScript(t, "anchor.js")) |
| + test, listen := strings.Index(js, `CSS.supports("position-area: block-end")`), strings.Index(js, "addEventListener") |
| + if test < 0 || listen < 0 || test > listen { |
| + t.Error("anchor.js must test for anchor positioning, and return, before it listens for anything") |
| + } |
| + if !strings.Contains(js, "is-flip-inline") || !strings.Contains(string(fiesta.TokensCSS()), "[rst-dropdown].is-flip-inline > [rst-dropdown-menu]") { |
| + t.Error("anchor.js and tokens.css no longer agree on .is-flip-inline") |
| + } |
| + if n := len(js); n > 4*1024 { |
| + t.Errorf("anchor.js is %d bytes; it does one thing, keep it small", n) |
| + } |
| + if strings.Contains(js, "\t") { |
| + t.Error("anchor.js uses two-space indentation, not tabs") |
| + } |
| +} |
| + |
| +// busy.js was split out of the shim when the hold was added (the shim |
| +// sat 20 bytes under its cap). Its contract, held the same way: the |
| +// vocabulary, the spinner and submitter, the cancelled-submit hand-back, |
| +// the hold (its duration, the window listener that runs last, the |
| +// re-submit that keeps the submitter), and Back in both directions. |
| +func TestBusyContract(t *testing.T) { |
| + js := string(shippedScript(t, "busy.js")) |
| + for _, want := range []string{ |
| + "data-busy", "data-busy-label", |
| + `spin.setAttribute("rst-spin", "")`, "e.submitter", "defaultPrevented", |
| + "HOLD_MS = 650", `window.addEventListener("submit"`, "requestSubmit(btn || undefined)", |
| + "held.has(e)", "form === releasing", "checkValidity()", |
| + "pagehide", "clearTimeout", "pageshow", "e.persisted", |
| + // Review fixes: an opted-out button is never left disabled, and a |
| + // submitter that left the form during the hold hands the form back. |
| + "btn.disabled = wasDisabled", "btn.form !== form", `getAttribute("formtarget")`, |
| + // Only a submit with a spinner showing is held. |
| + `btn.getAttribute("aria-busy") !== "true") return;`, |
| + } { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("busy.js does not mention %q", want) |
| + } |
| + } |
| + if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") { |
| + t.Error("busy.js should be its contract comment and a single IIFE") |
| + } |
| + if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") { |
| + t.Error("busy.js must stay CSP-clean and use two-space indentation") |
| + } |
| + if n := len(js); n > 16*1024 { |
| + t.Fatalf("busy.js is %d bytes; keep it readable in one sitting", n) |
| + } |
| +} |
| + |
| +// shell.js holds to the contract every scaffolded script does, with an |
| +// 8 KiB cap of its own (busy.js's precedent): the three behaviours it |
| +// exists for, named; both spellings of what it reads; the storage guard; |
| +// the prerender and pageswap facts its ordering depends on. |
| +func TestShellContract(t *testing.T) { |
| + js := string(shippedScript(t, "shell.js")) |
| + for _, want := range []string{ |
| + // 1. Direction. |
| + "pagereveal", "viewTransition.types.add", `"back"`, `"forward"`, "navigationType", `"traverse"`, |
| + // 2. History reuse, only when proven. The Navigation API is read |
| + // through a local (nav), so the call is spelled nav.entries(). |
| + "history.back()", "nav.entries()", "sameDocument", "e.button !== 0", "defaultPrevented", |
| + // 3. Focus return, record first, never while prerendering. |
| + "rst-shell-return", "pageswap", "pagehide", "document.prerendering", "decodeURIComponent", "sessionStorage", |
| + // Both spellings. |
| + "[rst-shell-back]", ".rst-shell__back", `[rst-shell-sidebar~="index"]`, ".rst-shell-sidebar--index", |
| + `[rst-shell-console~="page"]`, ".rst-shell-console--page", |
| + } { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("shell.js does not mention %q", want) |
| + } |
| + } |
| + if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") { |
| + t.Error("shell.js should be its contract comment and a single IIFE") |
| + } |
| + if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") { |
| + t.Error("shell.js must stay CSP-clean and use two-space indentation") |
| + } |
| + if n := len(js); n > 8*1024 { |
| + t.Fatalf("shell.js is %d bytes; keep it readable in one sitting (8 KiB)", n) |
| + } |
| +} |
diff --git a/check/select_browser_test.go b/check/select_browser_test.go| new file mode 100644 |
| index 0000000..4c6ead4 |
| --- /dev/null |
| +++ b/check/select_browser_test.go |
| @@ -0,0 +1,1723 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +// Browser drives for select.js's converged behaviour, ported from Tito |
| +// Go's searchselect fences (internal/instance/ui_test.go) by way of |
| +// rastrillo's ui/select_browser_test.go. Each drive runs its steps |
| +// inside one page script and reads the result back as JSON, because |
| +// what is being fenced is timing inside a frame and the exact DOM |
| +// writes a keystroke makes — things a step-per-round-trip drive would |
| +// blur. driveSelectFilter, the type-to-filter-and-commit journey, is |
| +// standalone so the release build's tests can run it against the |
| +// stripped scripts on a rig of their own built from selectFilterPages. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "html" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// afterFrame resolves once the next frame has been drawn and the task |
| +// after it has run: where a keystroke's deferred search has landed. |
| +const afterFrame = `new Promise((r) => requestAnimationFrame(() => setTimeout(() => r(0), 0)))` |
| + |
| +// zones is a time-zone list long enough (over fifty rows) that a |
| +// keystroke moving every row would show, with "Europe/Lo…" and "Dubl" |
| +// among them. |
| +var zones = []string{ |
| + "Africa/Abidjan", "Africa/Accra", "Africa/Algiers", "Africa/Cairo", "Africa/Casablanca", |
| + "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi", "Africa/Tunis", "America/Anchorage", |
| + "America/Bogota", "America/Buenos_Aires", "America/Caracas", "America/Chicago", "America/Denver", |
| + "America/Halifax", "America/Havana", "America/Lima", "America/Los_Angeles", "America/Mexico_City", |
| + "America/New_York", "America/Phoenix", "America/Santiago", "America/Sao_Paulo", "America/Toronto", |
| + "America/Vancouver", "Asia/Bangkok", "Asia/Dhaka", "Asia/Dubai", "Asia/Hong_Kong", |
| + "Asia/Jakarta", "Asia/Jerusalem", "Asia/Karachi", "Asia/Kathmandu", "Asia/Kolkata", |
| + "Asia/Manila", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Tehran", |
| + "Asia/Tokyo", "Atlantic/Azores", "Atlantic/Reykjavik", "Australia/Adelaide", "Australia/Brisbane", |
| + "Australia/Perth", "Australia/Sydney", "Europe/Amsterdam", "Europe/Athens", "Europe/Berlin", |
| + "Europe/Brussels", "Europe/Bucharest", "Europe/Dublin", "Europe/Helsinki", "Europe/Istanbul", |
| + "Europe/Lisbon", "Europe/London", "Europe/Madrid", "Europe/Moscow", "Europe/Oslo", |
| + "Europe/Paris", "Europe/Prague", "Europe/Rome", "Europe/Stockholm", "Europe/Vienna", |
| + "Europe/Warsaw", "Europe/Zurich", "Pacific/Auckland", "Pacific/Fiji", "Pacific/Honolulu", |
| +} |
| + |
| +// flag is a country's regional-indicator pair, the glyph data-rst-lead |
| +// carries. |
| +func flag(iso string) string { |
| + var b strings.Builder |
| + for _, r := range iso { |
| + b.WriteRune(0x1F1E6 + (r - 'A')) |
| + } |
| + return b.String() |
| +} |
| + |
| +// selectFilterPages is the one page the drives below open, at "/": |
| +// select.js, the stylesheets and one form holding the selects. The |
| +// fixture server (sizingMux) serves the scripts and stylesheets it |
| +// links; a release rig serves the same page through sizingMuxWith. |
| +func selectFilterPages(t *testing.T) map[string]string { |
| + t.Helper() |
| + var body strings.Builder |
| + body.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` + |
| + `<title>select</title><link rel="stylesheet" href="/tokens.css">` + |
| + `<link rel="stylesheet" href="/theme.css">` + |
| + `<script defer src="/select.js"></script></head><body>` + |
| + `<form method="post" action="/submit">`) |
| + |
| + // A time zone, no blank: the keeps-pace drive. |
| + body.WriteString(`<label rst-field-label for="tz">Time zone</label>` + |
| + `<select rst-input id="tz" name="tz" data-rst-select>`) |
| + for _, z := range zones { |
| + fmt.Fprintf(&body, `<option value="%s">%s</option>`, z, z) |
| + } |
| + body.WriteString(`</select>`) |
| + |
| + // A REQUIRED country question: pinned countries above a divider, then |
| + // the rest, under a disabled "Choose one". |
| + pinned := []string{"AE", "SA", "GB", "IN"} |
| + byISO := map[string]countryRow{"SA": {"SA", "Saudi Arabia", "Saudi-Arabien", "966"}} |
| + for _, c := range countries { |
| + byISO[c.iso] = c |
| + } |
| + option := func(c countryRow) { |
| + fmt.Fprintf(&body, `<option value="%s" data-rst-lead="%s" data-rst-terms="%s +%s">%s</option>`, |
| + c.iso, flag(c.iso), c.iso, c.code, html.EscapeString(c.en)) |
| + } |
| + body.WriteString(`<label rst-field-label for="country">Country</label>` + |
| + `<select rst-input id="country" name="country" required data-rst-select>` + |
| + `<option value="" disabled selected>Choose one</option>`) |
| + for _, iso := range pinned { |
| + option(byISO[iso]) |
| + } |
| + body.WriteString(`<hr>`) |
| + for _, c := range countries { |
| + if c.iso != "GB" && c.iso != "AE" && c.iso != "IN" { |
| + option(c) |
| + } |
| + } |
| + body.WriteString(`</select>`) |
| + |
| + // A phone number's country: a compact box, a MARKED prompt. |
| + body.WriteString(`<select rst-input id="cc" name="cc" aria-label="Country" required data-rst-select data-rst-select-filter="Search">` + |
| + `<option data-rst-prompt value="" selected>Country</option>`) |
| + for _, c := range countries { |
| + first := "" |
| + if primary[c.iso] { |
| + first = " data-rst-first" |
| + } |
| + fmt.Fprintf(&body, `<option value="%s" data-rst-short="+%s" data-rst-lead="%s" data-rst-terms="%s +%s"%s>%s (+%s)</option>`, |
| + c.iso, c.code, flag(c.iso), c.iso, c.code, first, html.EscapeString(c.en), c.code) |
| + } |
| + body.WriteString(`</select><input type="tel" name="phone">`) |
| + |
| + // An optional select whose blank is a real answer — until something |
| + // makes the select required. |
| + body.WriteString(`<label rst-field-label for="size">Size</label>` + |
| + `<select rst-input id="size" name="size" data-rst-select>` + |
| + `<option value="" selected>None</option><option value="s">Small</option><option value="m">Medium</option><option value="l">Large</option>` + |
| + `</select>`) |
| + |
| + // A grouped select, for the extra rows a page adds under a search. |
| + body.WriteString(`<label rst-field-label for="dept">Department</label>` + |
| + `<select rst-input id="dept" name="dept" data-rst-select>` + |
| + `<optgroup label="Sales"><option value="acc">Accounts</option><option value="ret">Retail</option></optgroup>` + |
| + `<optgroup label="Support"><option value="asi">Assistance</option><option value="bil">Billing</option></optgroup>` + |
| + `</select>`) |
| + |
| + // A select the page replaces outright, and one whose options it |
| + // rebuilds, each in a host of its own. |
| + body.WriteString(`<label rst-field-label for="colour">Colour</label><div id="colour-host">` + |
| + `<select rst-input id="colour" name="colour" data-rst-select>` + |
| + `<option value="red">Red</option><option value="green">Green</option><option value="blue">Blue</option>` + |
| + `</select></div>`) |
| + body.WriteString(`<label rst-field-label for="flavour">Flavour</label><div id="flavour-host">` + |
| + `<select rst-input id="flavour" name="flavour" data-rst-select>` + |
| + `<option value="apple">Apple</option><option value="banana">Banana</option><option value="cherry">Cherry</option>` + |
| + `</select></div>`) |
| + |
| + body.WriteString(`<button type="submit" id="go">Save</button></form></body></html>`) |
| + return map[string]string{"/": body.String()} |
| +} |
| + |
| +// drive opens the page on a rig of its own and runs one page script, |
| +// decoding its result. |
| +func drive(t *testing.T, script string, out any) { |
| + t.Helper() |
| + driveScript(t, sizingRig(t, false, selectFilterPages(t)), script, out) |
| +} |
| + |
| +// driveScript is drive against a rig the caller built from |
| +// selectFilterPages. |
| +func driveScript(t *testing.T, rig *harness.Rig, script string, out any) { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + // A page in a background tab gets no focus events from focus(), |
| + // so a handler that reacts to focus could not be observed at all. |
| + emulation.SetFocusEmulationEnabled(true), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`#tz-combo`, chromedp.ByQuery), |
| + chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+script+`})()))()`, &raw, awaitPromise), |
| + ); err != nil { |
| + t.Fatalf("drive failed: %v", err) |
| + } |
| + if err := json.Unmarshal([]byte(raw), out); err != nil { |
| + t.Fatalf("decode %q: %v", raw, err) |
| + } |
| +} |
| + |
| +// driveSelectFilter is TestSelectKeepsPaceWithTyping's body, the |
| +// type-to-filter-and-commit journey, against any rig that serves |
| +// selectFilterPages. A burst of typing is searched once at the time |
| +// and once in the frame, not once per input event; Enter typed in the |
| +// same task acts on the words typed; a keystroke moves only the rows |
| +// it shows and writes a row's hidden state only when it changes; and |
| +// clearing puts every row back in its own place. |
| +func driveSelectFilter(t *testing.T, rig *harness.Rig) { |
| + t.Helper() |
| + var got struct { |
| + SyncSearches int `json:"syncSearches"` |
| + FrameSearches int `json:"frameSearches"` |
| + Picked string `json:"picked"` |
| + Rows int `json:"rows"` |
| + Moves int `json:"moves"` |
| + HiddenMoved []string `json:"hiddenMoved"` |
| + NoopWrites []string `json:"noopWrites"` |
| + OutOfPlace []string `json:"outOfPlace"` |
| + Shown int `json:"shown"` |
| + } |
| + driveScript(t, rig, ` |
| + const input = document.querySelector('#tz-combo'); |
| + const select = document.querySelector('#tz'); |
| + const list = document.querySelector('#tz-listbox'); |
| + const frame = () => `+afterFrame+`; |
| + const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; |
| + const out = { hiddenMoved: [], noopWrites: [], outOfPlace: [], moves: 0 }; |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const seen = []; |
| + const mo = new MutationObserver((m) => seen.push(...m)); |
| + mo.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'], attributeOldValue: true }); |
| + |
| + // A search shows itself as rows shown and hidden. Four inputs in one |
| + // task: the first is searched at once (rows change now) and the rest |
| + // once, in the frame (rows change again then). Had every input been |
| + // searched at once, the frame would have nothing left to do. Counted |
| + // as batches, since the highlight is no longer rewritten when it does |
| + // not move. |
| + mo.takeRecords(); |
| + type('D'); |
| + out.syncSearches = mo.takeRecords().length ? 1 : 0; |
| + for (const v of ['Du', 'Dub', 'Dubl']) type(v); |
| + out.syncSearches += mo.takeRecords().length ? 1 : 0; |
| + await frame(); |
| + out.frameSearches = (seen.length + mo.takeRecords().length) ? 1 : 0; |
| + seen.length = 0; |
| + |
| + type('Dubl'); |
| + type('Lond'); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + out.picked = select.value; |
| + await frame(); |
| + mo.takeRecords(); |
| + seen.length = 0; |
| + |
| + input.click(); |
| + await frame(); |
| + mo.takeRecords(); |
| + seen.length = 0; |
| + const word = 'Europe/Lo'; |
| + for (let i = 1; i <= word.length; i++) { |
| + type(word.slice(0, i)); |
| + await frame(); |
| + const recs = [...seen, ...mo.takeRecords()]; |
| + seen.length = 0; |
| + const writes = new Map(); |
| + for (const r of recs) { |
| + if (r.type === 'childList') { |
| + for (const n of r.addedNodes) { |
| + out.moves++; |
| + if (n.style.display === 'none') out.hiddenMoved.push(word.slice(0, i) + ': ' + (n.id || n.tagName)); |
| + } |
| + } else { |
| + if (!writes.has(r.target)) writes.set(r.target, []); |
| + writes.get(r.target).push((r.oldValue || '').includes('none')); |
| + } |
| + } |
| + for (const [el, olds] of writes) { |
| + olds.forEach((was, j) => { |
| + const now = j + 1 < olds.length ? olds[j + 1] : el.style.display === 'none'; |
| + if (was === now) out.noopWrites.push(word.slice(0, i) + ': ' + (el.id || el.tagName)); |
| + }); |
| + } |
| + } |
| + mo.disconnect(); |
| + out.shown = list.querySelectorAll('[rst-combo-option]:not([style*="none"])').length; |
| + |
| + type(''); |
| + await frame(); |
| + const rows = [...list.querySelectorAll('[rst-combo-option]')]; |
| + out.rows = rows.length; |
| + rows.forEach((li, i) => { |
| + if (li.id !== 'tz-listbox-' + i) out.outOfPlace.push(i + ': ' + li.id); |
| + }); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); |
| + return out; |
| + `, &got) |
| + if got.SyncSearches != 1 || got.FrameSearches != 1 { |
| + t.Errorf("four input events in one task were searched %d times at once and %d more in the frame, want 1 and 1", got.SyncSearches, got.FrameSearches) |
| + } |
| + if got.Picked != "Europe/London" { |
| + t.Errorf("typing \"Dubl\" then \"Lond\" and pressing Enter in the same task picked %q, want Europe/London: Enter acted on a list that had not caught up with the words", got.Picked) |
| + } |
| + if len(got.HiddenMoved) > 0 { |
| + t.Errorf("a keystroke moved %d rows it hides (first: %v): only the rows a search shows should move", len(got.HiddenMoved), got.HiddenMoved[0]) |
| + } |
| + if len(got.NoopWrites) > 0 { |
| + t.Errorf("%d hidden-state writes set a row to what it already was (first: %v)", len(got.NoopWrites), got.NoopWrites[0]) |
| + } |
| + // The fence above is only a fence if the observer saw rows move at all. |
| + if got.Shown == 0 || got.Moves == 0 { |
| + t.Errorf("typing \"Europe/Lo\" showed %d rows and moved %d: the observer saw no search happen", got.Shown, got.Moves) |
| + } |
| + if got.Rows < 50 || len(got.OutOfPlace) > 0 { |
| + t.Errorf("after clearing the search, %d of %d rows are out of their own place (first: %v)", len(got.OutOfPlace), got.Rows, got.OutOfPlace) |
| + } |
| +} |
| + |
| +func TestSelectKeepsPaceWithTyping(t *testing.T) { |
| + t.Parallel() |
| + driveSelectFilter(t, sizingRig(t, false, selectFilterPages(t))) |
| +} |
| + |
| +// A required country question with pinned countries above a divider: |
| +// clearing a search puts a lifted pinned country back ABOVE the divider |
| +// and the divider shows again; the required blank is never a row, even |
| +// for words that name it; words typed and cleared on the unanswered |
| +// question leave nothing highlighted, so Enter cannot answer it; and a |
| +// pick closes the list without leaving the next search waiting on a frame. |
| +func TestSelectKeepsItsDividerAndPrompt(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + Sep int `json:"sep"` |
| + Restored bool `json:"restored"` |
| + Order []string `json:"order"` |
| + SepSearching bool `json:"sepSearching"` |
| + SepAfter bool `json:"sepAfter"` |
| + PromptIdle bool `json:"promptIdle"` |
| + PromptTyped bool `json:"promptTyped"` |
| + ClearedActive string `json:"clearedActive"` |
| + ClearedEnter string `json:"clearedEnter"` |
| + ClearedOpen bool `json:"clearedOpen"` |
| + Picked string `json:"picked"` |
| + ActiveAfter string `json:"activeAfter"` |
| + TopAfter string `json:"topAfter"` |
| + } |
| + drive(t, ` |
| + const input = document.querySelector('#country-combo'); |
| + const list = document.querySelector('#country-listbox'); |
| + const select = document.querySelector('#country'); |
| + const frame = () => `+afterFrame+`; |
| + const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; |
| + const seq = () => [...list.children].map((c) => c.id || c.tagName + (c.hasAttribute('rst-combo-sep') ? ':sep' : '')); |
| + const blank = document.querySelector('#country-listbox-0'); |
| + const sep = list.querySelector('[rst-combo-sep]'); |
| + const out = { sep: list.querySelectorAll('[rst-combo-sep]').length }; |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const s0 = seq(); |
| + out.promptIdle = blank.style.display !== 'none'; |
| + for (const q of ['a', 'i', 'united k']) { |
| + type(q); |
| + await frame(); |
| + if (q === 'a') out.sepSearching = sep.style.display !== 'none'; |
| + } |
| + type(''); |
| + await frame(); |
| + out.order = seq(); |
| + out.restored = out.order.join() === s0.join(); |
| + out.sepAfter = sep.style.display !== 'none'; |
| + type('choose'); |
| + await frame(); |
| + out.promptTyped = blank.style.display !== 'none'; |
| + type('ger'); |
| + await frame(); |
| + type(''); |
| + await frame(); |
| + out.clearedActive = input.getAttribute('aria-activedescendant') || ''; |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + out.clearedEnter = select.value; |
| + out.clearedOpen = input.getAttribute('aria-expanded') === 'true'; |
| + |
| + type('irel'); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + out.picked = select.value; |
| + type('germ'); |
| + const act = input.getAttribute('aria-activedescendant'); |
| + out.activeAfter = act ? select.options[Number(act.split('-listbox-')[1])].value : ''; |
| + const top = list.querySelector('[rst-combo-option]:not([style*="none"])'); |
| + out.topAfter = top ? select.options[Number(top.id.split('-listbox-')[1])].value : ''; |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); |
| + return out; |
| + `, &got) |
| + if got.Sep != 1 { |
| + t.Fatalf("the country list drew %d dividers, want 1: the fence below needs one", got.Sep) |
| + } |
| + if !got.Restored { |
| + head := got.Order |
| + if len(head) > 8 { |
| + head = head[:8] |
| + } |
| + t.Errorf("after searching \"a\", \"i\" and \"united k\" and clearing, the list is not in its own order: it starts %v", head) |
| + } |
| + if got.SepSearching || !got.SepAfter { |
| + t.Errorf("the divider showed %v while searching and %v after, want false then true", got.SepSearching, got.SepAfter) |
| + } |
| + if got.PromptIdle || got.PromptTyped { |
| + t.Errorf("the required blank was a row with nothing typed (%v) or for \"choose\" (%v): it is a prompt, never an answer", got.PromptIdle, got.PromptTyped) |
| + } |
| + if got.ClearedActive != "" || got.ClearedEnter != "" || !got.ClearedOpen { |
| + t.Errorf("typing \"ger\" and clearing it on the unanswered question left %q highlighted, and Enter picked %q (list open: %v): a cleared search must highlight nothing, as opening does", got.ClearedActive, got.ClearedEnter, got.ClearedOpen) |
| + } |
| + if got.Picked != "IE" { |
| + t.Errorf("\"irel\" then Enter picked %q, want IE", got.Picked) |
| + } |
| + if got.ActiveAfter != "DE" || got.TopAfter != "DE" { |
| + t.Errorf("typing \"germ\" in the frame after a pick highlighted %q with %q on top, want DE and DE: the search waited on the frame the earlier typing armed", got.ActiveAfter, got.TopAfter) |
| + } |
| +} |
| + |
| +// A phone number's country: a MARKED prompt, "Country", which is a row |
| +// while nothing is typed. Unanswered, it highlights nothing — on opening, |
| +// and when a search is cleared — and the box shows only its placeholder, |
| +// so Enter picks nothing and the list stays open. The prompt is never a |
| +// stop for the arrows, Home or End, and never aria-selected. Answered, |
| +// the box reopens on its pick and a cleared search comes back to it. |
| +func TestSelectPromptClearsToItsPlaceholder(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + PromptRow bool `json:"promptRow"` |
| + OpenActive string `json:"openActive"` |
| + ClearedActive string `json:"clearedActive"` |
| + ClearedValue string `json:"clearedValue"` |
| + Placeholder string `json:"placeholder"` |
| + ClearedEnter string `json:"clearedEnter"` |
| + ClearedOpen bool `json:"clearedOpen"` |
| + Picked string `json:"picked"` |
| + Shown string `json:"shown"` |
| + PromptSelected string `json:"promptSelected"` |
| + DownFromNothing string `json:"downFromNothing"` |
| + UpFromFirst string `json:"upFromFirst"` |
| + End string `json:"end"` |
| + Home string `json:"home"` |
| + GBRow string `json:"gbRow"` |
| + GBSelected string `json:"gbSelected"` |
| + ReopenActive string `json:"reopenActive"` |
| + SearchActive string `json:"searchActive"` |
| + AnsweredClearedActive string `json:"answeredClearedActive"` |
| + } |
| + drive(t, ` |
| + const input = document.querySelector('#cc-combo'); |
| + const select = document.querySelector('#cc'); |
| + const frame = () => `+afterFrame+`; |
| + const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; |
| + const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); |
| + const act = () => input.getAttribute('aria-activedescendant') || ''; |
| + const out = {}; |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const prompt = document.querySelector('#cc-listbox-0'); |
| + out.promptRow = prompt.style.display !== 'none'; |
| + out.openActive = act(); |
| + out.promptSelected = prompt.getAttribute('aria-selected'); |
| + key('ArrowDown'); |
| + out.downFromNothing = act(); |
| + key('ArrowUp'); |
| + out.upFromFirst = act(); |
| + key('End'); |
| + out.end = act(); |
| + key('Home'); |
| + out.home = act(); |
| + type('44'); |
| + await frame(); |
| + type(''); |
| + await frame(); |
| + out.clearedActive = act(); |
| + out.clearedValue = input.value; |
| + out.placeholder = input.placeholder; |
| + key('Enter'); |
| + out.clearedEnter = select.value; |
| + out.clearedOpen = input.getAttribute('aria-expanded') === 'true'; |
| + type('+44'); |
| + key('Enter'); |
| + out.picked = select.value; |
| + out.shown = input.value; |
| + out.gbRow = 'cc-listbox-' + select.selectedIndex; |
| + out.gbSelected = document.querySelector('#' + out.gbRow).getAttribute('aria-selected'); |
| + input.blur(); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + out.reopenActive = act(); |
| + type('ger'); |
| + await frame(); |
| + out.searchActive = act(); |
| + type(''); |
| + await frame(); |
| + out.answeredClearedActive = act(); |
| + key('Escape'); |
| + return out; |
| + `, &got) |
| + if !got.PromptRow { |
| + t.Error("the \"Country\" prompt is not a row with nothing typed") |
| + } |
| + if got.OpenActive != "" { |
| + t.Errorf("the unanswered box opened with %q highlighted, want nothing", got.OpenActive) |
| + } |
| + if got.ClearedActive != "" || got.ClearedEnter != "" || !got.ClearedOpen { |
| + t.Errorf("typing \"44\" and clearing it left %q highlighted, and Enter picked %q (list open: %v): a cleared search highlights nothing", got.ClearedActive, got.ClearedEnter, got.ClearedOpen) |
| + } |
| + if got.ClearedValue != "" || got.Placeholder != "Country" { |
| + t.Errorf("the cleared box reads %q under placeholder %q, want empty under \"Country\"", got.ClearedValue, got.Placeholder) |
| + } |
| + if got.Picked != "GB" || got.Shown != "+44" { |
| + t.Errorf("\"+44\" then Enter picked %q showing %q, want GB showing +44", got.Picked, got.Shown) |
| + } |
| + // The prompt is never a stop for the keys, and never "selected". |
| + const first = "cc-listbox-1" |
| + last := fmt.Sprintf("cc-listbox-%d", len(countries)) |
| + if got.DownFromNothing != first { |
| + t.Errorf("ArrowDown from nothing highlighted %q, want the first country %s: the prompt is not a stop", got.DownFromNothing, first) |
| + } |
| + if got.UpFromFirst != first || got.Home != first { |
| + t.Errorf("ArrowUp from the first country went to %q and Home to %q, want both %s: the keys never land on the prompt", got.UpFromFirst, got.Home, first) |
| + } |
| + if got.End != last { |
| + t.Errorf("End highlighted %q, want the last country %s", got.End, last) |
| + } |
| + if got.PromptSelected != "false" { |
| + t.Errorf("the prompt reads aria-selected=%q on an unanswered box, want false: a prompt is never selected", got.PromptSelected) |
| + } |
| + if got.GBSelected != "true" { |
| + t.Errorf("after picking GB its row reads aria-selected=%q, want true", got.GBSelected) |
| + } |
| + // Opening lands where opening() says: the pick, on an answered box. |
| + if got.ReopenActive != got.GBRow { |
| + t.Errorf("the answered box reopened on %q, want its pick %s", got.ReopenActive, got.GBRow) |
| + } |
| + if got.SearchActive == "" || got.SearchActive == got.GBRow || got.AnsweredClearedActive != got.GBRow { |
| + t.Errorf("on the answered box, \"ger\" highlighted %q and clearing it %q, want another row and then the pick %s", got.SearchActive, got.AnsweredClearedActive, got.GBRow) |
| + } |
| +} |
| + |
| +// A select that becomes required (or stops) re-derives its prompts: an |
| +// optional select's "None" is a real answer — shown, listed, selected — |
| +// and once the select is required the same blank is a prompt, so the box |
| +// empties and no row is selected. And a highlighted row a page takes |
| +// away (its extra suggestions replaced) is never left as the highlight. |
| +func TestSelectFollowsRequiredAndExtras(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + OptionalShown string `json:"optionalShown"` |
| + OptionalSelected string `json:"optionalSelected"` |
| + RequiredShown string `json:"requiredShown"` |
| + RequiredSelected string `json:"requiredSelected"` |
| + BackShown string `json:"backShown"` |
| + ExtraActive string `json:"extraActive"` |
| + AfterRemove string `json:"afterRemove"` |
| + AfterRemoveInDOM bool `json:"afterRemoveInDOM"` |
| + BorrowedCleared bool `json:"borrowedCleared"` |
| + AfterRemoval string `json:"afterRemovalValue"` |
| + AfterCancel string `json:"afterCancelValue"` |
| + Requery string `json:"requeryValue"` |
| + RemovedShown string `json:"removedSelectedShown"` |
| + RemovedValue string `json:"removedSelectedValue"` |
| + NoneActive string `json:"noneActive"` |
| + NoneIndex int `json:"noneIndex"` |
| + BorrowedDisabled bool `json:"borrowedClearedDisabled"` |
| + AriaRequired string `json:"ariaRequired"` |
| + OpenFlipListed bool `json:"openFlipListed"` |
| + OpenFlipActive string `json:"openFlipActive"` |
| + OpenFlipShown int `json:"openFlipShown"` |
| + OpenFlipText string `json:"openFlipText"` |
| + IdleExtrasShown int `json:"idleExtrasShown"` |
| + ScreenOrder string `json:"screenOrder"` |
| + KeyOrder string `json:"keyOrder"` |
| + } |
| + drive(t, ` |
| + let input = document.querySelector('#size-combo'); |
| + // Changing the options rebuilds the box, so the drive re-reads it. |
| + const again = async () => { await frame(); input = document.querySelector('#size-combo'); }; |
| + const select = document.querySelector('#size'); |
| + const blank = document.querySelector('#size-listbox-0'); |
| + const frame = () => `+afterFrame+`; |
| + const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); |
| + const out = {}; |
| + out.optionalShown = input.value; |
| + // Read with the list open, which is when rows are marked. |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + out.optionalSelected = blank.getAttribute('aria-selected'); |
| + key('Escape'); |
| + select.required = true; |
| + await frame(); |
| + out.requiredShown = input.value; |
| + out.requiredSelected = blank.getAttribute('aria-selected'); |
| + out.ariaRequired = input.getAttribute('aria-required'); |
| + select.required = false; |
| + await frame(); |
| + // And with the list OPEN when it flips: the blank leaves the list |
| + // and is not left highlighted. |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + select.required = true; |
| + await frame(); |
| + out.openFlipListed = blank.style.display !== 'none'; |
| + out.openFlipActive = input.getAttribute('aria-activedescendant') || ''; |
| + // Idle (nothing typed), the refresh is not a search for the pick's |
| + // own text: every real choice stays, and the box drops "None". |
| + out.openFlipShown = document.querySelectorAll('#size-listbox [rst-combo-option]:not([style*="none"])').length; |
| + out.openFlipText = input.value; |
| + key('Escape'); |
| + select.required = false; |
| + await frame(); |
| + // And extras handed over while the box shows the pick hide nothing. |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + input.closest('[rst-combo]').rstExtras([{ label: 'Something else', run: () => {} }]); |
| + out.idleExtrasShown = document.querySelectorAll('#size-listbox [rst-combo-option]:not([style*="none"])').length; |
| + input.closest('[rst-combo]').rstExtras([]); |
| + key('Escape'); |
| + select.required = false; |
| + await frame(); |
| + out.backShown = input.value; |
| + |
| + // Extras: arrow onto one, then have the page take them all away. |
| + const wrap = input.closest('[rst-combo]'); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + wrap.rstExtras([{ label: 'Something else', run: () => {} }]); |
| + key('End'); |
| + out.extraActive = input.getAttribute('aria-activedescendant') || ''; |
| + wrap.rstExtras([]); |
| + const id = input.getAttribute('aria-activedescendant') || ''; |
| + out.afterRemove = id; |
| + out.afterRemoveInDOM = id === '' || !!document.getElementById(id); |
| + key('Escape'); |
| + |
| + // An option the app removes after enhancement moves every index: |
| + // the row chosen is still the option submitted. |
| + const small = select.querySelector('option[value="s"]'); |
| + small.remove(); |
| + await again(); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const medium = [...document.querySelectorAll('#size-listbox [rst-combo-option]')].find((li) => li.textContent === 'Medium'); |
| + medium.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); |
| + out.afterRemovalValue = select.value; |
| + select.insertBefore(small, select.querySelector('option[value="m"]')); |
| + await again(); |
| + select.value = ''; |
| + select.dispatchEvent(new Event('change', { bubbles: true })); |
| + |
| + // The SELECTED option removed: the next pick is what the box shows. |
| + select.value = 's'; |
| + select.dispatchEvent(new Event('change', { bubbles: true })); |
| + const small2 = select.querySelector('option[value="s"]'); |
| + small2.remove(); |
| + await again(); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const large = [...document.querySelectorAll('#size-listbox [rst-combo-option]')].find((li) => li.textContent === 'Large'); |
| + large.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); |
| + out.removedSelectedShown = input.value; |
| + out.removedSelectedValue = select.value; |
| + select.insertBefore(small2, select.querySelector('option[value="m"]')); |
| + await again(); |
| + |
| + // Nothing selected at all is unanswered: nothing is highlighted, and |
| + // a bare Enter answers nothing. |
| + select.selectedIndex = -1; |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + out.noneActive = input.getAttribute('aria-activedescendant') || ''; |
| + key('Enter'); |
| + out.noneIndex = select.selectedIndex; |
| + key('Escape'); |
| + select.value = ''; |
| + select.dispatchEvent(new Event('change', { bubbles: true })); |
| + |
| + // A cancelled search takes its steering with it: type, arrow, |
| + // Escape, type the same again, leave — nothing is committed. |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const typeSize = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; |
| + typeSize('m'); |
| + await frame(); |
| + key('ArrowDown'); |
| + key('Escape'); |
| + typeSize('m'); |
| + await frame(); |
| + key('Tab'); |
| + out.afterCancelValue = select.value; |
| + key('Escape'); |
| + |
| + // And the same words after a cancelled search are a NEW search: it |
| + // re-ranks, so Enter takes its best match, not the pick the old one |
| + // left highlighted. Large is picked; "a" ranks Small first. |
| + select.value = 'l'; |
| + select.dispatchEvent(new Event('change', { bubbles: true })); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + typeSize('a'); |
| + await frame(); |
| + key('Escape'); |
| + typeSize('a'); |
| + await frame(); |
| + key('Enter'); |
| + out.requeryValue = select.value; |
| + select.value = ''; |
| + select.dispatchEvent(new Event('change', { bubbles: true })); |
| + |
| + // A borrowed "please pick one" goes the moment the select stops |
| + // objecting, even while the box keeps focus. |
| + select.required = true; |
| + select.value = ''; |
| + select.dispatchEvent(new Event('change', { bubbles: true })); |
| + select.required = true; |
| + input.setCustomValidity(select.validationMessage || 'Please pick one'); |
| + select.required = false; |
| + await frame(); |
| + out.borrowedCleared = input.validity.valid; |
| + // And when it is disabled instead: a disabled select is never |
| + // validated, whatever its validity says. |
| + select.required = true; |
| + input.setCustomValidity(select.validationMessage || 'Please pick one'); |
| + select.disabled = true; |
| + await frame(); |
| + out.borrowedClearedDisabled = input.validity.valid; |
| + select.disabled = false; |
| + select.required = false; |
| + await frame(); |
| + |
| + // Grouped hits under a search sit above the page's extra rows, in |
| + // the order the keys walk them. |
| + const dInput = document.querySelector('#dept-combo'); |
| + const dWrap = dInput.closest('[rst-combo]'); |
| + const dList = document.querySelector('#dept-listbox'); |
| + dInput.focus(); |
| + dInput.click(); |
| + await frame(); |
| + dWrap.rstExtras([{ label: 'Other', run: () => {} }]); |
| + dInput.value = 'a'; |
| + dInput.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + out.screenOrder = [...dList.querySelectorAll('[rst-combo-option]:not([style*="none"])')].map((li) => li.textContent).join('|'); |
| + const walked = []; |
| + const dkey = (k) => dInput.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); |
| + dkey('Home'); |
| + for (let i = 0; i < 6; i++) { |
| + const a = dInput.getAttribute('aria-activedescendant'); |
| + const t = a ? document.getElementById(a).textContent : ''; |
| + if (!walked.includes(t)) walked.push(t); |
| + dkey('ArrowDown'); |
| + } |
| + out.keyOrder = walked.join('|'); |
| + dkey('Escape'); |
| + return out; |
| + `, &got) |
| + if got.OptionalShown != "None" || got.OptionalSelected != "true" { |
| + t.Errorf("an optional select's blank shows %q and reads aria-selected=%q, want \"None\" and true: it is a real answer", got.OptionalShown, got.OptionalSelected) |
| + } |
| + if got.RequiredShown != "" || got.RequiredSelected != "false" { |
| + t.Errorf("made required, the blank shows %q and reads aria-selected=%q, want empty and false: the observer did not re-derive the prompt", got.RequiredShown, got.RequiredSelected) |
| + } |
| + if !got.BorrowedDisabled { |
| + t.Error("the box kept the select's borrowed message after the select was disabled: a disabled field would still block the form") |
| + } |
| + if got.AriaRequired != "true" { |
| + t.Errorf("the box of a required select reads aria-required=%q, want \"true\": an ARIA boolean is a string", got.AriaRequired) |
| + } |
| + if got.OpenFlipListed || got.OpenFlipActive == "size-listbox-0" { |
| + t.Errorf("made required with the list open, the blank is still listed (%v) or highlighted (%q): the open list did not re-read its prompts", got.OpenFlipListed, got.OpenFlipActive) |
| + } |
| + if got.OpenFlipShown != 3 || got.OpenFlipText != "" { |
| + t.Errorf("made required with the list open and nothing typed, %d rows show and the box reads %q, want the 3 real choices and an empty box: the refresh searched for the old pick's text", got.OpenFlipShown, got.OpenFlipText) |
| + } |
| + if got.IdleExtrasShown != 5 { |
| + t.Errorf("extras handed to an idle open list left %d rows showing, want 5 (the 4 options and the extra)", got.IdleExtrasShown) |
| + } |
| + if got.BackShown != "None" { |
| + t.Errorf("made optional again, the box shows %q, want \"None\"", got.BackShown) |
| + } |
| + if !strings.Contains(got.ExtraActive, "-extra-") { |
| + t.Fatalf("End did not reach the extra row (highlighted %q): the fence below needs it", got.ExtraActive) |
| + } |
| + if !got.AfterRemoveInDOM { |
| + t.Errorf("after the page took its extra rows away the highlight still names %q, a row that is gone", got.AfterRemove) |
| + } |
| + if got.AfterRemoval != "m" { |
| + t.Errorf("after the app removed an option, choosing the Medium row submitted %q, want m: a cached index picked another option", got.AfterRemoval) |
| + } |
| + if got.RemovedShown != "Large" || got.RemovedValue != "l" { |
| + t.Errorf("after the selected option was removed, choosing Large shows %q and submits %q, want Large and l: a detached option still read as selected", got.RemovedShown, got.RemovedValue) |
| + } |
| + if got.NoneActive != "" || got.NoneIndex != -1 { |
| + t.Errorf("with nothing selected the list opened on %q and Enter left selectedIndex %d, want nothing highlighted and -1: no selection is unanswered", got.NoneActive, got.NoneIndex) |
| + } |
| + if got.Requery != "s" { |
| + t.Errorf("\"a\" typed again after a cancelled \"a\" and Enter picked %q, want s (Small, its best match): the cancelled search's query survived, so it did not re-rank", got.Requery) |
| + } |
| + if got.AfterCancel != "" { |
| + t.Errorf("a search typed, arrowed, cancelled and typed again committed %q on leaving, want nothing: the cancelled search's steering survived", got.AfterCancel) |
| + } |
| + if !got.BorrowedCleared { |
| + t.Error("the box kept the select's borrowed message after the select stopped being required: the form would still refuse to submit") |
| + } |
| + if got.ScreenOrder == "" || got.ScreenOrder != got.KeyOrder || !strings.HasSuffix(got.ScreenOrder, "|Other") { |
| + t.Errorf("a grouped search shows %q but the keys walk %q: the matches must sit above the page's extra row, in the keys' order", got.ScreenOrder, got.KeyOrder) |
| + } |
| +} |
| + |
| +// A page may replace a whole <select> (a question flow swapping its |
| +// answer control). The box that spoke for the old one steps aside and the |
| +// new one is enhanced afresh: before, Tito Go's box kept driving the |
| +// detached select, showing "Green" while the form posted "Blue". |
| +func TestSelectStepsAsideWhenReplaced(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + Boxes int `json:"boxes"` |
| + Shown string `json:"shown"` |
| + LabelFor string `json:"labelFor"` |
| + PostedNow string `json:"postedNow"` |
| + Picked string `json:"picked"` |
| + PostedPick string `json:"postedPick"` |
| + SameBoxes int `json:"sameTaskBoxes"` |
| + SameShown string `json:"sameTaskShown"` |
| + SameErrors int `json:"sameTaskErrors"` |
| + BothLabelled bool `json:"bothLabelled"` |
| + CloneBoxes int `json:"cloneBoxes"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const form = document.querySelector('form'); |
| + const posted = () => new FormData(form).get('colour'); |
| + // Pick Green through the box, then the page swaps the select for |
| + // one that holds Blue. |
| + let input = document.querySelector('#colour-combo'); |
| + input.focus(); |
| + input.value = 'green'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + input.blur(); |
| + const fresh = document.createElement('select'); |
| + fresh.id = 'colour'; |
| + fresh.name = 'colour'; |
| + fresh.setAttribute('rst-input', ''); |
| + fresh.setAttribute('data-rst-select', ''); |
| + fresh.innerHTML = '<option value="red">Red</option><option value="green">Green</option><option value="blue" selected>Blue</option>'; |
| + document.querySelector('#colour').replaceWith(fresh); |
| + await frame(); |
| + const out = {}; |
| + out.boxes = document.querySelectorAll('#colour-host [rst-combo]').length; |
| + input = document.querySelector('#colour-combo'); |
| + out.shown = input ? input.value : ''; |
| + out.labelFor = document.querySelector('label[for="colour-combo"]') ? 'colour-combo' : ''; |
| + out.postedNow = posted(); |
| + input.focus(); |
| + input.value = 'red'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + out.picked = input.value; |
| + out.postedPick = posted(); |
| + // Two selects replaced in one task: each new box is named by its label. |
| + const swapIn = (id, opts) => { |
| + const n = document.createElement('select'); |
| + n.id = id; |
| + n.name = id; |
| + n.setAttribute('data-rst-select', ''); |
| + n.innerHTML = opts; |
| + document.querySelector('#' + id).replaceWith(n); |
| + }; |
| + swapIn('colour', '<option value="red">Red</option><option value="blue">Blue</option>'); |
| + swapIn('flavour', '<option value="fig">Fig</option><option value="lime">Lime</option>'); |
| + await frame(); |
| + out.bothLabelled = !!document.querySelector('label[for="colour-combo"]') && !!document.querySelector('label[for="flavour-combo"]'); |
| + // Changed and replaced in the same task: a handover, and no error. |
| + let errors = 0; |
| + window.addEventListener('error', () => errors++); |
| + const again = document.createElement('select'); |
| + again.id = 'colour'; |
| + again.name = 'colour'; |
| + again.setAttribute('data-rst-select', ''); |
| + again.innerHTML = '<option value="teal" selected>Teal</option><option value="navy">Navy</option>'; |
| + const current = document.querySelector('#colour'); |
| + current.innerHTML = ''; |
| + current.replaceWith(again); |
| + await frame(); |
| + out.sameTaskBoxes = document.querySelectorAll('#colour-host [rst-combo]').length; |
| + out.sameTaskShown = (document.querySelector('#colour-combo') || {}).value || ''; |
| + // Replaced with a clone of itself, which copies the enhancement |
| + // marker: the clone is still enhanced. |
| + const orig = document.querySelector('#colour'); |
| + orig.replaceWith(orig.cloneNode(true)); |
| + await frame(); |
| + out.cloneBoxes = document.querySelectorAll('#colour-host [rst-combo]').length; |
| + // Options changed and the whole host cleared in one task: nothing |
| + // is left to enhance, and nothing throws. |
| + const last = document.querySelector('#colour'); |
| + last.append(new Option('Grey', 'grey')); |
| + document.querySelector('#colour-host').innerHTML = ''; |
| + await frame(); |
| + out.sameTaskErrors = errors; |
| + return out; |
| + `, &got) |
| + if got.Boxes != 1 { |
| + t.Errorf("after the select was replaced its host holds %d boxes, want 1: the old box did not step aside, or the new select was not enhanced", got.Boxes) |
| + } |
| + if got.Shown != "Blue" || got.PostedNow != "blue" { |
| + t.Errorf("the box shows %q while the form posts %q, want Blue and blue: the box speaks for a select no longer in the form", got.Shown, got.PostedNow) |
| + } |
| + if got.LabelFor != "colour-combo" { |
| + t.Error("the label does not name the new box") |
| + } |
| + if got.CloneBoxes != 1 { |
| + t.Errorf("a select replaced with its own clone left %d boxes, want 1: the clone's copied marker was trusted", got.CloneBoxes) |
| + } |
| + if !got.BothLabelled { |
| + t.Error("two selects replaced in one task: a new box is left without its label, which an old box handed back to the hidden select") |
| + } |
| + if got.SameBoxes != 1 || got.SameShown != "Teal" || got.SameErrors != 0 { |
| + t.Errorf("options changed and select replaced in one task: %d boxes showing %q, %d errors; want 1 box showing Teal and none", got.SameBoxes, got.SameShown, got.SameErrors) |
| + } |
| + if got.Picked != "Red" || got.PostedPick != "red" { |
| + t.Errorf("picking Red on the new box shows %q and posts %q, want Red and red", got.Picked, got.PostedPick) |
| + } |
| +} |
| + |
| +// A page may rebuild a select's options while somebody is searching it. |
| +// The box is rebuilt from what the select now holds — every row an option |
| +// it really has — and the person keeps their focus and their words. |
| +func TestSelectRebuildsWhenItsOptionsChange(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + Boxes int `json:"boxes"` |
| + Focused bool `json:"focused"` |
| + Typed string `json:"typed"` |
| + Rows string `json:"rows"` |
| + Picked string `json:"picked"` |
| + BoxesAfterTwo int `json:"boxesAfterTwo"` |
| + StillHidden bool `json:"stillHidden"` |
| + LiveListeners int `json:"liveListeners"` |
| + Recorded int `json:"recorded"` |
| + ExtraKept bool `json:"extraKept"` |
| + RebuildInputs int `json:"rebuildInputs"` |
| + StayedClosed bool `json:"stayedClosed"` |
| + KeptFocus bool `json:"keptFocus"` |
| + Caret int `json:"caret"` |
| + StillOn string `json:"stillOn"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const select = document.querySelector('#flavour'); |
| + // Record every listener a box puts on the select from here on, so a |
| + // retired box's can be counted. |
| + const listeners = []; |
| + const add = select.addEventListener; |
| + select.addEventListener = function (type, fn, opt) { |
| + listeners.push({ type, signal: opt && opt.signal }); |
| + return add.call(this, type, fn, opt); |
| + }; |
| + let input = document.querySelector('#flavour-combo'); |
| + input.focus(); |
| + input.value = 'bl'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + // The caret mid-word, as when somebody goes back to correct a letter. |
| + input.setSelectionRange(1, 1); |
| + input.closest('[rst-combo]').rstExtras([{ label: 'Other', run: () => {} }]); |
| + // A rebuild restores the search quietly: an input event would let a |
| + // page that refreshes options on input loop forever. |
| + let inputs = 0; |
| + document.addEventListener('input', () => inputs++, true); |
| + select.innerHTML = '<option value="blueberry">Blueberry</option><option value="blackberry">Blackberry</option><option value="kiwi">Kiwi</option>'; |
| + await frame(); |
| + const out = {}; |
| + out.boxes = document.querySelectorAll('#flavour-host [rst-combo]').length; |
| + input = document.querySelector('#flavour-combo'); |
| + out.focused = document.activeElement === input; |
| + out.typed = input.value; |
| + out.caret = input.selectionStart; |
| + out.rows = [...document.querySelectorAll('#flavour-listbox [rst-combo-option]:not([style*="none"])')].map((li) => li.textContent).join('|'); |
| + out.extraKept = [...document.querySelectorAll('#flavour-listbox [rst-combo-option]')].some((li) => li.textContent === 'Other'); |
| + out.rebuildInputs = inputs; |
| + input.closest('[rst-combo]').rstExtras([]); |
| + // Arrow to the second row, then the page changes the options again: |
| + // the row somebody is on is still the one Enter takes. |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); |
| + select.append(new Option('Bilberry', 'bilberry')); |
| + await frame(); |
| + input = document.querySelector('#flavour-combo'); |
| + const on = input.getAttribute('aria-activedescendant'); |
| + out.stillOn = on ? document.getElementById(on).textContent : ''; |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + out.picked = select.value; |
| + // A second change: only the current box may answer it. An old box |
| + // still listening would step aside again and hand the select back |
| + // half-restored, visible and unenhanced. |
| + // Enter closed the list and focus stayed: a rebuild now must not |
| + // reopen it (and take the next Enter from the form). |
| + select.append(new Option('Lime', 'lime')); |
| + await frame(); |
| + out.stayedClosed = document.querySelector('#flavour-combo').getAttribute('aria-expanded') === 'false'; |
| + out.keptFocus = document.activeElement === document.querySelector('#flavour-combo'); |
| + out.boxesAfterTwo = document.querySelectorAll('#flavour-host [rst-combo]').length; |
| + out.stillHidden = select.classList.contains('rst-sr-only') && select.dataset.rstEnhanced === 'true'; |
| + out.liveListeners = listeners.filter((l) => !(l.signal && l.signal.aborted)).length; |
| + out.recorded = listeners.length; |
| + return out; |
| + `, &got) |
| + if got.Boxes != 1 { |
| + t.Errorf("after the options were rebuilt the host holds %d boxes, want 1", got.Boxes) |
| + } |
| + if got.Recorded < 9 || got.LiveListeners != 3 { |
| + t.Errorf("after three rebuilds %d of the %d listeners boxes put on the select are live, want only the current box's 3: a retired box still answers the select's events", got.LiveListeners, got.Recorded) |
| + } |
| + if got.Caret != 1 { |
| + t.Errorf("after the rebuild the caret is at %d, want 1: it jumped to the end of the words being corrected", got.Caret) |
| + } |
| + if got.StillOn != "Blackberry" { |
| + t.Errorf("after arrowing to Blackberry and a rebuild, %q is highlighted, want Blackberry: Enter would take another row", got.StillOn) |
| + } |
| + if got.BoxesAfterTwo != 1 || !got.StillHidden { |
| + t.Errorf("after a second change the host holds %d boxes and the select is enhanced-and-hidden %v, want 1 and true: an earlier box was still listening", got.BoxesAfterTwo, got.StillHidden) |
| + } |
| + if !got.Focused || got.Typed != "bl" { |
| + t.Errorf("after the rebuild the box has focus %v and reads %q, want true and \"bl\": the person lost their place", got.Focused, got.Typed) |
| + } |
| + if got.RebuildInputs != 0 { |
| + t.Errorf("rebuilding fired %d input events, want 0: a page refreshing options on input would loop", got.RebuildInputs) |
| + } |
| + if !got.StayedClosed || !got.KeptFocus { |
| + t.Errorf("a rebuild after a pick left the list closed=%v and focus kept=%v, want both: a closed list stays closed", got.StayedClosed, got.KeptFocus) |
| + } |
| + if !got.ExtraKept { |
| + t.Error("the page's extra row was lost when the options were rebuilt") |
| + } |
| + if got.Rows != "Blueberry|Blackberry|Other" { |
| + t.Errorf("after the rebuild \"bl\" shows %q, want Blueberry|Blackberry|Other: the rows are not the options the select now holds (and the page's extra)", got.Rows) |
| + } |
| + if got.Picked != "blackberry" { |
| + t.Errorf("Enter after the rebuilds picked %q, want blackberry", got.Picked) |
| + } |
| +} |
| + |
| +// Near the bottom of the viewport the list opens UPWARD and fits above |
| +// the box; near the top it opens below. It is re-placed when the viewport |
| +// changes while open (a phone's keyboard shrinking the visual viewport, |
| +// or a resize), and the keyboard order is the same either way. |
| +func TestSelectOpensUpWhenThereIsNoRoomBelow(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + BottomUp bool `json:"bottomUp"` |
| + BottomListTop float64 `json:"bottomListTop"` |
| + BottomListEnd float64 `json:"bottomListEnd"` |
| + BottomBoxTop float64 `json:"bottomBoxTop"` |
| + TopUp bool `json:"topUp"` |
| + TopListTop float64 `json:"topListTop"` |
| + TopBoxEnd float64 `json:"topBoxEnd"` |
| + MovedUp bool `json:"movedUp"` |
| + ScrolledDown bool `json:"scrolledDown"` |
| + PanelUp bool `json:"panelUp"` |
| + NarrowUp bool `json:"narrowUp"` |
| + BroadUp bool `json:"broadUp"` |
| + DroppedOnRemoval int `json:"droppedOnRemoval"` |
| + FirstDownUp string `json:"firstDownUp"` |
| + FirstDownBelow string `json:"firstDownBelow"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const input = document.querySelector('#tz-combo'); |
| + const wrap = input.closest('[rst-combo]'); |
| + const list = document.querySelector('#tz-listbox'); |
| + const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); |
| + const pin = (edge) => { |
| + wrap.style.position = 'fixed'; |
| + wrap.style.left = '10px'; |
| + wrap.style.width = '300px'; |
| + wrap.style.top = edge === 'top' ? '10px' : ''; |
| + wrap.style.bottom = edge === 'bottom' ? '10px' : ''; |
| + }; |
| + const out = {}; |
| + pin('bottom'); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + await frame(); |
| + out.bottomUp = wrap.hasAttribute('rst-combo-up'); |
| + out.bottomListTop = list.getBoundingClientRect().top; |
| + out.bottomListEnd = list.getBoundingClientRect().bottom; |
| + out.bottomBoxTop = input.getBoundingClientRect().top; |
| + key('ArrowDown'); |
| + out.firstDownUp = input.getAttribute('aria-activedescendant') || ''; |
| + key('Escape'); |
| + input.blur(); |
| + pin('top'); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + await frame(); |
| + out.topUp = wrap.hasAttribute('rst-combo-up'); |
| + out.topListTop = list.getBoundingClientRect().top; |
| + out.topBoxEnd = input.getBoundingClientRect().bottom; |
| + key('ArrowDown'); |
| + out.firstDownBelow = input.getAttribute('aria-activedescendant') || ''; |
| + // Moved to the top while open: a scroll in any panel re-places it. |
| + key('Escape'); |
| + pin('bottom'); |
| + input.blur(); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + await frame(); |
| + pin('top'); |
| + document.querySelector('form').dispatchEvent(new Event('scroll')); |
| + await frame(); |
| + await frame(); |
| + out.scrolledDown = !wrap.hasAttribute('rst-combo-up'); |
| + // Moved to the bottom while open: a resize re-places it. |
| + pin('bottom'); |
| + window.dispatchEvent(new Event('resize')); |
| + await frame(); |
| + await frame(); |
| + out.movedUp = wrap.hasAttribute('rst-combo-up'); |
| + key('Escape'); |
| + // Opened low with one match, then cleared: the list's own height |
| + // does not fit below either way, so it opens up either way. |
| + input.blur(); |
| + wrap.style.cssText = 'position:fixed;left:10px;width:300px;top:' + (window.innerHeight - 130) + 'px'; |
| + input.focus(); |
| + input.value = 'Europe/Lond'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + await frame(); |
| + out.narrowUp = wrap.hasAttribute('rst-combo-up'); |
| + input.value = ''; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + await frame(); |
| + out.broadUp = wrap.hasAttribute('rst-combo-up'); |
| + key('Escape'); |
| + wrap.removeAttribute('style'); |
| + // Near the top of a scrolling panel that sits low on the screen: |
| + // the room above the box is outside the panel, so it opens down. |
| + const panel = document.createElement('div'); |
| + panel.style.cssText = 'position:fixed;left:10px;width:320px;height:200px;overflow-y:auto;top:' + (window.innerHeight - 220) + 'px'; |
| + wrap.parentNode.insertBefore(panel, wrap); |
| + panel.append(wrap, document.querySelector('#tz')); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + await frame(); |
| + out.panelUp = wrap.hasAttribute('rst-combo-up'); |
| + key('Escape'); |
| + // And a box removed with its fragment while open lets go of the |
| + // page-wide listeners at the next event. |
| + let dropped = 0; |
| + const rm = window.removeEventListener; |
| + window.removeEventListener = function (type, ...a) { if (type === 'resize') dropped++; return rm.call(this, type, ...a); }; |
| + // Opened without focus (a synthetic click gives none), so removal |
| + // fires no blur here, as on a browser that never does. |
| + input.blur(); |
| + input.click(); |
| + await frame(); |
| + panel.remove(); |
| + window.dispatchEvent(new Event('resize')); |
| + window.removeEventListener = rm; |
| + out.droppedOnRemoval = dropped; |
| + return out; |
| + `, &got) |
| + if !got.BottomUp || got.BottomListTop < 0 || got.BottomListEnd > got.BottomBoxTop+1 { |
| + t.Errorf("at the bottom of the viewport the list opened up=%v spanning %.0f..%.0f with the box at %.0f, want upward, above the box and inside the viewport", got.BottomUp, got.BottomListTop, got.BottomListEnd, got.BottomBoxTop) |
| + } |
| + if got.TopUp || got.TopListTop < got.TopBoxEnd-1 { |
| + t.Errorf("at the top of the viewport the list opened up=%v starting at %.0f with the box ending at %.0f, want downward, below the box", got.TopUp, got.TopListTop, got.TopBoxEnd) |
| + } |
| + // Placement is decided by the room against the list's own height, not |
| + // by how many rows a search shows, so typing never re-places it. |
| + if !got.NarrowUp || !got.BroadUp { |
| + t.Errorf("low on the screen, one match opened up=%v and clearing the search left up=%v, want up both times: the room, not the search, decides", got.NarrowUp, got.BroadUp) |
| + } |
| + if got.PanelUp { |
| + t.Error("near the top of a low scrolling panel the list opened upward, into the part of the panel that is clipped") |
| + } |
| + if got.DroppedOnRemoval == 0 { |
| + t.Error("a box removed with its fragment while open kept its page-wide resize listener") |
| + } |
| + if !got.ScrolledDown { |
| + t.Error("moved to the top while open and a panel scrolled, the list still opened upward: a scrolling ancestor must re-place it") |
| + } |
| + if !got.MovedUp { |
| + t.Error("moved to the bottom while open and resized, the list was not re-placed upward") |
| + } |
| + if got.FirstDownUp == "" || got.FirstDownUp != got.FirstDownBelow { |
| + t.Errorf("ArrowDown highlighted %q opening up and %q opening down, want the same row: only placement flips", got.FirstDownUp, got.FirstDownBelow) |
| + } |
| +} |
| + |
| +// Typing never rebuilds a box (its own writes must not look like the page |
| +// changing the options), and reads layout at most once a frame: on a long |
| +// form with several boxes, forced reflow per keystroke was what made |
| +// typing slow on a phone. |
| +func TestSelectTypingIsCheap(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + Rebuilds int `json:"rebuilds"` |
| + HiddenWrites int `json:"hiddenWrites"` |
| + Outside []string `json:"outside"` |
| + Noops []string `json:"noops"` |
| + PerFrame []int `json:"perFrame"` |
| + Searched bool `json:"searched"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const out = { rebuilds: 0, perFrame: [] }; |
| + const hosts = new MutationObserver((m) => { |
| + for (const r of m) for (const n of r.addedNodes) if (n.nodeType === 1 && n.hasAttribute('rst-combo')) out.rebuilds++; |
| + }); |
| + hosts.observe(document.body, { childList: true, subtree: true }); |
| + // Every attribute written anywhere while typing: a page whose |
| + // stylesheet has [hidden] inside a :has() pays for each one. |
| + const tzWrap = document.querySelector('#tz-combo').closest('[rst-combo]'); |
| + out.hiddenWrites = 0; |
| + out.outside = []; |
| + let counting = false; |
| + const attrs = new MutationObserver((m) => { |
| + if (!counting) return; |
| + for (const r of m) { |
| + if (r.attributeName === 'hidden') out.hiddenWrites++; |
| + if (!tzWrap.contains(r.target)) out.outside.push((r.target.id || r.target.tagName) + '@' + (r.attributeName || r.type)); |
| + } |
| + }); |
| + attrs.observe(document.documentElement, { attributes: true, childList: true, characterData: true, subtree: true }); |
| + // And every attribute written inside the box to the value it already |
| + // had: still a mutation the page's stylesheet is asked about. |
| + out.noops = []; |
| + // Records delivered to the callback (a search run in the frame) are |
| + // kept for drain, not dropped. |
| + const pendingRecords = []; |
| + const same = new MutationObserver((m) => { if (counting) pendingRecords.push(...m); }); |
| + same.observe(tzWrap, { attributes: true, attributeOldValue: true, subtree: true }); |
| + const drain = () => { |
| + const by = new Map(); |
| + const recs = [...pendingRecords.splice(0), ...same.takeRecords()]; |
| + for (const r of recs) { |
| + if (!counting) continue; |
| + const k = r.target; |
| + if (!by.has(k)) by.set(k, new Map()); |
| + const m = by.get(k); |
| + if (!m.has(r.attributeName)) m.set(r.attributeName, []); |
| + m.get(r.attributeName).push(r.oldValue); |
| + } |
| + for (const [el, m] of by) for (const [name, olds] of m) olds.forEach((was, j) => { |
| + const now = j + 1 < olds.length ? olds[j + 1] : el.getAttribute(name); |
| + if (was === now) out.noops.push((el.id || el.tagName) + '@' + name); |
| + }); |
| + }; |
| + let reads = 0; |
| + const count = (proto, name) => { |
| + const was = proto[name]; |
| + proto[name] = function (...a) { reads++; return was.apply(this, a); }; |
| + }; |
| + count(Element.prototype, 'getBoundingClientRect'); |
| + count(Element.prototype, 'scrollIntoView'); |
| + const st = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop'); |
| + Object.defineProperty(Element.prototype, 'scrollTop', { configurable: true, get() { return st.get.call(this); }, set(v) { reads++; st.set.call(this, v); } }); |
| + // With a told band and a floor in play, typing still reads layout at |
| + // most once a frame: placement is not redone per keystroke. |
| + document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: window.innerHeight - 10 } })); |
| + const bar = document.createElement('div'); |
| + bar.setAttribute('data-rst-select-floor', ''); |
| + bar.style.cssText = 'position:fixed;left:0;right:0;bottom:0;height:8px'; |
| + document.body.append(bar); |
| + const input = document.querySelector('#tz-combo'); |
| + input.focus(); |
| + input.click(); |
| + await frame(); |
| + const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; |
| + counting = true; |
| + for (const [a, b] of [['E', 'Eu'], ['Eur', 'Euro'], ['Europ', 'Europe'], ['Europe/', 'Europe/L']]) { |
| + reads = 0; |
| + // Two keystrokes inside one frame, as a phone keyboard sends |
| + // them: each a new query, each searched (one at once, one in the |
| + // frame). |
| + type(a); |
| + drain(); |
| + type(b); |
| + drain(); |
| + await frame(); |
| + drain(); |
| + out.perFrame.push(reads); |
| + } |
| + attrs.takeRecords(); |
| + counting = false; |
| + attrs.disconnect(); |
| + out.searched = document.querySelectorAll('#tz-listbox [rst-combo-option]:not([style*="none"])').length < 70; |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); |
| + hosts.disconnect(); |
| + Object.defineProperty(Element.prototype, 'scrollTop', st); |
| + return out; |
| + `, &got) |
| + if got.HiddenWrites != 0 { |
| + t.Errorf("typing wrote the hidden attribute %d times, want 0: a page with [hidden] in a :has() pays for every write", got.HiddenWrites) |
| + } |
| + if len(got.Noops) != 0 { |
| + t.Errorf("typing rewrote %d attributes inside the box to the value they had (first: %s), want 0", len(got.Noops), got.Noops[0]) |
| + } |
| + if len(got.Outside) != 0 { |
| + t.Errorf("typing mutated %d things outside the combobox (first: %s), want none", len(got.Outside), got.Outside[0]) |
| + } |
| + if got.Rebuilds != 0 { |
| + t.Errorf("typing rebuilt a box %d times, want 0: the box's own writes were taken for the page changing its options", got.Rebuilds) |
| + } |
| + if !got.Searched { |
| + t.Fatal("typing did not narrow the list: the layout fence below would prove nothing") |
| + } |
| + for i, n := range got.PerFrame { |
| + if n > 1 { |
| + t.Errorf("frame %d of typing read layout %d times, want at most 1", i, n) |
| + } |
| + } |
| +} |
| + |
| +// A rebuild in the middle of something: typing not yet searched, an Enter |
| +// whose character has not arrived, a highlighted extra row, a highlighted |
| +// row the page just disabled, a box whose pick is selected for replacing. |
| +// Each must come through the rebuild meaning what it meant before it. |
| +func TestSelectRebuildKeepsWhatIsInFlight(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + FlushKept string `json:"flushKept"` |
| + EnterGuarded bool `json:"enterGuarded"` |
| + ExtraOn string `json:"extraOn"` |
| + DisabledOn string `json:"disabledOn"` |
| + PickSelection string `json:"pickSelection"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const select = document.querySelector('#flavour'); |
| + const box = () => document.querySelector('#flavour-combo'); |
| + const type = (v) => { const i = box(); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }; |
| + const key = (k) => { const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); box().dispatchEvent(e); return e; }; |
| + const on = () => { const a = box().getAttribute('aria-activedescendant'); return a ? document.getElementById(a).textContent : ''; }; |
| + const out = {}; |
| + select.innerHTML = '<option value="blueberry">Blueberry</option><option value="blackberry">Blackberry</option><option value="kiwi">Kiwi</option>'; |
| + await frame(); |
| + |
| + // "ber", an arrow to its second row, then "berry" in the same frame — |
| + // not yet searched — and an options change in the same task. "berry" ties every row, so leaving settles nothing; |
| + // the old search's arrowed row must not ride through. |
| + box().focus(); |
| + box().click(); |
| + await frame(); |
| + const before = select.value; |
| + type('ber'); |
| + key('ArrowDown'); |
| + type('berry'); |
| + select.append(new Option('Bilberry', 'bilberry')); |
| + await frame(); |
| + key('Tab'); |
| + out.flushKept = select.value === before ? '' : select.value; |
| + key('Escape'); |
| + |
| + // Enter picks a row; the page's change handler then rebuilds the |
| + // options; the Enter's character arrives on the new box and must not |
| + // submit the form. |
| + select.addEventListener('change', () => select.append(new Option('Fig', 'fig')), { once: true }); |
| + box().focus(); |
| + box().click(); |
| + await frame(); |
| + type('kiwi'); |
| + await frame(); |
| + key('Enter'); |
| + await Promise.resolve(); |
| + const kp = new KeyboardEvent('keypress', { key: 'Enter', bubbles: true, cancelable: true }); |
| + box().dispatchEvent(kp); |
| + out.enterGuarded = kp.defaultPrevented; |
| + box().dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })); |
| + |
| + // The highlight on one of the page's extra rows survives a rebuild. |
| + box().focus(); |
| + box().click(); |
| + await frame(); |
| + box().closest('[rst-combo]').rstExtras([{ label: 'Other', run: () => {} }]); |
| + key('End'); |
| + select.append(new Option('Lime', 'lime')); |
| + await frame(); |
| + out.extraOn = on(); |
| + box().closest('[rst-combo]').rstExtras([]); |
| + key('Escape'); |
| + |
| + // A highlighted row the page disables is not restored as the highlight. |
| + box().focus(); |
| + box().click(); |
| + await frame(); |
| + type('kiwi'); |
| + await frame(); |
| + select.querySelector('option[value="kiwi"]').disabled = true; |
| + await frame(); |
| + out.disabledOn = on(); |
| + key('Escape'); |
| + select.querySelector('option[value="kiwi"]').disabled = false; |
| + await frame(); |
| + |
| + // Focusing an answered box selects its pick, so the first keystroke |
| + // replaces it; a rebuild keeps that. |
| + box().blur(); |
| + box().focus(); |
| + const len = box().value.length; |
| + select.append(new Option('Plum', 'plum')); |
| + await frame(); |
| + out.pickSelection = box().selectionStart + '..' + box().selectionEnd + '/' + len; |
| + return out; |
| + `, &got) |
| + if got.FlushKept != "" { |
| + t.Errorf("leaving after \"berry\" committed %q: the rebuild restored the arrowed row of a search the typing had already replaced", got.FlushKept) |
| + } |
| + if !got.EnterGuarded { |
| + t.Error("an Enter that picked a row, whose change handler rebuilt the box, went on to submit the form") |
| + } |
| + if got.ExtraOn != "Other" { |
| + t.Errorf("a highlighted extra row came through a rebuild as %q, want Other: Enter would do something else", got.ExtraOn) |
| + } |
| + if got.DisabledOn == "Kiwi" { |
| + t.Error("a row the page disabled was restored as the highlight") |
| + } |
| + if !strings.HasPrefix(got.PickSelection, "0..") || strings.Split(strings.TrimPrefix(got.PickSelection, "0.."), "/")[0] != strings.Split(got.PickSelection, "/")[1] { |
| + t.Errorf("a focused answered box reads selection %s after a rebuild, want its whole pick selected", got.PickSelection) |
| + } |
| +} |
| + |
| +// A select added after load stays native until the page asks with |
| +// rst:select-scan. Asked, the new select is enhanced once; asked again, |
| +// nothing is enhanced twice; and a select outside the root it names is |
| +// left alone. |
| +func TestSelectScanEvent(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + Unasked int `json:"unasked"` |
| + Asked int `json:"asked"` |
| + AskedTwice int `json:"askedTwice"` |
| + OutsideRoot int `json:"outsideRoot"` |
| + WholeDoc int `json:"wholeDoc"` |
| + PageBoxes int `json:"pageBoxes"` |
| + PageBoxesNow int `json:"pageBoxesNow"` |
| + ReplacedLabelled bool `json:"replacedLabelled"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const boxes = (el) => el.querySelectorAll('[rst-combo]').length; |
| + const add = () => { |
| + const host = document.createElement('div'); |
| + host.innerHTML = '<select data-rst-select name="late"><option value="a">A</option><option value="b">B</option></select>'; |
| + document.querySelector('form').append(host); |
| + return host; |
| + }; |
| + const out = { pageBoxes: boxes(document) }; |
| + const one = add(); |
| + await frame(); |
| + out.unasked = boxes(one); |
| + const ask = async (root) => { |
| + document.dispatchEvent(new CustomEvent('rst:select-scan', root ? { detail: { root } } : {})); |
| + await Promise.resolve(); |
| + }; |
| + await ask(one); |
| + out.asked = boxes(one); |
| + await ask(one); |
| + out.askedTwice = boxes(one); |
| + const other = add(); |
| + const third = add(); |
| + await ask(third); |
| + out.outsideRoot = boxes(other); |
| + await ask(); |
| + out.wholeDoc = boxes(other); |
| + out.pageBoxesNow = boxes(document); |
| + // Replaced and asked for in the same task: the new box is named by |
| + // the label, which the old box has handed back by then. |
| + const fresh = document.createElement('select'); |
| + fresh.id = 'colour'; |
| + fresh.name = 'colour'; |
| + fresh.setAttribute('data-rst-select', ''); |
| + fresh.innerHTML = '<option value="red">Red</option><option value="blue">Blue</option>'; |
| + document.querySelector('#colour').replaceWith(fresh); |
| + await ask(document.querySelector('#colour-host')); |
| + await frame(); |
| + out.replacedLabelled = !!document.querySelector('label[for="colour-combo"]'); |
| + return out; |
| + `, &got) |
| + if got.Unasked != 0 { |
| + t.Errorf("a select added after load was enhanced %d times without being asked, want 0: added content stays native by default", got.Unasked) |
| + } |
| + if got.Asked != 1 || got.AskedTwice != 1 { |
| + t.Errorf("asked once the new select has %d boxes, asked twice %d, want 1 and 1", got.Asked, got.AskedTwice) |
| + } |
| + if got.OutsideRoot != 0 { |
| + t.Errorf("a select outside the named root was enhanced (%d boxes), want 0", got.OutsideRoot) |
| + } |
| + if got.WholeDoc != 1 { |
| + t.Errorf("a scan with no root left the other added select with %d boxes, want 1", got.WholeDoc) |
| + } |
| + if !got.ReplacedLabelled { |
| + t.Error("a select replaced and scanned for in the same task: the new box lost its label to the old box's teardown") |
| + } |
| + if got.PageBoxesNow != got.PageBoxes+3 { |
| + t.Errorf("the page went from %d boxes to %d after three added selects, want %d: something was enhanced twice", got.PageBoxes, got.PageBoxesNow, got.PageBoxes+3) |
| + } |
| +} |
| + |
| +// Which way the list opens, and how tall, under Tito Go #3146's rule: up |
| +// only when the list does not fit below and there is more room above; |
| +// held to the room on its side, never past it. The room is the frame's |
| +// own viewport, cut to a band a host tells it (rst:select-viewport), and |
| +// to the top of any bar marked data-rst-select-floor below the box. |
| +func TestSelectPlacement(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + FrameDown bool `json:"frameDown"` |
| + FrameUp bool `json:"frameUp"` |
| + FrameBack bool `json:"frameBack"` |
| + FloorUp bool `json:"floorUp"` |
| + FloorRoom float64 `json:"floorRoom"` |
| + FloorMax float64 `json:"floorMax"` |
| + AboveFloorMax float64 `json:"aboveFloorMax"` |
| + ThreeUp bool `json:"threeUp"` |
| + ThreeMax float64 `json:"threeMax"` |
| + ThreeRoom float64 `json:"threeRoom"` |
| + ClampUp bool `json:"clampUp"` |
| + ClampMax float64 `json:"clampMax"` |
| + ClampRoom float64 `json:"clampRoom"` |
| + ClampTop float64 `json:"clampTop"` |
| + BandTop float64 `json:"bandTop"` |
| + TieUp bool `json:"tieUp"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const out = {}; |
| + |
| + // A framed page: its own viewport is 600px tall and the box sits in |
| + // the middle, so the list opens down. The host then says only the |
| + // top of the frame is on screen (a keyboard over the rest): the |
| + // open list is re-placed upward on receipt. Null goes back. |
| + const iframe = document.createElement('iframe'); |
| + iframe.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:600px;border:0'; |
| + iframe.src = '/'; |
| + await new Promise((r) => { iframe.onload = r; document.body.append(iframe); }); |
| + const fw = iframe.contentWindow, fd = iframe.contentDocument; |
| + await new Promise((r) => fw.requestAnimationFrame(() => fw.setTimeout(r, 0))); |
| + const ffr = () => new Promise((r) => fw.requestAnimationFrame(() => fw.setTimeout(r, 0))); |
| + const fin = fd.querySelector('#tz-combo'); |
| + const fwrap = fin.closest('[rst-combo]'); |
| + fwrap.style.cssText = 'position:fixed;left:10px;width:300px;top:250px'; |
| + fin.focus(); |
| + fin.click(); |
| + await ffr(); await ffr(); |
| + out.frameDown = !fwrap.hasAttribute('rst-combo-up'); |
| + const fbox = fwrap.getBoundingClientRect(); |
| + fd.dispatchEvent(new fw.CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: fbox.bottom + 40 } })); |
| + await ffr(); await ffr(); |
| + out.frameUp = fwrap.hasAttribute('rst-combo-up'); |
| + fd.dispatchEvent(new fw.CustomEvent('rst:select-viewport', { detail: null })); |
| + await ffr(); await ffr(); |
| + out.frameBack = !fwrap.hasAttribute('rst-combo-up'); |
| + iframe.remove(); |
| + |
| + const input = document.querySelector('#tz-combo'); |
| + const wrap = input.closest('[rst-combo]'); |
| + const list = document.querySelector('#tz-listbox'); |
| + const open = async () => { input.blur(); input.focus(); input.click(); await frame(); await frame(); }; |
| + const close = () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); |
| + // The height the list actually takes on screen, padding and borders |
| + // in: what has to fit the room. |
| + const max = () => list.getBoundingClientRect().height; |
| + const at = (top) => { wrap.style.cssText = 'position:fixed;left:10px;width:300px;top:' + top + 'px'; }; |
| + |
| + // A floor 60px under a box near the top of the screen: the room |
| + // below is what is above the bar. |
| + at(10); |
| + const bar = document.createElement('div'); |
| + bar.setAttribute('data-rst-select-floor', ''); |
| + const boxBottom = () => wrap.getBoundingClientRect().bottom; |
| + bar.style.cssText = 'position:fixed;left:0;right:0;height:40px;top:' + (boxBottom() + 60) + 'px'; |
| + document.body.append(bar); |
| + await open(); |
| + out.floorUp = wrap.hasAttribute('rst-combo-up'); |
| + out.floorRoom = bar.getBoundingClientRect().top - boxBottom(); |
| + out.floorMax = max(); |
| + close(); |
| + // The same bar ABOVE the box is ignored. |
| + bar.style.top = '0px'; |
| + bar.style.height = '5px'; |
| + at(40); |
| + await open(); |
| + out.aboveFloorMax = max(); |
| + close(); |
| + bar.remove(); |
| + |
| + // Neither side fits the list: it takes the bigger side (above, |
| + // 100px) and is held to it. |
| + const h = wrap.getBoundingClientRect().height; |
| + at(200); |
| + document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 100, bottom: 200 + h + 80 } })); |
| + await open(); |
| + out.threeUp = wrap.hasAttribute('rst-combo-up'); |
| + out.threeMax = max(); |
| + out.threeRoom = 100; |
| + close(); |
| + // Neither side has room for three rows: the bigger side, clamped to |
| + // it, and never past the band. |
| + document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 200 - 46, bottom: 200 + h + 30 } })); |
| + await open(); |
| + out.clampUp = wrap.hasAttribute('rst-combo-up'); |
| + out.clampMax = max(); |
| + out.clampRoom = 46; |
| + out.clampTop = list.getBoundingClientRect().top; |
| + out.bandTop = 200 - 46; |
| + close(); |
| + // A tie goes down. |
| + document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 200 - 50, bottom: 200 + h + 50 } })); |
| + await open(); |
| + out.tieUp = wrap.hasAttribute('rst-combo-up'); |
| + close(); |
| + document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: null })); |
| + wrap.removeAttribute('style'); |
| + return out; |
| + `, &got) |
| + if !got.FrameDown || !got.FrameUp || !got.FrameBack { |
| + t.Errorf("in a frame: down %v, then told a short band up %v, then null down %v; want all true", got.FrameDown, got.FrameUp, got.FrameBack) |
| + } |
| + if got.FloorUp || got.FloorMax > got.FloorRoom || got.FloorMax < got.FloorRoom-12 { |
| + t.Errorf("a floor %.0fpx below the box: up=%v height %.0fpx, want down and held to the room above the bar", got.FloorRoom, got.FloorUp, got.FloorMax) |
| + } |
| + if got.AboveFloorMax < 200 { |
| + t.Errorf("a floor above the box cut the room below to %.0fpx: it should be ignored", got.AboveFloorMax) |
| + } |
| + if !got.ThreeUp || got.ThreeMax > got.ThreeRoom || got.ThreeMax < got.ThreeRoom-12 { |
| + t.Errorf("100px above and 80px below: up=%v height %.0fpx, want up, held to the room above", got.ThreeUp, got.ThreeMax) |
| + } |
| + if !got.ClampUp || got.ClampMax > got.ClampRoom || got.ClampTop < got.BandTop-1 { |
| + t.Errorf("46px above and 30px below: up=%v height %.0fpx, top %.0f vs band %.0f; want up, clamped to the room, never past the band", got.ClampUp, got.ClampMax, got.ClampTop, got.BandTop) |
| + } |
| + if got.TieUp { |
| + t.Error("equal room above and below opened upward: a tie goes down") |
| + } |
| +} |
| + |
| +// data-rst-class: a row wears its option's classes, the closed box wears |
| +// the pick's while it shows the pick, takes them off once somebody types |
| +// (their words are a search, not the pick), and wears the new pick's |
| +// once one is chosen. An option without the attribute adds nothing, and |
| +// is-active, the highlight's own class, is never taken from an option. |
| +func TestSelectOptionClassStylesRowAndBox(t *testing.T) { |
| + t.Parallel() |
| + var got struct { |
| + Rows []string `json:"rows"` |
| + Closed string `json:"closed"` |
| + External []string `json:"external"` |
| + Typing string `json:"typing"` |
| + Escaped string `json:"escaped"` |
| + Chosen string `json:"chosen"` |
| + Plain string `json:"plain"` |
| + Submitted string `json:"submitted"` |
| + } |
| + drive(t, ` |
| + const frame = () => `+afterFrame+`; |
| + const host = document.createElement('div'); |
| + host.innerHTML = '<label for="face">Face</label><select data-rst-select id="face" name="face">' + |
| + '<option value="serif" data-rst-class="f-serif big" selected>Serif</option>' + |
| + '<option value="mono" data-rst-class="f-mono is-active">Mono</option>' + |
| + '<option value="none">None</option></select>'; |
| + document.querySelector('form').append(host); |
| + document.dispatchEvent(new CustomEvent('rst:select-scan', { detail: { root: host } })); |
| + await Promise.resolve(); |
| + const input = document.querySelector('#face-combo'); |
| + const native = document.querySelector('#face'); |
| + const own = () => [...input.classList].sort().join(' '); |
| + const out = {}; |
| + out.rows = [...document.querySelectorAll('#face-listbox [role=option]')].map((li) => [...li.classList].sort().join(' ')); |
| + out.closed = own(); |
| + // The page changing the pick, with nobody typing: each pick's own |
| + // classes, and none left over from the one before. |
| + const set = (v) => { native.value = v; native.dispatchEvent(new Event('change', { bubbles: true })); return own(); }; |
| + out.external = [set('mono'), set('none'), set('serif')]; |
| + input.focus(); |
| + input.value = 'mo'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + out.typing = own(); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); |
| + await frame(); |
| + out.escaped = own(); |
| + input.value = 'mono'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + await frame(); |
| + out.chosen = own(); |
| + out.submitted = document.querySelector('#face').value; |
| + document.querySelector('#face').value = 'none'; |
| + document.querySelector('#face').dispatchEvent(new Event('change', { bubbles: true })); |
| + input.focus(); |
| + input.value = 'none'; |
| + input.dispatchEvent(new Event('input', { bubbles: true })); |
| + await frame(); |
| + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); |
| + await frame(); |
| + out.plain = own(); |
| + return out; |
| + `, &got) |
| + if strings.Join(got.Rows, "|") != "big f-serif|f-mono|" { |
| + t.Errorf("rows wear %q, want each its own option's classes and the plain row none", got.Rows) |
| + } |
| + if got.Closed != "big f-serif" { |
| + t.Errorf("the closed box showing Serif wears %q, want %q", got.Closed, "big f-serif") |
| + } |
| + if strings.Join(got.External, "|") != "f-mono||big f-serif" { |
| + t.Errorf("the page picking Mono, None, then Serif left the box wearing %q, want each pick's classes and nothing else", got.External) |
| + } |
| + if got.Typing != "" { |
| + t.Errorf("the box wears %q while somebody types, want nothing: a query is not the pick", got.Typing) |
| + } |
| + if got.Escaped != "big f-serif" { |
| + t.Errorf("Escape put the pick back but the box wears %q, want %q", got.Escaped, "big f-serif") |
| + } |
| + if got.Submitted != "mono" || got.Chosen != "f-mono" { |
| + t.Errorf("after choosing Mono the select holds %q and the box wears %q, want mono and %q", got.Submitted, got.Chosen, "f-mono") |
| + } |
| + if got.Plain != "" { |
| + t.Errorf("a pick with no data-rst-class leaves the box wearing %q, want nothing", got.Plain) |
| + } |
| +} |
diff --git a/check/select_focus_browser_test.go b/check/select_focus_browser_test.go| new file mode 100644 |
| index 0000000..88480da |
| --- /dev/null |
| +++ b/check/select_focus_browser_test.go |
| @@ -0,0 +1,132 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/cdproto/input" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| +) |
| + |
| +// selection reads the colour box's value, its selection and how many |
| +// rows its list shows. |
| +const selectionJS = `(() => { const i = document.getElementById("colour-combo"), l = document.getElementById(i.getAttribute("aria-controls")); |
| + return JSON.stringify({Value: i.value, Start: i.selectionStart, End: i.selectionEnd, Focused: document.activeElement === i, |
| + Rows: l ? [...l.querySelectorAll("[role=option]")].filter(o => o.checkVisibility()).length : -1}); })()` |
| + |
| +type comboSelection struct { |
| + Value string |
| + Start, End, Rows int |
| + Focused bool |
| +} |
| + |
| +// TestSelectFocusSelectsThePick: focusing a combobox that shows its pick |
| +// selects the whole pick, so typing or Delete replaces it, by a click as |
| +// much as by Tab. A click put the caret where it landed, and clearing |
| +// "Red" took a double-click or a run of backspaces first. Only the |
| +// focusing click does it: a second click in the focused box places the |
| +// caret, so a partial edit still works, and a drag across the pick as it |
| +// is focused keeps what the drag selected. Real pointer and key events, |
| +// because what is fenced is the order the browser runs mousedown, focus, |
| +// mouseup and click in, which a scripted focus() never goes through. In |
| +// both directions of text. |
| +func TestSelectFocusSelectsThePick(t *testing.T) { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + t.Run(dir, func(t *testing.T) { |
| + rig := sizingRig(t, false, selectFilterPages(t)) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, emulation.SetFocusEmulationEnabled(true), chromedp.EmulateViewport(1000, 900), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`#colour-combo`, chromedp.ByQuery), |
| + chromedp.Evaluate(`document.documentElement.dir = "`+dir+`"; document.getElementById("colour-combo").scrollIntoView({block: "center"}); true`, nil)) |
| + var box struct{ L, R, Y, PadL, PadR, Gr float64 } |
| + at(t, ctx, `(() => { const i = document.getElementById("colour-combo"), r = i.getBoundingClientRect(), s = getComputedStyle(i); |
| + return JSON.stringify({L: r.left, R: r.right, Y: r.top + r.height / 2, PadL: parseFloat(s.paddingLeft) + parseFloat(s.borderLeftWidth), PadR: parseFloat(s.paddingRight) + parseFloat(s.borderRightWidth), |
| + Gr: Object.assign(document.createElement("canvas").getContext("2d"), {font: s.font}).measureText("Gr").width}); })()`, &box) |
| + // After a frame: the focusing click selects in the next one. |
| + read := func() comboSelection { |
| + var raw string |
| + mustRun(t, ctx, chromedp.Evaluate(afterFrame, nil, awaitPromise), chromedp.Evaluate(selectionJS, &raw)) |
| + var s comboSelection |
| + if err := json.Unmarshal([]byte(raw), &s); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s |
| + } |
| + // Inside the box, clear of the lead and the chevron: a third |
| + // of the way in from either end. |
| + x1, x2 := box.L+(box.R-box.L)/3, box.L+2*(box.R-box.L)/3 |
| + blur := func() { |
| + mustRun(t, ctx, chromedp.Evaluate(`document.activeElement.blur(); document.querySelectorAll("[rst-combo] [role=listbox]").forEach(l => l.hidden = true); true`, nil)) |
| + } |
| + |
| + mustRun(t, ctx, chromedp.MouseClickXY(x1, box.Y)) |
| + if s := read(); !s.Focused || s.Value != "Red" || s.Start != 0 || s.End != len(s.Value) { |
| + t.Errorf("a click into the box showing %q left the selection at %d–%d (focused %v); want the whole pick, 0–%d", s.Value, s.Start, s.End, s.Focused, len(s.Value)) |
| + } |
| + mustRun(t, ctx, chromedp.MouseClickXY(x2, box.Y)) |
| + if s := read(); s.Start != s.End { |
| + t.Errorf("a second click in the focused box left %d–%d selected; it should place the caret", s.Start, s.End) |
| + } |
| + |
| + blur() |
| + mustRun(t, ctx, chromedp.Focus(`#dept-combo`, chromedp.ByQuery), chromedp.KeyEvent(kb.Tab)) |
| + if s := read(); !s.Focused || s.Start != 0 || s.End != len(s.Value) { |
| + t.Errorf("Tab into the box left %d–%d selected (focused %v); want the whole pick", s.Start, s.End, s.Focused) |
| + } |
| + mustRun(t, ctx, chromedp.KeyEvent("gr")) |
| + if s := read(); s.Value != "gr" || s.Rows != 1 { |
| + t.Errorf("typing over the selected pick left %q with %d rows showing; want \"gr\", filtered to Green", s.Value, s.Rows) |
| + } |
| + |
| + // A drag across "Green" as it focuses the box: from just past |
| + // its "Gr" to well past its end, which selects "een". |
| + // Whatever the drag selected stays. |
| + blur() |
| + mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("colour").value = "green"; document.getElementById("colour").dispatchEvent(new Event("change")); true`, nil)) |
| + from, to := box.L+box.PadL+box.Gr+1, x2 |
| + if dir == "rtl" { |
| + from, to = box.R-box.PadR-box.Gr-1, x1 |
| + } |
| + mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| + if err := input.DispatchMouseEvent(input.MousePressed, from, box.Y).WithButton(input.Left).WithClickCount(1).Do(ctx); err != nil { |
| + return err |
| + } |
| + if err := input.DispatchMouseEvent(input.MouseMoved, to, box.Y).WithButton(input.Left).Do(ctx); err != nil { |
| + return err |
| + } |
| + return input.DispatchMouseEvent(input.MouseReleased, to, box.Y).WithButton(input.Left).WithClickCount(1).Do(ctx) |
| + })) |
| + if s := read(); !s.Focused || s.Start == 0 && s.End == len(s.Value) { |
| + t.Errorf("a drag as the box was focused ended with %d–%d of %q selected (focused %v); the drag's own selection should stay", s.Start, s.End, s.Value, s.Focused) |
| + } |
| + |
| + // A tap selects the pick too: a tap selects on focus and a |
| + // mouse on its click, so each path is read. Not a second tap: |
| + // with the protocol's raw touch events Chromium keeps a |
| + // selection under any later tap, with or without this file's |
| + // handlers, so it would fence the emulation, not select.js. |
| + blur() |
| + tap := func(x float64) { |
| + mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| + pt := []*input.TouchPoint{{X: x, Y: box.Y}} |
| + if err := input.DispatchTouchEvent(input.TouchStart, pt).Do(ctx); err != nil { |
| + return err |
| + } |
| + return input.DispatchTouchEvent(input.TouchEnd, []*input.TouchPoint{}).Do(ctx) |
| + }), chromedp.Evaluate(afterFrame, nil, awaitPromise)) |
| + } |
| + mustRun(t, ctx, emulation.SetTouchEmulationEnabled(true)) |
| + tap(x1) |
| + if s := read(); !s.Focused || s.Start != 0 || s.End != len(s.Value) { |
| + t.Errorf("a tap into the box showing %q left %d–%d selected (focused %v); want the whole pick", s.Value, s.Start, s.End, s.Focused) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/check/select_test.go b/check/select_test.go| new file mode 100644 |
| index 0000000..0fb3217 |
| --- /dev/null |
| +++ b/check/select_test.go |
| @@ -0,0 +1,293 @@ |
| +package check |
| + |
| +import ( |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/nodetest" |
| +) |
| + |
| +// A country picker is where select.js's ranking was learned (Tito Go's |
| +// phone field), so it is what these tests search: each row written the |
| +// way a phone field writes it — "Name (+code)", the ISO code and the |
| +// calling code as data-rst-terms, data-rst-first on the country that owns |
| +// a shared code. The list is curated, not complete: every country an |
| +// assertion names is here, and so is every country that competes with it |
| +// for the query (Guinea-Bissau for "guin", Oman for "man", the four +44s). |
| +type countryRow struct{ iso, en, de, code string } |
| + |
| +// primary marks the country that owns a calling code others share. |
| +var primary = map[string]bool{"GB": true, "US": true, "RU": true, "RE": true} |
| + |
| +var countries = []countryRow{ |
| + {"DZ", "Algeria", "Algerien", "213"}, |
| + {"AS", "American Samoa", "Amerikanisch-Samoa", "1684"}, |
| + {"AT", "Austria", "Österreich", "43"}, |
| + {"KH", "Cambodia", "Kambodscha", "855"}, |
| + {"CM", "Cameroon", "Kamerun", "237"}, |
| + {"CA", "Canada", "Kanada", "1"}, |
| + {"CI", "Côte d’Ivoire", "Côte d’Ivoire", "225"}, |
| + {"DK", "Denmark", "Dänemark", "45"}, |
| + {"GQ", "Equatorial Guinea", "Äquatorialguinea", "240"}, |
| + {"GE", "Georgia", "Georgien", "995"}, |
| + {"DE", "Germany", "Deutschland", "49"}, |
| + {"GG", "Guernsey", "Guernsey", "44"}, |
| + {"GN", "Guinea", "Guinea", "224"}, |
| + {"GW", "Guinea-Bissau", "Guinea-Bissau", "245"}, |
| + {"IN", "India", "Indien", "91"}, |
| + {"ID", "Indonesia", "Indonesien", "62"}, |
| + {"IR", "Iran", "Iran", "98"}, |
| + {"IE", "Ireland", "Irland", "353"}, |
| + {"IM", "Isle of Man", "Isle of Man", "44"}, |
| + {"IL", "Israel", "Israel", "972"}, |
| + {"JM", "Jamaica", "Jamaika", "1876"}, |
| + {"JP", "Japan", "Japan", "81"}, |
| + {"JE", "Jersey", "Jersey", "44"}, |
| + {"JO", "Jordan", "Jordanien", "962"}, |
| + {"KZ", "Kazakhstan", "Kasachstan", "7"}, |
| + {"YT", "Mayotte", "Mayotte", "262"}, |
| + {"NE", "Niger", "Niger", "227"}, |
| + {"NG", "Nigeria", "Nigeria", "234"}, |
| + {"OM", "Oman", "Oman", "968"}, |
| + {"PG", "Papua New Guinea", "Papua-Neuguinea", "675"}, |
| + {"RE", "Réunion", "Réunion", "262"}, |
| + {"RU", "Russia", "Russland", "7"}, |
| + {"SS", "South Sudan", "Südsudan", "211"}, |
| + {"SD", "Sudan", "Sudan", "249"}, |
| + {"AE", "United Arab Emirates", "Vereinigte Arabische Emirate", "971"}, |
| + {"GB", "United Kingdom", "Vereinigtes Königreich", "44"}, |
| + {"US", "United States", "Vereinigte Staaten", "1"}, |
| +} |
| + |
| +type rankOption struct { |
| + Value string `json:"value"` |
| + Name string `json:"name"` |
| + Text string `json:"text"` |
| + Terms []string `json:"terms"` |
| + First bool `json:"first"` |
| +} |
| + |
| +func countryOptions(lang string) []rankOption { |
| + var out []rankOption |
| + for _, c := range countries { |
| + name := c.en |
| + if lang == "de" { |
| + name = c.de |
| + } |
| + text := name + " (+" + c.code + ")" |
| + out = append(out, rankOption{Value: c.iso, Name: text, Text: text, Terms: []string{c.iso, "+" + c.code}, First: primary[c.iso]}) |
| + } |
| + return out |
| +} |
| + |
| +func runRank(t *testing.T, lang string, queries []string) (map[string][]string, map[string]string) { |
| + t.Helper() |
| + in, err := json.Marshal(map[string]any{"options": countryOptions(lang), "queries": queries}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got struct { |
| + Order map[string][]string `json:"order"` |
| + Settled map[string]string `json:"settled"` |
| + } |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/select_node.mjs"}, Stdin: in}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + return got.Order, got.Settled |
| +} |
| + |
| +// Leaving the box after typing (Tab, or a tap on the next field) commits |
| +// the highlighted row only when the search can mean one: a single match, |
| +// an exact hit on three characters, or a calling code its country owns. |
| +// Two letters never do: "ge" is Georgia's code and the start of Germany, |
| +// and a wrong calling code under a phone number is worse than an unpicked |
| +// box the form then asks about. |
| +func TestLeavingTheBoxCommitsOnlyWhatASearchCanMean(t *testing.T) { |
| + t.Parallel() |
| + _, settled := runRank(t, "en", []string{"irel", "germ", "india", "ger", "ire", "+44", "44", "+1", "1", "+7", "+971", "ge", "ir", "ind", "zzzz"}) |
| + for q, want := range map[string]string{ |
| + "irel": "IE", "germ": "DE", "india": "IN", "ger": "DE", "ire": "IE", |
| + "+44": "GB", "44": "GB", "+1": "US", "1": "US", "+7": "RU", "+971": "AE", |
| + "ge": "", "ir": "", "ind": "", "zzzz": "", |
| + } { |
| + if got := settled[q]; got != want { |
| + t.Errorf("leaving after %q commits %q, want %q", q, got, want) |
| + } |
| + } |
| +} |
| + |
| +// Two rows that are both an exact hit settle nothing: the brackets are |
| +// glosses, so "Springfield" names "Springfield (IL)" and |
| +// "Springfield (MA)" equally, and leaving the box must not pick one. |
| +func TestLeavingTheBoxNeverPicksBetweenTwoExactHits(t *testing.T) { |
| + t.Parallel() |
| + opts := []rankOption{ |
| + {Value: "IL", Name: "Springfield (IL)", Text: "Springfield (IL)"}, |
| + {Value: "MA", Name: "Springfield (MA)", Text: "Springfield (MA)"}, |
| + {Value: "SH", Name: "Shelbyville", Text: "Shelbyville"}, |
| + {Value: "PA", Name: "Portland (OR)", Text: "Portland (OR)", First: true}, |
| + {Value: "PM", Name: "Portland (ME)", Text: "Portland (ME)"}, |
| + } |
| + in, err := json.Marshal(map[string]any{"options": opts, "queries": []string{"springfield", "shelbyville", "portland"}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got struct { |
| + Settled map[string]string `json:"settled"` |
| + } |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/select_node.mjs"}, Stdin: in}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Settled["springfield"] != "" { |
| + t.Errorf("leaving after \"springfield\" picked %q between two exact hits, want nothing", got.Settled["springfield"]) |
| + } |
| + // Exactly one of the exact ties marked first is the single best row, |
| + // the rule a shared calling code settles by. |
| + if got.Settled["portland"] != "PA" { |
| + t.Errorf("leaving after \"portland\" picked %q, want PA: one exact tie is marked first", got.Settled["portland"]) |
| + } |
| + if got.Settled["shelbyville"] != "SH" { |
| + t.Errorf("leaving after \"shelbyville\" picked %q, want SH: a lone exact hit still settles", got.Settled["shelbyville"]) |
| + } |
| +} |
| + |
| +// Someone types a country's name in their own language, its ISO code, or |
| +// the calling code with or without its "+", and the country they meant is |
| +// the first row — the one Enter takes. Substring matching alone got most |
| +// of these wrong: "ae" is inside "Israel", and "+1" is inside a dozen |
| +// codes, alphabetically led by American Samoa. |
| +func TestSearchRanksTheRightRowFirst(t *testing.T) { |
| + t.Parallel() |
| + en, _ := runRank(t, "en", []string{"c", "j", "44", "262", "+971", "971", "ae", "de", "gb", "united k", "man", "sudan", "suda", "guin", "+44", "+1", "cote", "cote d'ivoire", "ireland", "zzzz"}) |
| + for q, want := range map[string]string{ |
| + "c": "KH", // one letter reads alphabetically |
| + "j": "JM", // owning a calling code only breaks a tie on the code |
| + "44": "GB", // a code typed whole without its "+" is still exact |
| + "262": "RE", |
| + "+971": "AE", // the calling code, as read off a number |
| + "971": "AE", // ...and without the plus |
| + "ae": "AE", // an ISO code beats a name that merely contains the letters |
| + "de": "DE", // ...and one that starts with them (Denmark) |
| + "gb": "GB", |
| + "united k": "GB", // the start of a name |
| + "man": "IM", // the start of a WORD beats the middle of one (Oman, Germany) |
| + "sudan": "SD", // the start of the name beats a later word (South Sudan) |
| + "suda": "SD", |
| + "guin": "GN", // Guinea before Equatorial Guinea and Papua New Guinea |
| + "+44": "GB", // a shared code opens on the country that owns it |
| + "+1": "US", |
| + "cote": "CI", // accents fold away: Côte d’Ivoire |
| + "cote d'ivoire": "CI", // ...and a typed apostrophe is its curly one |
| + "ireland": "IE", |
| + } { |
| + if len(en[q]) == 0 || en[q][0] != want { |
| + head := en[q] |
| + if len(head) > 4 { |
| + head = head[:4] |
| + } |
| + t.Errorf("en %q: first rows %v, want %s first", q, head, want) |
| + } |
| + } |
| + if len(en["zzzz"]) != 0 { |
| + t.Errorf("a query nothing matches found %v", en["zzzz"]) |
| + } |
| + // The name someone reads is in their language, so that is what they type. |
| + de, _ := runRank(t, "de", []string{"osterreich", "vereinigte"}) |
| + if len(de["osterreich"]) == 0 || de["osterreich"][0] != "AT" { |
| + t.Errorf("de \"osterreich\": got %v, want AT first", de["osterreich"]) |
| + } |
| + if len(de["vereinigte"]) != 3 { |
| + t.Errorf("de \"vereinigte\": got %v, want the three Vereinigte", de["vereinigte"]) |
| + } |
| +} |
| + |
| +// The blank of a REQUIRED select ("Choose one") is a prompt, not an |
| +// answer: the box shows nothing for it, so its placeholder is what is |
| +// read, and the list never offers it — choosing it would only leave the |
| +// question unanswered. A marked prompt ("Country") drops out once |
| +// something is typed. An optional select's blank ("No answer") is a real |
| +// answer everywhere, and a disabled blank never is. |
| +func TestRequiredBlankIsAPrompt(t *testing.T) { |
| + t.Parallel() |
| + type blank struct { |
| + Name string `json:"-"` |
| + Value string `json:"value"` |
| + Marked bool `json:"marked"` |
| + Required bool `json:"required"` |
| + Searching bool `json:"searching"` |
| + Disabled bool `json:"disabled"` |
| + Prompt bool `json:"-"` |
| + Offered bool `json:"-"` |
| + } |
| + cases := []blank{ |
| + {Name: "required blank, idle", Required: true, Prompt: true, Offered: false}, |
| + {Name: "required blank, searching", Required: true, Searching: true, Prompt: true, Offered: false}, |
| + {Name: "marked prompt, idle", Marked: true, Prompt: true, Offered: true}, |
| + {Name: "marked prompt, searching", Marked: true, Searching: true, Prompt: true, Offered: false}, |
| + {Name: "marked required prompt, idle", Marked: true, Required: true, Prompt: true, Offered: true}, |
| + {Name: "marked required prompt, searching", Marked: true, Required: true, Searching: true, Prompt: true, Offered: false}, |
| + {Name: "optional blank, idle", Prompt: false, Offered: true}, |
| + {Name: "optional blank, searching", Searching: true, Prompt: false, Offered: true}, |
| + // A page that renders "Choose one" disabled means it, and a select |
| + // that then stops being required must not bring it back as a pick. |
| + {Name: "disabled blank on a select no longer required", Disabled: true, Prompt: true, Offered: false}, |
| + {Name: "a real answer on a required select", Value: "AE", Required: true, Searching: true, Prompt: false, Offered: true}, |
| + } |
| + in, err := json.Marshal(map[string]any{"blanks": cases}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got []struct { |
| + Prompt bool `json:"prompt"` |
| + Offered bool `json:"offered"` |
| + } |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/select_node.mjs"}, Stdin: in}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != len(cases) { |
| + t.Fatalf("got %d answers for %d cases", len(got), len(cases)) |
| + } |
| + for i, c := range cases { |
| + if got[i].Prompt != c.Prompt { |
| + t.Errorf("%s: prompt = %v, want %v (the box would show it as the pick)", c.Name, got[i].Prompt, c.Prompt) |
| + } |
| + if got[i].Offered != c.Offered { |
| + t.Errorf("%s: offered = %v, want %v", c.Name, got[i].Offered, c.Offered) |
| + } |
| + } |
| +} |
| + |
| +// The rules above are only true of a screen if the combobox calls them. |
| +// A source check, because the wiring is one line each; the browser drives |
| +// in select_browser_test.go hold the behaviour. |
| +func TestSelectUsesItsPromptRules(t *testing.T) { |
| + t.Parallel() |
| + src := string(shippedScript(t, "select.js")) |
| + for _, want := range []string{ |
| + "prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, o.disabled),", |
| + "offered(o.el.value, o.marked, native.required, searching, o.disabled)", |
| + // Up from nothing highlighted goes to the last row. |
| + "setActive((active ? v[Math.max(v.indexOf(active) - 1, 0)] : v[v.length - 1]) || null);", |
| + // An unanswered question opens with nothing highlighted, so a bare |
| + // Enter cannot answer it with the first row... |
| + "if (unanswered()) return null;", |
| + // ...opening is where an idle list lands, from openList... |
| + "markSelected();\n setActive(opening());", |
| + // ...and from a cleared search. |
| + "if (!searching && !steered && (requeried || unanswered())) setActive(opening());", |
| + // A fallback highlight is never a prompt or a page's extra row. |
| + "visible().find((o) => !o.run && !o.prompt)", |
| + // A highlight that is no longer on the list is replaced. |
| + "!shown.includes(active)", |
| + // With the list open, Enter never submits the form. |
| + "if (open) {\n e.preventDefault();\n tookEnter = true;\n if (active) choose(active);", |
| + // A select that stops being required re-derives its prompts and |
| + // re-marks the selection. |
| + "if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, o.el.disabled);\n }\n markSelected();", |
| + } { |
| + if !strings.Contains(src, want) { |
| + t.Errorf("select.js no longer calls %q", want) |
| + } |
| + } |
| +} |
diff --git a/check/shell_test.go b/check/shell_test.go| new file mode 100644 |
| index 0000000..c4978f9 |
| --- /dev/null |
| +++ b/check/shell_test.go |
| @@ -0,0 +1,28 @@ |
| +package check |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/nodetest" |
| +) |
| + |
| +// A tap on the back control writes its record before any navigation |
| +// commits. When the reader then cancels the leave-page prompt and |
| +// follows some other link, the next page must slide forward: the record |
| +// was for a navigation that never happened. The real Back still slides |
| +// back. Here cancelling the prompt is one stub call; a browser drive |
| +// would have to dismiss the dialog over CDP and race the next click |
| +// against it, so this runs shell.js itself against stub pages in Node. |
| +func TestACancelledBackDoesNotSlideTheNextPageBack(t *testing.T) { |
| + var got struct{ Taken, Cancelled string } |
| + if err := json.Unmarshal(nodetest.Run(t, nodetest.Cmd{Args: []string{"node/shell_node.mjs"}}), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Taken != "back" { |
| + t.Errorf("Back tapped and taken: the up page slid %q, want back", got.Taken) |
| + } |
| + if got.Cancelled != "forward" { |
| + t.Errorf("Back tapped, the prompt cancelled, then a link to another page followed: it slid %q, want forward", got.Cancelled) |
| + } |
| +} |
diff --git a/check/shelljs_browser_test.go b/check/shelljs_browser_test.go| new file mode 100644 |
| index 0000000..90aeef0 |
| --- /dev/null |
| +++ b/check/shelljs_browser_test.go |
| @@ -0,0 +1,535 @@ |
| +//go:build browser |
| + |
| +// shell.js in a browser: the back control that reuses history only |
| +// when it can prove what is behind it, the slide that knows which way |
| +// it is going, and a link beside the back control that is not the |
| +// back control. |
| +package check |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + cdppage "github.com/chromedp/cdproto/page" |
| + "github.com/chromedp/cdproto/runtime" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// shellDoc is a page in the vocabulary the sidebar and console layouts |
| +// write for shell.js: a root marked with its view, a back link, a rail |
| +// of nav links, and a main with a same-page link. The markup is by hand |
| +// because this drive is about the script. It links shell.css but not |
| +// tokens.css, so what is measured is shell.js and not the stylesheet's |
| +// narrow layout: a link the stylesheet hides cannot take focus, and the |
| +// drive would fail for a reason that has nothing to do with the script. |
| +func shellDoc(view, up, nav, main string) string { |
| + return `<!doctype html><html lang="en" dir="ltr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>shell</title>` + |
| + `<link rel="stylesheet" href="/shell.css">` + |
| + `<script defer blocking="render" src="/shell.js"></script></head><body>` + |
| + `<div rst-shell-sidebar="` + view + `"><div rst-shell-back><a id="back" href="` + up + `" rel="up">Sections</a></div>` + |
| + `<aside rst-shell-rail><nav rst-shell-nav>` + nav + `</nav></aside>` + |
| + `<main rst-shell-main id="main">` + main + `</main></div></body></html>` |
| +} |
| + |
| +// shellSite is three small apps on one origin. / has ids on its nav and |
| +// fragments on its up links; /b/ has neither (focus return must come |
| +// from the record alone); /q/ has an index with a query, nav hrefs |
| +// written absolute, and an id that needs decoding. /old redirects to /, |
| +// the "up that redirects" case, and /self is its own up. |
| +func shellSite(t *testing.T) func(origin string) http.Handler { |
| + return func(origin string) http.Handler { |
| + nav := `<a id="nav-invoices" href="/invoices">Invoices</a><a id="nav-orders" href="/orders">Orders</a><a id="nav-r" href="/r/page">Redirecting</a>` |
| + content := func(name string) string { |
| + return `<h1>` + name + `</h1><p><a id="section-link" href="#part">To a part of this page</a></p><div style="block-size: 150vh"></div><h2 id="part">Part</h2>` |
| + } |
| + navB := `<a href="/b/invoices">Invoices</a><a href="/b/orders">Orders</a>` |
| + navQ := `<a id="nav-q-orders" href="` + origin + `/q/orders?status=open">Orders</a><a id="nav-café" href="/q/cafe">Café</a>` |
| + pages := map[string]string{ |
| + "/": shellDoc("index", "/", nav, `<h1>Home</h1>`), |
| + "/invoices": shellDoc("page", "/#nav-invoices", nav, content("Invoices")), |
| + "/orders": shellDoc("page", "/#nav-orders", nav, content("Orders")), |
| + "/r/page": shellDoc("page", "/old#nav-r", nav, content("Redirecting")), |
| + "/self": shellDoc("page", "/self", nav, content("Self")), |
| + "/b/": shellDoc("index", "/b/", navB, `<h1>B</h1>`), |
| + "/b/invoices": shellDoc("page", "/b/", navB, content("B invoices")), |
| + "/q/": shellDoc("index", "/q/?tab=all", navQ, `<h1>Q</h1>`), |
| + "/q/orders": shellDoc("page", "/q/?tab=all#nav-q-orders", navQ, content("Q orders")), |
| + "/q/cafe": shellDoc("page", "/q/?tab=all#nav-caf%C3%A9", navQ, content("Café")), |
| + // A content page whose back strip also holds a menu, the way |
| + // the gallery puts its display settings there, with a link to |
| + // the very page the back control points at. |
| + "/strip": strings.Replace(shellDoc("page", "/#nav-invoices", nav, content("Strip")), |
| + `rel="up">Sections</a></div>`, |
| + `rel="up">Sections</a><details open><summary>Settings</summary><a id="strip-menu-link" href="/?theme=other">Other theme</a></details></div>`, 1), |
| + } |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + for name, body := range map[string][]byte{"shell.js": shippedScript(t, "shell.js"), "shell.css": fiesta.ShellCSS()} { |
| + body, ct := body, map[bool]string{true: "text/javascript", false: "text/css"}[strings.HasSuffix(name, ".js")] |
| + mux.HandleFunc("GET /"+name, func(w http.ResponseWriter, _ *http.Request) { |
| + w.Header().Set("Content-Type", ct) |
| + w.Write(body) |
| + }) |
| + } |
| + mux.HandleFunc("GET /old", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) }) |
| + for path, html := range pages { |
| + html, pattern := html, "GET "+path |
| + if strings.HasSuffix(path, "/") { |
| + pattern += "{$}" |
| + } |
| + mux.HandleFunc(pattern, func(w http.ResponseWriter, _ *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, html) |
| + }) |
| + } |
| + return mux |
| + } |
| +} |
| + |
| +// tab opens a fresh tab (a fresh session history, so "what is behind |
| +// this entry" is only what the leg put there) at 390 wide, with an |
| +// optional script run in every document before the page's own. |
| +func tab(t *testing.T, rig *harness.Rig, init string, media ...*emulation.MediaFeature) (context.Context, func(), *[]string) { |
| + t.Helper() |
| + ctx, cancel := chromedp.NewContext(rig.Context()) |
| + ctx, stop := context.WithTimeout(ctx, 90*time.Second) |
| + thrown := &[]string{} |
| + chromedp.ListenTarget(ctx, func(ev any) { |
| + if e, ok := ev.(*runtime.EventExceptionThrown); ok { |
| + *thrown = append(*thrown, e.ExceptionDetails.Error()) |
| + } |
| + }) |
| + acts := []chromedp.Action{chromedp.EmulateViewport(390, 844)} |
| + if len(media) > 0 { |
| + acts = append(acts, emulation.SetEmulatedMedia().WithFeatures(media)) |
| + } |
| + if init != "" { |
| + acts = append(acts, chromedp.ActionFunc(func(c context.Context) error { |
| + _, err := cdppage.AddScriptToEvaluateOnNewDocument(init).Do(c) |
| + return err |
| + })) |
| + } |
| + if err := chromedp.Run(ctx, acts...); err != nil { |
| + t.Fatal(err) |
| + } |
| + return ctx, func() { stop(); cancel() }, thrown |
| +} |
| + |
| +type shellState struct { |
| + Path, Focus string |
| + Len int |
| +} |
| + |
| +// state is where the tab is, how long its history is, and what has |
| +// focus (an id, else the href, else the tag). |
| +func state(t *testing.T, ctx context.Context) shellState { |
| + t.Helper() |
| + var s shellState |
| + at(t, ctx, `JSON.stringify({Path: location.pathname + location.search + location.hash, Len: history.length, |
| + Focus: (a => a.id || a.getAttribute("href") || a.tagName)(document.activeElement)})`, &s) |
| + return s |
| +} |
| + |
| +// visit navigates with a real load; follow clicks through the page, |
| +// which is what works after a back/forward-cache restore (no load event |
| +// fires there, so chromedp's own waits would hang). |
| +func visit(t *testing.T, ctx context.Context, url string) { |
| + t.Helper() |
| + if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// follow clicks sel from a timer, so the evaluation returns before the |
| +// document it runs in is replaced, then waits until the destination's |
| +// own marker holds and the destination has loaded. |
| +func follow(t *testing.T, ctx context.Context, sel, until string) { |
| + t.Helper() |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`setTimeout(() => document.querySelector(%q).click(), 0), true`, sel), nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + settleUntil(t, ctx, loaded(until)) |
| +} |
| + |
| +// loaded adds "and the document has loaded" to a leg's marker. A path |
| +// holds the moment the new document commits, before its deferred |
| +// shell.js has run; blocking="render" holds back paint, not a click |
| +// from the drive. A click in that gap reaches a page with no listener |
| +// and takes the scriptless path, which is correct for a reader and |
| +// wrong for a leg that means to drive the script: on a loaded fleet |
| +// runner the back control then followed its link and history grew by |
| +// one (#127). Deferred scripts run before readyState is "complete", |
| +// and a page restored from the back/forward cache is already complete. |
| +func loaded(until string) string { |
| + return "(" + until + `) && document.readyState === "complete"` |
| +} |
| + |
| +// revealed waits until the page has loaded and painted twice more, by |
| +// which time pagereveal has fired and shell.js has done whatever it was |
| +// going to do with focus. A leg that asserts focus did NOT move needs |
| +// this, or it would pass by reading too early. |
| +func revealed(t *testing.T, ctx context.Context) { |
| + t.Helper() |
| + settleUntil(t, ctx, `document.readyState === "complete"`) |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, |
| + func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestTheBackControlReusesHistoryWhenItCanProveWhatIsBehind drives the |
| +// scripted journeys through the index and its pages: when the back |
| +// control may reuse history, when it must follow its link instead, and |
| +// where focus lands on the index afterwards. |
| +func TestTheBackControlReusesHistoryWhenItCanProveWhatIsBehind(t *testing.T) { |
| + rig := harness.New(t, shellSite(t)) |
| + |
| + t.Run("index, page, back: history reused, focus on the link followed", func(t *testing.T) { |
| + ctx, done, thrown := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/") |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| + before := state(t, ctx) |
| + follow(t, ctx, "#back", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| + if after := state(t, ctx); after.Len != before.Len || after.Path != "/" || after.Focus != "nav-invoices" { |
| + t.Errorf("after the back control: %+v (before it %+v); want history unchanged, at /, focus on nav-invoices", after, before) |
| + } |
| + if len(*thrown) > 0 { |
| + t.Errorf("uncaught: %v", *thrown) |
| + } |
| + }) |
| + |
| + t.Run("index, page, #fragment, back: the link is followed", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/") |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| + follow(t, ctx, "#section-link", `location.hash === "#part"`) |
| + before := state(t, ctx) |
| + follow(t, ctx, "#back", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| + if after := state(t, ctx); after.Len != before.Len+1 || after.Focus != "nav-invoices" { |
| + t.Errorf("back from a fragment on the same page: %+v (before %+v); want the link followed (history +1) and focus on nav-invoices", after, before) |
| + } |
| + }) |
| + |
| + t.Run("a deep link, back: the link is followed and the record focuses", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/orders") |
| + follow(t, ctx, "#back", `location.pathname === "/"`) |
| + settleUntil(t, ctx, `document.activeElement.id === "nav-orders"`) |
| + }) |
| + |
| + t.Run("no ids, no fragment: focus from the record alone", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/b/invoices") |
| + follow(t, ctx, "#back", `location.pathname === "/b/"`) |
| + settleUntil(t, ctx, `document.activeElement.getAttribute("href") === "/b/invoices"`) |
| + }) |
| + |
| + const brokenStorage = `Object.defineProperty(window, "sessionStorage", {configurable: true, get() { throw new DOMException("denied", "SecurityError"); }});` |
| + // Chromium focuses a fragment's target itself when it loads a URL |
| + // with one, so landing on /#nav-invoices proves nothing about the |
| + // script. A history return to that URL does not refocus: the drive |
| + // blurs, leaves, and comes back, and only shell.js's fragment rule |
| + // can put focus on the link then. |
| + t.Run("storage throws, with a fragment: focus from the fragment", func(t *testing.T) { |
| + ctx, done, thrown := tab(t, rig, brokenStorage) |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/invoices") |
| + follow(t, ctx, "#back", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| + follow(t, ctx, "#nav-orders", `location.pathname === "/orders"`) |
| + back(t, ctx, `location.pathname === "/"`) |
| + settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`) |
| + if len(*thrown) > 0 { |
| + t.Errorf("storage that throws broke the script: %v", *thrown) |
| + } |
| + }) |
| + |
| + t.Run("storage throws, no fragment: browser default, nothing thrown", func(t *testing.T) { |
| + ctx, done, thrown := tab(t, rig, brokenStorage) |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/b/invoices") |
| + follow(t, ctx, "#back", `location.pathname === "/b/"`) |
| + revealed(t, ctx) |
| + if s := state(t, ctx); s.Focus != "BODY" { |
| + t.Errorf("with no record and no fragment, focus is on %q, want the browser's default", s.Focus) |
| + } |
| + if len(*thrown) > 0 { |
| + t.Errorf("uncaught: %v", *thrown) |
| + } |
| + }) |
| + |
| + t.Run("modified and middle clicks are not intercepted", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/") |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| + for _, init := range []string{"{ctrlKey: true}", "{metaKey: true}", "{shiftKey: true}", "{altKey: true}", "{button: 1}"} { |
| + var prevented bool |
| + // A listener on window runs after shell.js's on document, |
| + // records whether it was intercepted, then stops the real |
| + // navigation so the next click starts from the same page. |
| + js := `(() => { let seen = null; addEventListener("click", e => { seen = e.defaultPrevented; e.preventDefault(); }, {once: true}); |
| + document.getElementById("back").dispatchEvent(new MouseEvent("click", Object.assign({bubbles: true, cancelable: true, button: 0}, ` + init + `))); |
| + return seen; })()` |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(js, &prevented)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if prevented { |
| + t.Errorf("a click with %s on the back control was intercepted; it must do what the browser does", init) |
| + } |
| + } |
| + // The control: a plain click IS intercepted here. |
| + var plain bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { let seen = null; addEventListener("click", e => { seen = e.defaultPrevented; }, {once: true}); |
| + document.getElementById("back").dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, button: 0})); return seen; })()`, &plain)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !plain { |
| + t.Error("CONTROL: a plain click on the back control was not intercepted either, so the modifier legs prove nothing") |
| + } |
| + }) |
| + |
| + t.Run("an up that redirects: the link is followed", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/") |
| + follow(t, ctx, "#nav-r", `location.pathname === "/r/page"`) |
| + before := state(t, ctx) |
| + follow(t, ctx, "#back", `location.pathname === "/"`) |
| + if after := state(t, ctx); after.Len != before.Len+1 { |
| + t.Errorf("an up behind a redirect: history %d -> %d; the link must be followed, not history reused", before.Len, after.Len) |
| + } |
| + }) |
| + |
| + t.Run("two sections in a row: the record beats a stale fragment", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/invoices") |
| + follow(t, ctx, "#back", `location.pathname === "/" && location.hash === "#nav-invoices"`) |
| + follow(t, ctx, "#nav-orders", `location.pathname === "/orders"`) |
| + back(t, ctx, `location.pathname === "/" && document.activeElement.id === "nav-orders"`) |
| + }) |
| + |
| + t.Run("query, absolute hrefs and an encoded id", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/q/?tab=all") |
| + follow(t, ctx, "#nav-q-orders", `location.pathname === "/q/orders"`) |
| + before := state(t, ctx) |
| + follow(t, ctx, "#back", `location.pathname === "/q/" && document.activeElement.id === "nav-q-orders"`) |
| + if after := state(t, ctx); after.Len != before.Len { |
| + t.Errorf("an up with a query and an absolute nav href: history %d -> %d; path+query were not compared resolved", before.Len, after.Len) |
| + } |
| + // The encoded id, through the history return the fragment leg |
| + // above explains: location.hash keeps the percent-encoding, and |
| + // only a decoded id finds the link. |
| + ctx2, done2, _ := tab(t, rig, brokenStorage) |
| + defer done2() |
| + visit(t, ctx2, rig.Origin+"/q/cafe") |
| + follow(t, ctx2, "#back", `location.pathname === "/q/" && document.activeElement.id === "nav-café"`) |
| + follow(t, ctx2, "#nav-q-orders", `location.pathname === "/q/orders"`) |
| + back(t, ctx2, `location.pathname === "/q/"`) |
| + settleUntil(t, ctx2, `document.activeElement.id === "nav-café"`) |
| + }) |
| + |
| + // An engine with cross-document view transitions but no types: the |
| + // stub, installed before shell.js's listener, hands every reveal a |
| + // transition without a types set. The slide's direction is cosmetic; |
| + // the focus return must still happen and nothing may throw. |
| + t.Run("a transition with no types: focus still returns", func(t *testing.T) { |
| + const untyped = `addEventListener("pagereveal", e => Object.defineProperty(e, "viewTransition", {value: {}}));` |
| + ctx, done, thrown := tab(t, rig, untyped) |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/") |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| + follow(t, ctx, "#back", `location.pathname === "/"`) |
| + settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`) |
| + if len(*thrown) > 0 { |
| + t.Errorf("a transition without types broke the script: %v", *thrown) |
| + } |
| + }) |
| + |
| + // A record left by a content page is taken by the next index revealed, |
| + // whatever its width. Left behind by a wide reveal, it would focus a |
| + // stale link on the next phone-width index reached without leaving a |
| + // page (here, a reload of the index). |
| + t.Run("a wide reveal consumes the record", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 800)); err != nil { |
| + t.Fatal(err) |
| + } |
| + visit(t, ctx, rig.Origin+"/b/invoices") |
| + follow(t, ctx, "#back", `location.pathname === "/b/"`) |
| + revealed(t, ctx) |
| + if err := chromedp.Run(ctx, chromedp.EmulateViewport(390, 844)); err != nil { |
| + t.Fatal(err) |
| + } |
| + visit(t, ctx, rig.Origin+"/b/") |
| + revealed(t, ctx) |
| + if s := state(t, ctx); s.Focus != "BODY" { |
| + t.Errorf("a phone-width index reached by reload focused %q: a record from a wide reveal was left behind", s.Focus) |
| + } |
| + }) |
| + |
| + // An index left in the default page view carries a back control whose |
| + // up is the page itself. After a same-page #fragment, the entry behind |
| + // is that same URL but the same document, and going back to it would |
| + // be a scroll with no reveal: no slide, no focus return. The link is |
| + // followed instead. |
| + t.Run("an up that is this document: the link is followed", func(t *testing.T) { |
| + ctx, done, _ := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/self") |
| + follow(t, ctx, "#section-link", `location.hash === "#part"`) |
| + before := state(t, ctx) |
| + follow(t, ctx, "#back", `location.pathname === "/self" && location.hash === ""`) |
| + if after := state(t, ctx); after.Len != before.Len+1 { |
| + t.Errorf("an up behind a same-document entry: history %d -> %d; the link must be followed, not history reused", before.Len, after.Len) |
| + } |
| + }) |
| +} |
| + |
| +// back is the browser's own Back button: a traversal the back control |
| +// did not start, so shell.js has no one-shot flag to read. Before it, |
| +// focus is dropped, so what has focus afterwards is what the returning |
| +// page put there and not what the page kept from before it was left. |
| +func back(t *testing.T, ctx context.Context, until string) { |
| + t.Helper() |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`document.activeElement && document.activeElement.blur(), setTimeout(() => history.back(), 0), true`, nil)); err != nil { |
| + t.Fatal(err) |
| + } |
| + settleUntil(t, ctx, loaded(until)) |
| +} |
| + |
| +// recordReveal logs every pagereveal's transition types into |
| +// sessionStorage. This listener is installed before the page's own |
| +// scripts, so it runs BEFORE shell.js's, when no type has been added |
| +// yet: the types are read in log, which runs once the reveal is over |
| +// (after the transition's finished promise settles, or on a timer when |
| +// there is no transition), by which point every listener has run. The |
| +// drive waits for each entry before it starts the next navigation, so |
| +// no step begins while the previous slide is still running. |
| +const recordReveal = `addEventListener("pagereveal", e => { const vt = e.viewTransition; |
| + const log = () => { const types = vt ? [...vt.types].join("+") || "untyped" : "none"; |
| + const l = JSON.parse(sessionStorage.getItem("reveal-log") || "[]"); |
| + l.push(location.pathname + ":" + types); sessionStorage.setItem("reveal-log", JSON.stringify(l)); }; |
| + if (vt) vt.finished.then(log, log); else setTimeout(log, 0); });` |
| + |
| +// TestTheSlideKnowsWhichWayItIsGoing: forward going in; back coming out |
| +// through the back control, through the browser's own Back (which sets |
| +// no flag, so only the Navigation API's traverse can say which way it |
| +// went), and through a back control that follows its link (a new entry, |
| +// not a traverse, so only the flag can); and no transition at all under |
| +// reduced motion (shell.css never opts in). |
| +func TestTheSlideKnowsWhichWayItIsGoing(t *testing.T) { |
| + rig := harness.New(t, shellSite(t)) |
| + for _, reduced := range []bool{false, true} { |
| + t.Run(fmt.Sprintf("reduced motion %v", reduced), func(t *testing.T) { |
| + var media []*emulation.MediaFeature |
| + want := `["/:none","/invoices:forward","/:back","/orders:forward","/:back","/invoices:forward","/:back"]` |
| + if reduced { |
| + media = []*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}} |
| + want = `["/:none","/invoices:none","/:none","/orders:none","/:none","/invoices:none","/:none"]` |
| + } |
| + ctx, done, _ := tab(t, rig, recordReveal, media...) |
| + defer done() |
| + logged := func(n int) string { |
| + return fmt.Sprintf(`JSON.parse(sessionStorage.getItem("reveal-log") || "[]").length === %d`, n) |
| + } |
| + visit(t, ctx, rig.Origin+"/") |
| + settleUntil(t, ctx, logged(1)) |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices" && `+logged(2)) |
| + follow(t, ctx, "#back", `location.pathname === "/" && `+logged(3)) |
| + follow(t, ctx, "#nav-orders", `location.pathname === "/orders" && `+logged(4)) |
| + back(t, ctx, `location.pathname === "/" && `+logged(5)) |
| + // A same-page fragment first, so the back control cannot |
| + // reuse history and follows its link. |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices" && `+logged(6)) |
| + follow(t, ctx, "#section-link", `location.hash === "#part"`) |
| + follow(t, ctx, "#back", `location.pathname === "/" && `+logged(7)) |
| + var log string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(`sessionStorage.getItem("reveal-log")`, &log)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if log != want { |
| + t.Errorf("pagereveal saw %s, want %s", log, want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestALinkBesideTheBackControlIsNotTheBackControl: a page may put more |
| +// in the back strip than the back link (the gallery's display settings |
| +// menu sits at its inline end), and those links are ordinary links. A |
| +// click on one must not record a "back" for the slide, nor call |
| +// history.back() when the entry behind happens to be the same page: it |
| +// is followed, and history grows by one. |
| +func TestALinkBesideTheBackControlIsNotTheBackControl(t *testing.T) { |
| + rig := harness.New(t, shellSite(t)) |
| + init := `(() => { const set = Storage.prototype.setItem, back = History.prototype.back; |
| + Storage.prototype.setItem = function (k, v) { if (k === "rst-shell-back") set.call(this, "__went_back", v); return set.call(this, k, v); }; |
| + History.prototype.back = function () { sessionStorage.setItem("__history_back", "1"); return back.call(this); }; })()` |
| + ctx, done, thrown := tab(t, rig, init) |
| + defer done() |
| + // The entry behind /strip is the very page its menu link names, so |
| + // the back control's own rule would reuse history for it. |
| + visit(t, ctx, rig.Origin+"/?theme=other") |
| + revealed(t, ctx) |
| + visit(t, ctx, rig.Origin+"/strip") |
| + revealed(t, ctx) |
| + before := state(t, ctx) |
| + follow(t, ctx, "#strip-menu-link", `location.pathname === "/" && location.search === "?theme=other"`) |
| + revealed(t, ctx) |
| + after := state(t, ctx) |
| + var rec struct{ WentBack, HistoryBack string } |
| + at(t, ctx, `JSON.stringify({WentBack: sessionStorage.getItem("__went_back") || "", HistoryBack: sessionStorage.getItem("__history_back") || ""})`, &rec) |
| + if rec.WentBack != "" || rec.HistoryBack != "" { |
| + t.Errorf("a menu link in the back strip was taken for the back control: recorded back %q, history.back called %q", rec.WentBack, rec.HistoryBack) |
| + } |
| + if after.Len != before.Len+1 { |
| + t.Errorf("history %d -> %d after the menu link; want it followed (+1)", before.Len, after.Len) |
| + } |
| + if errs, _ := scriptErrors(t, *thrown); len(errs) > 0 { |
| + t.Errorf("uncaught: %v", errs) |
| + } |
| +} |
| + |
| +// skippedTransition is the rejection headless Chromium reports when it |
| +// skips a cross-document view transition before the new page reveals. |
| +const skippedTransition = `AbortError: Transition was skipped` |
| + |
| +// scriptErrors is every uncaught exception except a skipped transition, |
| +// and how many skipped transitions it set aside. |
| +// Measured on this machine's Chromium with the real layouts: on roughly |
| +// one navigation in five the incoming transition is skipped before |
| +// pagereveal (the event carries no viewTransition, so no script ever saw |
| +// it) and the browser reports its own unhandled promise. The skip |
| +// happens with every deferred script the layouts load and not with |
| +// shell.js alone, and navigation, history and focus are unaffected: the |
| +// page simply arrives without the slide. Anything else thrown is still a |
| +// failure, and so is every navigation in a drive being skipped: that is |
| +// no longer a race, it is a slide that never runs. |
| +func scriptErrors(t *testing.T, thrown []string) (errs []string, skipped int) { |
| + t.Helper() |
| + for _, e := range thrown { |
| + if strings.Contains(e, skippedTransition) && strings.Contains(e, "Uncaught (in promise)") { |
| + t.Logf("the browser skipped a transition (no slide this time): %s", e) |
| + skipped++ |
| + continue |
| + } |
| + errs = append(errs, e) |
| + } |
| + return errs, skipped |
| +} |