//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 rastrillo'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 = `
Invoices
A link in the page
`
// 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 ), 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 := `Invoices
Target
`
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)
}
}
})
}
}