Retarget the shell drives at the layout examples
The shell drives from rastrillo e14b745's ui/ (shell, index, console shell, profile, card, and the prefetched-index leg of prerender), ported with the same mechanical substitutions as the earlier ports. Every one of the 35 tests passed on its first run against the tree, before any control ran; nothing was loosened. Each page comes from one of two places. Three drives whose assertions read only the slot's content or chrome every shell example carries (the fragment link under the back strip, the 56px phone header strip, the modal over the bar) drive shellPage: a layout example's default state through the manifest, the content in its slot through example.Slot, and the links rewritten to the sizing mux's root paths by assetLinks, which parityLinks now shares. The other 32 depend on chrome the examples lack (the brand Harbour, nav-invoices, #rail-locale and #bar-locale, #acct-profile, the scheme menu, a whitespace view, the layouts from before the phone index), so they read the page rastrillo's drive rendered at SEED through fixture, or legacyLayout over Task 8's legacyFile; no page is retyped and no assertion changed to fit an example. The only adapted text is the tall-rail drive's two failure messages, which name the tall fixture instead of a const that no longer renders it. The prefetched-index drive serves rastrillo's speculation ruleset and Speculation-Rules header itself, since Fiesta has no server; the hover, pointer-down and no-rules legs are server behaviour and stay with rastrillo. profileDefs, shellLayoutPage, topbarPageWith and railTallNav are gone with the templates they fed; stylesheets, scriptErrors, skippedTransition, legacyFile and parityLinks are not redeclared. The keep list in rig_browser_test.go is gone: classSpelling has its caller. Controls, each in a scratch copy. The slot test (Task 5's, already covering every layout through example.Slot) fails by name with the topbar layout's slot comments removed. The brief's rail control could not fail where the brief says: the rail has no inline-size (its width is the sidebar grid's 15rem column) and shell_browser_test.go has no rail-width assertion. The column set to auto reddens the width assertion that does exist, TestTheWideLayoutIsUnchanged, and the profile drop-up; the rail's box-sizing set back to content-box reddens the rail drive in shell_browser_test.go on six readings. Both are in the report. Weights: SEED's twelve lines for the moved tests, as browser check, and measured lines for the two profile drives SEED never weighted (17s and 6s). Shards 170/158/162s by hand, 160/154/166s in make ci; make ci green.
Human
Prompt
Task 11: port the shell drives (shell, index, console shell, profile, card, and only TestAPrefetchedIndexStillReturnsFocus from prerender) from rastrillo e14b745 ui/ into check/, with shellPage over the layout examples where they hold the case and the seeded fixtures where they do not; confirm the slot test and run its scratch control; run the rail inline-size control in a scratch copy; update the weights, run the three shards and make ci once, 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/: shells_browser_test.go (shellPage, legacyLayout, shellAssets) and the shell, index, console shell, profile, card and prerender browser drives, reading the layout examples through shellPage for three drives and the seeded fixtures for the rest. parity_test.go: assetLinks(doc, dir) shared with parityLinks. rig_browser_test.go: the keep list removed. hack/test-weights.txt: the moved tests' lines and two measured ones.
Agent notes
All 35 ported drives green first run. The brief's rail control (inline-size: auto) has nothing to set and no rail-width assertion in shell_browser_test.go to fail; the grid column set to auto reddens TestTheWideLayoutIsUnchanged and the drop-up, and a box-sizing variant reddens the rail drive in shell_browser_test.go. The speculation ruleset is copied into the prefetch drive, since Fiesta has no server; whether Fiesta should own it is for the controller. profileDefs is not ported: the fixtures carry its defines. Report: .superpowers/sdd/2026-10-27-fiesta-1-package/task-11-report.md in the fiesta-design worktree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
10 files changed,
+4340
−11
check/card_browser_test.go+421 −0check/console_shell_browser_test.go+805 −0check/index_browser_test.go+619 −0check/parity_test.go+13 −1check/prerender_browser_test.go+146 −0check/profile_browser_test.go+380 −0check/rig_browser_test.go+0 −10check/shell_browser_test.go+1879 −0check/shells_browser_test.go+63 −0hack/test-weights.txt+14 −0
CI passed — run details
CI log
=== gofmt === === no-rastrillo === go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 . ok amadan.net/rastrillo/fiesta 0.086s === suite === --- shard 0 of 8 --- ==> shard 0 of 8: make root go build ./... go vet ./... FIESTA_TEST_REQUIRE_NODE=1 go test -count=1 ./... ok amadan.net/rastrillo/fiesta 0.346s ok amadan.net/rastrillo/fiesta/check 3.763s ok amadan.net/rastrillo/fiesta/example 0.060s ok amadan.net/rastrillo/fiesta/internal/axe 0.005s ? amadan.net/rastrillo/fiesta/internal/harness [no test files] ok amadan.net/rastrillo/fiesta/internal/markup 0.004s ok amadan.net/rastrillo/fiesta/internal/nodetest 0.270s --- shard 1 of 8 --- ==> shard 1 of 8: make staticcheck go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./... --- shard 2 of 8 --- ==> shard 2 of 8: make govulncheck go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... === Symbol Results === No vulnerabilities found. Your code is affected by 0 vulnerabilities. This scan also found 0 vulnerabilities in packages you import and 5 vulnerabilities in modules you require, but your code doesn't appear to call these vulnerabilities. Use '-show verbose' for more details. --- shard 3 of 8 --- ==> shard 3 of 8: make gitleaks go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' . [90m2:05PM[0m [32mINF[0m [1mUnknown SCM platform. Use --platform to include links in findings.[0m [36mhost=[0mamadan.net [90m2:05PM[0m [32mINF[0m [1m1 commits scanned.[0m [90m2:05PM[0m [32mINF[0m [1mscanned ~4270662 bytes (4.27 MB) in 2.64s[0m [90m2:05PM[0m [32mINF[0m [1mno leaks found[0m --- shard 4 of 8 --- ==> shard 4 of 8: make browser.internal.harness ./hack/gotest.sh browser 'internal.harness' gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium) ok amadan.net/rastrillo/fiesta/internal/harness 9.119s --- shard 5 of 8 --- ==> shard 5 of 8: make browser.check~1of3 ./hack/gotest.sh browser 'check~1of3' gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium) gotest: slice 1 of 3 of ./check/ runs 91 of 273 tests ok amadan.net/rastrillo/fiesta/check 200.009s --- shard 6 of 8 --- ==> shard 6 of 8: make browser.check~2of3 ./hack/gotest.sh browser 'check~2of3' gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium) gotest: slice 2 of 3 of ./check/ runs 91 of 273 tests ok amadan.net/rastrillo/fiesta/check 193.813s --- shard 7 of 8 --- ==> shard 7 of 8: make browser.check~3of3 ./hack/gotest.sh browser 'check~3of3' gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium) gotest: slice 3 of 3 of ./check/ runs 91 of 273 tests ok amadan.net/rastrillo/fiesta/check 200.406s
diff --git a/check/card_browser_test.go b/check/card_browser_test.go| new file mode 100644 |
| index 0000000..27764bd |
| --- /dev/null |
| +++ b/check/card_browser_test.go |
| @@ -0,0 +1,421 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// topbarPage is the real topbar layout as SEED's drive rendered it, in |
| +// one direction: a nav whose current item is Posts (#nav-posts, |
| +// #nav-drafts, #nav-settings), an account menu (#acct-profile), a |
| +// language menu (#bar-locale), and a link in the page (#main-link) far |
| +// enough down that the card never covers it. |
| +func topbarPage(t *testing.T, dir string) string { |
| + t.Helper() |
| + return fixture(t, "card_browser_test.go", "topbarPage", dir) |
| +} |
| + |
| +// cardPage is one of the other pages this file's drives read, as SEED |
| +// rendered it, by the drive's name and the page's case. |
| +func cardPage(t *testing.T, test, kase string) string { |
| + t.Helper() |
| + return fixture(t, "card_browser_test.go", test, kase) |
| +} |
| + |
| +// cardRig serves pages with the assets the layouts link, on a touch |
| +// screen. scripts=false disables script execution in the engine, which |
| +// is the no-JavaScript half of every claim below. |
| +func cardRig(t *testing.T, pages map[string]string, scripts bool) (*harness.Rig, context.Context, context.CancelFunc) { |
| + t.Helper() |
| + pages["/go/"] = sizingDoc("landed", `<p id="landed">landed</p>`) |
| + rig := harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| + if !scripts { |
| + if err := chromedp.Run(ctx, emulation.SetScriptExecutionDisabled(true)); err != nil { |
| + cancel() |
| + t.Fatal(err) |
| + } |
| + } |
| + return rig, ctx, cancel |
| +} |
| + |
| +// cardJS is one reading of the bar: the card's box, main's box, what is |
| +// open, where focus is, and the current item's marks. |
| +const cardJS = `(() => { |
| + const q = s => document.querySelector(s); |
| + const menu = q("[rst-shell-menu], .rst-shell__menu"), tail = q("[rst-shell-tail], .rst-shell__tail"); |
| + const acct = q("[rst-shell-account], .rst-shell__account"), main = q("main"); |
| + const cur = q("#nav-posts"), cs = getComputedStyle(cur), t = tail.getBoundingClientRect(), m = main.getBoundingClientRect(); |
| + const a = document.activeElement; |
| + return JSON.stringify({MenuOpen: menu.open, AcctOpen: acct.open, TailDisplay: getComputedStyle(tail).display, |
| + CardLeft: t.left, CardRight: t.right, CardTop: t.top, |
| + RootLeft: document.documentElement.getBoundingClientRect().left, RootRight: document.documentElement.getBoundingClientRect().right, |
| + MainBox: [m.left, m.top, m.width, m.height].map(Math.round).join(","), |
| + Focus: a ? (a.id || (a.closest("[rst-shell-menu], .rst-shell__menu") ? "menu-summary" : a.closest("[rst-shell-account], .rst-shell__account") ? "account-summary" : a.tagName)) : "none", |
| + FocusShown: !!a && a.getClientRects().length > 0, |
| + CurBg: cs.backgroundColor, CurShadow: cs.boxShadow, CurStart: cs.borderInlineStartWidth, CurEnd: cs.borderBlockEndWidth, |
| + AccentSoft: getComputedStyle(document.documentElement).getPropertyValue("--rst-accent-soft").trim(), |
| + AcctPanel: getComputedStyle(acct.querySelector("[rst-dropdown-menu], .rst-dropdown__menu")).position, |
| + Path: location.pathname}); |
| +})()` |
| + |
| +type cardReading struct { |
| + MenuOpen, AcctOpen, FocusShown bool |
| + TailDisplay, MainBox, Focus, Path string |
| + CardLeft, CardRight, CardTop float64 |
| + RootLeft, RootRight float64 |
| + CurBg, CurShadow, CurStart, CurEnd string |
| + AccentSoft, AcctPanel string |
| +} |
| + |
| +func readCard(t *testing.T, ctx context.Context) cardReading { |
| + t.Helper() |
| + var c cardReading |
| + at(t, ctx, cardJS, &c) |
| + return c |
| +} |
| + |
| +// resolvedColor turns a colour custom property into the colour a |
| +// computed style reports, so the current item's fill can be compared. |
| +func resolvedColor(t *testing.T, ctx context.Context, prop string) string { |
| + t.Helper() |
| + var s string |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const e = document.createElement("span"); e.style.backgroundColor = "var(`+prop+`)"; document.body.appendChild(e); const c = getComputedStyle(e).backgroundColor; e.remove(); return c; })()`, &s)) |
| + if s == "" || s == "rgba(0, 0, 0, 0)" { |
| + t.Fatalf("%s resolved to %q; a colour compared with it would be compared with nothing", prop, s) |
| + } |
| + return s |
| +} |
| + |
| +// TestTheTopbarMenuIsACardOverThePage checks the topbar's card with |
| +// scripts off and on, LTR and RTL, at 390 with a coarse pointer. |
| +func TestTheTopbarMenuIsACardOverThePage(t *testing.T) { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + for _, scripts := range []bool{false, true} { |
| + name := dir + map[bool]string{false: ", no script", true: ", scripts on"}[scripts] |
| + t.Run(name, func(t *testing.T) { |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, dir)}, scripts) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + requirePointer(t, ctx, true) |
| + closed := readCard(t, ctx) |
| + mustRun(t, ctx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + open := readCard(t, ctx) |
| + if !open.MenuOpen { |
| + t.Fatal("the Menu did not open") |
| + } |
| + if open.MainBox != closed.MainBox { |
| + t.Errorf("opening the card moved the page: main %s -> %s", closed.MainBox, open.MainBox) |
| + } |
| + // Against the root box, not clientWidth: this rig's touch |
| + // Chromium reports clientWidth as the whole 390 while |
| + // tokens.css's scrollbar-gutter: stable still reserves a |
| + // 15px classic gutter, so the page's own inline end is |
| + // 375 and clientWidth would count the gutter as gap. |
| + gap := open.RootRight - open.CardRight |
| + if dir == "rtl" { |
| + gap = open.CardLeft - open.RootLeft |
| + } |
| + if gap < 0 || gap > 12+1 { |
| + t.Errorf("the card's inline-end edge is %.1fpx from the viewport's, want within 0.75rem + 1px", gap) |
| + } |
| + // The current item wears the nav thumb: the theme's current |
| + // fill and the thumb's shadow layers (transparent in a flat |
| + // theme), never the rail's border marks. |
| + if fill := resolvedColor(t, ctx, "--rst-nav-current-bg, var(--rst-accent-soft)"); open.CurBg != fill || open.CurStart != "0px" || open.CurEnd != "0px" { |
| + t.Errorf("the current item is bg %s border-start %s border-end %s; want %s, 0, 0", open.CurBg, open.CurStart, open.CurEnd, fill) |
| + } |
| + // The premise of the no-script leg: fiesta.js would close |
| + // the card on Escape, so a card that survives one proves no |
| + // script is running, and the close below is the CSS layer's. |
| + if !scripts { |
| + mustRun(t, ctx, chromedp.KeyEvent(kb.Escape)) |
| + if !readCard(t, ctx).MenuOpen { |
| + t.Fatal("Escape closed the card with scripts disabled; something is still running script, and this leg would not be the no-JavaScript case") |
| + } |
| + } |
| + // An outside tap over a link in the page closes the card |
| + // and does not follow the link. A page with no Menu also |
| + // ends the wait: that is the link followed, which |
| + // clickAndStay then reports as the navigation it was. |
| + clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#main-link", 0.5, 0.5, 0, 0), "", `(() => { const m = document.querySelector("[rst-shell-menu]"); return !m || !m.open; })()`) |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// TestEscapeClosesTheWholeCard: from a plain nav link in the card, and |
| +// from inside the account menu in it, one Escape closes everything and |
| +// focus lands on the Menu summary; opening the account menu keeps the |
| +// card open. Both spellings of the whole topbar. |
| +func TestEscapeClosesTheWholeCard(t *testing.T) { |
| + for _, spelling := range []string{"attribute", "class"} { |
| + t.Run(spelling, func(t *testing.T) { |
| + page := topbarPage(t, "ltr") |
| + menu, acct := "[rst-shell-menu] > summary", "[rst-shell-account] > summary" |
| + if spelling == "class" { |
| + page, menu, acct = classSpelling(t, page), ".rst-shell__menu > summary", ".rst-shell__account > summary" |
| + if !strings.Contains(page, "rst-shell__tail") { |
| + t.Fatal("the class spelling of the topbar has no rst-shell__tail; the leg would test the attribute spelling twice") |
| + } |
| + } |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": page}, true) |
| + defer cancel() |
| + open := func() { |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(menu, chromedp.ByQuery), chromedp.Click(menu, chromedp.ByQuery)) |
| + } |
| + open() |
| + mustRun(t, ctx, chromedp.Focus("#nav-drafts", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + if c := readCard(t, ctx); c.MenuOpen || c.Focus != "menu-summary" { |
| + t.Errorf("Escape from a nav link: open=%v focus=%s, want closed and the Menu summary", c.MenuOpen, c.Focus) |
| + } |
| + open() |
| + mustRun(t, ctx, chromedp.Click(acct, chromedp.ByQuery)) |
| + if c := readCard(t, ctx); !c.MenuOpen || !c.AcctOpen { |
| + t.Errorf("opening the account menu inside the card: card open=%v, account open=%v; want both", c.MenuOpen, c.AcctOpen) |
| + } |
| + mustRun(t, ctx, chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + if c := readCard(t, ctx); c.MenuOpen || c.AcctOpen || c.Focus != "menu-summary" { |
| + t.Errorf("Escape from inside the account menu: card %v account %v focus %s; want both closed by one Escape and focus on the Menu summary", c.MenuOpen, c.AcctOpen, c.Focus) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestTheCardSurvivesAResize: open the card at 390 and widen to 1280, |
| +// and the bar is today's (the tail inline, the current item underlined) |
| +// and a click on a link in the page follows it while the <details> is |
| +// still open (no leftover layer); open the account menu in the card, |
| +// widen, and it is today's positioned panel, and Escape focuses its |
| +// visible summary; narrow again and the card is closed, because that |
| +// Escape closed every menu, the hidden one included. |
| +func TestTheCardSurvivesAResize(t *testing.T) { |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, "ltr")}, true) |
| + defer cancel() |
| + load := func() { |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), |
| + chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + } |
| + load() |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900)) |
| + wide := readCard(t, ctx) |
| + if !wide.MenuOpen { |
| + t.Fatal("the <details> closed on the resize; the leftover-layer case this leg is for has not arisen") |
| + } |
| + if wide.TailDisplay != "contents" { |
| + t.Errorf("widened with the card open: the tail is display %s, want today's contents", wide.TailDisplay) |
| + } |
| + if wide.CurEnd != "2px" { |
| + t.Errorf("widened: the current item's underline is %s, want today's 2px", wide.CurEnd) |
| + } |
| + // A leftover layer would swallow this click; landing is the proof |
| + // there is none. |
| + clickAndLand(t, ctx, probe(t, ctx, "#main-link", 0.5, 0.5, 0, 0), "/go/main") |
| + |
| + load() |
| + mustRun(t, ctx, chromedp.Click("[rst-shell-account] > summary", chromedp.ByQuery), chromedp.EmulateViewport(1280, 900)) |
| + if c := readCard(t, ctx); !c.AcctOpen || c.AcctPanel == "static" { |
| + t.Errorf("widened with the account menu open: open %v, panel position %s; want today's positioned panel", c.AcctOpen, c.AcctPanel) |
| + } |
| + mustRun(t, ctx, chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + if c := readCard(t, ctx); c.Focus != "account-summary" || !c.FocusShown { |
| + t.Errorf("Escape after widening focused %q (shown %v); want the account summary, which is visible", c.Focus, c.FocusShown) |
| + } |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844)) |
| + if c := readCard(t, ctx); c.MenuOpen { |
| + t.Error("narrowed again after Escape: the card is open, but Escape closes every menu, the hidden one included") |
| + } |
| +} |
| + |
| +// TestTheCardsControlsAreTaps: the Menu summary, the nav rows and the |
| +// account, language and colour-scheme summaries in the open card, at |
| +// 44px. |
| +func TestTheCardsControlsAreTaps(t *testing.T) { |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, "ltr")}, true) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + requirePointer(t, ctx, true) |
| + // The Menu and the card, not the whole bar: while the card is open |
| + // the brand is under the outside-tap layer on purpose, so a tap there |
| + // closes the card instead of following it. |
| + got := readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify([...measure(document.querySelector("[rst-shell-menu]")), ...measure(document.querySelector("[rst-shell-tail]"))]); })()`) |
| + if len(got) != 7 { |
| + t.Fatalf("measured %d controls in the open card, want exactly 7: the Menu summary, three nav rows, the account, language and colour-scheme summaries", len(got)) |
| + } |
| + assertTargets(t, "390 touch, the open card", got) |
| +} |
| + |
| +// TestAModalCoversTheTopbarOnAPhone: the bar is lifted above in-page |
| +// menu panels only while its card is open. Lifted for good, it painted |
| +// over a modal's scrim (fixed, z-index 10, inside main) on a phone: the |
| +// bar stayed undimmed and live, and its Menu could open a card over the |
| +// modal. Read at the brand and at the Menu summary, the two things a tap |
| +// on a phone's bar reaches. The sidebar's and console's content pages |
| +// have the same exposure through their sticky back strip: above the |
| +// scrim, Back stayed live under an open modal. |
| +// |
| +// The pages are the shell examples (layout-topbar, layout-sidebar-page |
| +// and layout-console-page) with the modal example in the slot: the |
| +// drive reads the brand, the Menu summary and the back control, which |
| +// every one of them carries, and the modal's overlay. |
| +func TestAModalCoversTheTopbarOnAPhone(t *testing.T) { |
| + modal := defaultMarkup(t, "modal") |
| + hits := func(t *testing.T, ctx context.Context, sels ...string) { |
| + t.Helper() |
| + for _, sel := range sels { |
| + var hit string |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const r = document.querySelector(`+"`"+sel+"`"+`).getBoundingClientRect(); |
| + const h = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); |
| + return !h ? "nothing" : h.closest("[rst-modal-overlay]") ? "overlay" : h.tagName + (h.getAttributeNames().filter(n => n.startsWith("rst-")).map(n => "[" + n + "]").join("")); })()`, &hit)) |
| + if hit != "overlay" { |
| + t.Errorf("a tap on %s with a modal open lands on %s, want the modal's overlay: it paints above the scrim", sel, hit) |
| + } |
| + } |
| + } |
| + t.Run("topbar", func(t *testing.T) { |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": shellPage(t, "topbar", modal)}, true) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-modal-overlay]", chromedp.ByQuery)) |
| + requirePointer(t, ctx, true) |
| + hits(t, ctx, "[rst-shell-brand]", "[rst-shell-menu] > summary") |
| + }) |
| + for _, name := range []string{"sidebar", "console"} { |
| + t.Run(name+" content page", func(t *testing.T) { |
| + pages := map[string]string{"/": shellPage(t, name+"-page", modal)} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-modal-overlay]", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-back] a", chromedp.ByQuery)) |
| + requirePointer(t, ctx, true) |
| + hits(t, ctx, "[rst-shell-back] a") |
| + }) |
| + } |
| +} |
| + |
| +// TestAClickOnTheDocumentItselfDoesNotThrow: a click whose target is the |
| +// document (a script's synthetic dispatch) has no closest(), and light |
| +// dismiss runs in a capture listener on every click, so a throw there |
| +// is an error on every such dispatch and leaves any open menu open. |
| +func TestAClickOnTheDocumentItselfDoesNotThrow(t *testing.T) { |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, "ltr")}, true) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + // The shim is deferred; the drive starts once it has bound, which |
| + // the account menu closing on an outside click shows. |
| + settleUntil(t, ctx, `(() => { const a = document.querySelector("[rst-shell-account]"); a.open = true; document.body.click(); return !a.open; })()`) |
| + var got string |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { let err = "none"; addEventListener("error", e => { err = e.message; }); |
| + const a = document.querySelector("[rst-shell-account]"); a.open = true; |
| + document.dispatchEvent(new MouseEvent("click", {bubbles: true})); |
| + return err + (a.open ? " (menu left open)" : ""); })()`, &got)) |
| + if got != "none" { |
| + t.Errorf("a click dispatched on the document: %s; want no error and the open menu closed", got) |
| + } |
| +} |
| + |
| +// TestTheCardFitsTheSmallestPhone: at 320, with this rig's classic |
| +// scrollbar gutter reserved, the card stays inside the page at its |
| +// inline start too. Sized from the viewport (100vw) it counted the |
| +// gutter as room and overhung the start edge by 3px. |
| +func TestTheCardFitsTheSmallestPhone(t *testing.T) { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + t.Run(dir, func(t *testing.T) { |
| + rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, dir)}, true) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(320, 640), chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), |
| + chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + c := readCard(t, ctx) |
| + if !c.MenuOpen { |
| + t.Fatal("the Menu did not open") |
| + } |
| + if c.CardLeft < c.RootLeft-0.5 || c.CardRight > c.RootRight+0.5 { |
| + t.Errorf("at 320 the card spans %.1f to %.1f and the page %.1f to %.1f; it overhangs", c.CardLeft, c.CardRight, c.RootLeft, c.RootRight) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestTheConsoleBarIsTheSameCard: the console's tail folds into the same |
| +// card as the topbar's: the page does not move, the card sits at the |
| +// inline end, an outside tap closes it with and without scripts, and |
| +// one Escape from inside its account menu closes both. |
| +func TestTheConsoleBarIsTheSameCard(t *testing.T) { |
| + for _, c := range []struct { |
| + dir string |
| + scripts bool |
| + }{{"ltr", false}, {"ltr", true}, {"rtl", false}, {"rtl", true}} { |
| + scripts := c.scripts |
| + t.Run(fmt.Sprintf("%s scripts %v", c.dir, scripts), func(t *testing.T) { |
| + // The console with one account link (#acct-profile) and a |
| + // link in the page (#main-link) 450px down. |
| + pages := map[string]string{"/": cardPage(t, "TestTheConsoleBarIsTheSameCard", c.dir)} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + acts := []chromedp.Action{chromedp.EmulateViewport(390, 844)} |
| + if !scripts { |
| + acts = append(acts, emulation.SetScriptExecutionDisabled(true)) |
| + } |
| + acts = append(acts, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + mustRun(t, ctx, acts...) |
| + var before, open struct { |
| + Main, Basis, Inner string |
| + Gap float64 |
| + } |
| + // Gap is the card's distance from the page's inline end, which is |
| + // the left edge in RTL, measured against the root element's box: |
| + // tokens.css reserves a stable scrollbar gutter, so the viewport's |
| + // edge is not the page's. Basis and Inner are the card's own box |
| + // rules: the console's base rule for its open tail (flex-basis 100% |
| + // and a gap) comes earlier in the file than the card's, at the same |
| + // weight, so these prove the card's rules won. |
| + read := `(() => { const m = document.querySelector("main").getBoundingClientRect(), tail = document.querySelector("[rst-shell-tail]"), t = tail.getBoundingClientRect(), cs = getComputedStyle(tail); |
| + const rtl = document.documentElement.dir === "rtl", h = document.documentElement.getBoundingClientRect(); |
| + return JSON.stringify({Main: [m.left, m.top, m.width].map(Math.round).join(","), Gap: rtl ? t.left - h.left : h.right - t.right, Basis: cs.flexBasis, Inner: cs.rowGap}); })()` |
| + at(t, ctx, read, &before) |
| + mustRun(t, ctx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-tail] [rst-shell-account] > summary", chromedp.ByQuery)) |
| + at(t, ctx, read, &open) |
| + if open.Main != before.Main || open.Gap < 0 || open.Gap > 13 { |
| + t.Errorf("main %s -> %s, card %.1fpx from the edge; want a card over a page that did not move", before.Main, open.Main, open.Gap) |
| + } |
| + if open.Basis != "auto" || open.Inner != "0px" { |
| + t.Errorf("the console's open tail has flex-basis %s and gap %s; the card's auto and 0 lost to the console's own rule", open.Basis, open.Inner) |
| + } |
| + clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#main-link", 0.5, 0.5, 0, 0), "", `!document.querySelector("[rst-shell-menu]").open`) |
| + if scripts { |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), |
| + chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-tail] [rst-shell-account] > summary", chromedp.ByQuery), |
| + chromedp.Click("[rst-shell-account] > summary", chromedp.ByQuery), chromedp.WaitVisible("#acct-profile", chromedp.ByQuery), |
| + chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + var s struct { |
| + Menu, Acct bool |
| + Focus string |
| + } |
| + at(t, ctx, `JSON.stringify({Menu: document.querySelector("[rst-shell-menu]").open, Acct: document.querySelector("[rst-shell-account]").open, Focus: document.activeElement.closest("[rst-shell-menu]") ? "menu" : document.activeElement.tagName})`, &s) |
| + if s.Menu || s.Acct || s.Focus != "menu" { |
| + t.Errorf("Escape from the account menu in the console's card: %+v; want both closed and focus on the Menu summary", s) |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/check/console_shell_browser_test.go b/check/console_shell_browser_test.go| new file mode 100644 |
| index 0000000..bd3425b |
| --- /dev/null |
| +++ b/check/console_shell_browser_test.go |
| @@ -0,0 +1,805 @@ |
| +//go:build browser |
| + |
| +// Browser drives for the console shell — the fourth page frame, and the |
| +// only one with two pieces of chrome to fold rather than one. |
| +// |
| +// Run with the rest of the family: |
| +// |
| +// go test -tags browser ./check/ |
| +// |
| +// Every drive in this file carries a CONTROL: a second page, served by |
| +// the same mux, measured by the same JavaScript, in the same browser, |
| +// in the same run — differing from the page under test by the few |
| +// declarations that ARE the thing being gated. The control has to come |
| +// out the other way. Design spec §7-v2: a drive that finds nothing must |
| +// be demonstrated finding something, or its zero cannot be told apart |
| +// from a broken instrument. This project has shipped twelve gates that |
| +// gated nothing, and the most recent survived independent reproduction |
| +// by the simple expedient of nobody asking it a question whose answer |
| +// was already known. |
| +// |
| +// There is no script under test here. The pages are markup, tokens.css |
| +// and a theme; the shell's own <script defer> tags resolve to addresses |
| +// this mux does not serve, exactly as the other shell drives leave |
| +// them. Every state change below is a click on a <summary>. The pages |
| +// are the ones rastrillo's drives rendered at SEED, read through |
| +// fixture (testdata/fixtures/). |
| +package check |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// consoleReading is one measurement of a console page. Everything here |
| +// is read off getBoundingClientRect and window.innerHeight — geometry |
| +// the engine computed, never a claim about the text of a rule. |
| +type consoleReading struct { |
| + // The chrome, and how much of it a reader can see. |
| + MenuShown bool |
| + MenuOpen bool |
| + TailShown bool |
| + RailShown bool |
| + VisibleSummaries int |
| + AccountMenu bool |
| + BackShown bool |
| + |
| + // The wide frame. |
| + TailEndPx int |
| + BarAboveRail bool |
| + RailBeforeMain bool |
| + |
| + // The viewport question. NavHeight is the border box, because |
| + // max-block-size is a promise about the window and the window |
| + // measures border boxes — that is the whole of the regression this |
| + // gate exists for. |
| + Viewport int |
| + NavTop int |
| + NavBottom int |
| + NavHeight int |
| + NavOverhang int |
| + NavScrolls int |
| + |
| + Overflow int |
| + Dir string |
| + |
| + // The legacy-engine readings. RailBottom against MainBottom is the |
| + // discriminator between "the rail is in its declared grid area" |
| + // (which spans the page row AND the footer row) and "the rail was |
| + // auto-placed into row 2" (which stops where main stops). They look |
| + // identical in a screenshot. |
| + RailBottom int |
| + MainBottom int |
| + RailBorderEnd string |
| + NavLinksShown int |
| +} |
| + |
| +// consoleMeasure is the one instrument. Both drives use it, and so does |
| +// each drive's control, which is what makes the control a control: a |
| +// different reading of the same measurement rather than a different |
| +// measurement. |
| +const consoleMeasure = `(() => { |
| + const shown = el => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; |
| + const de = document.documentElement; |
| + const bar = document.querySelector("[rst-shell-bar]"); |
| + const menu = document.querySelector("[rst-shell-menu]"); |
| + const rail = document.querySelector("[rst-shell-rail]"); |
| + const nav = document.querySelector("[rst-shell-nav]"); |
| + const account = document.querySelector("[rst-shell-account]"); |
| + const locale = document.querySelector("#bar-locale"); |
| + const main = document.querySelector("[rst-shell-main]"); |
| + const panel = account.querySelector("[rst-dropdown-menu]"); |
| + const br = bar.getBoundingClientRect(); |
| + const rr = rail.getBoundingClientRect(); |
| + const nr = nav.getBoundingClientRect(); |
| + const mr = main.getBoundingClientRect(); |
| + // The inline-end gap of the LAST thing on the bar, whichever of the |
| + // two menus that is: the current layout puts the language first and |
| + // the account last, a layout from before the profile the other way |
| + // round. margin-inline-start: auto pushes the pair to the end, and |
| + // measuring the one that is not last would read the other's width |
| + // and call it a layout. |
| + const lr = locale.getBoundingClientRect(), ar = account.getBoundingClientRect(); |
| + const ltr = getComputedStyle(de).direction !== "rtl"; |
| + const end = ltr ? Math.max(lr.right, ar.right) : Math.min(lr.left, ar.left); |
| + // Every <summary> a reader can see anywhere in the document. The |
| + // claim "one control" is a count, not an assertion about one element: |
| + // a second disclosure added beside this one would satisfy every |
| + // other reading here and fail only this. |
| + let summaries = 0; |
| + document.querySelectorAll("summary").forEach(s => { if (shown(s)) summaries++; }); |
| + return JSON.stringify({ |
| + MenuShown: shown(menu.querySelector("summary")), |
| + MenuOpen: menu.open, |
| + TailShown: shown(account) || shown(locale), |
| + RailShown: shown(rail), |
| + VisibleSummaries: summaries, |
| + AccountMenu: shown(panel), |
| + BackShown: shown(document.querySelector("[rst-shell-back] a")), |
| + TailEndPx: Math.round(ltr ? br.right - end : end - br.left), |
| + BarAboveRail: Math.round(br.bottom) <= Math.round(rr.top) + 1, |
| + RailBeforeMain: ltr ? Math.round(rr.right) <= Math.round(mr.left) + 1 |
| + : Math.round(rr.left) >= Math.round(mr.right) - 1, |
| + Viewport: window.innerHeight, |
| + NavTop: Math.round(nr.top), |
| + NavBottom: Math.round(nr.bottom), |
| + NavHeight: Math.round(nr.height), |
| + NavOverhang: Math.round(nr.bottom - window.innerHeight), |
| + NavScrolls: nav.scrollHeight - nav.clientHeight, |
| + Overflow: de.scrollWidth - de.clientWidth, |
| + Dir: getComputedStyle(de).direction, |
| + RailBottom: Math.round(rr.bottom), |
| + MainBottom: Math.round(mr.bottom), |
| + RailBorderEnd: getComputedStyle(rail).borderInlineEndWidth, |
| + NavLinksShown: [...nav.querySelectorAll("a")].filter(shown).length |
| + }); |
| +})()` |
| + |
| +// consoleControlCSS is the control's whole difference from the page |
| +// under test, and each half re-creates a bug this project has actually |
| +// shipped rather than one imagined for the occasion. |
| +// |
| +// The first half defeats the rule that hides the rail on a narrow |
| +// content page — the failure "the tail folded and the rail did not", |
| +// which is a phone page with its whole navigation stacked above the |
| +// content it was opened for. |
| +// |
| +// The second half is the rail regression itself, verbatim: a box sized |
| +// against the viewport that also carries padding, as a CONTENT box, so |
| +// its border box is 32px taller than the window it promised to fit. |
| +// The gate that missed it asked where the person sat inside the rail |
| +// and got a true answer about a rail hanging off the screen. |
| +const consoleControlCSS = ` |
| +@media (max-width: 799px) { |
| + [rst-shell-console] > [rst-shell-rail] { display: flex !important; } |
| +} |
| +@media (min-width: 800px) { |
| + [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { |
| + block-size: 100dvh !important; |
| + box-sizing: content-box !important; |
| + max-block-size: none !important; |
| + padding: 1rem !important; |
| + } |
| +}` |
| + |
| +// consolePage is the console shell as SEED's drive rendered it, by |
| +// case, and its control twin. Every case has two account links, the |
| +// language menu as #bar-locale (English and Gaeilge) and a foot; the |
| +// cases differ in their nav, direction and view: "ltr" is Posts (the |
| +// current page), Drafts and Settings; "ltr-index" the same with the |
| +// view written as index; "rtl" is Posts and Drafts, mirrored; "tall" |
| +// is the twenty-link nav that cannot fit a 420px window. |
| +func consolePage(t *testing.T, kase string) (page, control string) { |
| + t.Helper() |
| + return consolePageFrom(t, fixture(t, "console_shell_browser_test.go", "consolePage", kase)) |
| +} |
| + |
| +// legacyConsolePage is consolePage's "ltr" case through the console |
| +// layout as it was before the phone index, whose rail is gated on the |
| +// Menu's [open]. |
| +func legacyConsolePage(t *testing.T) (page, control string) { |
| + t.Helper() |
| + return consolePageFrom(t, fixture(t, "console_shell_browser_test.go", "legacyConsolePage", "ltr")) |
| +} |
| + |
| +// consolePageFrom is a page and its control twin: the page with |
| +// consoleControlCSS in a <style> before </head>. |
| +func consolePageFrom(t *testing.T, page string) (_, control string) { |
| + t.Helper() |
| + const head = "</head>" |
| + i := strings.Index(page, head) |
| + if i < 0 { |
| + t.Fatal("the console shell rendered no </head> to inject the control's stylesheet into") |
| + } |
| + control = page[:i] + "<style>" + consoleControlCSS + "</style>" + page[i:] |
| + return page, control |
| +} |
| + |
| +// consoleServe puts the page at / and its control twin at /control. |
| +func consoleServe(t *testing.T, page, control string) *harness.Rig { |
| + t.Helper() |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + serve := func(body string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, body) |
| + } |
| + } |
| + mux.HandleFunc("GET /control", serve(control)) |
| + mux.HandleFunc("GET /", serve(page)) |
| + return harness.New(t, func(string) http.Handler { return mux }) |
| +} |
| + |
| +func readConsole(t *testing.T, raw string) consoleReading { |
| + t.Helper() |
| + var got consoleReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("reading the measurement (%q): %v", raw, err) |
| + } |
| + return got |
| +} |
| + |
| +// TestTheConsoleFoldsItsBarAndIndexesItsRail is the design work of the |
| +// fourth shell, measured. |
| +// |
| +// Every other shell has ONE thing to put away below 800px. This one has |
| +// two — the bar's tail and the navigation rail — and each has its own |
| +// pattern: the tail folds behind one <details rst-shell-menu> and opens |
| +// as a card, and the rail is an index page, shown on the page whose |
| +// view block says index and replaced by a back control everywhere else. |
| +// |
| +// The claims, in the order they matter: |
| +// |
| +// 1. Wide, both chromes are drawn and neither is behind anything: bar |
| +// across the top, rail beside the page, account at the bar's inline |
| +// end. This is the layout the shell exists for. |
| +// 2. Narrow and closed, ONE control for the bar. Not "a control" — |
| +// exactly one visible summary in the whole document (the back |
| +// control is a link), which is the assertion a second disclosure |
| +// would fail and nothing else here would. |
| +// 3. One click reveals the tail card and NOT the rail: the rail |
| +// follows the page's view, never the Menu's [open]. On the index |
| +// view the rail is shown with the Menu closed. |
| +// 4. THE TRAP. The account menu is <details name="rst-menus">. |
| +// <details name> exclusivity is document-wide rather than |
| +// sibling-scoped, so a disclosure in that group would be closed by |
| +// the menu it just revealed, and the card with it. |
| +// 5. Nothing is reordered at any width, so the reader's eye and the |
| +// tab key agree: bar, then rail, at 320px and at 1280px, in both |
| +// directions of the language. |
| +// |
| +// THE CONTROL, and what it is for: /control is this page with the |
| +// rail's hide rule defeated. Leg 2's reading of RailShown must be false |
| +// on the page and TRUE on the control. A drive whose RailShown is |
| +// hard-wired false — a stale selector, a rail that never rendered, |
| +// getBoundingClientRect on a detached node — passes leg 2 and fails |
| +// here, which is the whole point. |
| +func TestTheConsoleFoldsItsBarAndIndexesItsRail(t *testing.T) { |
| + page, control := consolePage(t, "ltr") |
| + rig := consoleServe(t, page, control) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + at := func(t *testing.T, w, h int, path string, before ...chromedp.Action) consoleReading { |
| + t.Helper() |
| + acts := []chromedp.Action{ |
| + chromedp.EmulateViewport(int64(w), int64(h)), |
| + chromedp.Navigate(rig.Origin + path), |
| + chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| + } |
| + acts = append(acts, before...) |
| + var raw string |
| + acts = append(acts, chromedp.Evaluate(consoleMeasure, &raw)) |
| + if err := chromedp.Run(ctx, acts...); err != nil { |
| + t.Fatalf("driving %s at %dx%d: %v", path, w, h, err) |
| + } |
| + return readConsole(t, raw) |
| + } |
| + |
| + // 1. Wide: both chromes, no control, and the frame in the right |
| + // shape. |
| + wide := at(t, 1280, 900, "/") |
| + t.Logf("wide 1280x900: menu=%v tail=%v rail=%v summaries=%d barAboveRail=%v railBeforeMain=%v tailEnd=%dpx", |
| + wide.MenuShown, wide.TailShown, wide.RailShown, wide.VisibleSummaries, wide.BarAboveRail, wide.RailBeforeMain, wide.TailEndPx) |
| + if wide.MenuShown { |
| + t.Error("the collapse control is drawn at 1280px; above 800px both chromes are laid out and it has nothing to do") |
| + } |
| + if !wide.TailShown || !wide.RailShown { |
| + t.Fatalf("at 1280px tail=%v rail=%v: the console is not showing both chromes, which is the only reason this shell exists", wide.TailShown, wide.RailShown) |
| + } |
| + if !wide.BarAboveRail { |
| + t.Error("the bar is not above the rail at 1280px; a console is a bar ACROSS the top with the rail beneath it") |
| + } |
| + if !wide.RailBeforeMain { |
| + t.Error("the rail is not on the inline-start side of the page column at 1280px") |
| + } |
| + if wide.TailEndPx > 40 { |
| + t.Errorf("the bar's tail ends %dpx short of the bar's inline end; margin-inline-start: auto is no longer reaching it (the bar's own inline padding is 16px)", wide.TailEndPx) |
| + } |
| + if wide.Overflow > 1 { |
| + t.Errorf("the wide console spills %dpx sideways at 1280px", wide.Overflow) |
| + } |
| + |
| + // 1b. And at exactly 800px, the breakpoint itself: a min-width query |
| + // matches AT its own width, so 800 is wide and 799 is narrow. An |
| + // off-by-one here is a window width with neither layout. |
| + edge := at(t, 800, 780, "/") |
| + if edge.MenuShown || !edge.TailShown || !edge.RailShown { |
| + t.Errorf("at exactly 800px control=%v tail=%v rail=%v; a min-width query matches at its own width, so 800px is the wide layout", |
| + edge.MenuShown, edge.TailShown, edge.RailShown) |
| + } |
| + |
| + // 2. Narrow and closed: one control, and both chromes behind it. |
| + closed := at(t, 390, 780, "/") |
| + t.Logf("narrow 390x780 closed: menu=%v tail=%v rail=%v visible summaries=%d", |
| + closed.MenuShown, closed.TailShown, closed.RailShown, closed.VisibleSummaries) |
| + if !closed.MenuShown { |
| + t.Fatal("there is no collapse control at 390px: the console has no narrow layout") |
| + } |
| + if closed.TailShown { |
| + t.Error("the bar's account and language menus are still drawn at 390px with the disclosure closed") |
| + } |
| + if closed.RailShown { |
| + t.Error("the navigation rail is still drawn at 390px with the disclosure closed; the tail folded and the rail did not") |
| + } |
| + if closed.VisibleSummaries != 1 { |
| + t.Errorf("a reader at 390px meets %d disclosures, want exactly 1. Two chromes fold behind ONE control here; a second summary is a second thing to learn and is the answer this shell was designed against", |
| + closed.VisibleSummaries) |
| + } |
| + if closed.Overflow > 1 { |
| + t.Errorf("the collapsed console spills %dpx sideways at 390px", closed.Overflow) |
| + } |
| + |
| + // 2b. THE CONTROL for leg 2. Same instrument, same viewport, same |
| + // closed disclosure — one stylesheet apart. RailShown must come |
| + // out the other way, or the false above proves nothing. |
| + ctrl := at(t, 390, 780, "/control") |
| + if !ctrl.RailShown { |
| + t.Errorf("CONTROL FAILED: the control page defeats the rule that hides the rail while the disclosure is closed, and this drive still reports the rail hidden (rail=%v, summaries=%d). "+ |
| + "The measurement is not reading the rail, so leg 2's pass says nothing about the shell", ctrl.RailShown, ctrl.VisibleSummaries) |
| + } |
| + if !ctrl.MenuShown || ctrl.MenuOpen { |
| + t.Errorf("CONTROL FAILED: the control is meant to differ from the page only in the rail's hide rule, and its disclosure reads shown=%v open=%v", ctrl.MenuShown, ctrl.MenuOpen) |
| + } |
| + |
| + // 3. One click reveals the tail card, and not the rail: on a |
| + // content page the rail is the index's, behind the back control. |
| + open := at(t, 390, 780, "/", |
| + chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-account] > summary`, chromedp.ByQuery), |
| + ) |
| + if !open.TailShown { |
| + t.Error("opening the Menu did not reveal the bar's tail") |
| + } |
| + if open.RailShown { |
| + t.Error("opening the Menu revealed the rail on a content page; the rail follows the page's view, and the legacy :has() gate is matching new markup") |
| + } |
| + if open.Overflow > 1 { |
| + t.Errorf("the opened console spills %dpx sideways at 390px", open.Overflow) |
| + } |
| + |
| + // 4. The trap: the account menu must not close the disclosure it |
| + // sits behind, and opening it must not bring the rail in. |
| + var trapRaw string |
| + if err := chromedp.Run(ctx, |
| + // Settle rather than WaitVisible: the failure this step exists |
| + // for is the account menu never appearing, because it closed the |
| + // disclosure it lives behind. Waiting would report that as a |
| + // timeout, and a timeout says nothing about <details name>. |
| + chromedp.Click(`[rst-shell-account] > summary`, chromedp.ByQuery), |
| + chromedp.Sleep(250*time.Millisecond), |
| + chromedp.Evaluate(consoleMeasure, &trapRaw), |
| + ); err != nil { |
| + t.Fatalf("opening the account menu inside the collapsed console: %v", err) |
| + } |
| + trap := readConsole(t, trapRaw) |
| + if !trap.MenuOpen { |
| + t.Error("opening the account menu CLOSED the disclosure it sits behind: the two <details> are in one exclusivity group, and <details name> exclusivity is document-wide rather than sibling-scoped") |
| + } |
| + if !trap.TailShown { |
| + t.Error("the bar's tail vanished when the account menu opened") |
| + } |
| + if trap.RailShown { |
| + t.Error("the rail appeared when the account menu opened; on a content page it is the index's, never the Menu's") |
| + } |
| + if !trap.AccountMenu { |
| + t.Error("the account menu's panel is not drawn inside the collapsed console") |
| + } |
| + |
| + // 3b. The index view at 390: the rail is the page, with the Menu |
| + // closed, and still one visible summary. 5b below is the same |
| + // page at 320, which keeps the rail's reflow covered now that a |
| + // content page no longer shows it. |
| + indexHTML, indexControl := consolePage(t, "ltr-index") |
| + indexRig := consoleServe(t, indexHTML, indexControl) |
| + indexCtx, indexCancel := context.WithTimeout(indexRig.Context(), 60*time.Second) |
| + defer indexCancel() |
| + atIndex := func(w, h int) consoleReading { |
| + t.Helper() |
| + var raw string |
| + if err := chromedp.Run(indexCtx, |
| + chromedp.EmulateViewport(int64(w), int64(h)), |
| + chromedp.Navigate(indexRig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| + chromedp.Evaluate(consoleMeasure, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving the console's index at %dx%d: %v", w, h, err) |
| + } |
| + return readConsole(t, raw) |
| + } |
| + idx := atIndex(390, 780) |
| + if !idx.RailShown || idx.MenuOpen || idx.VisibleSummaries != 1 { |
| + t.Errorf("the console's index at 390: rail=%v menu open=%v summaries=%d; want the rail shown with the Menu closed and one visible summary", idx.RailShown, idx.MenuOpen, idx.VisibleSummaries) |
| + } |
| + |
| + // 5. The smallest viewport the shells promise, with the card open on |
| + // a content page: the tail, the back control, no rail. |
| + tiny := at(t, 320, 640, "/", |
| + chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-account] > summary`, chromedp.ByQuery), |
| + ) |
| + if tiny.Overflow > 1 { |
| + t.Errorf("the opened console spills %dpx sideways at 320px", tiny.Overflow) |
| + } |
| + if !tiny.TailShown || !tiny.BackShown || tiny.RailShown { |
| + t.Errorf("at 320px, card open: tail=%v back=%v rail=%v; want the tail and the back control, no rail", tiny.TailShown, tiny.BackShown, tiny.RailShown) |
| + } |
| + |
| + // 5b. And the index at 320: the rail, reflowed. |
| + tinyIndex := atIndex(320, 640) |
| + if !tinyIndex.RailShown || tinyIndex.Overflow > 1 { |
| + t.Errorf("the console's index at 320: rail=%v, spills %dpx sideways", tinyIndex.RailShown, tinyIndex.Overflow) |
| + } |
| + |
| + // 6. And the mirror. Every rule in this shell is a logical property |
| + // and the wide frame is a named grid area, so RTL should need no |
| + // second rule — which is a claim, and claims get measured. |
| + rtlPage, rtlControl := consolePage(t, "rtl") |
| + rtlRig := consoleServe(t, rtlPage, rtlControl) |
| + rtlCtx, rtlCancel := context.WithTimeout(rtlRig.Context(), 60*time.Second) |
| + defer rtlCancel() |
| + var rtlRaw string |
| + if err := chromedp.Run(rtlCtx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rtlRig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| + chromedp.Evaluate(consoleMeasure, &rtlRaw), |
| + ); err != nil { |
| + t.Fatalf("driving the mirrored console: %v", err) |
| + } |
| + rtl := readConsole(t, rtlRaw) |
| + if rtl.Dir != "rtl" { |
| + t.Fatalf("the mirrored page reports direction %q; the fixture is not mirrored and legs below prove nothing", rtl.Dir) |
| + } |
| + if !rtl.RailBeforeMain { |
| + t.Error("in RTL the rail is not on the inline-start (right) side of the page column: the grid areas are not following the writing mode") |
| + } |
| + if rtl.TailEndPx > 40 { |
| + t.Errorf("in RTL the bar's tail ends %dpx short of the bar's inline end; the auto margin is not following the writing mode", rtl.TailEndPx) |
| + } |
| + if rtl.Overflow > 1 { |
| + t.Errorf("the mirrored console spills %dpx sideways at 1280px", rtl.Overflow) |
| + } |
| +} |
| + |
| +// TestTheConsoleRailFitsTheViewport is the gate this project earned the |
| +// hard way, asked of the fourth shell before it ships rather than after. |
| +// |
| +// The regression: a rail with `padding` and `block-size: 100dvh` under |
| +// content-box has a BORDER box 32px taller than the window it was sized |
| +// against. Sticky at the top, its last 32px hung under the foot of the |
| +// screen at every scroll position, with the person block in them. |
| +// overflow-y: auto was no defence — the content fitted the content box, |
| +// so no scrollbar was ever owed — and the gate that missed it asserted |
| +// that the person sat at the foot OF THE RAIL, which stayed perfectly |
| +// true while the rail hung off the screen. |
| +// |
| +// So this drive asks the viewport, not the container: |
| +// |
| +// 1. The nav's BORDER box is never taller than the window. |
| +// 2. Scrolled to the foot of the document, the whole of it — top edge |
| +// and bottom edge — is inside the window. Nothing in it is |
| +// unreachable at every scroll position, which is what the |
| +// regression actually cost. |
| +// 3. And the fixture is doing its job: the nav is longer than the cap, |
| +// so the cap is engaged. A "it fits" from a nav with three links in |
| +// it is a measurement of nothing. |
| +// |
| +// THE CONTROL: /control is this page with the regression put back, |
| +// declaration for declaration. The same three readings must come out |
| +// the other way there. A drive that reports "fits" because it is |
| +// measuring a detached node, or the wrong element, or a box that never |
| +// laid out, passes 1–3 and fails the control. |
| +func TestTheConsoleRailFitsTheViewport(t *testing.T) { |
| + page, control := consolePage(t, "tall") |
| + rig := consoleServe(t, page, control) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + // A deliberately SHORT window, which is where the regression lived: |
| + // a viewport-sized box overhangs by its padding whatever the window |
| + // height is, but a short window is where a reader meets it and |
| + // where twenty links certainly exceed the cap. |
| + const w, h = 1280, 420 |
| + |
| + measureAt := func(t *testing.T, path string, scroll bool) consoleReading { |
| + t.Helper() |
| + acts := []chromedp.Action{ |
| + chromedp.EmulateViewport(w, h), |
| + chromedp.Navigate(rig.Origin + path), |
| + chromedp.WaitVisible(`[rst-shell-rail] [rst-shell-nav] a`, chromedp.ByQuery), |
| + } |
| + if scroll { |
| + acts = append(acts, |
| + chromedp.Evaluate(`window.scrollTo(0, document.documentElement.scrollHeight); "ok"`, new(string)), |
| + chromedp.Sleep(250*time.Millisecond)) |
| + } |
| + var raw string |
| + acts = append(acts, chromedp.Evaluate(consoleMeasure, &raw)) |
| + if err := chromedp.Run(ctx, acts...); err != nil { |
| + t.Fatalf("driving %s at %dx%d (scrolled=%v): %v", path, w, h, scroll, err) |
| + } |
| + return readConsole(t, raw) |
| + } |
| + |
| + // Logged before anything is asserted, and named against what it is |
| + // supposed to be compared to: the number that gave the regression |
| + // away was already in a passing test's output — "a 932px rail" at a |
| + // 900px viewport — and nobody read it, because nothing said what it |
| + // should have been. |
| + top := measureAt(t, "/", false) |
| + bottom := measureAt(t, "/", true) |
| + t.Logf("console rail, %dx%d viewport: nav border box %dpx (viewport %dpx), %dpx of it scrolls inside itself; at scroll top %d..%d, at document foot %d..%d", |
| + w, h, top.NavHeight, top.Viewport, top.NavScrolls, top.NavTop, top.NavBottom, bottom.NavTop, bottom.NavBottom) |
| + |
| + // 3 first, because 1 and 2 mean nothing without it. |
| + if top.NavScrolls <= 0 { |
| + t.Fatalf("the nav is %dpx and scrolls %dpx inside itself: it is SHORTER than the cap, so this drive is measuring a box that was never constrained. The fixture has to overflow or the fit proves nothing", |
| + top.NavHeight, top.NavScrolls) |
| + } |
| + // 1. The border box against the window. This is the regression's own |
| + // shape, and it is a claim about the box the window measures. |
| + // |
| + // Its caveat, recorded where it lives rather than in a report |
| + // nobody greps: in THIS shell the padding is on the rail, not on |
| + // the nav, so a content-box mutation alone does not inflate the |
| + // nav's border box and this leg does not fire on it — leg 2 is |
| + // what catches that one, by 16px. The leg is kept for three |
| + // reasons: it fires the day somebody gives the nav padding of its |
| + // own, which is the exact edit that shipped the regression the |
| + // first time; it is the reading the control below pins to the |
| + // pixel (452 = 420 + 32), which is what proves the height |
| + // measurement is live rather than inert; and its message names |
| + // the fix, which leg 2's cannot. |
| + if top.NavHeight > top.Viewport { |
| + t.Errorf("the rail's nav is %dpx in a %dpx window: %dpx taller than the viewport it is sized against, so whatever is at its foot is under the fold at every scroll position. box-sizing: border-box on the capped box is the fix", |
| + top.NavHeight, top.Viewport, top.NavHeight-top.Viewport) |
| + } |
| + // 2. And reachability, which is what the reader actually lost. |
| + if bottom.NavTop < 0 || bottom.NavBottom > bottom.Viewport { |
| + t.Errorf("scrolled to the foot of the document the rail's nav occupies %d..%dpx of a %dpx window: part of it is off-screen with nothing left to scroll to reach it", |
| + bottom.NavTop, bottom.NavBottom, bottom.Viewport) |
| + } |
| + |
| + // THE CONTROL. Same drive, same instrument, same window — the |
| + // regression put back in a <style> block. All three readings must |
| + // invert. If they do not, this test's zero is a broken harness and |
| + // not a shell that fits. |
| + ctop := measureAt(t, "/control", false) |
| + cbottom := measureAt(t, "/control", true) |
| + t.Logf("control (the regression restored): nav border box %dpx (viewport %dpx), scrolls %dpx; at scroll top %d..%d, at document foot %d..%d", |
| + ctop.NavHeight, ctop.Viewport, ctop.NavScrolls, ctop.NavTop, ctop.NavBottom, cbottom.NavTop, cbottom.NavBottom) |
| + |
| + if ctop.NavHeight <= ctop.Viewport { |
| + t.Errorf("CONTROL FAILED: the control page sizes the nav at 100dvh as a content box with 1rem of padding, so its border box must be %dpx taller than the %dpx window — and this drive measured %dpx. The height reading is not measuring a border box against the viewport, so the pass above says nothing", |
| + 32, ctop.Viewport, ctop.NavHeight) |
| + } |
| + if cbottom.NavTop >= 0 && cbottom.NavBottom <= cbottom.Viewport { |
| + t.Errorf("CONTROL FAILED: the control's oversized nav cannot fit a %dpx window at any scroll position, and this drive reports it fitting at %d..%dpx. The reachability reading is inert", |
| + cbottom.Viewport, cbottom.NavTop, cbottom.NavBottom) |
| + } |
| + // And the control differs from the page in exactly the way it says |
| + // it does, arithmetically: block-size: 100dvh on a content box with |
| + // 1rem of padding is a border box of viewport + 32, to the pixel. |
| + // This is the calibration proper — not "the control failed" but |
| + // "the control failed BY THE PREDICTED AMOUNT". An instrument that |
| + // reports some other number is measuring some other box, and its |
| + // agreement with the page above would be luck. |
| + if want := ctop.Viewport + 32; ctop.NavHeight != want { |
| + t.Errorf("CONTROL FAILED: the control's nav should measure exactly %dpx (a %dpx content box plus 2×1rem of padding) and this drive read %dpx. The height reading is not the border box of the element the rule names", |
| + want, ctop.Viewport, ctop.NavHeight) |
| + } |
| +} |
| + |
| +// hasRulePattern matches one CSS rule whose selector list mentions |
| +// :has(). Crude on purpose: it is a simulation of an engine, and the |
| +// engine's rule is coarser than a parser's — see stripHas. |
| +var hasRulePattern = regexp.MustCompile(`(?s)[^{}]*\{[^{}]*\}`) |
| + |
| +// stripHas returns tokens.css as an engine that does not implement |
| +// :has() would see it. |
| +// |
| +// The important part is what it drops, which is more than the selector. |
| +// A selector list is invalid AS A WHOLE if any selector in it is |
| +// invalid, so such an engine drops the entire rule — every declaration |
| +// in it, including the ones that have nothing to do with :has(), and |
| +// including the ones the OTHER selectors in the list were carrying. |
| +// That is the finding this drive was written for: the console's wide |
| +// rail rule used to co-list two plain selectors with two :has() ones, |
| +// so a legacy engine lost `grid-area: rail` along with the fight it was |
| +// having about `display`, and the rail landed in the right cell only |
| +// because grid auto-placement happened to agree with the declared area. |
| +// It looked correct. It was luck. |
| +func stripHas(css string) (string, int) { |
| + dropped := 0 |
| + // Comments come out FIRST, using css_test.go's own cssComment. A |
| + // browser tokenises them away before it ever looks at a selector, |
| + // and the first version of this function did not: the wide |
| + // layout's comment explains why the :has() spelling is repeated |
| + // there, so the characters ":has(" sat in the text immediately |
| + // before a rule that does not use it and the stripper took the |
| + // rule. The wide legs of the drive below caught it, which is the |
| + // useful thing to say about them — they are not only a gate on the |
| + // stylesheet, they are the control on this function. |
| + css = cssComment.ReplaceAllString(css, "") |
| + out := hasRulePattern.ReplaceAllStringFunc(css, func(rule string) string { |
| + prelude, _, ok := strings.Cut(rule, "{") |
| + if !ok || !strings.Contains(prelude, ":has(") { |
| + return rule |
| + } |
| + dropped++ |
| + return "\n" |
| + }) |
| + return out, dropped |
| +} |
| + |
| +// TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas is the gate on the |
| +// sentence the shell, tokens.css and docs/site/templates.md all make: |
| +// that this shell's dependence on :has() has a CHOSEN failure |
| +// direction. |
| +// |
| +// The claim has two halves, and only one of them was ever true by |
| +// accident: |
| +// |
| +// 1. Narrow, the rail stays VISIBLE. The rules are written as |
| +// hide-when-closed, so the rule an old engine drops is the one that |
| +// would have hidden the navigation. The page gets longer; nothing |
| +// becomes unreachable. Spelled the other way round, the same |
| +// missing selector would be a phone that cannot navigate. |
| +// 2. Wide, the frame is UNTOUCHED. Nothing about the console's grid |
| +// depends on :has() — and that has to be true of the rules as |
| +// written, not merely of the intent, which is what a co-listed |
| +// selector quietly took away. |
| +// |
| +// Half 2 is measured on the discriminator rather than on appearance: |
| +// the rail's declared area spans the page row AND the footer row, so |
| +// its bottom edge is below main's. An auto-placed rail stops where main |
| +// stops. The two are indistinguishable in a screenshot and this is the |
| +// only reading that tells them apart. |
| +// |
| +// THE CONTROL, twice over. The strip itself is checked to have dropped |
| +// rules that matter (a strip that silently did nothing would make every |
| +// reading below a reading of the ordinary page), and the same narrow |
| +// reading is taken on the REAL stylesheet, where the rail must be |
| +// hidden. If the rail reads visible on both, the visibility is the |
| +// fixture's and not the degradation's. |
| +func TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas(t *testing.T) { |
| + real := string(fiesta.TokensCSS()) |
| + legacy, dropped := stripHas(real) |
| + |
| + // Control on the instrument before anything is measured with it. |
| + if dropped == 0 { |
| + t.Fatal("the :has()-less simulation dropped no rules at all: it is serving the ordinary stylesheet under another name, and every reading below is a reading of the page as it already is") |
| + } |
| + if strings.Contains(legacy, ":has(") { |
| + t.Fatal("the :has()-less simulation left a :has() selector in the stylesheet; it is not the engine it claims to simulate") |
| + } |
| + consoleRules := strings.Count(legacy, "rst-shell-console") |
| + if consoleRules == 0 { |
| + t.Fatal("the simulation dropped every console rule in the file; the shell has no stylesheet left and the legs below would be measuring an unstyled document") |
| + } |
| + t.Logf(":has()-less simulation: %d rules dropped, %d console selectors survive", dropped, consoleRules) |
| + |
| + // The legacy layout: the :has() gate now lives only for markup with |
| + // no view, and the legs below are about that gate. |
| + page, _ := legacyConsolePage(t) |
| + newPage, _ := consolePage(t, "ltr") |
| + newIndex, _ := consolePage(t, "ltr-index") |
| + stripped := func(p string) string { return strings.Replace(p, `href="/tokens.css"`, `href="/legacy-tokens.css"`, 1) } |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + fmt.Fprint(w, real) |
| + }) |
| + mux.HandleFunc("GET /legacy-tokens.css", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + fmt.Fprint(w, legacy) |
| + }) |
| + mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { |
| + css, ok := fiesta.ThemeCSS(fiesta.ThemeNames()[0]) |
| + if !ok { |
| + http.Error(w, "no theme", http.StatusInternalServerError) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Write(css) |
| + }) |
| + mux.HandleFunc("GET /legacy", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, stripped(page)) |
| + }) |
| + mux.HandleFunc("GET /new-page", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, stripped(newPage)) |
| + }) |
| + mux.HandleFunc("GET /new-index", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, stripped(newIndex)) |
| + }) |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, page) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + |
| + at := func(t *testing.T, w, h int, path string) consoleReading { |
| + t.Helper() |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(int64(w), int64(h)), |
| + chromedp.Navigate(rig.Origin+path), |
| + chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| + chromedp.Evaluate(consoleMeasure, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving %s at %dx%d: %v", path, w, h, err) |
| + } |
| + return readConsole(t, raw) |
| + } |
| + |
| + // 2. Wide, on the legacy stylesheet: the frame is the declared one. |
| + lw := at(t, 1280, 900, "/legacy") |
| + t.Logf("legacy engine, 1280x900: rail shown=%v barAbove=%v beforeMain=%v borderInlineEnd=%s rail bottom %dpx vs main bottom %dpx", |
| + lw.RailShown, lw.BarAboveRail, lw.RailBeforeMain, lw.RailBorderEnd, lw.RailBottom, lw.MainBottom) |
| + if !lw.RailShown || !lw.TailShown { |
| + t.Fatalf("without :has() the wide console loses its chrome: rail=%v tail=%v", lw.RailShown, lw.TailShown) |
| + } |
| + if !lw.BarAboveRail || !lw.RailBeforeMain { |
| + t.Errorf("without :has() the wide frame is not bar-over-rail-beside-page: barAbove=%v beforeMain=%v", lw.BarAboveRail, lw.RailBeforeMain) |
| + } |
| + if lw.RailBorderEnd == "0px" { |
| + t.Error("without :has() the rail has no inline-end border: the wide rail rule is being dropped whole, which means it is co-listing plain selectors with :has() ones again") |
| + } |
| + // THE DISCRIMINATOR. |
| + if lw.RailBottom <= lw.MainBottom { |
| + t.Errorf("without :has() the rail ends at %dpx and main ends at %dpx: the rail is sitting in the page's row rather than in the area declared for it, which spans the footer row too. "+ |
| + "That is grid auto-placement agreeing with the declared area by coincidence — the wide rail rule is being dropped whole. Keep the :has() selectors in a rule of their own", |
| + lw.RailBottom, lw.MainBottom) |
| + } |
| + |
| + // 1. Narrow, on the legacy stylesheet: the rail is visible, whole, |
| + // and the page still fits. |
| + ln := at(t, 390, 780, "/legacy") |
| + t.Logf("legacy engine, 390x780 closed: rail shown=%v with %d/3 links visible, control shown=%v, overflow=%dpx", |
| + ln.RailShown, ln.NavLinksShown, ln.MenuShown, ln.Overflow) |
| + if !ln.RailShown { |
| + t.Error("without :has() the rail is HIDDEN at 390px: the hide rule is the one that survived, so the chosen failure direction is inverted and a reader on an old phone has no navigation at all") |
| + } |
| + if ln.NavLinksShown != 3 { |
| + t.Errorf("without :has() the disclosed-by-default rail shows %d of 3 navigation links at 390px", ln.NavLinksShown) |
| + } |
| + if ln.Overflow > 1 { |
| + t.Errorf("without :has() the console spills %dpx sideways at 390px", ln.Overflow) |
| + } |
| + |
| + // THE CONTROL for leg 1: the same reading on the REAL stylesheet, |
| + // where the answer is known and is the opposite one. |
| + rn := at(t, 390, 780, "/") |
| + if rn.RailShown { |
| + t.Errorf("CONTROL FAILED: on the real stylesheet the rail must be hidden at 390px with the disclosure closed, and this drive reports it shown. " + |
| + "The visible rail on the legacy page is then the fixture's doing and not the degradation's, and leg 1 measures nothing") |
| + } |
| + |
| + // 3. New markup needs no :has() at all: on the stripped stylesheet a |
| + // content page hides its rail and the index shows it, because the |
| + // view decides, not the Menu. |
| + np, ni := at(t, 390, 780, "/new-page"), at(t, 390, 780, "/new-index") |
| + if np.RailShown || !ni.RailShown { |
| + t.Errorf("without :has(), new markup at 390: rail on a content page %v, on the index %v; want hidden then shown", np.RailShown, ni.RailShown) |
| + } |
| +} |
diff --git a/check/index_browser_test.go b/check/index_browser_test.go| new file mode 100644 |
| index 0000000..ca84dc0 |
| --- /dev/null |
| +++ b/check/index_browser_test.go |
| @@ -0,0 +1,619 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "math" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// indexPage is one page of this file's drives as SEED's shellLayoutPage |
| +// rendered it: a layout with a nav of three sections (Invoices, Orders |
| +// and Team, with ids, so the scriptless focus return has fragments to |
| +// use), the title and brand Harbour, the language menu as #rail-locale |
| +// (English and Gaeilge), and whatever else the drive defined, by the |
| +// drive's name and the page's case. |
| +func indexPage(t *testing.T, test, kase string) string { |
| + t.Helper() |
| + return fixture(t, "index_browser_test.go", test, kase) |
| +} |
| + |
| +// shellContent is the content the shell drives put in a layout |
| +// example's slot when the drive reads the chrome and not the page: a |
| +// heading and a link far enough into the page to tap beside a card. |
| +const shellContent = `<h1>Invoices</h1><p><a id="main-link" href="/go/main">A link in the page</a></p>` |
| + |
| +// viewJS is what a reader of a shell page sees. BackStart is measured |
| +// from the root element's box, not the viewport: tokens.css reserves a |
| +// stable scrollbar gutter, which a right-to-left page puts on the left, |
| +// so the viewport's edge is not the page's. |
| +const viewJS = `(() => { |
| + const shown = el => !!el && el.getClientRects().length > 0 && el.getBoundingClientRect().width > 0; |
| + const q = s => document.querySelector(s), back = q("[rst-shell-back] a"), br = back ? back.getBoundingClientRect() : null; |
| + const title = q("[rst-shell-title]"), hr = document.documentElement.getBoundingClientRect(); |
| + return JSON.stringify({Rail: shown(q("[rst-shell-rail] [rst-shell-nav]")), Main: shown(q("[rst-shell-main]")), |
| + SkipDisplay: getComputedStyle(q("[rst-skip]")).display, Title: shown(title) ? title.innerText.trim() : "", |
| + Back: shown(back), BackStart: br ? Math.round(document.documentElement.dir === "rtl" ? hr.right - br.right : br.left - hr.left) : -1, |
| + BackTop: br ? Math.round(br.top) : -1, Drawer: !!q("[rst-shell-chrome]"), |
| + H1s: [...document.querySelectorAll("h1")].filter(shown).length, Len: history.length}); |
| +})()` |
| + |
| +// settled waits until the document has loaded and its view transition, |
| +// if any, is over. A tap during the slide lands on the transition's |
| +// snapshot, not on the page (elementFromPoint returns <html>), so a |
| +// click sent then does nothing, and a navigation started then skips the |
| +// running transition, which Chromium reports as an uncaught AbortError. |
| +// Script execution may be disabled: Runtime.evaluate still runs. |
| +func settled(t *testing.T, ctx context.Context) { |
| + t.Helper() |
| + settleUntil(t, ctx, `document.readyState === "complete" && !document.activeViewTransition`) |
| +} |
| + |
| +type viewReading struct { |
| + Rail, Main, Back, Drawer bool |
| + SkipDisplay, Title string |
| + BackStart, BackTop, H1s int |
| + Len int |
| +} |
| + |
| +// TestThePhoneIndexWorksWithNoScript is the scriptless leg, both |
| +// shells, LTR and RTL, at 390 with a coarse pointer and scripts |
| +// disabled in the engine: the index is the rail with the title as its |
| +// one visible h1 and no main, no skip link and no drawer; a nav link |
| +// opens the content page with the back control at the top inline-start |
| +// and no rail; the back control lands on /#nav-… where that link is the |
| +// target and the next Tab goes to the link after it. History grows one |
| +// entry per step, which is the scriptless trade (logged). |
| +func TestThePhoneIndexWorksWithNoScript(t *testing.T) { |
| + for _, shell := range []string{"sidebar", "console"} { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + t.Run(shell+" "+dir, func(t *testing.T) { |
| + pages := map[string]string{ |
| + "/": indexPage(t, "TestThePhoneIndexWorksWithNoScript", shell+"-"+dir+"-index"), |
| + "/invoices": indexPage(t, "TestThePhoneIndexWorksWithNoScript", shell+"-"+dir+"-invoices"), |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(390, 844), |
| + chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")) |
| + requirePointer(t, ctx, true) |
| + var idx, pg, back viewReading |
| + at(t, ctx, viewJS, &idx) |
| + if !idx.Rail || idx.Main || idx.SkipDisplay != "none" || idx.Title != "Harbour" || idx.Back || idx.Drawer || idx.H1s != 1 { |
| + t.Errorf("the index: %+v; want the rail, the title as the one h1, no main, no skip link, no back control, no drawer", idx) |
| + } |
| + // main is hidden on the index, so it being visible is a |
| + // marker only the content page has. |
| + mustRun(t, ctx, chromedp.Click("#nav-invoices", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-main]", chromedp.ByQuery)) |
| + settled(t, ctx) |
| + at(t, ctx, viewJS, &pg) |
| + if pg.Rail || !pg.Main || !pg.Back || pg.BackStart > 8 || pg.H1s != 1 { |
| + t.Errorf("the content page: %+v; want main, the back control within 8px of the top inline-start, no rail, one h1", pg) |
| + } |
| + // 6px: the 44px control centred in the 56px strip at the |
| + // top of the screen (TestThePhoneHeaderStripIs56Pixels). |
| + if shell == "sidebar" && pg.BackTop > 7 { |
| + t.Errorf("the sidebar's back control is %dpx from the top; want it centred in the strip at the top of the screen, 6px down", pg.BackTop) |
| + } |
| + // And the rail's nav is shown only on the index. |
| + mustRun(t, ctx, chromedp.Click("[rst-shell-back] a", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-rail] [rst-shell-nav]", chromedp.ByQuery)) |
| + settled(t, ctx) |
| + at(t, ctx, viewJS, &back) |
| + var target bool |
| + var next string |
| + mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-invoices").matches(":target")`, &target), |
| + chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`document.activeElement.id`, &next)) |
| + if !target || next != "nav-orders" { |
| + t.Errorf("back on the index: #nav-invoices is the target %v and the next Tab went to %q; want true and nav-orders", target, next) |
| + } |
| + t.Logf("%s %s: history %d -> %d -> %d (scripts off: one entry per step)", shell, dir, idx.Len, pg.Len, back.Len) |
| + if pg.Len != idx.Len+1 || back.Len != pg.Len+1 { |
| + t.Errorf("history grew %d then %d, want 1 and 1", pg.Len-idx.Len, back.Len-pg.Len) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// TestThePhoneIndexWorksWithScripts is the scripted journey on the REAL |
| +// layouts and the real stylesheet (shell.js's own drives use |
| +// hand-written markup): both shells, LTR and RTL, at 390 with a coarse |
| +// pointer. Index, a section, the back control: history is reused (its |
| +// length unchanged), the URL is the index, and focus is on the row the |
| +// reader left. Then a deep link's back control, which follows the link |
| +// and still focuses the row from the record. |
| +func TestThePhoneIndexWorksWithScripts(t *testing.T) { |
| + for _, shell := range []string{"sidebar", "console"} { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + t.Run(shell+" "+dir, func(t *testing.T) { |
| + pages := map[string]string{ |
| + "/": indexPage(t, "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-index"), |
| + "/invoices": indexPage(t, "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-invoices"), |
| + "/orders": indexPage(t, "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-orders"), |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, done, thrown := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/") |
| + requirePointer(t, ctx, true) |
| + settled(t, ctx) |
| + follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices" && document.querySelector("[rst-shell-back] a").checkVisibility()`) |
| + settled(t, ctx) |
| + before := state(t, ctx) |
| + follow(t, ctx, "[rst-shell-back] a", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| + settled(t, ctx) |
| + if after := state(t, ctx); after.Len != before.Len { |
| + t.Errorf("the back control grew history %d -> %d; with scripts it reuses the entry behind it", before.Len, after.Len) |
| + } |
| + errs, skipped := scriptErrors(t, *thrown) |
| + if len(errs) > 0 { |
| + t.Errorf("uncaught: %v", errs) |
| + } |
| + // A deep link: a fresh tab opened straight on a section, so |
| + // nothing is behind it. The link is followed (history +1) |
| + // and focus still comes back to the row, from the record. |
| + deep, doneDeep, deepThrown := tab(t, rig, "") |
| + defer doneDeep() |
| + visit(t, deep, rig.Origin+"/orders") |
| + settled(t, deep) |
| + before = state(t, deep) |
| + follow(t, deep, "[rst-shell-back] a", `location.pathname === "/" && document.activeElement.id === "nav-orders"`) |
| + settled(t, deep) |
| + if after := state(t, deep); after.Len != before.Len+1 { |
| + t.Errorf("a deep link's back control: history %d -> %d, want the link followed (+1)", before.Len, after.Len) |
| + } |
| + deepErrs, deepSkipped := scriptErrors(t, *deepThrown) |
| + if len(deepErrs) > 0 { |
| + t.Errorf("uncaught in the deep-link tab: %v", deepErrs) |
| + } |
| + // Three navigations slide in this drive: to the section, |
| + // back, and the deep link's back. |
| + if skipped+deepSkipped >= 3 { |
| + t.Errorf("every one of the drive's 3 navigations skipped its transition (%d skips); the slide never ran", skipped+deepSkipped) |
| + } |
| + |
| + // The console's index with fiesta.js loaded: the card |
| + // opens over the rail and an outside tap closes it, and |
| + // the rail, which no longer follows the Menu, stays. |
| + if shell == "console" { |
| + idx, doneIdx, _ := tab(t, rig, "") |
| + defer doneIdx() |
| + visit(t, idx, rig.Origin+"/") |
| + settled(t, idx) |
| + mustRun(t, idx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), |
| + chromedp.WaitVisible("[rst-shell-tail] #rail-locale > summary", chromedp.ByQuery)) |
| + clickAndStay(t, idx, probe(t, idx, "[rst-shell-rail]", 0.5, 0.8, 0, 0), "", |
| + `!document.querySelector("[rst-shell-menu]").open && document.querySelector("#nav-team").checkVisibility()`) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// boxesJS is the rail's and main's boxes, for comparing two layouts. |
| +const boxesJS = `(() => { const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return [r.left, r.top, r.width, r.height].map(Math.round).join(","); }; |
| + return JSON.stringify({Rail: b("[rst-shell-rail]"), Main: b("[rst-shell-main]")}); })()` |
| + |
| +// TestTheWideLayoutIsUnchanged: at 1280, in both directions, both views |
| +// keep the old layout's boxes (the same markup rendered through the |
| +// legacy layout is the golden), with no back control and no second h1. |
| +// |
| +// One deliberate change, asserted rather than tolerated: the sidebar |
| +// rail's foot is no longer the language menu and the person laid out |
| +// flat. It is one avatar button, the profile menu, and the language is |
| +// inside its card, so the language menu that the legacy layout shows in |
| +// the foot is not on screen until the avatar is opened. The rail and |
| +// main boxes do not move, because the foot was always inside the rail. |
| +func TestTheWideLayoutIsUnchanged(t *testing.T) { |
| + for _, c := range []struct{ shell, dir string }{{"sidebar", "ltr"}, {"sidebar", "rtl"}, {"console", "ltr"}, {"console", "rtl"}} { |
| + t.Run(c.shell+" "+c.dir, func(t *testing.T) { |
| + shell := c.shell |
| + pages := map[string]string{ |
| + "/index": indexPage(t, "TestTheWideLayoutIsUnchanged", shell+"-"+c.dir+"-index"), |
| + "/page": indexPage(t, "TestTheWideLayoutIsUnchanged", shell+"-"+c.dir+"-page"), |
| + "/legacy": indexPage(t, "TestTheWideLayoutIsUnchanged", shell+"-"+c.dir+"-legacy"), |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + read := func(path string) (map[string]string, viewReading) { |
| + var boxes map[string]string |
| + var v viewReading |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body")) |
| + at(t, ctx, boxesJS, &boxes) |
| + at(t, ctx, viewJS, &v) |
| + return boxes, v |
| + } |
| + footJS := `JSON.stringify({Locale: document.querySelector("#rail-locale > summary").checkVisibility(), Avatar: !!document.querySelector("[rst-shell-rail-foot] [rst-shell-profile] > summary") && document.querySelector("[rst-shell-rail-foot] [rst-shell-profile] > summary").checkVisibility()})` |
| + var foot struct{ Locale, Avatar bool } |
| + golden, _ := read("/legacy") |
| + at(t, ctx, footJS, &foot) |
| + if shell == "sidebar" && (!foot.Locale || foot.Avatar) { |
| + t.Errorf("the legacy sidebar's foot: language menu shown %v, avatar %v; the golden is the flat foot", foot.Locale, foot.Avatar) |
| + } |
| + for _, path := range []string{"/index", "/page"} { |
| + got, v := read(path) |
| + if shell == "sidebar" { |
| + at(t, ctx, footJS, &foot) |
| + if foot.Locale || !foot.Avatar { |
| + t.Errorf("%s %s at 1280: the rail's foot shows the language menu %v and the avatar %v; want the avatar alone, the language inside its card", c.dir, path, foot.Locale, foot.Avatar) |
| + } |
| + } |
| + if got["Rail"] != golden["Rail"] || got["Main"] != golden["Main"] { |
| + t.Errorf("%s %s %s at 1280: rail %s main %s; the old layout gives rail %s main %s", shell, c.dir, path, got["Rail"], got["Main"], golden["Rail"], golden["Main"]) |
| + } |
| + if v.Back || v.Title != "" || v.H1s != 1 { |
| + t.Errorf("%s %s at 1280: back %v, title %q, %d h1s; want no back control and only the page's own h1", shell, path, v.Back, v.Title, v.H1s) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAnOldSidebarLayoutKeepsItsDrawer: an app that re-vendors |
| +// tokens.css and keeps its old layout still opens its drawer at 390. |
| +func TestAnOldSidebarLayoutKeepsItsDrawer(t *testing.T) { |
| + pages := map[string]string{"/": legacyLayout(t, "sidebar")} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + var before, after viewReading |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-chrome] > summary", chromedp.ByQuery)) |
| + at(t, ctx, viewJS, &before) |
| + mustRun(t, ctx, chromedp.Click("[rst-shell-chrome] > summary", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-rail] [rst-shell-nav] a", chromedp.ByQuery)) |
| + at(t, ctx, viewJS, &after) |
| + if before.Rail || !after.Rail || !after.Main { |
| + t.Errorf("the old layout's drawer: rail before %v, after opening %v, main %v; want closed, then open with the page still there", before.Rail, after.Rail, after.Main) |
| + } |
| +} |
| + |
| +// TestTheIndexRowsRoundEachRunOfLinks: whatever the nav starts with, |
| +// every row has its side and bottom borders, the first row of each run |
| +// its top border and top corners, and the last row of each run its |
| +// bottom corners. On a coarse pointer, where the touch block's 44px |
| +// floor for nav links would otherwise win, every row is still 3rem. |
| +func TestTheIndexRowsRoundEachRunOfLinks(t *testing.T) { |
| + // Three sidebar indexes, each with its own nav: two groups (Sales |
| + // with A and B, Settings with C), one link, and three links with |
| + // no groups (C current). |
| + rows := map[string]int{"/group-first": 3, "/one": 1, "/no-groups": 3} |
| + pages := map[string]string{} |
| + for path := range rows { |
| + pages[path] = indexPage(t, "TestTheIndexRowsRoundEachRunOfLinks", path[1:]) |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + for path := range rows { |
| + var got struct { |
| + Rows int |
| + Bad []string |
| + } |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body")) |
| + requirePointer(t, ctx, true) |
| + at(t, ctx, `(() => { |
| + const bad = [], links = document.querySelectorAll("[rst-shell-nav] > a"); |
| + for (const a of links) { |
| + const cs = getComputedStyle(a), prev = a.previousElementSibling, next = a.nextElementSibling; |
| + const first = !prev || prev.hasAttribute("rst-shell-group"), last = !next || next.hasAttribute("rst-shell-group"); |
| + const px = v => parseFloat(v) || 0; |
| + if (px(cs.borderLeftWidth) !== 1 || px(cs.borderRightWidth) !== 1 || px(cs.borderBottomWidth) !== 1) bad.push(a.textContent + ": sides/bottom"); |
| + if ((px(cs.borderTopWidth) === 1) !== first) bad.push(a.textContent + ": top border " + cs.borderTopWidth); |
| + if ((px(cs.borderStartStartRadius) > 0) !== first) bad.push(a.textContent + ": top corner " + cs.borderStartStartRadius); |
| + if ((px(cs.borderEndStartRadius) > 0) !== last) bad.push(a.textContent + ": bottom corner " + cs.borderEndStartRadius); |
| + if (a.getBoundingClientRect().height < 47.5) bad.push(a.textContent + ": under 3rem (" + a.getBoundingClientRect().height + "px)"); |
| + } |
| + return JSON.stringify({Rows: [...links].filter(a => a.getClientRects().length > 0).length, Bad: bad}); |
| + })()`, &got) |
| + if got.Rows != rows[path] { |
| + t.Fatalf("%s: %d index rows shown, want %d; the leg would be checking nothing", path, got.Rows, rows[path]) |
| + } |
| + if len(got.Bad) > 0 { |
| + t.Errorf("%s: %v", path, got.Bad) |
| + } |
| + } |
| +} |
| + |
| +// TestTheConsoleIndexRailFillsTheScreen: the console's phone index is |
| +// the bar, then the rail, then the foot. The three share the window: a |
| +// rail a whole window tall under the bar made every index scroll by the |
| +// bar's and the foot's height, however short its nav. With a short nav |
| +// they fill the window exactly and nothing scrolls, with a foot or |
| +// without one; with a long nav the rail grows, the page scrolls, and |
| +// every row can still be reached and hit. |
| +func TestTheConsoleIndexRailFillsTheScreen(t *testing.T) { |
| + // Three console indexes: a two-link nav with an About foot, the |
| + // same nav with no foot, and thirty sections (nav-1 to nav-30) |
| + // with the foot. |
| + pages := map[string]string{ |
| + "/short": indexPage(t, "TestTheConsoleIndexRailFillsTheScreen", "short"), |
| + "/nofoot": indexPage(t, "TestTheConsoleIndexRailFillsTheScreen", "nofoot"), |
| + "/long": indexPage(t, "TestTheConsoleIndexRailFillsTheScreen", "long"), |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + type geometry struct { |
| + BarTop, BarH, RailTop, RailH, RailBottom, NavBottom float64 |
| + FootShown bool |
| + FootTop, FootBottom, DocH, VH float64 |
| + } |
| + read := func(path string) geometry { |
| + t.Helper() |
| + var g geometry |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body")) |
| + // Every box in document coordinates, so a scrolled page reads the |
| + // same as one at the top. |
| + at(t, ctx, `(() => { const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return {top: r.top + scrollY, bottom: r.bottom + scrollY, h: r.height}; }; |
| + const bar = b("[rst-shell-bar]"), rail = b("[rst-shell-rail]"), foot = b("[rst-shell-foot]"), nav = b("[rst-shell-nav]"); |
| + return JSON.stringify({BarTop: bar.top, BarH: bar.h, RailTop: rail.top, RailH: rail.h, RailBottom: rail.bottom, NavBottom: nav.bottom, |
| + FootShown: foot.h > 0, FootTop: foot.top, FootBottom: foot.bottom, DocH: document.documentElement.scrollHeight, VH: innerHeight}); })()`, &g) |
| + return g |
| + } |
| + near := func(a, b float64) bool { return math.Abs(a-b) <= 1 } |
| + |
| + g := read("/short") |
| + if g.NavBottom > g.VH/2 || !g.FootShown { |
| + t.Fatalf("/short: the nav ends at %.0f of %.0f, foot shown %v; the short-nav-with-a-foot case this leg is for has not arisen", g.NavBottom, g.VH, g.FootShown) |
| + } |
| + if !near(g.BarTop, 0) || !near(g.RailTop, g.BarTop+g.BarH) || !near(g.FootTop, g.RailBottom) || !near(g.FootBottom, g.VH) { |
| + t.Errorf("/short: the bar is %.0fpx at %.0f, the rail %.0fpx at %.0f and the foot %.0f to %.0f in a %.0fpx window; together they fill the window exactly", g.BarH, g.BarTop, g.RailH, g.RailTop, g.FootTop, g.FootBottom, g.VH) |
| + } |
| + if !near(g.DocH, g.VH) { |
| + t.Errorf("/short: the document is %.0fpx in a %.0fpx window; a short index does not scroll", g.DocH, g.VH) |
| + } |
| + |
| + g = read("/nofoot") |
| + if g.FootShown { |
| + t.Fatal("/nofoot: an empty foot is drawn; the no-foot case has not arisen") |
| + } |
| + if !near(g.BarH+g.RailH, g.VH) || !near(g.DocH, g.VH) { |
| + t.Errorf("/nofoot: bar %.0f + rail %.0f in a %.0fpx window, document %.0fpx; with no foot the index is exactly the window and nothing scrolls", g.BarH, g.RailH, g.VH, g.DocH) |
| + } |
| + |
| + g = read("/long") |
| + if g.NavBottom < g.VH { |
| + t.Fatalf("/long: thirty rows end at %.0f in a %.0fpx window; the long-nav case has not arisen", g.NavBottom, g.VH) |
| + } |
| + if !near(g.FootTop, g.RailBottom) || !near(g.DocH, g.FootBottom) { |
| + t.Errorf("/long: the rail ends at %.0f, the foot spans %.0f to %.0f and the document is %.0fpx; the foot follows a long rail too", g.RailBottom, g.FootTop, g.FootBottom, g.DocH) |
| + } |
| + var missed []string |
| + at(t, ctx, `(() => { const out = []; |
| + for (const a of document.querySelectorAll("[rst-shell-nav] a")) { |
| + a.scrollIntoView({block: "center"}); |
| + const r = a.getBoundingClientRect(), h = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); |
| + if (!(h === a || a.contains(h))) out.push(a.id + " -> " + (h ? h.tagName : "nothing")); |
| + } |
| + return JSON.stringify(out); })()`, &missed) |
| + if len(missed) > 0 { |
| + t.Errorf("/long: rows that cannot be scrolled to and hit: %v", missed) |
| + } |
| +} |
| + |
| +// A view block with whitespace matches the index rules in the engine, |
| +// not only in the template's output. |
| +func TestAWhitespaceViewIsTheIndexInTheBrowser(t *testing.T) { |
| + pages := map[string]string{"/": indexPage(t, "TestAWhitespaceViewIsTheIndexInTheBrowser", "")} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + var v viewReading |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")) |
| + at(t, ctx, viewJS, &v) |
| + if !v.Rail || v.Main { |
| + t.Errorf("a view written as \"\\n index\\n\" renders rail %v main %v; want the index", v.Rail, v.Main) |
| + } |
| +} |
| + |
| +// TestAnOldConsoleLayoutKeepsItsRailOpen: an app that re-vendors |
| +// fiesta.js and tokens.css but keeps a console layout from before |
| +// the phone index still has a rail it can use at 390. Its rail is gated |
| +// on the Menu's [open], so dismissing that Menu on an outside click, or |
| +// covering the page with the card's tap-to-close layer, would close the |
| +// whole navigation on a tap at a group label or the rail's empty space. |
| +// The control: a dropdown in the page IS dismissed by the same click, |
| +// so fiesta.js is running and saw it. It is in the page rather than |
| +// in the bar's tail because the tail's open panel would lie over the |
| +// rail and take the click itself. The page is the legacy console with |
| +// a grouped nav (#group, Sales) and the Sort dropdown (#page-menu) in |
| +// its content. |
| +func TestAnOldConsoleLayoutKeepsItsRailOpen(t *testing.T) { |
| + pages := map[string]string{"/": indexPage(t, "TestAnOldConsoleLayoutKeepsItsRailOpen", "")} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + type reading struct { |
| + Menu, Dropdown, Rail bool |
| + } |
| + const read = `JSON.stringify({Menu: document.querySelector("[rst-shell-menu]").open, Dropdown: document.querySelector("#page-menu").open, |
| + Rail: document.querySelector("#nav-invoices").getClientRects().length > 0})` |
| + for _, c := range []struct { |
| + where string |
| + fx, fy float64 |
| + sel string |
| + }{ |
| + {"a group label", 0.5, 0.5, "#group"}, |
| + // The rail's padding under its last link: rail, and nothing else. |
| + {"the rail's empty space", 0.5, 1, "[rst-shell-rail]"}, |
| + } { |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| + requirePointer(t, ctx, true) |
| + mustRun(t, ctx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.WaitVisible("#nav-invoices", chromedp.ByQuery), |
| + chromedp.Click("#page-menu > summary", chromedp.ByQuery), chromedp.WaitVisible("#page-menu [rst-dropdown-menu]", chromedp.ByQuery)) |
| + dy := 0.0 |
| + if c.fy == 1 { |
| + dy = -6 |
| + } |
| + p := probe(t, ctx, c.sel, c.fx, c.fy, 0, dy) |
| + mustRun(t, ctx, chromedp.MouseClickXY(p.X, p.Y)) |
| + settleUntil(t, ctx, `!document.querySelector("#page-menu").open`) |
| + var got reading |
| + at(t, ctx, read, &got) |
| + if !got.Menu || !got.Rail { |
| + t.Errorf("a tap on %s (over %s) in an old console layout: Menu open %v, rail shown %v; want both, the rail is that layout's only navigation", c.where, p.Hit, got.Menu, got.Rail) |
| + } |
| + } |
| +} |
| + |
| +// backStripJS reads the back strip's bottom edge, the fragment target's |
| +// top edge (both as the viewport sees them, after any landing scroll |
| +// has settled), and the root's resolved scroll-padding-block-start, so |
| +// one probe serves both the narrow leg and the wide one. -1 stands in |
| +// for an element the page does not have, which the narrow leg's content |
| +// page always does and the wide leg's assertion never looks at. |
| +const backStripJS = `(() => { |
| + const back = document.querySelector("[rst-shell-back]"), target = document.getElementById("target"); |
| + return JSON.stringify({ |
| + BackBottom: back ? Math.round(back.getBoundingClientRect().bottom) : -1, |
| + TargetTop: target ? Math.round(target.getBoundingClientRect().top) : -1, |
| + ScrollPadding: getComputedStyle(document.documentElement).scrollPaddingBlockStart, |
| + }); |
| +})()` |
| + |
| +type backStripReading struct { |
| + BackBottom, TargetTop int |
| + ScrollPadding string |
| +} |
| + |
| +// TestAFragmentLinkLandsBelowTheBackStrip: the back strip is sticky at |
| +// the top of a phone content page (tokens.css, just above), so the |
| +// browser's native landing scroll for a #fragment link parks the |
| +// target's top edge at the document root's scroll origin — which a |
| +// sticky strip then covers, because a sticky element is not part of the |
| +// flow a plain scroll-to-element measures against. A reader who follows |
| +// a link to a heading finds the heading hidden under Back. |
| +// |
| +// Both shells, at 390 with a coarse pointer; and, because the fix must |
| +// not reach past the media query that gates the strip itself, a second |
| +// leg per shell at 1280 holding the root's scroll-padding unchanged. |
| +// |
| +// The page is each shell's content-page example (layout-sidebar-page, |
| +// layout-console-page) with the target far enough down its slot to |
| +// scroll to: the drive reads the back strip and the target, and |
| +// nothing else about the chrome. |
| +func TestAFragmentLinkLandsBelowTheBackStrip(t *testing.T) { |
| + content := `<h1>Invoices</h1><div style="block-size: 150vh"></div><h2 id="target">Target</h2><div style="block-size: 150vh"></div>` |
| + for _, shell := range []string{"sidebar", "console"} { |
| + pages := map[string]string{"/page": shellPage(t, shell+"-page", content)} |
| + |
| + t.Run(shell+" at 390", func(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/page#target"), chromedp.WaitVisible("#target", chromedp.ByQuery)) |
| + requirePointer(t, ctx, true) |
| + settled(t, ctx) |
| + var got backStripReading |
| + at(t, ctx, backStripJS, &got) |
| + if got.BackBottom < 0 || got.TargetTop < 0 { |
| + t.Fatalf("the back strip or #target is missing from the page: %+v", got) |
| + } |
| + if got.TargetTop < got.BackBottom { |
| + t.Errorf("#target's top is %dpx and the strip's bottom is %dpx; the heading landed %dpx under the strip", got.TargetTop, got.BackBottom, got.BackBottom-got.TargetTop) |
| + } |
| + // Exactly at the strip's edge, not merely below it: a scroll |
| + // padding that kept an old strip height, or guessed a bigger |
| + // one, leaves a gap or a hidden band whenever the strip grows. |
| + if got.TargetTop > got.BackBottom+1 { |
| + t.Errorf("#target's top is %dpx and the strip's bottom is %dpx; the heading landed %dpx below the strip, so the scroll padding is not the strip's height", got.TargetTop, got.BackBottom, got.TargetTop-got.BackBottom) |
| + } |
| + }) |
| + |
| + t.Run(shell+" at 1280 is unchanged", func(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/page#target"), chromedp.WaitReady("body")) |
| + var got backStripReading |
| + at(t, ctx, backStripJS, &got) |
| + if got.ScrollPadding != "auto" && got.ScrollPadding != "0px" { |
| + t.Errorf("the root's scroll-padding-block-start at 1280 is %q, want auto or 0px: the narrow-screen fix must not reach the desktop", got.ScrollPadding) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// headerStripJS measures a phone header strip and the tap target in |
| +// it: the strip's border box and the target's, as the viewport sees |
| +// them, so a target that is 44px but pinned to the strip's top edge |
| +// reads as off centre rather than passing. |
| +const headerStripJS = `((strip, target) => { |
| + const s = document.querySelector(strip), c = document.querySelector(target); |
| + if (!s || !c || !s.checkVisibility() || !c.checkVisibility()) return JSON.stringify({Missing: true}); |
| + const sr = s.getBoundingClientRect(), cr = c.getBoundingClientRect(); |
| + return JSON.stringify({Top: sr.top, H: sr.height, TargetH: cr.height, Off: (cr.top + cr.height / 2) - (sr.top + sr.height / 2)}); |
| +})(%q, %q)` |
| + |
| +type headerStripReading struct { |
| + Missing bool |
| + Top, H, TargetH float64 |
| + Off float64 |
| +} |
| + |
| +// TestThePhoneHeaderStripIs56Pixels: on a phone every strip a shell |
| +// puts at the top of the screen is 56px, the height of a mobile app's |
| +// top bar. At 44px the back strip was the bare tap target with no room |
| +// round it, and read as cramped next to every native app's header. The |
| +// tap target inside stays 44px and sits on the strip's centre line: a |
| +// taller strip with the control at its top edge would make the strip's |
| +// lower part a dead zone that looks tappable. The index's header row |
| +// and a content page's back strip are both checked, so going from one |
| +// to the other does not change the header's height under the slide. |
| +// |
| +// The pages are each shell's index and content-page examples |
| +// (layout-sidebar and layout-sidebar-page, layout-console and |
| +// layout-console-page), whose chrome carries every strip and target |
| +// measured here, with shellContent in the slot. |
| +func TestThePhoneHeaderStripIs56Pixels(t *testing.T) { |
| + type strip struct{ where, page, strip, target string } |
| + shells := map[string][]strip{ |
| + "sidebar": { |
| + {"the back strip", "/page", "[rst-shell-back]", "[rst-shell-back] > a"}, |
| + {"the index's header row", "/", "[rst-shell-title]", "[rst-shell-profile] > summary"}, |
| + }, |
| + "console": { |
| + {"the bar over a page", "/page", "[rst-shell-bar]", "[rst-shell-menu] > summary"}, |
| + {"the back strip", "/page", "[rst-shell-back]", "[rst-shell-back] > a"}, |
| + {"the index's header row", "/", "[rst-shell-bar]", "[rst-shell-menu] > summary"}, |
| + }, |
| + } |
| + for shell, strips := range shells { |
| + pages := map[string]string{ |
| + "/": shellPage(t, shell, shellContent), |
| + "/page": shellPage(t, shell+"-page", shellContent), |
| + } |
| + t.Run(shell, func(t *testing.T) { |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + for _, s := range strips { |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+s.page), chromedp.WaitReady("body")) |
| + requirePointer(t, ctx, true) |
| + settled(t, ctx) |
| + var got headerStripReading |
| + at(t, ctx, fmt.Sprintf(headerStripJS, s.strip, s.target), &got) |
| + if got.Missing { |
| + t.Fatalf("%s on %s: %s or %s is not shown; the leg would be checking nothing", s.where, s.page, s.strip, s.target) |
| + } |
| + if math.Abs(got.H-56) > 0.5 || got.TargetH < 44 || math.Abs(got.Off) > 1 { |
| + t.Errorf("%s on %s is %.1fpx tall with a %.1fpx target %.1fpx off its centre line; want a 56px strip with a 44px target centred in it", s.where, s.page, got.H, got.TargetH, got.Off) |
| + } |
| + if s.page == "/" && math.Abs(got.Top) > 0.5 { |
| + t.Errorf("%s starts %.1fpx down the page; want it at the top, where a content page's strip is", s.where, got.Top) |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/check/parity_test.go b/check/parity_test.go| index 52314b4..5390592 100644 |
| --- a/check/parity_test.go |
| +++ b/check/parity_test.go |
| @@ -25,6 +25,13 @@ import ( |
| // share a base name under different directories would collide; the |
| // tree has none. |
| func parityLinks(doc string) string { |
| + return assetLinks(doc, "") |
| +} |
| + |
| +// assetLinks is parityLinks with the flat names under dir: "" for the |
| +// names beside the page, "/" for the root paths the sizing mux serves |
| +// (shellPage), so a page served below the root still finds them. |
| +func assetLinks(doc, dir string) string { |
| return assetLink.ReplaceAllStringFunc(doc, func(tag string) string { |
| return assetURL.ReplaceAllStringFunc(tag, func(attr string) string { |
| m := assetURL.FindStringSubmatch(attr) |
| @@ -35,7 +42,7 @@ func parityLinks(doc string) string { |
| if strings.Contains(m[2], "/themes/") { |
| name = "theme.css" |
| } |
| - return m[1] + name + `"` |
| + return m[1] + dir + name + `"` |
| }) |
| }) |
| } |
| @@ -103,6 +110,11 @@ func TestParityLinksAreRewritten(t *testing.T) { |
| if got, want := parityLinks(mixed), `<head>`+foreign+`<link rel="stylesheet" href="theme.css"><script defer src="fiesta.js"></script></head>`; got != want { |
| t.Errorf("the rewrite of foreign and root links:\n got %s\n want %s", got, want) |
| } |
| + // Under a directory, for the shell drives: the same names from the |
| + // root, the foreign URLs still untouched. |
| + if got, want := assetLinks(mixed, "/"), `<head>`+foreign+`<link rel="stylesheet" href="/theme.css"><script defer src="/fiesta.js"></script></head>`; got != want { |
| + t.Errorf("the rewrite under /:\n got %s\n want %s", got, want) |
| + } |
| } |
| // assetLink is a stylesheet link or a script tag, the tags parityLinks |
diff --git a/check/prerender_browser_test.go b/check/prerender_browser_test.go| new file mode 100644 |
| index 0000000..3563019 |
| --- /dev/null |
| +++ b/check/prerender_browser_test.go |
| @@ -0,0 +1,146 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/input" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// speculationRulesPath is where the site below answers with its |
| +// speculation rules, and what the Speculation-Rules header on every |
| +// response names. |
| +const speculationRulesPath = "/_speculation-rules" |
| + |
| +// speculationRules is the ruleset rastrillo's server sends for the |
| +// shells, as the drive below needs it: document rules scoped by a |
| +// selector, in both markup spellings, to shell navigation and the back |
| +// control inside a sidebar or console root that names its view. Only |
| +// the layouts that ship the phone index write a view; a layout copied |
| +// before them has a bare root and gets no prerender at all. Fiesta |
| +// serves nothing, so the drive serves the rules itself, by the header |
| +// rather than inline: a header ruleset is not a script, so an app's |
| +// CSP never stops it loading, which is how rastrillo delivers it. |
| +const speculationRules = `{"prerender":[{"source":"document","where":{"selector_matches":":is([rst-shell-sidebar~=page],[rst-shell-sidebar~=index],[rst-shell-console~=page],[rst-shell-console~=index],.rst-shell-sidebar--page,.rst-shell-sidebar--index,.rst-shell-console--page,.rst-shell-console--index) :is([rst-shell-nav],[rst-shell-back],.rst-shell__nav,.rst-shell__back) a[href]"},"eagerness":"moderate"}]}` |
| + |
| +// prerenderSite is the sidebar app SEED's drives served through |
| +// rastrillo's handler, read from the seeded pages: the index, three |
| +// sections, a plain link in the index's content, a stage page with a |
| +// link on it, and a page in a sidebar layout copied before the phone |
| +// index; every response carries the Speculation-Rules header, and the |
| +// rules are answered with the content type browsers accept for a |
| +// ruleset (a static file would go out as application/json, which they |
| +// refuse). Every document request is reported with its Sec-Purpose, |
| +// which is how a prerender is seen (headless Chromium fetches the page |
| +// for it; enabling CDP's Preload domain would switch prerendering off, |
| +// measured). |
| +func prerenderSite(t *testing.T, hits chan<- string) func(string) http.Handler { |
| + return func(string) http.Handler { |
| + pages := map[string]string{} |
| + for path, kase := range map[string]string{ |
| + "/": "index", "/invoices": "invoices", "/orders": "orders", "/team": "team", |
| + "/plain": "plain", "/signin": "signin", "/legacy": "legacy", |
| + } { |
| + pages[path] = fixture(t, "prerender_browser_test.go", "prerenderSite", kase) |
| + } |
| + mux := shellAssets(t, pages) |
| + mux.HandleFunc("GET "+speculationRulesPath, func(w http.ResponseWriter, _ *http.Request) { |
| + w.Header().Set("Content-Type", "application/speculationrules+json") |
| + w.Header().Set("Cache-Control", "public, max-age=86400") |
| + w.Write([]byte(speculationRules)) |
| + }) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // On every response rather than sniffed for HTML: browsers |
| + // act on it for documents and ignore it elsewhere. A |
| + // structured-field list of one string, which is the |
| + // header's form. |
| + w.Header().Set("Speculation-Rules", `"`+speculationRulesPath+`"`) |
| + recordDocuments(mux, hits).ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
| + |
| +// recordDocuments reports every request for a page (not an asset) with |
| +// its Sec-Purpose. |
| +func recordDocuments(next *http.ServeMux, hits chan<- string) *http.ServeMux { |
| + out := http.NewServeMux() |
| + out.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| + if !strings.Contains(r.URL.Path, ".") { |
| + select { |
| + case hits <- r.URL.Path + " " + r.Header.Get("Sec-Purpose"): |
| + default: |
| + } |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + return out |
| +} |
| + |
| +// waitFor reports the first hit for path within d, or "". |
| +func waitFor(c chan string, path string, d time.Duration) string { |
| + deadline := time.After(d) |
| + for { |
| + select { |
| + case h := <-c: |
| + if strings.HasPrefix(h, path+" ") { |
| + return h |
| + } |
| + case <-deadline: |
| + return "" |
| + } |
| + } |
| +} |
| + |
| +func centre(t *testing.T, ctx context.Context, sel string) (float64, float64) { |
| + p := probe(t, ctx, sel, 0.5, 0.5, 0, 0) |
| + return p.X, p.Y |
| +} |
| + |
| +// TestAPrefetchedIndexStillReturnsFocus: headless Chromium does not |
| +// activate a prerender (it delivers the page from the prefetch, |
| +// measured: deliveryType "navigational-prefetch"), so this is the |
| +// nearest a headless drive gets to the prerendered-index leg: a deep |
| +// link's back control, pressed long enough for the rules to fetch the |
| +// index, lands on an index delivered from the speculation, with focus |
| +// on the section it left. The true activation leg stays on the by-hand |
| +// list. The hover and pointer-down legs, and the control with the |
| +// rules switched off, are the server's behaviour and stay with |
| +// rastrillo's drive. |
| +func TestAPrefetchedIndexStillReturnsFocus(t *testing.T) { |
| + hits := make(chan string, 64) |
| + rig := harness.New(t, prerenderSite(t, hits), harness.WithCoarsePointer()) |
| + ctx, done, thrown := tab(t, rig, "") |
| + defer done() |
| + visit(t, ctx, rig.Origin+"/invoices") |
| + settleUntil(t, ctx, `!!document.querySelector("[rst-shell-back] a")`) |
| + x, y := centre(t, ctx, "[rst-shell-back] a") |
| + mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, x, y, chromedp.ButtonLeft, chromedp.ClickCount(1))) |
| + if h := waitFor(hits, "/", 4*time.Second); !strings.Contains(h, "prerender") { |
| + t.Fatalf("pressing the back control fetched no speculation of the index (got %q); the leg has not arisen", h) |
| + } |
| + mustRun(t, ctx, chromedp.MouseEvent(input.MouseReleased, x, y, chromedp.ButtonLeft, chromedp.ClickCount(1))) |
| + settleUntil(t, ctx, `location.pathname === "/"`) |
| + var delivery string |
| + mustRun(t, ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].deliveryType`, &delivery)) |
| + if delivery != "navigational-prefetch" { |
| + t.Fatalf("the index was delivered as %q, not from the speculation; the leg has not arisen", delivery) |
| + } |
| + settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`) |
| + // scriptErrors, not a raw length check: the back-control navigation |
| + // is the same shape as TestThePhoneIndexWorksWithScripts's, which |
| + // headless Chromium itself skips the incoming transition for on |
| + // roughly one navigation in five (see scriptErrors's comment) — |
| + // an unfiltered check would flake on exactly that. |
| + errs, _ := scriptErrors(t, *thrown) |
| + if len(errs) > 0 { |
| + t.Errorf("uncaught: %v", errs) |
| + } |
| +} |
diff --git a/check/profile_browser_test.go b/check/profile_browser_test.go| new file mode 100644 |
| index 0000000..6bb4012 |
| --- /dev/null |
| +++ b/check/profile_browser_test.go |
| @@ -0,0 +1,380 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/emulation" |
| + "github.com/chromedp/chromedp" |
| + "github.com/chromedp/chromedp/kb" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// profilePage is one page of this file's drives as SEED rendered it, |
| +// by the drive's name and the page's case: a layout with the drives' |
| +// standard nav (Invoices, Orders, Team) and language menu |
| +// (#rail-locale, English and Gaeilge), and the blocks SEED's |
| +// profileDefs filled for the profile menu: the app's name as the |
| +// brand, Grace Hopper as the person, the account's links (#acct-profile |
| +// and #acct-out), and a heading with a link in the page (#main-link). |
| +func profilePage(t *testing.T, test, kase string) string { |
| + t.Helper() |
| + return fixture(t, "profile_browser_test.go", test, kase) |
| +} |
| + |
| +// profileShell is where each shell keeps the profile menu: the sidebar's |
| +// own <details rst-shell-profile> in the rail's foot, and the console's |
| +// Menu, whose summary shows the avatar on the index and whose card is |
| +// the tail beside it. The two cards read the same: the name and email, |
| +// the Language menu, then the account's links laid out flat. |
| +type profileShell struct { |
| + trigger, card, account string |
| +} |
| + |
| +var profileShells = map[string]profileShell{ |
| + "sidebar": {"[rst-shell-profile] > summary", "[rst-shell-profile] > [rst-dropdown-menu]", ""}, |
| + "console": {"[rst-shell-menu] > summary", "[rst-shell-tail]", ""}, |
| +} |
| + |
| +// profileJS reads the index's header row and the card: the heading's |
| +// text, size and height, where the avatar sits against the root's box |
| +// (tokens.css reserves a stable scrollbar gutter, which RTL puts on the |
| +// left, so the viewport's edge is not the page's), and for each element |
| +// the card should show, whether it is on screen and the topmost thing |
| +// at its centre: in the card and above the page. |
| +const profileJS = `((trigger, card) => { |
| + const q = s => document.querySelector(s), h = document.documentElement.getBoundingClientRect(); |
| + const rtl = document.documentElement.dir === "rtl", sum = q(trigger), r = sum.getBoundingClientRect(); |
| + const title = q("[rst-shell-title]"), t = title.getBoundingClientRect(), foot = q("[rst-shell-rail-foot]"); |
| + const nav = [...document.querySelectorAll("[rst-shell-nav] > a")].pop().getBoundingClientRect(); |
| + const seen = sel => [...document.querySelectorAll(sel)].filter(el => el.checkVisibility()).map(el => { |
| + const b = el.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); |
| + return {Sel: sel, Text: el.textContent.trim(), On: b.width > 0 && b.top >= 0 && b.bottom <= innerHeight && b.left >= h.left && b.right <= h.right, Top: !!hit && (hit === el || el.contains(hit))}; |
| + }); |
| + const c = q(card), cb = c.getBoundingClientRect(); |
| + return JSON.stringify({ |
| + Open: sum.parentElement.open, Title: title.innerText.trim(), TitleH: Math.round(t.height), TitleSize: parseFloat(getComputedStyle(title).fontSize), |
| + TitleOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, |
| + AvTop: Math.round(r.top), AvEnd: Math.round(rtl ? r.left - h.left : h.right - r.right), AvW: Math.round(r.width), AvH: Math.round(r.height), |
| + FootBelowNav: !!foot && foot.getBoundingClientRect().top > nav.bottom, |
| + CardShown: c.checkVisibility(), CardTop: Math.round(cb.top), AvBottom: Math.round(r.bottom), |
| + Items: [].concat(seen("[rst-shell-who] [rst-person-name]"), seen("[rst-shell-who] [rst-person-email]"), |
| + seen("#acct-profile"), seen("#acct-out"), seen("#rail-locale [rst-dropdown-menu] a")), |
| + Focus: document.activeElement === sum, |
| + }); |
| +})(%q, %q)` |
| + |
| +type profileItem struct { |
| + Sel, Text string |
| + On, Top bool |
| +} |
| + |
| +type profileReading struct { |
| + Open, CardShown, FootBelowNav, Focus bool |
| + Title string |
| + TitleH, TitleOverflow int |
| + TitleSize float64 |
| + AvTop, AvEnd, AvW, AvH int |
| + CardTop, AvBottom int |
| + Items []profileItem |
| +} |
| + |
| +// requireCardItems holds an open card's readings to what it must show: |
| +// the name and the email, both account links and both languages, each |
| +// present with its own text, on screen and the topmost thing at its |
| +// centre. Asserting only the ones that were found would pass a card |
| +// that had lost an item. |
| +func requireCardItems(t *testing.T, where string, items []profileItem) { |
| + t.Helper() |
| + want := map[string]string{"[rst-shell-who] [rst-person-name]": "Grace Hopper", "[rst-shell-who] [rst-person-email]": "grace@example.com", "#acct-profile": "Profile", "#acct-out": "Sign out"} |
| + got := map[string]bool{} |
| + locales := map[string]bool{} |
| + for _, it := range items { |
| + if !it.On || !it.Top { |
| + t.Errorf("%s: %s (%q) in the open card: on screen %v, topmost %v; want it on screen and above the page", where, it.Sel, it.Text, it.On, it.Top) |
| + } |
| + if w, ok := want[it.Sel]; ok && it.Text != w { |
| + t.Errorf("%s: %s reads %q, want %q", where, it.Sel, it.Text, w) |
| + } |
| + got[it.Sel] = true |
| + if it.Sel == "#rail-locale [rst-dropdown-menu] a" { |
| + locales[it.Text] = true |
| + } |
| + } |
| + for sel := range want { |
| + if !got[sel] { |
| + t.Errorf("%s: the open card never showed %s", where, sel) |
| + } |
| + } |
| + if !locales["English"] || !locales["Gaeilge"] { |
| + t.Errorf("%s: the card showed the languages %v, want English and Gaeilge", where, locales) |
| + } |
| +} |
| + |
| +func readProfile(t *testing.T, ctx context.Context, p profileShell) profileReading { |
| + t.Helper() |
| + var r profileReading |
| + at(t, ctx, fmt.Sprintf(profileJS, p.trigger, p.card), &r) |
| + return r |
| +} |
| + |
| +// TestTheProfileMenuOnThePhoneIndex is the phone index of both shells, |
| +// LTR and RTL, scripts on and off, at 390 on a coarse pointer. The |
| +// heading is the app's name, about 21px, on one line however long the |
| +// name; the avatar is a 44px button at the top inline-end, named with |
| +// the person's name; nothing is left at the foot. Opened, the card |
| +// shows the name, the email, the account's links and every language, |
| +// each on screen and above the page. An outside tap closes it without |
| +// reaching the page, with and without scripts; with scripts Escape |
| +// closes it and hands focus back to the avatar. |
| +func TestTheProfileMenuOnThePhoneIndex(t *testing.T) { |
| + const long = "Harbour Freight Forwarding and Customs Brokerage" |
| + for _, shell := range []string{"sidebar", "console"} { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + for _, scripts := range []bool{true, false} { |
| + shell, dir, scripts := shell, dir, scripts |
| + t.Run(fmt.Sprintf("%s %s scripts %v", shell, dir, scripts), func(t *testing.T) { |
| + p := profileShells[shell] |
| + // Both indexes: the brand Harbour, and the long brand. |
| + pages := map[string]string{ |
| + "/": profilePage(t, "TestTheProfileMenuOnThePhoneIndex", shell+"-"+dir+"-index"), |
| + "/long": profilePage(t, "TestTheProfileMenuOnThePhoneIndex", shell+"-"+dir+"-long"), |
| + "/go/": sizingDoc("landed", `<p id="landed">landed</p>`), |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + acts := []chromedp.Action{chromedp.EmulateViewport(390, 844)} |
| + if !scripts { |
| + acts = append(acts, emulation.SetScriptExecutionDisabled(true)) |
| + } |
| + mustRun(t, ctx, append(acts, chromedp.Navigate(rig.Origin+"/long"), chromedp.WaitVisible(p.trigger, chromedp.ByQuery))...) |
| + requirePointer(t, ctx, true) |
| + lr := readProfile(t, ctx, p) |
| + if lr.Title != long || lr.TitleH > 56 || lr.TitleOverflow > 0 { |
| + t.Errorf("a long brand: heading %q, %dpx tall, the page %dpx wider than the screen; want the whole name, on one line, eliding inside the screen", lr.Title, lr.TitleH, lr.TitleOverflow) |
| + } |
| + |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(p.trigger, chromedp.ByQuery)) |
| + settled(t, ctx) |
| + closed := readProfile(t, ctx, p) |
| + if closed.Title != "Harbour" || closed.TitleH > 56 || closed.TitleSize < 19 || closed.TitleSize > 23 { |
| + t.Errorf("the heading is %q at %.1fpx, %dpx tall; want the brand, Harbour, at about 21px on one line in the 56px header row", closed.Title, closed.TitleSize, closed.TitleH) |
| + } |
| + if closed.AvTop > 32 || closed.AvEnd > 24 || closed.AvW < 44 || closed.AvH < 44 { |
| + t.Errorf("the avatar is %dx%dpx, %dpx from the top and %dpx from the inline end; want a 44px tap at the top inline-end", closed.AvW, closed.AvH, closed.AvTop, closed.AvEnd) |
| + } |
| + if closed.FootBelowNav { |
| + t.Error("the rail's foot is still under the nav on the phone index") |
| + } |
| + if closed.Open || closed.CardShown { |
| + t.Error("the card is open before anyone opened it") |
| + } |
| + if name := axName(ctx, t, p.trigger); name != "Grace Hopper" { |
| + t.Errorf("the avatar's accessible name is %q, want the person's name", name) |
| + } |
| + |
| + // Opened, both shells' cards read in one order with the |
| + // language closed: the name, the email, the Language menu, |
| + // then the account's links, flat, with no Account menu to |
| + // open first. |
| + mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery)) |
| + var order []string |
| + at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-shell-who] [rst-person-name], [rst-shell-who] [rst-person-email], #rail-locale > summary, #acct-profile, #acct-out")] |
| + .filter(e => e.checkVisibility()).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top).map(e => e.id || e.getAttribute("rst-person-name") !== null && "name" || e.getAttribute("rst-person-email") !== null && "email" || "language"))`, &order) |
| + if got := strings.Join(order, " "); got != "name email language acct-profile acct-out" { |
| + t.Errorf("the open card reads %q, top to bottom; want the name, the email, the language, then the account's links, flat", got) |
| + } |
| + var items []profileItem |
| + if p.account != "" { |
| + mustRun(t, ctx, chromedp.Click(p.account, chromedp.ByQuery), chromedp.WaitVisible("#acct-out", chromedp.ByQuery)) |
| + items = append(items, readProfile(t, ctx, p).Items...) |
| + } |
| + mustRun(t, ctx, chromedp.Click("#rail-locale > summary", chromedp.ByQuery), chromedp.WaitVisible("#rail-locale [rst-dropdown-menu] a", chromedp.ByQuery)) |
| + open := readProfile(t, ctx, p) |
| + items = append(items, open.Items...) |
| + if !open.Open || !open.CardShown || open.CardTop < open.AvBottom { |
| + t.Errorf("opened: open %v, card shown %v, card top %d under an avatar ending at %d; want a card dropping from the avatar", open.Open, open.CardShown, open.CardTop, open.AvBottom) |
| + } |
| + if open.AvW < 44 || open.AvH < 44 { |
| + t.Errorf("the open avatar is %dx%dpx; the open state must stay a 44px tap", open.AvW, open.AvH) |
| + } |
| + requireCardItems(t, "the phone index", items) |
| + |
| + // An outside tap: on a nav row, clear of the card at the |
| + // row's inline start. It closes the card and the page |
| + // under it never sees it. |
| + fx := 0.03 |
| + if dir == "rtl" { |
| + fx = 0.97 |
| + } |
| + clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#nav-team", fx, 0.5, 0, 0), "", |
| + fmt.Sprintf(`!document.querySelector(%q).parentElement.open`, p.trigger)) |
| + |
| + if scripts { |
| + mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery)) |
| + focus := "#acct-profile" |
| + if p.account != "" { |
| + mustRun(t, ctx, chromedp.Click(p.account, chromedp.ByQuery), chromedp.WaitVisible(focus, chromedp.ByQuery)) |
| + } |
| + mustRun(t, ctx, chromedp.Focus(focus, chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + if after := readProfile(t, ctx, p); after.Open || !after.Focus { |
| + t.Errorf("Escape from a link in the card: open %v, focus on the avatar %v; want it closed and focus handed back", after.Open, after.Focus) |
| + } |
| + } else { |
| + // Scripts off, the avatar's own toggle is all there is. |
| + mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery), |
| + chromedp.Click(p.trigger, chromedp.ByQuery)) |
| + settleUntil(t, ctx, fmt.Sprintf(`!document.querySelector(%q).parentElement.open`, p.trigger)) |
| + } |
| + }) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheProfileMenuIsADropUpAtTheRailsFoot is the sidebar on a |
| +// desktop, where the profile menu is the rail's foot: an avatar at the |
| +// bottom of the rail at its inline start, opening a card UPWARD that is |
| +// anchored at the avatar's inline start and grows across the page. It |
| +// has to stay on screen in a 1280x720 window and a 1024x600 one, with |
| +// the language menu open inside it; close on an outside tap, scripts on |
| +// or off, and on Escape; and mirror in RTL, which the inline-start |
| +// readings cover by being measured from the inline start of each |
| +// direction. |
| +func TestTheProfileMenuIsADropUpAtTheRailsFoot(t *testing.T) { |
| + p := profileShells["sidebar"] |
| + // 1280x420 is a laptop with devtools open: the shortest window the |
| + // rail still sits beside the page in. Anchored, the card cannot |
| + // flip downward at the rail's foot: the foot is at the bottom of a |
| + // rail the window's height, so there is never more room below than |
| + // above, and the card's height cap shrinks with the window. What the |
| + // short window has to prove is that the card still fits above. |
| + for _, vp := range [][2]int64{{1280, 720}, {1024, 600}, {1280, 420}} { |
| + for _, dir := range []string{"ltr", "rtl"} { |
| + for _, scripts := range []bool{true, false} { |
| + for _, anchored := range []bool{true, false} { |
| + vp, dir, scripts, anchored := vp, dir, scripts, anchored |
| + t.Run(fmt.Sprintf("%dx%d %s scripts %v anchored %v", vp[0], vp[1], dir, scripts, anchored), func(t *testing.T) { |
| + pages := map[string]string{"/": profilePage(t, "TestTheProfileMenuIsADropUpAtTheRailsFoot", dir), "/go/": sizingDoc("landed", `<p id="landed">landed</p>`)} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + acts := []chromedp.Action{chromedp.EmulateViewport(vp[0], vp[1])} |
| + if !scripts { |
| + acts = append(acts, emulation.SetScriptExecutionDisabled(true)) |
| + } |
| + mustRun(t, ctx, append(acts, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(p.trigger, chromedp.ByQuery))...) |
| + settled(t, ctx) |
| + // Without anchor positioning, the path every engine |
| + // without it takes: the card absolute in the rail, |
| + // opened upward by the hand-written insets. |
| + if !anchored { |
| + mustRun(t, ctx, chromedp.Evaluate(WithoutAnchorPositioning, nil, awaitPromise)) |
| + } |
| + RequireAnchorPositioning(t, ctx, "the drop-up", "[rst-shell-profile] > [rst-dropdown-menu]", anchored) |
| + const geom = `(() => { |
| + const q = s => document.querySelector(s), rtl = document.documentElement.dir === "rtl"; |
| + const rail = q("[rst-shell-rail]").getBoundingClientRect(), s = q("[rst-shell-profile] > summary").getBoundingClientRect(); |
| + const c = q("[rst-shell-profile] > [rst-dropdown-menu]"), cb = c.getBoundingClientRect(); |
| + const start = b => rtl ? rail.right - b.right : b.left - rail.left; |
| + return JSON.stringify({FootGap: Math.round(rail.bottom - s.bottom), AvStart: Math.round(start(s)), |
| + Shown: c.checkVisibility(), CardStart: Math.round(start(cb)), CardBottom: Math.round(cb.bottom), AvTop: Math.round(s.top), |
| + OnScreen: cb.top >= 0 && cb.bottom <= innerHeight && cb.left >= 0 && cb.right <= document.documentElement.clientWidth}); |
| + })()` |
| + var g struct { |
| + FootGap, AvStart, CardStart, CardBottom, AvTop int |
| + Shown, OnScreen bool |
| + } |
| + at(t, ctx, geom, &g) |
| + if g.FootGap > 40 || g.AvStart > 24 || g.Shown { |
| + t.Errorf("closed: the avatar is %dpx above the rail's foot and %dpx from its inline start, card shown %v; want it at the foot's inline start, closed", g.FootGap, g.AvStart, g.Shown) |
| + } |
| + if name := axName(ctx, t, p.trigger); name != "Grace Hopper" { |
| + t.Errorf("the avatar's accessible name is %q, want the person's name", name) |
| + } |
| + mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery), |
| + chromedp.Click("#rail-locale > summary", chromedp.ByQuery), chromedp.WaitVisible("#rail-locale [rst-dropdown-menu] a", chromedp.ByQuery)) |
| + at(t, ctx, geom, &g) |
| + if !g.Shown || g.CardBottom > g.AvTop || !g.OnScreen { |
| + t.Errorf("open: card shown %v, its bottom at %d over an avatar whose top is %d, on screen %v; want a drop-up wholly in the window", g.Shown, g.CardBottom, g.AvTop, g.OnScreen) |
| + } |
| + if d := g.CardStart - g.AvStart; d < -2 || d > 2 { |
| + t.Errorf("the card starts %dpx from the rail's inline start and the avatar %dpx; want it anchored at the avatar's inline start", g.CardStart, g.AvStart) |
| + } |
| + requireCardItems(t, "the drop-up", readProfile(t, ctx, p).Items) |
| + clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#main-link", 0.5, 0.5, 0, 0), "", |
| + `!document.querySelector("[rst-shell-profile]").open`) |
| + if scripts { |
| + mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible("#acct-profile", chromedp.ByQuery), |
| + chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| + if after := readProfile(t, ctx, p); after.Open || !after.Focus { |
| + t.Errorf("Escape from a link in the card: open %v, focus on the avatar %v", after.Open, after.Focus) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestThePersonNeverVanishesFromTheRail: an app with a profile and |
| +// nothing else, no language and no account links, still shows the |
| +// signed-in person's avatar, on a desktop at the rail's foot and on the |
| +// phone index at the top; the card then holds the name and email. Only |
| +// the shell's placeholder, an app with no profile at all and nothing to |
| +// choose, leaves the foot out — and "nothing to choose" now means the |
| +// app emptied the scheme block too, since the shipped shell always |
| +// offers the colour scheme and so always has a card worth opening. |
| +func TestThePersonNeverVanishesFromTheRail(t *testing.T) { |
| + // Three sidebar pages: the person with no language menu (a content |
| + // page, then the index), and the placeholder with no language menu |
| + // and no scheme menu either. |
| + pages := map[string]string{ |
| + "/person": profilePage(t, "TestThePersonNeverVanishesFromTheRail", "person"), |
| + "/person-idx": profilePage(t, "TestThePersonNeverVanishesFromTheRail", "person-idx"), |
| + "/placeholder": profilePage(t, "TestThePersonNeverVanishesFromTheRail", "placeholder"), |
| + } |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + shown := `(() => { const s = document.querySelector("[rst-shell-profile] > summary"); return JSON.stringify({Avatar: !!s && s.checkVisibility()}); })()` |
| + for _, c := range []struct { |
| + path string |
| + w, h int64 |
| + want bool |
| + }{{"/person", 1280, 720, true}, {"/person-idx", 390, 844, true}, {"/placeholder", 1280, 720, false}} { |
| + mustRun(t, ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+c.path), chromedp.WaitReady("body")) |
| + var got struct{ Avatar bool } |
| + at(t, ctx, shown, &got) |
| + if got.Avatar != c.want { |
| + t.Errorf("%s at %d: avatar shown %v, want %v", c.path, c.w, got.Avatar, c.want) |
| + } |
| + } |
| +} |
| + |
| +// TestABlankBrandFallsBackToTheTitle: a brand drawn from data that has |
| +// none renders only whitespace, and the phone index's heading is then |
| +// the page's title rather than nothing. |
| +func TestABlankBrandFallsBackToTheTitle(t *testing.T) { |
| + // The sidebar index with the title Harbour Ledger and a brand block |
| + // that renders only whitespace. |
| + pages := map[string]string{"/": profilePage(t, "TestABlankBrandFallsBackToTheTitle", "")} |
| + rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")) |
| + var v viewReading |
| + at(t, ctx, viewJS, &v) |
| + if v.Title != "Harbour Ledger" || v.H1s != 1 { |
| + t.Errorf("a whitespace brand: the index heading reads %q (%d h1s); want the title, Harbour Ledger", v.Title, v.H1s) |
| + } |
| +} |
diff --git a/check/rig_browser_test.go b/check/rig_browser_test.go| index 8db3ea4..27a87f1 100644 |
| --- a/check/rig_browser_test.go |
| +++ b/check/rig_browser_test.go |
| @@ -571,16 +571,6 @@ func classSpelling(t *testing.T, html string) string { |
| return out |
| } |
| -// ── Landed ahead of their drives ───────────────────────────────────── |
| - |
| -// The helpers above arrived before the drives that call them, which |
| -// the porting tasks bring one file at a time. Until then this list is |
| -// the use staticcheck's U1000 asks for. Each entry goes when its first |
| -// drive lands; the list goes with the last of them. |
| -var _ = []any{ |
| - classSpelling, |
| -} |
| - |
| // ── The fixture server's own test ──────────────────────────────────── |
| // scriptTags matches the script elements sizingDoc links, for a page |
diff --git a/check/shell_browser_test.go b/check/shell_browser_test.go| new file mode 100644 |
| index 0000000..44d8f9b |
| --- /dev/null |
| +++ b/check/shell_browser_test.go |
| @@ -0,0 +1,1879 @@ |
| +//go:build browser |
| + |
| +// Browser drives for the things a Go test cannot see, because they are |
| +// geometry a real engine computes: whether a menu opened inside a card |
| +// is actually painted outside it, whether a child that owns its own |
| +// corners keeps them inside one, and where the sidebar rail puts the |
| +// person and which way its language menu opens. |
| +// |
| +// Run with the rest of the family: |
| +// |
| +// go test -tags browser ./check/ |
| +// |
| +// There is no script under test in this file at all: every page here |
| +// is static markup plus tokens.css and a theme, and the only |
| +// interaction is a click on a <details> summary, which the browser |
| +// handles synchronously. The pages are the ones rastrillo's drives |
| +// rendered at SEED, read through fixture (testdata/fixtures/), and the |
| +// two routes every page links come from stylesheets |
| +// (rig_browser_test.go). |
| +package check |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/runtime" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// shellPiece is one seeded piece of this file's pages: the partial a |
| +// drive rendered at SEED, by the drive's name and the piece's. |
| +func shellPiece(t *testing.T, test, piece string) string { |
| + t.Helper() |
| + return fixture(t, "shell_browser_test.go", test, piece) |
| +} |
| + |
| +// clipReading is what the clipping drive measures. Overhang and |
| +// HitIsInMenu are the two halves of one claim: the menu really does |
| +// hang below the card (or the hit test proves nothing), and the part |
| +// that hangs below is really painted (or the card is still clipping). |
| +type clipReading struct { |
| + Overhang int |
| + HitIsInMenu bool |
| + Hit string |
| + FirstCorner string |
| + LastCorner string |
| + CardOverflow string |
| +} |
| + |
| +// TestAMenuOpenedInsideAListCardEscapesTheCard is the live-page bug |
| +// Paul found: a bulk bar's Actions menu opened inside a [rst-list] was |
| +// sliced off at the card's edge. |
| +// |
| +// The cause was [rst-list]'s overflow: hidden, which was there to clip |
| +// the rows' corners to the card's radius and, in doing so, made the |
| +// card a clipping context for every absolutely positioned panel inside |
| +// it. |
| +// |
| +// getBoundingClientRect is NOT the measurement: an ancestor's overflow |
| +// does not change a descendant's layout rect, so the menu's rect hangs |
| +// below the card whether or not it is visible. What clipping does |
| +// change is hit testing, so the assertion is elementFromPoint at a |
| +// spot below the card's own bottom edge: clipped, nothing of the menu |
| +// is there; unclipped, the menu is. |
| +// |
| +// The second half is the fix's own risk. Dropping the overflow must |
| +// not leave the first and last rows square inside a rounded card, so |
| +// the corner radii are read back off the engine too. |
| +func TestAMenuOpenedInsideAListCardEscapesTheCard(t *testing.T) { |
| + // The bulk bar as SEED rendered it: three selected, an Actions menu |
| + // of Export, Unpublish and a dangerous Delete. |
| + bulkBar := shellPiece(t, "TestAMenuOpenedInsideAListCardEscapesTheCard", "bulk-bar") |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + var body strings.Builder |
| + body.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` + |
| + `<title>list card</title><link rel="stylesheet" href="/tokens.css">` + |
| + `<link rel="stylesheet" href="/theme.css"></head><body>` + |
| + `<div rst-page><form method="post" action="/act">` + |
| + `<div rst-list>`) |
| + body.WriteString(bulkBar) |
| + body.WriteString(`</div></form></div></body></html>`) |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, body.String()) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-list] [rst-bulkbar]`, chromedp.ByQuery), |
| + // The menu is a native <details>: one real click on the |
| + // summary, no script anywhere. |
| + chromedp.Click(`[rst-list] [rst-bulkbar] details > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[rst-list] [rst-dropdown-menu]`, chromedp.ByQuery), |
| + chromedp.Evaluate(`(() => { |
| + const card = document.querySelector("[rst-list]"); |
| + const menu = document.querySelector("[rst-list] [rst-dropdown-menu]"); |
| + const c = card.getBoundingClientRect(); |
| + const m = menu.getBoundingClientRect(); |
| + // A point inside the menu and below the card's own bottom |
| + // edge — the part a clipping card throws away. |
| + const x = Math.round(m.left + m.width / 2); |
| + const y = Math.round(c.bottom + Math.max(2, (m.bottom - c.bottom) / 2)); |
| + const hit = document.elementFromPoint(x, y); |
| + const first = card.firstElementChild, last = card.lastElementChild; |
| + return JSON.stringify({ |
| + Overhang: Math.round(m.bottom - c.bottom), |
| + HitIsInMenu: !!(hit && menu.contains(hit)), |
| + Hit: hit ? (hit.tagName.toLowerCase() + "." + (hit.className || "")) : "nothing", |
| + FirstCorner: getComputedStyle(first).borderStartStartRadius, |
| + LastCorner: getComputedStyle(last).borderEndEndRadius, |
| + CardOverflow: getComputedStyle(card).overflowY |
| + }); |
| + })()`, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving the list card: %v", err) |
| + } |
| + |
| + var got clipReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("reading the measurement (%q): %v", raw, err) |
| + } |
| + |
| + // Sanity first: without an overhang the hit test below proves |
| + // nothing at all. |
| + if got.Overhang <= 4 { |
| + t.Fatalf("the open menu hangs only %dpx below the card; this drive cannot tell clipped from contained", got.Overhang) |
| + } |
| + if !got.HitIsInMenu { |
| + t.Errorf("a point %dpx of overhang inside the open menu hit %s, not the menu: the card is still clipping it (overflow-y: %s)", |
| + got.Overhang, got.Hit, got.CardOverflow) |
| + } |
| + // And the card still draws itself as a card: the rows it holds are |
| + // rounded to its corners rather than squared off at them. |
| + for _, corner := range []struct{ name, value string }{ |
| + {"the first row's start corner", got.FirstCorner}, |
| + {"the last row's end corner", got.LastCorner}, |
| + } { |
| + if corner.value == "" || corner.value == "0px" { |
| + t.Errorf("%s is %q — dropping the card's overflow left its rows square inside a rounded border", corner.name, corner.value) |
| + } |
| + } |
| +} |
| + |
| +// railReading is where the sidebar shell's rail puts things, in the |
| +// window Paul was looking at. |
| +type railReading struct { |
| + RailHeight int |
| + FootGap int |
| + MenuLift int |
| + // The frame the first version of this drive did not have. FootGap |
| + // answers "is the person at the foot of the rail", which was true |
| + // on the day the rail overflowed the window by its own padding — |
| + // the rail was 932px in a 900px viewport, the number was in this |
| + // test's own passing output, and the person was at the foot of a box |
| + // whose last 32px were under the fold. Viewport and the two |
| + // overhangs are what turn a claim about the rail into a claim about |
| + // the window it has to fit. |
| + Viewport int |
| + RailOverhang int |
| + PersonOverhang int |
| + // Whether the rail overflows, and — a different question that was |
| + // once measured as if it were the same one — whether it SCROLLS. |
| + // |
| + // RailScroll is scrollHeight - clientHeight: how much content does |
| + // not fit. That is a fine reading and a useless assertion, because |
| + // overflow: visible reports it too — with overflow-y removed and a |
| + // twenty-link rail it still read 371px. So it is the PREMISE now: |
| + // it says the fixture is big enough for this leg to mean anything. |
| + // |
| + // The claim is the three below it. RailOverflowY is what the engine |
| + // computed. RailScrolled is where scrollTop actually landed after |
| + // the box was asked to go to its own bottom, which a non-scrollable |
| + // box clamps to 0. PersonOverhangScrolled is the promise itself: |
| + // after that scroll, is the thing below the fold on screen. |
| + RailScroll int |
| + RailOverflowY string |
| + RailScrolled int |
| + PersonOverhangScrolled int |
| + LocaleBefore bool |
| + // The phone index's two: how far the foot sits from the top of the |
| + // rail, and where the open card ends. |
| + FootFromTop int |
| + MenuBottom int |
| +} |
| + |
| +// railMeasure is the one reading every leg of the rail drive takes, so |
| +// a viewport added below cannot quietly measure less than the ones |
| +// above it — which is how the frame went missing the first time. |
| +const railMeasure = `(() => { |
| + const rail = document.querySelector("[rst-shell-rail]"); |
| + const person = document.querySelector("[rst-shell-profile] > summary"); |
| + const loc = document.querySelector("#rail-locale"); |
| + const sum = person; |
| + const menu = document.querySelector("[rst-shell-profile] > [rst-dropdown-menu]"); |
| + // Both scrollers wound back to the top first. chromedp.Click scrolls |
| + // its target into view, and the locale summary sits at the foot of |
| + // the rail — so on a rail that overflows, opening the language menu |
| + // has already scrolled something, and every "before" reading below |
| + // would be a reading of an already-scrolled box. Which one it |
| + // scrolled depends on the very property under test: the rail, if the |
| + // rail is a scroll container, and otherwise the whole page. Winding |
| + // both back is what makes the two cases comparable. |
| + window.scrollTo(0, 0); |
| + rail.scrollTop = 0; |
| + const r = rail.getBoundingClientRect(); |
| + const p = person.getBoundingClientRect(); |
| + const s = sum.getBoundingClientRect(); |
| + const m = menu.getBoundingClientRect(); |
| + // Every rect above is taken BEFORE the scroll probe below, because |
| + // scrolling the rail moves everything inside it and FootGap is a |
| + // reading of where the person sits in an unscrolled rail. |
| + const overflowY = getComputedStyle(rail).overflowY; |
| + const railScroll = rail.scrollHeight - rail.clientHeight; |
| + rail.scrollTop = rail.scrollHeight; |
| + const scrolled = Math.round(rail.scrollTop); |
| + const pAfter = person.getBoundingClientRect(); |
| + const personAfter = Math.round(pAfter.bottom - window.innerHeight); |
| + rail.scrollTop = 0; |
| + return JSON.stringify({ |
| + RailHeight: Math.round(r.height), |
| + FootGap: Math.round(r.bottom - p.bottom), |
| + MenuLift: Math.round(s.top - m.bottom), |
| + Viewport: window.innerHeight, |
| + RailOverhang: Math.round(r.bottom - window.innerHeight), |
| + RailScroll: railScroll, |
| + RailOverflowY: overflowY, |
| + RailScrolled: scrolled, |
| + PersonOverhang: Math.round(p.bottom - window.innerHeight), |
| + PersonOverhangScrolled: personAfter, |
| + LocaleBefore: !!(loc.compareDocumentPosition(document.querySelector("#acct-profile")) & Node.DOCUMENT_POSITION_FOLLOWING), |
| + FootFromTop: Math.round(document.querySelector("[rst-shell-rail-foot]").getBoundingClientRect().top - r.top), |
| + MenuBottom: Math.round(m.bottom) |
| + }); |
| +})()` |
| + |
| +// TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward |
| +// is the other live-page bug: the sidebar shell left the person block |
| +// mid-rail, directly under the nav, with acres of empty rail below it. |
| +// |
| +// Two claims, both measured rather than asserted about the CSS text: |
| +// |
| +// 1. The person, now the profile menu's avatar, sits at the rail's |
| +// foot — the distance from its bottom to the bottom of the rail is |
| +// small. Before the fix it was most of a 900px window. |
| +// 2. The profile menu is a DROPUP. Its card's bottom edge is at or |
| +// above the avatar's top edge; a dropdown's would be below it. |
| +// |
| +// The 390 leg measures the phone index, where the rail is the whole |
| +// page and the same menu sits at the top beside the heading: the foot |
| +// is at the top of the rail and the open card stays on screen. |
| +func TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward(t *testing.T) { |
| + // The sidebar shell as SEED's drive rendered it: a two-link nav, |
| + // Grace Hopper as the person, one account link (#acct-profile) and |
| + // the language menu (#rail-locale, English and Gaeilge). Three |
| + // pages: the content page, the index (the view written as index), |
| + // and the tall page below. |
| + const drive = "TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward" |
| + html := shellPiece(t, drive, "page") |
| + indexHTML := shellPiece(t, drive, "index") |
| + |
| + // The same shell with a rail that does not fit a short window, for |
| + // the third leg below. A separate page rather than a taller nav on |
| + // the one above, because the first two legs assert numbers that a |
| + // twenty-link rail would move — "the person is at the foot" and |
| + // "the collapsed rail is not stretched" are claims about a rail |
| + // with room to spare, and they are the claims worth keeping. |
| + // |
| + // Twenty links is not decoration: the design system's own rail |
| + // carries about thirty, and the leg below exists because a rail |
| + // that overflows is the case a two-link fixture can never reach. |
| + // The tall page's twenty-link nav (Posts, Drafts, Comments, Media |
| + // and on to Settings) is what makes it overflow at 420px, and the |
| + // leg FAILS if it stops doing so. |
| + tallHTML := shellPiece(t, drive, "tall") |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + mux.HandleFunc("GET /index", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, indexHTML) |
| + }) |
| + mux.HandleFunc("GET /tall", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, tallHTML) |
| + }) |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, html) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery), |
| + chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery), |
| + chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery), |
| + chromedp.Evaluate(railMeasure, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving the sidebar rail: %v", err) |
| + } |
| + |
| + var got railReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("reading the measurement (%q): %v", raw, err) |
| + } |
| + |
| + // Logged, not just asserted: the number that gave the regression |
| + // away was already in this test's passing output — "a 932px rail" at |
| + // a 900px viewport — and nobody read it, because nothing in the |
| + // output said what it was supposed to be compared to. It says so now. |
| + t.Logf("wide: rail %dpx in a %dpx viewport (rail overhang %dpx, person overhang %dpx), person %dpx above the rail's foot", |
| + got.RailHeight, got.Viewport, got.RailOverhang, got.PersonOverhang, got.FootGap) |
| + |
| + // Sanity: the rail is the full-height column this claim is about. |
| + if got.RailHeight < 600 { |
| + t.Fatalf("the rail is only %dpx tall in a 900px window; it is not the sticky full-height rail this drive measures", got.RailHeight) |
| + } |
| + |
| + // THE FRAME. Everything below asks where things sit inside the rail; |
| + // this asks whether the rail sits inside the window, which is the |
| + // question the first version of this drive never put and the one the |
| + // regression it missed was an answer to. |
| + // |
| + // block-size: 100dvh is a promise about the viewport, so the box |
| + // that has to keep it is the BORDER box: with the rail as a content |
| + // box its padding was added on top, the border box came to |
| + // 100dvh + 2×var(--rst-sp-4), and a rail sticky at |
| + // inset-block-start: 0 hung its last 32px under the foot of the |
| + // window. overflow-y: auto is no defence — the content fits the |
| + // content box, so no scrollbar is ever owed — and no assertion about |
| + // where the person sits INSIDE the rail can see it. |
| + if got.RailHeight > got.Viewport { |
| + t.Errorf("the rail's border box is %dpx in a %dpx viewport: it is %dpx taller than the window it is sized against, so whatever sits at its foot is under the fold. box-sizing on the rail is the fix", |
| + got.RailHeight, got.Viewport, got.RailHeight-got.Viewport) |
| + } |
| + if got.RailOverhang > 0 { |
| + t.Errorf("the sticky rail's bottom edge is %dpx below the foot of a %dpx window", got.RailOverhang, got.Viewport) |
| + } |
| + // And the symptom, measured where a reader met it: the person block |
| + // at the rail's foot, clipped by the window rather than by the rail. |
| + if got.PersonOverhang > 0 { |
| + t.Errorf("the person block at the rail's foot ends %dpx below the foot of the window: it is off-screen with nothing to scroll to reach it", got.PersonOverhang) |
| + } |
| + // A rail's padding is var(--rst-sp-4); anything inside a couple of |
| + // steps of the bottom edge is at the foot, anything further is not. |
| + if got.FootGap > 48 { |
| + t.Errorf("the person block ends %dpx above the foot of a %dpx rail; the profile is not at the rail's foot", got.FootGap, got.RailHeight) |
| + } |
| + if !got.LocaleBefore { |
| + t.Error("the account's links come before the language switcher in the card; the language follows the name and the links close the card") |
| + } |
| + if got.MenuLift < 0 { |
| + t.Errorf("the profile card ends %dpx BELOW the avatar's top edge: it is a dropdown, not the dropup a menu at the foot of the rail has to be", -got.MenuLift) |
| + } |
| + |
| + // And the phone index, which replaced the drawer. The rail IS the |
| + // page there, at least the height of the window, and the profile |
| + // menu is lifted to its top, beside the heading: at the foot of a |
| + // long index it would be out of reach. So the foot's distance from |
| + // the rail's top is the measurement, and the card, language open, |
| + // is asserted to be on screen. |
| + var narrow string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(390, 780), |
| + chromedp.Navigate(rig.Origin+"/index"), |
| + chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery), |
| + chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery), |
| + chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery), |
| + chromedp.Evaluate(railMeasure, &narrow), |
| + ); err != nil { |
| + t.Fatalf("driving the phone index: %v", err) |
| + } |
| + var small railReading |
| + if err := json.Unmarshal([]byte(narrow), &small); err != nil { |
| + t.Fatalf("reading the index measurement (%q): %v", narrow, err) |
| + } |
| + t.Logf("index: rail %dpx in a %dpx viewport, foot %dpx from the rail's top", small.RailHeight, small.Viewport, small.FootFromTop) |
| + if small.RailHeight < small.Viewport-1 { |
| + t.Errorf("the phone index's rail is %dpx in a %dpx window; the rail is the page there", small.RailHeight, small.Viewport) |
| + } |
| + if small.FootFromTop < 0 || small.FootFromTop > 32 { |
| + t.Errorf("the profile menu is %dpx from the top of the index's rail; it belongs in the header row, by the rail's padding", small.FootFromTop) |
| + } |
| + if small.MenuBottom > small.Viewport { |
| + t.Errorf("the profile card on the index ends %dpx below the window", small.MenuBottom-small.Viewport) |
| + } |
| + |
| + // And a SHORT window, which is the other half of "fits the |
| + // viewport" and the half a desktop reviewer never sees. 1280×420 is |
| + // a laptop with the devtools open, or a phone in landscape: wide |
| + // enough for the full-height rail, too short for its content. |
| + // |
| + // The claim here is deliberately weaker than the one above, because |
| + // the honest one is weaker: the rail must still fit the window, and |
| + // whatever does not fit INSIDE the rail must be reachable by |
| + // scrolling it. A person below the fold of a rail that scrolls is |
| + // fine; a person below the fold of a rail that does not is the bug |
| + // this test is named after, one viewport smaller. |
| + // |
| + // Two things this leg used to get wrong, and both made it a gate |
| + // that gated nothing — removing overflow-y: auto from the rail |
| + // passed it green: |
| + // |
| + // - it ran on the two-link fixture, which fits 420px with room |
| + // to spare, so the overflow case it was written for never |
| + // arose. It now runs on /tall, and it FAILS if that page stops |
| + // overflowing: a leg whose premise has quietly become false is |
| + // worse than no leg, because it reads as coverage. |
| + // - it measured scrollHeight - clientHeight, which is overflow, |
| + // not scrollability: overflow: visible reports it too. It now |
| + // asks the engine what it computed, asks the box to scroll to |
| + // its own bottom, and asks whether the person came into view. |
| + var shortRaw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(1280, 420), |
| + chromedp.Navigate(rig.Origin+"/tall"), |
| + chromedp.WaitVisible(`[rst-shell-profile] > summary`, chromedp.ByQuery), |
| + chromedp.Click(`[rst-shell-profile] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#rail-locale > summary`, chromedp.ByQuery), |
| + chromedp.Click(`#rail-locale > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#rail-locale [rst-dropdown-menu]`, chromedp.ByQuery), |
| + chromedp.Evaluate(railMeasure, &shortRaw), |
| + ); err != nil { |
| + t.Fatalf("driving the rail in a short window: %v", err) |
| + } |
| + var short railReading |
| + if err := json.Unmarshal([]byte(shortRaw), &short); err != nil { |
| + t.Fatalf("reading the short-window measurement (%q): %v", shortRaw, err) |
| + } |
| + t.Logf("short: rail %dpx in a %dpx viewport (overhang %dpx, %dpx of content over, overflow-y %s), person overhang %dpx before the scroll, %dpx after it, scrollTop %dpx", |
| + short.RailHeight, short.Viewport, short.RailOverhang, short.RailScroll, short.RailOverflowY, |
| + short.PersonOverhang, short.PersonOverhangScrolled, short.RailScrolled) |
| + if short.RailHeight > short.Viewport { |
| + t.Errorf("the rail's border box is %dpx in a %dpx viewport: a short window overflows for the same reason a tall one did", short.RailHeight, short.Viewport) |
| + } |
| + if short.RailOverhang > 0 { |
| + t.Errorf("the sticky rail hangs %dpx below the foot of a %dpx window", short.RailOverhang, short.Viewport) |
| + } |
| + // The premise, asserted before anything that depends on it, and in |
| + // terms of the FIXTURE rather than of the property under test: if |
| + // the rail's content fits, every assertion below is vacuously true |
| + // and this leg is decoration, which is exactly what it was. |
| + if short.RailScroll <= 0 { |
| + t.Fatalf("the tall rail's content fits inside a %dpx rail (%dpx over): this leg is measuring nothing. Give the tall fixture's nav more links.", short.RailHeight, short.RailScroll) |
| + } |
| + if short.PersonOverhang <= 0 { |
| + t.Fatalf("the person is %dpx ABOVE the fold of a %dpx window before anything scrolls: the case this leg exists for has not arisen. Give the tall fixture's nav more links.", -short.PersonOverhang, short.Viewport) |
| + } |
| + // It overflows, so it has to scroll. Three readings, because the |
| + // one that used to be here could not tell a scrollable box from a |
| + // leaking one. |
| + if short.RailOverflowY != "auto" && short.RailOverflowY != "scroll" { |
| + t.Errorf("the rail computed overflow-y: %s. Its content is %dpx below the fold of a %dpx window with no way to bring it back", short.RailOverflowY, short.PersonOverhang, short.Viewport) |
| + } |
| + if short.RailScrolled <= 0 { |
| + t.Errorf("the rail was asked to scroll to its own bottom and scrollTop stayed at %dpx: it does not scroll, it just overflows", short.RailScrolled) |
| + } |
| + if short.PersonOverhangScrolled > 0 { |
| + t.Errorf("the person block is still %dpx below the window after the rail was scrolled to its bottom: it is unreachable, not merely off-screen", short.PersonOverhangScrolled) |
| + } |
| +} |
| + |
| +// barReading is one measurement of the topbar shell's bar: what the |
| +// collapse control and the tail are doing, how the tail's three blocks |
| +// are laid out, and whether the bar spills sideways. |
| +type barReading struct { |
| + MenuShown bool // the disclosure's summary is drawn |
| + MenuOpen bool // ...and its open attribute is set |
| + TailShown bool // nav/account/locale are on the page at all |
| + Rows int // distinct block-start positions among the three: 1 = inline, 3 = stacked |
| + AccountEndPx int // gap from the end of the account, language and scheme menus to the bar's |
| + AccountMenu bool // the account dropdown's panel is drawn |
| + Overflow int // how far the document scrolls past its own client width |
| + MainTop int // main's block-start edge: the page under the bar |
| +} |
| + |
| +// TestTheTopbarCollapsesItsTailBehindOneDisclosure is §9: the topbar |
| +// shell had no narrow layout at all. [rst-shell-bar] is a wrapping flex |
| +// row and [rst-shell-account] has margin-inline-start: auto, so |
| +// narrowing the window did not collapse anything — it wrapped the |
| +// account and locale menus onto a second row and shoved them to the |
| +// trailing edge. Nothing was broken; nothing had been written. |
| +// |
| +// Three claims, in the order they matter: |
| +// |
| +// 1. Wide, nothing changed. Above 800px the disclosure is not drawn and |
| +// nav, account and locale sit inline on the bar's own row with the |
| +// account at the inline end, which is the layout that shipped before |
| +// the collapse existed. |
| +// 2. Narrow, one control. Below 800px the tail is not drawn until the |
| +// disclosure is opened, and opened it is a card over the page: three |
| +// stacked rows that push nothing down. |
| +// 3. THE TRAP. The account menu is <details name="rst-menus">. Opening |
| +// it must not close the navigation it was opened from. <details |
| +// name> exclusivity is document-wide rather than sibling-scoped, so |
| +// this is not bought by keeping the two elements out of each other's |
| +// subtree — the disclosure has to be in a different group, and this |
| +// is the assertion that says so out loud. |
| +// |
| +// There is no script on this page: the shell's <script> tags resolve to |
| +// addresses this mux does not serve, exactly as the rail drive above |
| +// leaves them. So everything here is <details>, a media query and two |
| +// display rules, which is the claim §9 makes. |
| +func TestTheTopbarCollapsesItsTailBehindOneDisclosure(t *testing.T) { |
| + // The topbar shell as SEED's drive rendered it: a two-link nav, two |
| + // account links, the language menu as #bar-locale, and the scheme |
| + // menu the layout ships, which the four-row count below includes. |
| + html := shellPiece(t, "TestTheTopbarCollapsesItsTailBehindOneDisclosure", "") |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, html) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| + defer cancel() |
| + |
| + const measure = `(() => { |
| + const shown = el => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; |
| + const bar = document.querySelector("[rst-shell-bar]"); |
| + const menu = document.querySelector("[rst-shell-menu]"); |
| + const tail = document.querySelector("[rst-shell-tail]"); |
| + const nav = document.querySelector("[rst-shell-nav]"); |
| + const account = document.querySelector("[rst-shell-account]"); |
| + const locale = document.querySelector("#bar-locale"); |
| + const scheme = document.querySelector("[rst-scheme]"); |
| + const panel = account.querySelector("[rst-dropdown-menu]"); |
| + // Rows by overlap, not by equal tops: the bar is align-items: |
| + // center, so three inline blocks of three different heights sit on |
| + // one row with three different top edges. Two boxes are on the |
| + // same row when their vertical ranges overlap. |
| + const boxes = [nav, account, locale, scheme].filter(shown) |
| + .map(el => el.getBoundingClientRect()).sort((a, b) => a.top - b.top); |
| + let rows = 0, edge = -Infinity; |
| + for (const b of boxes) { |
| + if (b.top >= edge - 1) rows++; |
| + if (b.bottom > edge) edge = b.bottom; |
| + } |
| + const de = document.documentElement; |
| + return JSON.stringify({ |
| + MenuShown: shown(menu.querySelector("summary")), |
| + MenuOpen: menu.open, |
| + TailShown: shown(nav) || shown(account) || shown(locale) || shown(scheme), |
| + Rows: rows, |
| + // The account menu's margin-inline-start: auto pushes it and the |
| + // language and scheme menus after it to the bar's end, so the gap |
| + // is read after the last of the three that is drawn. |
| + AccountEndPx: Math.round(bar.getBoundingClientRect().right - Math.max(...[account, locale, scheme].filter(shown).map(el => el.getBoundingClientRect().right))), |
| + AccountMenu: shown(panel), |
| + Overflow: de.scrollWidth - de.clientWidth, |
| + MainTop: Math.round(document.querySelector("main").getBoundingClientRect().top) |
| + }); |
| + })()` |
| + |
| + read := func(t *testing.T, raw string) barReading { |
| + t.Helper() |
| + var got barReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("reading the measurement (%q): %v", raw, err) |
| + } |
| + return got |
| + } |
| + |
| + // 1. Wide: the collapse is not there. |
| + var wideRaw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &wideRaw), |
| + ); err != nil { |
| + t.Fatalf("driving the wide topbar: %v", err) |
| + } |
| + wide := read(t, wideRaw) |
| + if wide.MenuShown { |
| + t.Error("the collapse disclosure is drawn at 1280px; above 800px the bar lays its tail out inline and the control has nothing to do") |
| + } |
| + if !wide.TailShown { |
| + t.Fatal("nav, account and locale are not drawn at 1280px at all: the wide layout is gone, not preserved") |
| + } |
| + if wide.Rows != 1 { |
| + t.Errorf("the bar's tail blocks sit on %d rows at 1280px, want 1 — the wide layout is one row and the collapse must not have changed it", wide.Rows) |
| + } |
| + if wide.AccountEndPx > 120 { |
| + t.Errorf("the account, language and scheme menus end %dpx short of the bar's inline end; margin-inline-start: auto is no longer reaching the account menu", wide.AccountEndPx) |
| + } |
| + |
| + // 1b. And at exactly 800px, which is the breakpoint itself. A |
| + // min-width query matches AT its own width, so 800 is the wide |
| + // layout and 799 is the narrow one; an off-by-one here is a window |
| + // width at which the bar has neither layout. |
| + var edgeRaw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(800, 780), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-bar]`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &edgeRaw), |
| + ); err != nil { |
| + t.Fatalf("driving the topbar at the breakpoint: %v", err) |
| + } |
| + if edge := read(t, edgeRaw); edge.MenuShown || !edge.TailShown || edge.Rows != 1 { |
| + t.Errorf("at exactly 800px the bar shows control=%v tail=%v on %d rows; a min-width query matches at its own width, so 800px is the wide layout", |
| + edge.MenuShown, edge.TailShown, edge.Rows) |
| + } |
| + |
| + // 2. Narrow: one control, and the tail behind it. |
| + var closedRaw, openRaw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(390, 780), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &closedRaw), |
| + chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-nav]`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &openRaw), |
| + ); err != nil { |
| + t.Fatalf("driving the collapsed topbar: %v", err) |
| + } |
| + closed, open := read(t, closedRaw), read(t, openRaw) |
| + if !closed.MenuShown { |
| + t.Fatal("there is no collapse control at 390px: the topbar still has no narrow layout") |
| + } |
| + if closed.TailShown { |
| + t.Error("nav, account and locale are still drawn at 390px with the disclosure closed; the tail did not collapse, it only gained a button") |
| + } |
| + if closed.Overflow > 1 { |
| + t.Errorf("the collapsed topbar spills %dpx sideways at 390px", closed.Overflow) |
| + } |
| + if !open.TailShown { |
| + t.Fatal("opening the disclosure at 390px revealed nothing") |
| + } |
| + if open.Rows != 4 { |
| + t.Errorf("the opened tail puts its four blocks (nav, account, language, scheme) on %d rows, want 4 — collapsed, they stack; a wrapped row is the state this replaced", open.Rows) |
| + } |
| + // The opened tail is a card OVER the page, not a column in the |
| + // bar's flow: the page under it does not move. |
| + if open.MainTop != closed.MainTop { |
| + t.Errorf("opening the disclosure moved the page down %dpx; the tail is a card that overlays it", open.MainTop-closed.MainTop) |
| + } |
| + if open.Overflow > 1 { |
| + t.Errorf("the opened topbar spills %dpx sideways at 390px", open.Overflow) |
| + } |
| + |
| + // 3. The trap: opening the account menu must not close the |
| + // navigation it was opened from. |
| + var trapRaw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.Click(`[rst-shell-account] > summary`, chromedp.ByQuery), |
| + // Settle rather than WaitVisible: the failure this step exists |
| + // for is the account menu never becoming visible, because it |
| + // closed the disclosure it lives behind. Waiting for it would |
| + // report that as a timeout instead of as the three sentences |
| + // below, and a timeout says nothing about <details name>. |
| + chromedp.Sleep(250*time.Millisecond), |
| + chromedp.Evaluate(measure, &trapRaw), |
| + ); err != nil { |
| + t.Fatalf("opening the account menu inside the collapsed tail: %v", err) |
| + } |
| + trap := read(t, trapRaw) |
| + if !trap.MenuOpen { |
| + t.Error("opening the account menu CLOSED the disclosure it sits behind: the two <details> are in one exclusivity group, and <details name> exclusivity is document-wide rather than sibling-scoped. Give the disclosure a group of its own") |
| + } |
| + if !trap.TailShown { |
| + t.Error("the tail vanished when the account menu opened; the navigation a reader opened the menu from is gone from under them") |
| + } |
| + if !trap.AccountMenu { |
| + t.Error("the account menu's panel is not drawn inside the collapsed tail") |
| + } |
| + |
| + // 4. And the smallest viewport the shells promise, with the tail |
| + // open, because a stack that fits at 390px can still spill at 320. |
| + var tinyRaw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(320, 640), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| + chromedp.Click(`[rst-shell-menu] > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`[rst-shell-tail] [rst-shell-nav]`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &tinyRaw), |
| + ); err != nil { |
| + t.Fatalf("driving the collapsed topbar at 320px: %v", err) |
| + } |
| + if tiny := read(t, tinyRaw); tiny.Overflow > 1 { |
| + t.Errorf("the opened topbar spills %dpx sideways at 320px", tiny.Overflow) |
| + } |
| +} |
| + |
| +// cornerReading is one element's four logical corner radii, as strings |
| +// straight off getComputedStyle so a comparison is exact rather than |
| +// parsed-and-rounded. |
| +type cornerReading struct { |
| + StartStart string |
| + StartEnd string |
| + EndStart string |
| + EndEnd string |
| +} |
| + |
| +func (c cornerReading) String() string { |
| + return c.StartStart + "/" + c.StartEnd + " " + c.EndStart + "/" + c.EndEnd |
| +} |
| + |
| +func (c cornerReading) uniform() bool { |
| + return c.StartStart == c.StartEnd && c.StartStart == c.EndStart && c.StartStart == c.EndEnd |
| +} |
| + |
| +// TestSelfShapedChildrenKeepTheirCornersInsideACard gates the cascade |
| +// relationship the corner-rounding rules depend on, which a comment |
| +// cannot: the rules are wrapped in :where() so they weigh (0,0,0), and |
| +// therefore lose to ANY child that declares a radius of its own. |
| +// |
| +// Written before it was true. A bare `[rst-list] > :first-child` weighs |
| +// (0,2,0) and beats [rst-search]'s and [rst-empty]'s (0,1,0) in every |
| +// source order, so a hand-written <form rst-search> as the |
| +// direct first child of a list card painted 7px on its top corners and |
| +// 6px on its bottom ones in day — lopsided, and the exact arrangement |
| +// ui/partials/list-bar-search.html exists to support. The gallery does |
| +// not show it, because list-bar-search renders inside a <search> |
| +// landmark and the direct child is that unpainted element, so this gate |
| +// is built on bare markup rather than on a rendered page. |
| +// |
| +// The assertion is deliberately theme-independent and token-independent: |
| +// the SAME element is rendered inside a card and outside one, and the |
| +// two readings must agree. No pixel value appears in this test, so a |
| +// theme that changes its radii cannot make it stale. Every theme is |
| +// measured, because --rst-radius and --rst-radius-sm differ between them |
| +// and a rule that ties in one could win in another. |
| +func TestSelfShapedChildrenKeepTheirCornersInsideACard(t *testing.T) { |
| + // list-bar-search as SEED rendered it, with /posts as its action. |
| + searchForm := shellPiece(t, "TestSelfShapedChildrenKeepTheirCornersInsideACard", "list-bar-search") |
| + // The partial wraps its form in the <search> landmark. Strip that |
| + // wrapper: the bare form as a card's direct child is the case under |
| + // test, and it is the case an app hand-writes. |
| + bare := searchForm |
| + if i, j := strings.Index(bare, "<form"), strings.LastIndex(bare, "</form>"); i >= 0 && j > i { |
| + bare = bare[i : j+len("</form>")] |
| + } else { |
| + t.Fatalf("list-bar-search no longer renders a <form>: %q", searchForm) |
| + } |
| + empty := `<div rst-empty><p>Nothing here yet.</p></div>` |
| + row := `<div rst-row><span>A row</span></div>` |
| + |
| + // Each case: a self-shaped element in the position under test |
| + // inside a card, and the same element loose on the page. Plus the |
| + // negative control — a plain row, which declares no radius and so |
| + // MUST take the card's corners, or the rule has stopped working |
| + // altogether and the rest of this test would pass vacuously. |
| + cases := []struct { |
| + name, inCard, loose string |
| + wantOwn bool |
| + }{ |
| + {"a bare search form as the first child", `<div rst-list>` + bare + row + `</div>`, bare, true}, |
| + {"an empty state as the last child", `<div rst-list>` + row + empty + `</div>`, empty, true}, |
| + {"a plain row as the first child", `<div rst-list>` + row + row + `</div>`, row, false}, |
| + } |
| + |
| + var body strings.Builder |
| + for i, c := range cases { |
| + fmt.Fprintf(&body, `<div id="in-%d" rst-page>%s</div><div id="out-%d" rst-page>%s</div>`, i, c.inCard, i, c.loose) |
| + } |
| + pageHTML := body.String() |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /tokens.css", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Write(fiesta.TokensCSS()) |
| + }) |
| + // One route per theme, named by ThemeNames() rather than hardcoded: |
| + // a fourth theme has to appear here without an edit. |
| + for _, name := range fiesta.ThemeNames() { |
| + css, ok := fiesta.ThemeCSS(name) |
| + if !ok { |
| + t.Fatalf("ThemeCSS(%q) reports no such theme", name) |
| + } |
| + mux.HandleFunc("GET /theme-"+name+".css", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/css") |
| + w.Write(css) |
| + }) |
| + } |
| + mux.HandleFunc("GET /{theme}/", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprintf(w, `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>corners</title>`+ |
| + `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme-%s.css">`+ |
| + `</head><body>%s</body></html>`, r.PathValue("theme"), pageHTML) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| + defer cancel() |
| + |
| + const measure = `(() => { |
| + const read = el => { |
| + const s = getComputedStyle(el); |
| + return { |
| + StartStart: s.borderStartStartRadius, StartEnd: s.borderStartEndRadius, |
| + EndStart: s.borderEndStartRadius, EndEnd: s.borderEndEndRadius |
| + }; |
| + }; |
| + const out = []; |
| + let i = 0; |
| + for (;;) { |
| + const inCard = document.querySelector("#in-" + i); |
| + const loose = document.querySelector("#out-" + i); |
| + if (!inCard || !loose) break; |
| + // The element under test is the first child of the card for the |
| + // first-child cases and the last for the last-child ones; the |
| + // loose copy is the page div's only child either way. |
| + const probe = loose.firstElementChild; |
| + // The identity of the element under test. It used to be the class |
| + // list; the vocabulary is attributes now, so an empty state and a |
| + // row both read as "DIV." and the wrong child was measured. The |
| + // attribute NAMES are the signature — never their values, so a |
| + // variant cannot change what matches — with id left out, because |
| + // that is what tells the two copies apart. |
| + const sig = el => el.tagName + "|" + |
| + [...el.attributes].map(a => a.name).filter(n => n !== "id").sort().join(","); |
| + const tag = sig(probe); |
| + const card = inCard.firstElementChild; |
| + let mine = null; |
| + for (const kid of card.children) { |
| + if (mine === null && sig(kid) === tag) { mine = kid; } |
| + } |
| + out.push({ In: mine ? read(mine) : null, Out: read(probe) }); |
| + i++; |
| + } |
| + return JSON.stringify(out); |
| + })()` |
| + |
| + for _, theme := range fiesta.ThemeNames() { |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(1280, 900), |
| + chromedp.Navigate(rig.Origin+"/"+theme+"/"), |
| + chromedp.WaitVisible(`#in-0`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &raw), |
| + ); err != nil { |
| + t.Fatalf("%s: driving the corner probe: %v", theme, err) |
| + } |
| + var got []struct{ In, Out *cornerReading } |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the measurement (%q): %v", theme, raw, err) |
| + } |
| + if len(got) != len(cases) { |
| + t.Fatalf("%s: measured %d cases, want %d", theme, len(got), len(cases)) |
| + } |
| + for i, c := range cases { |
| + name := theme + ": " + c.name |
| + if got[i].In == nil { |
| + t.Errorf("%s: the element was not found inside the card", name) |
| + continue |
| + } |
| + in, out := *got[i].In, *got[i].Out |
| + if c.wantOwn { |
| + if in != out { |
| + t.Errorf("%s: it computes %s inside a card and %s outside one — the card's corner rule is out-weighing the child's own radius", name, in, out) |
| + } |
| + // The symptom a reader sees is lopsidedness, so name it |
| + // as well as the mismatch: a self-shaped child that |
| + // keeps one pair of corners and loses the other reads |
| + // as a rendering bug, not as a cascade one. |
| + if !in.uniform() { |
| + t.Errorf("%s: its corners are %s inside a card — lopsided, so only some of them lost the cascade", name, in) |
| + } |
| + continue |
| + } |
| + // The negative control. Without it every assertion above |
| + // would also pass if the corner rules were simply deleted. |
| + if in == out { |
| + t.Errorf("%s: it computes the same %s inside a card as outside one; the card is no longer rounding the rows that have no radius of their own", name, in) |
| + } |
| + if in.StartStart == "0px" { |
| + t.Errorf("%s: it computes %s inside a card — the card's own corner rule is not reaching a full-bleed row", name, in) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// menuCorner is one corner of the window with a twelve-item menu in it — |
| +// the shape of the bug §6-v2.1b was written about, since the language |
| +// menu is exactly twelve entries and measures 388px. |
| +type menuCorner struct { |
| + id string |
| + style string |
| + // short cuts the menu to three items and mid says it sits in open |
| + // window with room on every side. They go together, and they exist |
| + // for one reason: a corner fixture cannot tell a right default from |
| + // a wrong one. flip-block is symmetric, so a stylesheet that opened |
| + // every menu UPWARD by default would be flipped back to correct in |
| + // all four corners and the drive would stay green while every |
| + // ordinary page in every app dropped its menus the wrong way. That |
| + // mutation was run against the first version of this drive and it |
| + // passed. The mid fixture is what fails it. |
| + short bool |
| + mid bool |
| +} |
| + |
| +// Each corner's menu is the piece SEED's drive rendered for it: the |
| +// dropdown partial with twelve entries, except bl, which is the |
| +// hand-written row menu (so the drive covers BOTH panels the rule |
| +// names), and mid, which has three. |
| +var menuCorners = []menuCorner{ |
| + {id: "tl", style: "inset-block-start:8px;inset-inline-start:8px"}, |
| + {id: "tr", style: "inset-block-start:8px;inset-inline-end:8px"}, |
| + {id: "bl", style: "inset-block-end:8px;inset-inline-start:8px"}, |
| + {id: "br", style: "inset-block-end:8px;inset-inline-end:8px"}, |
| + {id: "mid", style: "inset-block-start:50%;inset-inline-start:40%", short: true, mid: true}, |
| +} |
| + |
| +// menuReading is one opened menu, measured against the window it opened |
| +// in. Every field is here because an assertion below needs it AND |
| +// because a failure is unreadable without it: "the menu did not flip" |
| +// says nothing without the two rectangles that prove it. |
| +type menuReading struct { |
| + SummaryTop, SummaryBottom int |
| + SummaryLeft, SummaryRight int |
| + MenuTop, MenuBottom int |
| + MenuLeft, MenuRight int |
| + ScrollHeight, ClientHeight int |
| + MaxBlockSize, OverflowY string |
| + SupportsArea, SupportsTry bool |
| + SupportsScope, SupportsNonsense bool |
| + SupportsControl bool |
| + ViewportW, ViewportH int |
| + Direction string |
| +} |
| + |
| +// menuMeasure opens every menu on the page and reads it back. The |
| +// capability probes ride along with the geometry deliberately: they are |
| +// read from the same engine in the same run, so a failure names both |
| +// halves at once. |
| +const menuMeasure = `(() => { |
| + const out = {}; |
| + for (const box of document.querySelectorAll('[data-corner]')) { |
| + const d = box.querySelector('details'); |
| + d.open = true; |
| + const s = d.querySelector('summary').getBoundingClientRect(); |
| + const m = d.querySelector('[rst-dropdown-menu], [rst-row-menu-panel]'); |
| + const r = m.getBoundingClientRect(); |
| + const cs = getComputedStyle(m); |
| + out[box.dataset.corner] = { |
| + SummaryTop: Math.round(s.top), SummaryBottom: Math.round(s.bottom), |
| + SummaryLeft: Math.round(s.left), SummaryRight: Math.round(s.right), |
| + MenuTop: Math.round(r.top), MenuBottom: Math.round(r.bottom), |
| + MenuLeft: Math.round(r.left), MenuRight: Math.round(r.right), |
| + ScrollHeight: m.scrollHeight, ClientHeight: m.clientHeight, |
| + MaxBlockSize: cs.maxBlockSize, OverflowY: cs.overflowY, |
| + SupportsArea: CSS.supports('position-area: block-end'), |
| + SupportsTry: CSS.supports('position-try-fallbacks: flip-block'), |
| + SupportsScope: CSS.supports('anchor-scope: --rst-menu'), |
| + SupportsNonsense: CSS.supports('position-area: rastrillo-no-such-value'), |
| + SupportsControl: CSS.supports('color: red'), |
| + ViewportW: window.innerWidth, ViewportH: window.innerHeight, |
| + Direction: getComputedStyle(document.documentElement).direction, |
| + }; |
| + } |
| + return JSON.stringify(out); |
| +})()` |
| + |
| +// TestMenusCapTheirHeightScrollAndFlipToFitTheViewport is design spec |
| +// §6-v2.1b, sub-sections 1 and 2, in one drive because they are one |
| +// surface: [rst-dropdown-menu] and [rst-row-menu-panel] had no height |
| +// cap, no scroll, and no idea what was underneath them. |
| +// |
| +// THE CAP AND THE SCROLL are the everywhere half. A twelve-locale |
| +// language menu is 388px; on a short window its last entries were |
| +// simply unreachable, with nothing to drag. The drive requires the menu |
| +// to be really scrolling — scrollHeight above clientHeight — so a |
| +// future change that makes the fixture short enough to fit fails here |
| +// rather than quietly measuring nothing. |
| +// |
| +// THE FLIP is CSS anchor positioning, and it is Chromium-only today. |
| +// This drive runs Chromium, so it does not ask permission: the flip is |
| +// asserted flat, and the four capability probes are asserted alongside |
| +// it. That combination is the point, and it is what stops this being |
| +// the tenth gate on this branch that gated nothing: |
| +// |
| +// - The geometry is asserted unconditionally. If the properties are |
| +// renamed, dropped, or land behind a flag, the menu stops flipping |
| +// and this goes red. There is no `if supported` for it to fall |
| +// through. |
| +// - The probes are asserted TRUE for the exact declarations |
| +// tokens.css writes. A rename that CSS.supports starts answering |
| +// false to fails here too, which is what tells the next reader |
| +// WHY the geometry broke. |
| +// - The probe is controlled in both directions: a nonsense |
| +// position-area value must be answered FALSE and `color: red` must |
| +// be answered TRUE. A CSS.supports that has started agreeing with |
| +// everything, or refusing everything, cannot make this test green. |
| +// - The fixture is proved to NEED the flip before the flip is |
| +// asserted: the bottom corners are checked to have less room below |
| +// the summary than the menu is tall. Without that a taller window |
| +// would pass this test with no flip happening at all. |
| +// |
| +// RTL is a second pass over the same fixture, since every declaration |
| +// involved is a logical one and the inline flip is the half that would |
| +// give that away. |
| +func TestMenusCapTheirHeightScrollAndFlipToFitTheViewport(t *testing.T) { |
| + // Twelve, because twelve locales is the menu that broke. The |
| + // autonyms are transliterated in the seeded pieces rather than in |
| + // their own scripts: this drive measures boxes, and a font fallback |
| + // that changed a glyph's width would move a number nobody meant to |
| + // move. The mid corner's piece has three entries, so that fixture |
| + // has room on every side and the default drop direction is what |
| + // shows: twelve would not fit above AND below a 620px window, and |
| + // a fixture that only fits one way is a corner again. |
| + render := func(dir string) string { |
| + var b strings.Builder |
| + b.WriteString(`<!doctype html><html lang="en" dir="` + dir + `"><head><meta charset="utf-8">` + |
| + `<title>menus</title><link rel="stylesheet" href="/tokens.css">` + |
| + `<link rel="stylesheet" href="/theme.css"></head><body>`) |
| + for _, c := range menuCorners { |
| + b.WriteString(`<div data-corner="` + c.id + `" style="position:fixed;` + c.style + `">`) |
| + b.WriteString(shellPiece(t, "TestMenusCapTheirHeightScrollAndFlipToFitTheViewport", c.id)) |
| + b.WriteString(`</div>`) |
| + } |
| + b.WriteString(`</body></html>`) |
| + return b.String() |
| + } |
| + |
| + ltr, rtl := render("ltr"), render("rtl") |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, ltr) |
| + }) |
| + mux.HandleFunc("GET /rtl", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, rtl) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + for _, leg := range []struct{ name, path string }{{"ltr", "/"}, {"rtl", "/rtl"}} { |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + // 620px is short on purpose: it is the window in which a |
| + // 388px menu opened from either bottom corner has nowhere |
| + // to go, which is the whole subject. |
| + chromedp.EmulateViewport(760, 620), |
| + chromedp.Navigate(rig.Origin+leg.path), |
| + chromedp.WaitVisible(`[data-corner="tl"] details`, chromedp.ByQuery), |
| + chromedp.Evaluate(menuMeasure, &raw), |
| + ); err != nil { |
| + t.Fatalf("%s: driving the menus: %v", leg.name, err) |
| + } |
| + var got map[string]menuReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the measurement (%q): %v", leg.name, raw, err) |
| + } |
| + |
| + for _, c := range menuCorners { |
| + m, ok := got[c.id] |
| + if !ok { |
| + t.Fatalf("%s/%s: the menu was never measured", leg.name, c.id) |
| + } |
| + where := leg.name + "/" + c.id |
| + t.Logf("%s: summary [%d,%d]-[%d,%d], menu [%d,%d]-[%d,%d] in %dx%d; %d of %dpx shown, max-block-size %s", |
| + where, m.SummaryLeft, m.SummaryTop, m.SummaryRight, m.SummaryBottom, |
| + m.MenuLeft, m.MenuTop, m.MenuRight, m.MenuBottom, |
| + m.ViewportW, m.ViewportH, m.ClientHeight, m.ScrollHeight, m.MaxBlockSize) |
| + |
| + // THE PROBE, AND THE PROBE'S OWN CONTROL. Read first, |
| + // because everything below it is meaningless if CSS.supports |
| + // has stopped discriminating. |
| + if !m.SupportsControl { |
| + t.Fatalf("%s: CSS.supports says this engine does not support `color: red`; the probe itself is broken, so nothing this drive reports about anchor positioning can be believed", where) |
| + } |
| + if m.SupportsNonsense { |
| + t.Fatalf("%s: CSS.supports agreed to a position-area value that does not exist; it is answering true to everything, so a support check here would be worthless", where) |
| + } |
| + if !m.SupportsArea || !m.SupportsTry || !m.SupportsScope { |
| + t.Fatalf("%s: this engine reports position-area %v, position-try-fallbacks %v, anchor-scope %v. tokens.css writes all three; if one has been renamed the menus below have silently stopped flipping", where, m.SupportsArea, m.SupportsTry, m.SupportsScope) |
| + } |
| + |
| + // THE CAP AND THE SCROLL. |
| + if m.OverflowY != "auto" && m.OverflowY != "scroll" { |
| + t.Errorf("%s: the menu computes overflow-y: %s, so a menu taller than the window still cannot be scrolled", where, m.OverflowY) |
| + } |
| + switch { |
| + case c.short && m.ScrollHeight > m.ClientHeight: |
| + // The mid fixture's three entries have to FIT, or it is |
| + // not the "room on every side" fixture the default |
| + // direction is read off below. |
| + t.Errorf("%s: the three-item menu is scrolling — %d of %dpx shown — so it no longer has room on every side and cannot pin the default direction", where, m.ClientHeight, m.ScrollHeight) |
| + case !c.short && m.ScrollHeight <= m.ClientHeight: |
| + t.Errorf("%s: the menu shows all %dpx of its %dpx of entries, so this fixture is not tall enough to prove anything about the cap. Add entries or shorten the window.", where, m.ClientHeight, m.ScrollHeight) |
| + } |
| + if height := m.MenuBottom - m.MenuTop; height > m.ViewportH { |
| + t.Errorf("%s: the menu is %dpx tall in a %dpx window — the cap is not holding", where, height, m.ViewportH) |
| + } |
| + |
| + // IN VIEW AT ALL. The plainest statement of what Paul |
| + // asked for, and the one that fails whichever of the two |
| + // flips has stopped working. |
| + if m.MenuTop < -1 || m.MenuLeft < -1 || m.MenuBottom > m.ViewportH+1 || m.MenuRight > m.ViewportW+1 { |
| + t.Errorf("%s: the menu sits at [%d,%d]-[%d,%d], outside the %dx%d window", where, |
| + m.MenuLeft, m.MenuTop, m.MenuRight, m.MenuBottom, m.ViewportW, m.ViewportH) |
| + } |
| + |
| + // THE BLOCK AXIS, and first the proof that this fixture is |
| + // the one it claims to be. Without those checks a taller |
| + // window would make the corner assertions pass with nothing |
| + // flipping, and a mid fixture that had drifted into a |
| + // corner would stop pinning anything. |
| + menuHeight := m.MenuBottom - m.MenuTop |
| + above, below := m.SummaryTop, m.ViewportH-m.SummaryBottom |
| + switch { |
| + case c.mid: |
| + // THE DEFAULT DIRECTION. A corner fixture cannot assert |
| + // this: flip-block is symmetric, so a stylesheet that |
| + // preferred block-start would be flipped back to |
| + // correct in every corner and this drive would stay |
| + // green while every ordinary page opened its menus |
| + // upward. Here there is room both ways, so what shows |
| + // IS the default. |
| + if above < menuHeight+4 || below < menuHeight+4 { |
| + t.Fatalf("%s: the menu is %dpx tall with %dpx above the summary and %dpx below. This fixture is supposed to have room on BOTH sides — with room on only one, flip-block rescues a wrong default and the assertion below stops meaning anything.", where, menuHeight, above, below) |
| + } |
| + if m.MenuTop < m.SummaryBottom-1 { |
| + t.Errorf("%s: the menu's head is at %d, above the summary's foot at %d — with room on both sides a menu must drop DOWNWARD; the default direction has been inverted", where, m.MenuTop, m.SummaryBottom) |
| + } |
| + // The same hole on the inline axis: with room both ways |
| + // the default alignment is what shows, and it is |
| + // span-inline-start — the panel's inline-END edge on |
| + // the summary's inline-end edge, which is what |
| + // inset-inline-end: 0 always did. |
| + gotEdge, wantEdge := m.MenuRight, m.SummaryRight |
| + if m.Direction == "rtl" { |
| + gotEdge, wantEdge = m.MenuLeft, m.SummaryLeft |
| + } |
| + if gotEdge < wantEdge-1 || gotEdge > wantEdge+1 { |
| + t.Errorf("%s: the menu's inline-end edge is at %d and the summary's is at %d (dir=%s) — with room on both sides the default alignment has changed", where, gotEdge, wantEdge, m.Direction) |
| + } |
| + case strings.HasPrefix(c.id, "b"): |
| + if below >= menuHeight { |
| + t.Fatalf("%s: there is %dpx under the summary and the menu is %dpx tall, so it fits below and no flip is required. This fixture no longer tests the flip.", where, below, menuHeight) |
| + } |
| + if m.MenuBottom > m.SummaryTop+1 { |
| + t.Errorf("%s: the menu's foot is at %d and the summary's head is at %d — it opened downward off the bottom of the window instead of flipping up", where, m.MenuBottom, m.SummaryTop) |
| + } |
| + default: |
| + if m.MenuTop < m.SummaryBottom-1 { |
| + t.Errorf("%s: the menu's head is at %d, above the summary's foot at %d — it flipped upward where there was room below", where, m.MenuTop, m.SummaryBottom) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTheScrollbarGutterHoldsTheLayoutStill is design spec §6-v2.1b.4: |
| +// clicking between a short screen and a long one moved the whole page |
| +// sideways by the scrollbar's width, and opening a modal did the same |
| +// thing in the other direction because its scroll lock takes the |
| +// scrollbar away. |
| +// |
| +// The control is the whole drive. --rst-scrollbar-gutter: auto is the |
| +// documented opt-out, so the same fixture with the token overridden is |
| +// the page as it was before the fix, and it MUST show the shift. If it |
| +// does not, this browser is drawing overlay scrollbars — the macOS |
| +// default, and chromedp's headless default until harness.WithScrollbars |
| +// turns it off — and the drive fails saying exactly that rather than |
| +// reporting a fix it never saw. |
| +func TestTheScrollbarGutterHoldsTheLayoutStill(t *testing.T) { |
| + // A ruler and, for the tall pages, something to scroll. The ruler |
| + // is a width: 100% block and body's own first child, so it reports |
| + // the inline space the layout actually got and nothing in between |
| + // can absorb a difference. |
| + // |
| + // document.documentElement.clientWidth is NOT that number, and was |
| + // the first thing this drive tried: Chromium reports the full window |
| + // width there whether or not a gutter is reserved, so every |
| + // measurement agreed with every other one and the drive proved |
| + // nothing at all. |
| + const ruler = `<div id="ruler" style="width:100%;height:4px"></div>` |
| + page := func(extra, body string) string { |
| + return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>gutter</title>` + |
| + `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme.css">` + extra + |
| + `</head><body>` + ruler + body + `</body></html>` |
| + } |
| + const optOut = `<style>:root{--rst-scrollbar-gutter:auto}</style>` |
| + const tall = `<div style="height:3000px"></div>` |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + for path, html := range map[string]string{ |
| + "/short": page("", ""), |
| + "/tall": page("", tall), |
| + "/short-auto": page(optOut, ""), |
| + "/tall-auto": page(optOut, tall), |
| + } { |
| + html := html |
| + mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, html) |
| + }) |
| + } |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }, harness.WithScrollbars()) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + // The modal half is measured on one page rather than two, by adding |
| + // the backdrop and reading the ruler again: nothing else about the |
| + // document changes between the two numbers, which is the only way |
| + // the difference can be attributed to the scroll lock. |
| + const measure = `(() => { |
| + const w = () => document.getElementById('ruler').getBoundingClientRect().width; |
| + const before = w(); |
| + const b = document.createElement('div'); |
| + // The attribute, which is what the gallery and every partial emit. |
| + // It used to be a class, and tokens.css pairs both until stage 3 — |
| + // so this went on passing while measuring a selector nothing writes. |
| + b.setAttribute('rst-backdrop', ''); |
| + document.body.appendChild(b); |
| + const after = w(); |
| + b.remove(); |
| + return JSON.stringify({before, after}); |
| +})()` |
| + |
| + read := func(path string) (width int) { |
| + t.Helper() |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(900, 600), |
| + chromedp.Navigate(rig.Origin+path), |
| + chromedp.WaitVisible(`#ruler`, chromedp.ByQuery), |
| + chromedp.Evaluate(`JSON.stringify(Math.round(document.getElementById('ruler').getBoundingClientRect().width))`, &raw), |
| + ); err != nil { |
| + t.Fatalf("measuring %s: %v", path, err) |
| + } |
| + if err := json.Unmarshal([]byte(raw), &width); err != nil { |
| + t.Fatalf("reading %s (%q): %v", path, raw, err) |
| + } |
| + return width |
| + } |
| + |
| + short, tallW := read("/short"), read("/tall") |
| + shortAuto, tallAuto := read("/short-auto"), read("/tall-auto") |
| + t.Logf("stable: short %dpx, long %dpx — auto: short %dpx, long %dpx", short, tallW, shortAuto, tallAuto) |
| + |
| + // THE CONTROL, first, because every assertion after it depends on |
| + // this browser being able to show the bug at all. |
| + if shortAuto == tallAuto { |
| + t.Fatalf("with --rst-scrollbar-gutter: auto a short page and a long one both measure %dpx. This browser's scrollbars take no layout space, so nothing here can tell a working gutter from a missing one — check harness.WithScrollbars still reaches the browser.", shortAuto) |
| + } |
| + |
| + // THE FIX. |
| + if short != tallW { |
| + t.Errorf("a short page measures %dpx and a long one %dpx: the layout still slides by %dpx when the scrollbar comes and goes", short, tallW, short-tallW) |
| + } |
| + |
| + // THE SECOND BUG, which nobody had attributed to the scrollbar: the |
| + // modal's scroll lock removes it, and the page jumps sideways while |
| + // the reader is watching. |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(900, 600), |
| + chromedp.Navigate(rig.Origin+"/tall"), |
| + chromedp.WaitVisible(`#ruler`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving the modal scroll lock: %v", err) |
| + } |
| + var stable struct{ Before, After float64 } |
| + if err := json.Unmarshal([]byte(raw), &stable); err != nil { |
| + t.Fatalf("reading the modal measurement (%q): %v", raw, err) |
| + } |
| + if err := chromedp.Run(ctx, |
| + chromedp.Navigate(rig.Origin+"/tall-auto"), |
| + chromedp.WaitVisible(`#ruler`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving the modal scroll lock without the gutter: %v", err) |
| + } |
| + var auto struct{ Before, After float64 } |
| + if err := json.Unmarshal([]byte(raw), &auto); err != nil { |
| + t.Fatalf("reading the modal control (%q): %v", raw, err) |
| + } |
| + t.Logf("modal scroll lock: stable %.0f→%.0fpx, auto %.0f→%.0fpx", stable.Before, stable.After, auto.Before, auto.After) |
| + |
| + if auto.Before == auto.After { |
| + t.Fatalf("without the gutter, opening a modal left the page %vpx wide either way — the scroll lock is not removing a scrollbar here, so this leg is measuring nothing", auto.Before) |
| + } |
| + if stable.Before != stable.After { |
| + t.Errorf("opening a modal took the page from %vpx to %vpx: body:has([rst-backdrop]) { overflow: hidden } is still yanking the scrollbar out from under the layout", stable.Before, stable.After) |
| + } |
| +} |
| + |
| +// clearReading is the clear affordance measured where a finger lands: |
| +// its own box, and what is actually on top at the middle of it. |
| +type clearReading struct { |
| + W, H float64 |
| + Href string |
| + HitIsTheClear bool |
| + Hit string |
| + InputRight float64 |
| + InputTop float64 |
| + InputBottom float64 |
| + FormLeft float64 |
| + FormRight float64 |
| + ClearLeft float64 |
| + ClearRight float64 |
| +} |
| + |
| +const clearMeasure = `(() => { |
| + const form = document.querySelector('[rst-search]'); |
| + const input = form.querySelector('input[type=search]'); |
| + const a = form.querySelector('[rst-search-clear]'); |
| + const r = a.getBoundingClientRect(), ir = input.getBoundingClientRect(), fr = form.getBoundingClientRect(); |
| + const hit = document.elementFromPoint(r.left + r.width/2, r.top + r.height/2); |
| + return JSON.stringify({ |
| + W: Math.round(r.width*100)/100, H: Math.round(r.height*100)/100, |
| + Href: a.getAttribute('href'), |
| + HitIsTheClear: !!(hit && hit.closest('[rst-search-clear]')), |
| + Hit: hit ? hit.tagName + '.' + (hit.getAttribute('class') || '') : 'nothing', |
| + InputRight: ir.right, InputTop: ir.top, InputBottom: ir.bottom, |
| + FormLeft: fr.left, FormRight: fr.right, |
| + ClearLeft: r.left, ClearRight: r.right, |
| + }); |
| +})()` |
| + |
| +// TestClearingASearchIsALinkAndTheNativeCrossIsGone is design spec |
| +// §6-v2.1b.6, the bug Paul screenshotted: a search field holding "sere", |
| +// a ✕ beside it, and clicking the ✕ left the results and the ?q= exactly |
| +// where they were. |
| +// |
| +// That ✕ was ::-webkit-search-cancel-button doing precisely what it is |
| +// specified to do — clearing the input's VALUE — in a GET form that only |
| +// submits on submit. list-bar-search now renders a real link instead, |
| +// and tokens.css takes the native one away, because two affordances one |
| +// of which lies is worse than one that works. |
| +// |
| +// Three things are asserted, and the second is the one with teeth: |
| +// |
| +// 1. The link goes where clearing a search should go: the same screen |
| +// with q dropped and the carried filter kept. |
| +// 2. The native ✕ is really gone. This is proved against a CONTROL |
| +// page — a bare <input type="search"> with none of this library's |
| +// CSS on it — which is clicked in the same place and MUST clear. |
| +// If Chromium ever stops drawing that button, the control fails and |
| +// says so, instead of the subject passing because there was never |
| +// anything there to suppress. |
| +// 3. The target is at least 24×24 CSS px, WCAG 2.2 SC 2.5.8 (AA), and |
| +// is really on top at its own centre — a 24px box under something |
| +// else is not a 24px target. |
| +// |
| +// RTL is a second leg: the ✕ has to stay inside the field box when the |
| +// field mirrors, which is a claim about logical properties and nothing |
| +// else. |
| +func TestClearingASearchIsALinkAndTheNativeCrossIsGone(t *testing.T) { |
| + // list-bar-search as SEED rendered it: action /posts, the query |
| + // "sere", the placeholder "Search posts", and status=paid carried |
| + // as a hidden field. |
| + search := shellPiece(t, "TestClearingASearchIsALinkAndTheNativeCrossIsGone", "search") |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + page := func(dir string) string { |
| + return `<!doctype html><html lang="en" dir="` + dir + `"><head><meta charset="utf-8">` + |
| + `<title>search</title><link rel="stylesheet" href="/tokens.css">` + |
| + `<link rel="stylesheet" href="/theme.css"></head><body>` + |
| + `<div rst-page><div rst-list>` + search + `</div></div></body></html>` |
| + } |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, page("ltr")) |
| + }) |
| + mux.HandleFunc("GET /rtl", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, page("rtl")) |
| + }) |
| + // The control: the same input with NONE of this library's CSS, so |
| + // the browser draws its own ✕ exactly as it did before the fix. |
| + // One per direction, because the button is not in the same place in |
| + // both and a control in the wrong direction proves nothing about |
| + // the leg it is controlling. |
| + native := func(dir string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, `<!doctype html><html lang="en" dir="`+dir+`"><head><meta charset="utf-8">`+ |
| + `<title>native</title></head><body style="margin:0"><form>`+ |
| + `<input id="q" type="search" name="q" value="sere" `+ |
| + `style="position:absolute;inset-block-start:0;inset-inline-start:0;inline-size:300px;block-size:40px;font-size:20px">`+ |
| + `</form></body></html>`) |
| + } |
| + } |
| + mux.HandleFunc("GET /native", native("ltr")) |
| + mux.HandleFunc("GET /native-rtl", native("rtl")) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + // clickTheNativeCross clicks where the browser draws its cancel |
| + // button: just inside the field's INLINE-END edge, on its centre |
| + // line. Inline-end, not right: Chromium mirrors the button with the |
| + // direction, so in an RTL document it sits at the left. The first |
| + // version of this drive clicked r.right - 8 in both legs, which in |
| + // RTL is the edge the button is never at — an assertion that could |
| + // not fail, on a branch that has shipped several. |
| + // |
| + // The field is focused first because Chromium only makes the button |
| + // live once the field has focus. Worth recording: a drive that |
| + // skips the focus measures a button that is there but inert, and |
| + // concludes it is absent. |
| + clickTheNativeCross := func(sel string) chromedp.Action { |
| + return chromedp.ActionFunc(func(ctx context.Context) error { |
| + var at []float64 |
| + expr := fmt.Sprintf(`(() => { |
| + const el = document.querySelector(%q); |
| + const r = el.getBoundingClientRect(); |
| + const rtl = getComputedStyle(el).direction === "rtl"; |
| + return [rtl ? r.left + 8 : r.right - 8, r.top + r.height / 2]; |
| + })()`, sel) |
| + if err := chromedp.Evaluate(expr, &at).Do(ctx); err != nil { |
| + return err |
| + } |
| + return chromedp.MouseClickXY(at[0], at[1]).Do(ctx) |
| + }) |
| + } |
| + |
| + for _, leg := range []struct{ name, path, control string }{ |
| + {"ltr", "/", "/native"}, |
| + {"rtl", "/rtl", "/native-rtl"}, |
| + } { |
| + // THE CONTROL, per leg and per direction: the browser's own ✕, |
| + // clicked in the same place, must empty a bare input. If it |
| + // does not, Chromium is not drawing the button where this leg |
| + // clicks and the leg's own assertion could not have failed. |
| + var nativeValue string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(900, 600), |
| + chromedp.Navigate(rig.Origin+leg.control), |
| + chromedp.WaitVisible(`#q`, chromedp.ByQuery), |
| + chromedp.Focus(`#q`, chromedp.ByQuery), |
| + clickTheNativeCross(`#q`), |
| + chromedp.Evaluate(`document.getElementById('q').value`, &nativeValue), |
| + ); err != nil { |
| + t.Fatalf("%s: driving the control input: %v", leg.name, err) |
| + } |
| + if nativeValue != "" { |
| + t.Fatalf("%s: the control input still holds %q after a click on its inline-end edge: this browser is not drawing ::-webkit-search-cancel-button there, so this leg cannot tell a suppressed one from an absent one", leg.name, nativeValue) |
| + } |
| + |
| + var raw, stillThere string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(900, 600), |
| + chromedp.Navigate(rig.Origin+leg.path), |
| + chromedp.WaitVisible(`[rst-search-clear]`, chromedp.ByQuery), |
| + chromedp.Evaluate(clearMeasure, &raw), |
| + chromedp.Focus(`[rst-search] input[type=search]`, chromedp.ByQuery), |
| + clickTheNativeCross(`[rst-search] input[type=search]`), |
| + chromedp.Evaluate(`document.querySelector('[rst-search] input[type=search]').value`, &stillThere), |
| + ); err != nil { |
| + t.Fatalf("%s: driving the search form: %v", leg.name, err) |
| + } |
| + var got clearReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("%s: reading the measurement (%q): %v", leg.name, raw, err) |
| + } |
| + t.Logf("%s: clear target %.2f×%.2fpx to %s, hit %s; field [%.0f..%.0f], clear [%.0f..%.0f]", |
| + leg.name, got.W, got.H, got.Href, got.Hit, got.FormLeft, got.FormRight, got.ClearLeft, got.ClearRight) |
| + |
| + if want := "/posts?status=paid"; got.Href != want { |
| + t.Errorf("%s: the clear link goes to %q, want %q — clearing a search must drop q and keep the filter", leg.name, got.Href, want) |
| + } |
| + // The native ✕ is gone: the same click that emptied the control |
| + // input must leave this one alone. |
| + if stillThere != "sere" { |
| + t.Errorf("%s: clicking the field's trailing edge changed its value to %q — the native cancel button is still there beside the link, and it still lies", leg.name, stillThere) |
| + } |
| + // WCAG 2.2 SC 2.5.8, AA. A 17px chip on this branch already |
| + // failed this once. |
| + if got.W < 24 || got.H < 24 { |
| + t.Errorf("%s: the clear target is %.2f×%.2fpx, under WCAG 2.2 SC 2.5.8's 24×24", leg.name, got.W, got.H) |
| + } |
| + if !got.HitIsTheClear { |
| + t.Errorf("%s: the middle of the clear target hits %s, not the link — the box is the right size and something is over it", leg.name, got.Hit) |
| + } |
| + // It has to sit inside the field it belongs to, in both |
| + // directions, or the mirrored layout has put it somewhere a |
| + // reader will not look for it. |
| + if got.ClearLeft < got.FormLeft || got.ClearRight > got.FormRight { |
| + t.Errorf("%s: the clear target spans [%.0f..%.0f] and the field box is [%.0f..%.0f] — it has escaped the field", leg.name, got.ClearLeft, got.ClearRight, got.FormLeft, got.FormRight) |
| + } |
| + } |
| +} |
| + |
| +// frameReading is one preview-sized iframe with a menu open inside it. |
| +type frameReading struct { |
| + FrameHeight int |
| + DVH int |
| + MaxBlockSize string |
| + ShortClient int |
| + ShortScroll int |
| + LongClient int |
| + LongScroll int |
| +} |
| + |
| +// TestAMenuOpenedInsideAShortFrameIsStillUsable is the bug the first |
| +// version of the cap shipped, and it is worth stating plainly because |
| +// it was the fix causing it: `max-block-size: 100dvh - 6rem` has no |
| +// floor, and inside an iframe `dvh` is THE FRAME. |
| +// |
| +// In the design gallery's own 100px preview frames the cap therefore |
| +// computed to 4px, and the bulk-bar sample's Actions menu rendered as a |
| +// 12px sliver of its 103px of entries. Below a 96px frame the cap |
| +// computed to zero and the menu was invisible. A fix aimed at menu |
| +// entries nobody could reach had shipped a menu with no entries at all, |
| +// and nothing caught it because no gate anywhere opened a menu inside a |
| +// frame. This one does. |
| +// |
| +// It is not a gallery test. Any app embedding a rastrillo screen in a |
| +// short iframe had the same bug, which is why the floor is in |
| +// tokens.css and the drive is here. |
| +// |
| +// The dvh control is the load-bearing part. Every assertion below is |
| +// about what happens when the viewport is the frame, so the drive first |
| +// reads a 100dvh box inside the frame and requires it to measure the |
| +// frame's own height. If a future engine changed that, this drive would |
| +// otherwise keep passing while testing nothing. |
| +func TestAMenuOpenedInsideAShortFrameIsStillUsable(t *testing.T) { |
| + // The two menus as SEED rendered them through the dropdown partial: |
| + // short has three entries, long the twelve transliterated locales. |
| + menus := map[string]string{ |
| + "short": shellPiece(t, "TestAMenuOpenedInsideAShortFrameIsStillUsable", "short"), |
| + "long": shellPiece(t, "TestAMenuOpenedInsideAShortFrameIsStillUsable", "long"), |
| + } |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + // The framed document: the same shape the gallery frames, a sample |
| + // with its menu already open, plus a 100dvh ruler for the control. |
| + mux.HandleFunc("GET /frame", func(w http.ResponseWriter, r *http.Request) { |
| + var b strings.Builder |
| + b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` + |
| + `<title>frame</title><link rel="stylesheet" href="/tokens.css">` + |
| + `<link rel="stylesheet" href="/theme.css"></head><body style="margin:0">` + |
| + `<div id="dvh" style="block-size:100dvh;inline-size:1px"></div>`) |
| + for _, id := range []string{"short", "long"} { |
| + b.WriteString(`<div id="` + id + `" style="position:absolute;inset-block-start:0;inset-inline-start:8px">`) |
| + b.WriteString(menus[id]) |
| + b.WriteString(`</div>`) |
| + } |
| + b.WriteString(`</body></html>`) |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, b.String()) |
| + }) |
| + // 100px and 190px are two of the gallery's own previewHeights; 80px |
| + // is under the 96px at which the unfloored cap reached zero, which |
| + // is the case that has to be covered rather than avoided. |
| + heights := []int{80, 100, 190, 260} |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + var b strings.Builder |
| + b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` + |
| + `<title>frames</title></head><body style="margin:0">`) |
| + for _, h := range heights { |
| + b.WriteString(fmt.Sprintf( |
| + `<iframe data-h="%d" src="/frame" style="display:block;width:420px;height:%dpx;border:0"></iframe>`, h, h)) |
| + } |
| + b.WriteString(`</body></html>`) |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, b.String()) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + const measure = `(() => { |
| + const out = []; |
| + for (const f of document.querySelectorAll("iframe[data-h]")) { |
| + const d = f.contentDocument; |
| + const open = id => { |
| + const details = d.querySelector("#" + id + " details"); |
| + details.open = true; |
| + return details.querySelector("[rst-dropdown-menu]"); |
| + }; |
| + const s = open("short"), l = open("long"); |
| + out.push({ |
| + FrameHeight: parseInt(f.dataset.h, 10), |
| + DVH: Math.round(d.getElementById("dvh").getBoundingClientRect().height), |
| + MaxBlockSize: getComputedStyle(l).maxBlockSize, |
| + ShortClient: s.clientHeight, ShortScroll: s.scrollHeight, |
| + LongClient: l.clientHeight, LongScroll: l.scrollHeight, |
| + }); |
| + } |
| + return JSON.stringify(out); |
| +})()` |
| + |
| + var raw string |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(900, 1200), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`iframe[data-h]`, chromedp.ByQuery), |
| + chromedp.Evaluate(measure, &raw), |
| + ); err != nil { |
| + t.Fatalf("driving the framed menus: %v", err) |
| + } |
| + var got []frameReading |
| + if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| + t.Fatalf("reading the measurement (%q): %v", raw, err) |
| + } |
| + if len(got) != len(heights) { |
| + t.Fatalf("measured %d frames, want %d", len(got), len(heights)) |
| + } |
| + |
| + for _, f := range got { |
| + t.Logf("a %dpx frame: 100dvh is %dpx, max-block-size %s; three items show %d of %dpx, twelve show %d of %dpx", |
| + f.FrameHeight, f.DVH, f.MaxBlockSize, f.ShortClient, f.ShortScroll, f.LongClient, f.LongScroll) |
| + |
| + // THE CONTROL. Everything below is a claim about dvh inside a |
| + // frame, so prove dvh is the frame first. |
| + if f.DVH != f.FrameHeight { |
| + t.Fatalf("a %dpx frame reports 100dvh as %dpx. dvh is no longer the frame's own height, so this drive is not exercising the bug it was written for.", f.FrameHeight, f.DVH) |
| + } |
| + |
| + // A three-item menu is 103px and the floor is 8rem, so it must |
| + // fit whole — in an 80px frame as much as a 260px one. This is |
| + // the assertion that was 12-of-103 before the floor. These frames |
| + // are 420px wide, inside the touch query, where the rows are |
| + // 44px taps and the floor is three of them: the same claim. |
| + if f.ShortClient < f.ShortScroll { |
| + t.Errorf("in a %dpx frame a three-item menu shows %d of its %dpx: the cap has collapsed it, and a reader opening this menu sees a sliver", f.FrameHeight, f.ShortClient, f.ShortScroll) |
| + } |
| + // A twelve-item menu cannot fit a short frame and is not meant |
| + // to — it scrolls. What it must not do is disappear. |
| + if f.LongClient < 96 { |
| + t.Errorf("in a %dpx frame a twelve-item menu shows %dpx of its %dpx. An overflowing menu can be scrolled to; a 4px one cannot be used and a 0px one cannot be seen.", f.FrameHeight, f.LongClient, f.LongScroll) |
| + } |
| + } |
| +} |
| + |
| +// orphanReading is one menu in one scroller, read before and after the |
| +// scroll that takes its button away. |
| +type orphanReading struct { |
| + SummaryTop float64 |
| + SummaryBottom float64 |
| + ScrollerTop float64 |
| + ScrollerBottom float64 |
| + MenuTop float64 |
| + MenuBottom float64 |
| + Painted bool |
| + Hit string |
| + Visibility string |
| + Supports bool |
| + SupportsBogus bool |
| +} |
| + |
| +// TestAMenuDoesNotOutliveTheAnchorScrolledAwayFromUnderIt is the price |
| +// of the fixed positioning the flip needs, watched. |
| +// |
| +// A fixed panel is deliberately outside every scrolling ancestor's clip |
| +// — that is how a menu opened inside a list card escapes the card — and |
| +// Chromium keeps it tracking its anchor through both window and |
| +// inner-scroller scrolls. But the ANCHOR is still inside the clip, so |
| +// scrolling a rail's nav until the menu's own button is gone could |
| +// leave the menu painted over unrelated content with nothing under it. |
| +// The sidebar rail with its thirty-odd links is the live case. |
| +// |
| +// WHAT THIS DRIVE ACTUALLY GATES, stated plainly because the mutation |
| +// run says something a comment could easily be written to hide. |
| +// tokens.css writes `position-visibility: anchors-visible` on the |
| +// panel, and DELETING THAT LINE DOES NOT FAIL THIS TEST. Chromium's |
| +// initial value for the property is already anchors-visible — measured, |
| +// on body and on a plain fixed box as well as on an anchored one — so |
| +// the behaviour below is the engine's default and the declaration is a |
| +// no-op today. |
| +// |
| +// The declaration stays anyway, and the reason is not superstition: the |
| +// CSS spec's initial value is `always`, not anchors-visible. Chromium is |
| +// the one diverging, in the safe direction, and an engine that aligned |
| +// with the spec tomorrow would orphan every menu in the library with no |
| +// other warning. The line is what makes the behaviour ours rather than |
| +// borrowed, and the computed-value assertion below is what notices if |
| +// the line and the default ever both go. Forcing the property to |
| +// `always` — the spec-aligned engine, simulated — fails this drive on |
| +// both the computed value and the geometry. |
| +// |
| +// So this is a WATCH on the engine rather than a proof of our CSS, and |
| +// it is worth having as one: it is the only thing anywhere that would |
| +// catch the orphaned menu coming back. |
| +// |
| +// The always-visible control is what stops it passing by mistake. The |
| +// second scroller's panel overrides the property to `always`, so it |
| +// MUST still be painted after the same scroll. If the drive's way of |
| +// asking "is this painted?" ever starts answering no to everything — a |
| +// changed API, a mis-selected element, a scroll that moved the wrong |
| +// box — the control fails and says so, instead of the subject passing |
| +// for the wrong reason. |
| +func TestAMenuDoesNotOutliveTheAnchorScrolledAwayFromUnderIt(t *testing.T) { |
| + // Hand-written markup, the way tokens.css says these idioms are |
| + // used, because the second scroller needs an override the partial |
| + // has no key for. |
| + scroller := func(id, panelStyle string) string { |
| + return `<div id="` + id + `" style="block-size:220px;inline-size:320px;overflow-y:auto;` + |
| + `border:1px solid #888;margin:24px">` + |
| + `<details rst-dropdown id="` + id + `-menu" name="rst-menu-` + id + `">` + |
| + `<summary>Menu</summary>` + |
| + `<div rst-dropdown-menu style="` + panelStyle + `">` + |
| + `<a href="#">One</a><a href="#">Two</a><a href="#">Three</a>` + |
| + `</div></details>` + |
| + `<div style="block-size:1200px">tall</div></div>` |
| + } |
| + |
| + mux := http.NewServeMux() |
| + stylesheets(t, mux) |
| + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+ |
| + `<title>orphan</title><link rel="stylesheet" href="/tokens.css">`+ |
| + // The 200px of head room is the whole fixture. The scrollers |
| + // have to sit far enough down the page that an anchor |
| + // scrolled ABOVE its scroller's top edge is still at a |
| + // coordinate inside the window — that is the orphan case. A |
| + // scroller at the top of the page takes its anchor off the |
| + // window as well, which hides the bug rather than showing |
| + // it, and was this drive's first draft. |
| + `<link rel="stylesheet" href="/theme.css"></head>`+ |
| + `<body style="margin:0;padding-block-start:200px">`+ |
| + scroller("subject", "")+ |
| + scroller("control", "position-visibility: always")+ |
| + `</body></html>`) |
| + }) |
| + |
| + rig := harness.New(t, func(string) http.Handler { return mux }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + |
| + // elementFromPoint is the measurement, and that is not a style |
| + // choice. position-visibility hides the box at USED-value time: the |
| + // element keeps its computed `visibility: visible`, keeps its |
| + // layout rectangle, and answers true to |
| + // checkVisibility({visibilityProperty: true}) — all three were |
| + // tried, and all three said "visible" of a menu Chromium was no |
| + // longer painting. What actually changes is paint and hit testing, |
| + // so the question has to be asked of the compositor: is the menu |
| + // the thing at the middle of the menu? |
| + // |
| + // This is the same technique, and the same reason, as |
| + // TestAMenuOpenedInsideAListCardEscapesTheCard above. |
| + read := func(id string) string { |
| + return `(() => { |
| + const box = document.getElementById(` + "`" + id + "`" + `); |
| + const d = document.getElementById(` + "`" + id + `-menu` + "`" + `); |
| + const s = d.querySelector("summary").getBoundingClientRect(); |
| + const m = d.querySelector("[rst-dropdown-menu]"); |
| + const r = m.getBoundingClientRect(); |
| + const b = box.getBoundingClientRect(); |
| + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); |
| + return JSON.stringify({ |
| + SummaryTop: s.top, SummaryBottom: s.bottom, |
| + ScrollerTop: b.top, ScrollerBottom: b.bottom, |
| + MenuTop: r.top, MenuBottom: r.bottom, |
| + Painted: !!(hit && (hit === m || m.contains(hit))), |
| + Hit: hit ? hit.tagName + "." + (hit.getAttribute("class") || "") : "nothing", |
| + Visibility: getComputedStyle(m).positionVisibility, |
| + Supports: CSS.supports("position-visibility: anchors-visible"), |
| + SupportsBogus: CSS.supports("position-visibility: rastrillo-no-such-value"), |
| + }); |
| +})()` |
| + } |
| + get := func(id string) orphanReading { |
| + t.Helper() |
| + var raw string |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(read(id), &raw)); err != nil { |
| + t.Fatalf("reading %s: %v", id, err) |
| + } |
| + var out orphanReading |
| + if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| + t.Fatalf("reading %s (%q): %v", id, raw, err) |
| + } |
| + return out |
| + } |
| + |
| + if err := chromedp.Run(ctx, |
| + chromedp.EmulateViewport(900, 700), |
| + chromedp.Navigate(rig.Origin+"/"), |
| + chromedp.WaitVisible(`#subject-menu`, chromedp.ByQuery), |
| + chromedp.Click(`#subject-menu > summary`, chromedp.ByQuery), |
| + chromedp.Click(`#control-menu > summary`, chromedp.ByQuery), |
| + chromedp.WaitVisible(`#subject-menu [rst-dropdown-menu]`, chromedp.ByQuery), |
| + ); err != nil { |
| + t.Fatalf("opening the menus: %v", err) |
| + } |
| + |
| + before := get("subject") |
| + t.Logf("before the scroll: summary %.0f..%.0f in a scroller %.0f..%.0f; menu %.0f..%.0f, painted=%v (hit %s, position-visibility %s)", |
| + before.SummaryTop, before.SummaryBottom, before.ScrollerTop, before.ScrollerBottom, |
| + before.MenuTop, before.MenuBottom, before.Painted, before.Hit, before.Visibility) |
| + |
| + if !before.Supports { |
| + t.Fatalf("this engine does not support position-visibility: anchors-visible. tokens.css writes it; if it has been renamed, every menu below is orphaned again and nothing else would say so.") |
| + } |
| + if before.SupportsBogus { |
| + t.Fatal("CSS.supports agreed to a position-visibility value that does not exist; the support check above is worthless") |
| + } |
| + // The used value, whether it came from tokens.css or from the |
| + // engine's own initial value. This is the assertion that goes red if |
| + // Chromium ever aligns with the spec (whose initial value is |
| + // `always`) AND our own declaration has been lost along the way. |
| + if before.Visibility != "anchors-visible" { |
| + t.Errorf("the menu resolves position-visibility: %s, want anchors-visible — with `always` a menu outlives the button that opened it", before.Visibility) |
| + } |
| + if !before.Painted { |
| + t.Fatalf("the menu is not painted before anything has scrolled — the middle of it hits %s. This drive is measuring the wrong element.", before.Hit) |
| + } |
| + |
| + // Take the anchor away. |
| + if err := chromedp.Run(ctx, chromedp.Evaluate( |
| + `(() => { for (const id of ["subject", "control"]) document.getElementById(id).scrollTop = 150; return "ok"; })()`, |
| + new(string))); err != nil { |
| + t.Fatalf("scrolling the anchors away: %v", err) |
| + } |
| + // Anchor positioning — placement and position-visibility both — |
| + // settles during paint, not during the scroll, so the reading waits |
| + // for two frames rather than racing the compositor. |
| + if err := chromedp.Run(ctx, chromedp.Evaluate( |
| + `new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r("ok"))))`, |
| + new(string), func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })); err != nil { |
| + t.Fatalf("waiting for the anchored positions to settle: %v", err) |
| + } |
| + |
| + after, control := get("subject"), get("control") |
| + t.Logf("after the scroll: subject summary %.0f..%.0f, scroller %.0f..%.0f, menu %.0f..%.0f painted=%v (hit %s); control menu %.0f..%.0f painted=%v (hit %s)", |
| + after.SummaryTop, after.SummaryBottom, after.ScrollerTop, after.ScrollerBottom, |
| + after.MenuTop, after.MenuBottom, after.Painted, after.Hit, |
| + control.MenuTop, control.MenuBottom, control.Painted, control.Hit) |
| + |
| + // THE FIXTURE'S OWN PROOF, in two halves. The anchor really did |
| + // leave the scroller that clips it — and it is still at a |
| + // coordinate inside the window, which is what makes this the |
| + // orphan case rather than a menu that simply scrolled off the top |
| + // of the page with everything else. |
| + if after.SummaryBottom > after.ScrollerTop { |
| + t.Fatalf("the summary's foot is at %.0f and the scroller's head is at %.0f — the anchor has not been scrolled out of the box that clips it, so nothing below is being tested", after.SummaryBottom, after.ScrollerTop) |
| + } |
| + if after.SummaryTop < 0 { |
| + t.Fatalf("the summary is at %.0f, off the top of the WINDOW rather than merely out of its scroller. A menu that follows its anchor off the page is not orphaned, so this fixture would pass with the declaration deleted.", after.SummaryTop) |
| + } |
| + // THE CONTROL: the same scroll, the declaration overridden back. |
| + if !control.Painted { |
| + t.Fatalf("the control menu — position-visibility: always — is also gone after the same scroll; the middle of it hits %s. Whatever this drive is measuring, it is not the declaration under test.", control.Hit) |
| + } |
| + // THE SUBJECT. |
| + if after.Painted { |
| + t.Errorf("the menu is still painted at %.0f..%.0f with its button scrolled out of the box that clips it — it is floating over unrelated content with nothing under it", after.MenuTop, after.MenuBottom) |
| + } |
| +} |
diff --git a/check/shells_browser_test.go b/check/shells_browser_test.go| new file mode 100644 |
| index 0000000..269b31f |
| --- /dev/null |
| +++ b/check/shells_browser_test.go |
| @@ -0,0 +1,63 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// What the shell drives read. A page under test comes from one of two |
| +// places. Where an assertion reads only the slot's content, or shell |
| +// structure every shell example carries, the page is a layout example |
| +// with content in its slot (shellPage). Where it depends on chrome the |
| +// examples do not have (a brand, a nav link's id, the language menu's |
| +// id, a view written with whitespace, a layout from before the phone |
| +// index) the page is the one rastrillo's drive rendered at SEED, read |
| +// through fixture or legacyLayout, so the assertion keeps the page it |
| +// was written against. |
| + |
| +// shellPage is the default state of the layout example called |
| +// layout-<shell> with content in its slot, its asset links rewritten |
| +// from the tree's ../../css/… and ../../js/… to the root paths |
| +// sizingMux serves (assetLinks), so the page can sit at any path the |
| +// drive chooses. |
| +func shellPage(t *testing.T, shell, content string) string { |
| + t.Helper() |
| + name := "layout-" + shell |
| + m := manifest(t) |
| + state, ok := m.Default(name) |
| + if !ok || state == "" { |
| + t.Fatalf("examples.json names no default state for %s", name) |
| + } |
| + doc, err := example.Document(fiesta.FS(), name, state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + page, err := example.Slot(doc, content) |
| + if err != nil { |
| + t.Fatalf("%s/%s: %v", name, state, err) |
| + } |
| + return assetLinks(page, "/") |
| +} |
| + |
| +// legacyLayout is a layout as it was before the phone index, rendered |
| +// at SEED with the drives' standard blocks and kept under |
| +// testdata/legacy/ so the legacy rules in tokens.css stay pinned to |
| +// real markup. legacyFile (matrix_browser_test.go) does the reading. |
| +func legacyLayout(t *testing.T, name string) string { |
| + t.Helper() |
| + return string(legacyFile(t, name+".html")) |
| +} |
| + |
| +// shellAssets serves pages with everything a layout links. At SEED it |
| +// added shell.js and shell.css to the sizing mux; sizingMux serves |
| +// both here, with every other script in the tree, so this is the mux |
| +// under the name the drives call it by. |
| +func shellAssets(t *testing.T, pages map[string]string) *http.ServeMux { |
| + t.Helper() |
| + return sizingMux(t, pages) |
| +} |
diff --git a/hack/test-weights.txt b/hack/test-weights.txt| index 9e520c2..ec1bc09 100644 |
| --- a/hack/test-weights.txt |
| +++ b/hack/test-weights.txt |
| @@ -12,8 +12,11 @@ browser check TestEveryThemeRendersAsItDidExceptDay 45 |
| browser check TestEveryThemeAndShellRendersAsTheBaseline 37 |
| browser check TestBusyButtonDrive 22 |
| browser check TestTheLightStaysWithinItsFrameBudget 17 |
| +browser check TestTheProfileMenuIsADropUpAtTheRailsFoot 17 |
| browser check TestAnyAccentKeepsTheFillLabelReadable 13 |
| browser check TestFlatThemesFollowAnAppsOverrides 13 |
| +browser check TestThePhoneIndexWorksWithScripts 10 |
| +browser check TestThePhoneIndexWorksWithNoScript 9 |
| browser check TestASwitchSlidesAndSnaps 8 |
| browser check TestADropdownOpensUnderItsOwnSummary 7 |
| browser check TestAReleasedKnobSettlesWithAFirmCurve 7 |
| @@ -21,20 +24,29 @@ browser check TestARowMenuOpensBesideItsOwnKebab 7 |
| browser check TestAPostItemsSpinnerNeverMovesTheMenu 6 |
| browser check TestAPreferenceToggleForgetsSettledCards 6 |
| browser check TestKeysAndTouchLeavePointerMode 6 |
| +browser check TestTheConsoleBarIsTheSameCard 6 |
| +browser check TestTheConsoleFoldsItsBarAndIndexesItsRail 6 |
| +browser check TestTheProfileMenuOnThePhoneIndex 6 |
| browser check TestACardBreathes 5 |
| browser check TestEveryTapTargetIsAtLeast44Pixels 5 |
| browser check TestLightJSOnAnOldThemeWritesOntoNothingItPaints 5 |
| browser check TestTheSheenFollowsADraggedKnob 5 |
| +browser check TestTheTopbarMenuIsACardOverThePage 5 |
| browser check TestALeaveAfterAQueuedMoveNeverRelightsACard 4 |
| browser check TestATblockHeadDragsLikeASwitch 4 |
| browser check TestBothSpellingsComputeTheSameStyles 4 |
| browser check TestReducedMotionIsTotal 4 |
| browser check TestTheSlideKnowsWhichWayItIsGoing 4 |
| browser check TestTheWholeRowIsTheTarget 4 |
| +browser check TestTheWideLayoutIsUnchanged 4 |
| +browser check TestAFragmentLinkLandsBelowTheBackStrip 3 |
| +browser check TestAModalCoversTheTopbarOnAPhone 3 |
| browser check TestANarrowRowGivesItsTitleTheWidth 3 |
| +browser check TestEscapeClosesTheWholeCard 3 |
| browser check TestMediaGridFramesAreEvenAndCaptionsStayInTheTile 3 |
| browser check TestTheBackControlReusesHistoryWhenItCanProveWhatIsBehind 3 |
| browser check TestTheBulkBarNeverWrapsItsText 3 |
| +browser check TestTheConsoleRailFitsTheViewport 3 |
| browser check TestTheLightFollowsThePointerAndSettles 3 |
| browser check TestMediaGridTileIsTheTarget 2 |
| browser check TestPaginationIsOneBalancedLineOnAPhone 2 |
| @@ -49,4 +61,6 @@ browser check TestSelectRebuildsWhenItsOptionsChange 2 |
| browser check TestSelectStepsAsideWhenReplaced 2 |
| browser check TestSelectTypingIsCheap 2 |
| browser check TestTheCalendarDocksAndItsDaysAreTaps 2 |
| +browser check TestTheCardFitsTheSmallestPhone 2 |
| +browser check TestTheConsoleDegradesTheWayItSaysItDoesWithoutHas 2 |
| browser check TestTheTrackColourFollowsTheKnob 2 |