//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 rastrillo'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 rastrillo // 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", `

landed

`) 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
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
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) } } }) } }