| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "math" |
| 9 | "net/http" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/chromedp/cdproto/emulation" |
| 14 | "github.com/chromedp/chromedp" |
| 15 | "github.com/chromedp/chromedp/kb" |
| 16 | |
| 17 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 18 | ) |
| 19 | |
| 20 | // indexPage is one page of this file's drives as rastrillo's shellLayoutPage |
| 21 | // rendered it: a layout with a nav of three sections (Invoices, Orders |
| 22 | // and Team, with ids, so the scriptless focus return has fragments to |
| 23 | // use), the title and brand Harbour, the language menu as #rail-locale |
| 24 | // (English and Gaeilge), and whatever else the drive defined, by the |
| 25 | // drive's name and the page's case. |
| 26 | func indexPage(t *testing.T, test, kase string) string { |
| 27 | t.Helper() |
| 28 | return fixture(t, "index_browser_test.go", test, kase) |
| 29 | } |
| 30 | |
| 31 | // shellContent is the content the shell drives put in a layout |
| 32 | // example's slot when the drive reads the chrome and not the page: a |
| 33 | // heading and a link far enough into the page to tap beside a card. |
| 34 | const shellContent = `<h1>Invoices</h1><p><a id="main-link" href="/go/main">A link in the page</a></p>` |
| 35 | |
| 36 | // viewJS is what a reader of a shell page sees. BackStart is measured |
| 37 | // from the root element's box, not the viewport: tokens.css reserves a |
| 38 | // stable scrollbar gutter, which a right-to-left page puts on the left, |
| 39 | // so the viewport's edge is not the page's. |
| 40 | const viewJS = `(() => { |
| 41 | const shown = el => !!el && el.getClientRects().length > 0 && el.getBoundingClientRect().width > 0; |
| 42 | const q = s => document.querySelector(s), back = q("[rst-shell-back] a"), br = back ? back.getBoundingClientRect() : null; |
| 43 | const title = q("[rst-shell-title]"), hr = document.documentElement.getBoundingClientRect(); |
| 44 | return JSON.stringify({Rail: shown(q("[rst-shell-rail] [rst-shell-nav]")), Main: shown(q("[rst-shell-main]")), |
| 45 | SkipDisplay: getComputedStyle(q("[rst-skip]")).display, Title: shown(title) ? title.innerText.trim() : "", |
| 46 | Back: shown(back), BackStart: br ? Math.round(document.documentElement.dir === "rtl" ? hr.right - br.right : br.left - hr.left) : -1, |
| 47 | BackTop: br ? Math.round(br.top) : -1, Drawer: !!q("[rst-shell-chrome]"), |
| 48 | H1s: [...document.querySelectorAll("h1")].filter(shown).length, Len: history.length}); |
| 49 | })()` |
| 50 | |
| 51 | // settled waits until the document has loaded and its view transition, |
| 52 | // if any, is over. A tap during the slide lands on the transition's |
| 53 | // snapshot, not on the page (elementFromPoint returns <html>), so a |
| 54 | // click sent then does nothing, and a navigation started then skips the |
| 55 | // running transition, which Chromium reports as an uncaught AbortError. |
| 56 | // Script execution may be disabled: Runtime.evaluate still runs. |
| 57 | func settled(t *testing.T, ctx context.Context) { |
| 58 | t.Helper() |
| 59 | settleUntil(t, ctx, `document.readyState === "complete" && !document.activeViewTransition`) |
| 60 | } |
| 61 | |
| 62 | type viewReading struct { |
| 63 | Rail, Main, Back, Drawer bool |
| 64 | SkipDisplay, Title string |
| 65 | BackStart, BackTop, H1s int |
| 66 | Len int |
| 67 | } |
| 68 | |
| 69 | // TestThePhoneIndexWorksWithNoScript is the scriptless leg, both |
| 70 | // shells, LTR and RTL, at 390 with a coarse pointer and scripts |
| 71 | // disabled in the engine: the index is the rail with the title as its |
| 72 | // one visible h1 and no main, no skip link and no drawer; a nav link |
| 73 | // opens the content page with the back control at the top inline-start |
| 74 | // and no rail; the back control lands on /#nav-… where that link is the |
| 75 | // target and the next Tab goes to the link after it. History grows one |
| 76 | // entry per step, which is the scriptless trade (logged). |
| 77 | func TestThePhoneIndexWorksWithNoScript(t *testing.T) { |
| 78 | for _, shell := range []string{"sidebar", "console"} { |
| 79 | for _, dir := range []string{"ltr", "rtl"} { |
| 80 | t.Run(shell+" "+dir, func(t *testing.T) { |
| 81 | pages := map[string]string{ |
| 82 | "/": indexPage(t, "TestThePhoneIndexWorksWithNoScript", shell+"-"+dir+"-index"), |
| 83 | "/invoices": indexPage(t, "TestThePhoneIndexWorksWithNoScript", shell+"-"+dir+"-invoices"), |
| 84 | } |
| 85 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 86 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 87 | defer cancel() |
| 88 | mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(390, 844), |
| 89 | chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")) |
| 90 | requirePointer(t, ctx, true) |
| 91 | var idx, pg, back viewReading |
| 92 | at(t, ctx, viewJS, &idx) |
| 93 | if !idx.Rail || idx.Main || idx.SkipDisplay != "none" || idx.Title != "Harbour" || idx.Back || idx.Drawer || idx.H1s != 1 { |
| 94 | 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) |
| 95 | } |
| 96 | // main is hidden on the index, so it being visible is a |
| 97 | // marker only the content page has. |
| 98 | mustRun(t, ctx, chromedp.Click("#nav-invoices", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-main]", chromedp.ByQuery)) |
| 99 | settled(t, ctx) |
| 100 | at(t, ctx, viewJS, &pg) |
| 101 | if pg.Rail || !pg.Main || !pg.Back || pg.BackStart > 8 || pg.H1s != 1 { |
| 102 | t.Errorf("the content page: %+v; want main, the back control within 8px of the top inline-start, no rail, one h1", pg) |
| 103 | } |
| 104 | // 6px: the 44px control centred in the 56px strip at the |
| 105 | // top of the screen (TestThePhoneHeaderStripIs56Pixels). |
| 106 | if shell == "sidebar" && pg.BackTop > 7 { |
| 107 | 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) |
| 108 | } |
| 109 | // And the rail's nav is shown only on the index. |
| 110 | mustRun(t, ctx, chromedp.Click("[rst-shell-back] a", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-rail] [rst-shell-nav]", chromedp.ByQuery)) |
| 111 | settled(t, ctx) |
| 112 | at(t, ctx, viewJS, &back) |
| 113 | var target bool |
| 114 | var next string |
| 115 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-invoices").matches(":target")`, &target), |
| 116 | chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`document.activeElement.id`, &next)) |
| 117 | if !target || next != "nav-orders" { |
| 118 | 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) |
| 119 | } |
| 120 | t.Logf("%s %s: history %d -> %d -> %d (scripts off: one entry per step)", shell, dir, idx.Len, pg.Len, back.Len) |
| 121 | if pg.Len != idx.Len+1 || back.Len != pg.Len+1 { |
| 122 | t.Errorf("history grew %d then %d, want 1 and 1", pg.Len-idx.Len, back.Len-pg.Len) |
| 123 | } |
| 124 | }) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // TestThePhoneIndexWorksWithScripts is the scripted journey on the REAL |
| 130 | // layouts and the real stylesheet (shell.js's own drives use |
| 131 | // hand-written markup): both shells, LTR and RTL, at 390 with a coarse |
| 132 | // pointer. Index, a section, the back control: history is reused (its |
| 133 | // length unchanged), the URL is the index, and focus is on the row the |
| 134 | // reader left. Then a deep link's back control, which follows the link |
| 135 | // and still focuses the row from the record. |
| 136 | func TestThePhoneIndexWorksWithScripts(t *testing.T) { |
| 137 | for _, shell := range []string{"sidebar", "console"} { |
| 138 | for _, dir := range []string{"ltr", "rtl"} { |
| 139 | t.Run(shell+" "+dir, func(t *testing.T) { |
| 140 | pages := map[string]string{ |
| 141 | "/": indexPage(t, "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-index"), |
| 142 | "/invoices": indexPage(t, "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-invoices"), |
| 143 | "/orders": indexPage(t, "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-orders"), |
| 144 | } |
| 145 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 146 | ctx, done, thrown := tab(t, rig, "") |
| 147 | defer done() |
| 148 | visit(t, ctx, rig.Origin+"/") |
| 149 | requirePointer(t, ctx, true) |
| 150 | settled(t, ctx) |
| 151 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices" && document.querySelector("[rst-shell-back] a").checkVisibility()`) |
| 152 | settled(t, ctx) |
| 153 | before := state(t, ctx) |
| 154 | follow(t, ctx, "[rst-shell-back] a", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| 155 | settled(t, ctx) |
| 156 | if after := state(t, ctx); after.Len != before.Len { |
| 157 | t.Errorf("the back control grew history %d -> %d; with scripts it reuses the entry behind it", before.Len, after.Len) |
| 158 | } |
| 159 | errs, skipped := scriptErrors(t, *thrown) |
| 160 | if len(errs) > 0 { |
| 161 | t.Errorf("uncaught: %v", errs) |
| 162 | } |
| 163 | // A deep link: a fresh tab opened straight on a section, so |
| 164 | // nothing is behind it. The link is followed (history +1) |
| 165 | // and focus still comes back to the row, from the record. |
| 166 | deep, doneDeep, deepThrown := tab(t, rig, "") |
| 167 | defer doneDeep() |
| 168 | visit(t, deep, rig.Origin+"/orders") |
| 169 | settled(t, deep) |
| 170 | before = state(t, deep) |
| 171 | follow(t, deep, "[rst-shell-back] a", `location.pathname === "/" && document.activeElement.id === "nav-orders"`) |
| 172 | settled(t, deep) |
| 173 | if after := state(t, deep); after.Len != before.Len+1 { |
| 174 | t.Errorf("a deep link's back control: history %d -> %d, want the link followed (+1)", before.Len, after.Len) |
| 175 | } |
| 176 | deepErrs, deepSkipped := scriptErrors(t, *deepThrown) |
| 177 | if len(deepErrs) > 0 { |
| 178 | t.Errorf("uncaught in the deep-link tab: %v", deepErrs) |
| 179 | } |
| 180 | // Three navigations slide in this drive: to the section, |
| 181 | // back, and the deep link's back. |
| 182 | if skipped+deepSkipped >= 3 { |
| 183 | t.Errorf("every one of the drive's 3 navigations skipped its transition (%d skips); the slide never ran", skipped+deepSkipped) |
| 184 | } |
| 185 | |
| 186 | // The console's index with fiesta.js loaded: the card |
| 187 | // opens over the rail and an outside tap closes it, and |
| 188 | // the rail, which no longer follows the Menu, stays. |
| 189 | if shell == "console" { |
| 190 | idx, doneIdx, _ := tab(t, rig, "") |
| 191 | defer doneIdx() |
| 192 | visit(t, idx, rig.Origin+"/") |
| 193 | settled(t, idx) |
| 194 | mustRun(t, idx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), |
| 195 | chromedp.WaitVisible("[rst-shell-tail] #rail-locale > summary", chromedp.ByQuery)) |
| 196 | clickAndStay(t, idx, probe(t, idx, "[rst-shell-rail]", 0.5, 0.8, 0, 0), "", |
| 197 | `!document.querySelector("[rst-shell-menu]").open && document.querySelector("#nav-team").checkVisibility()`) |
| 198 | } |
| 199 | }) |
| 200 | } |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // boxesJS is the rail's and main's boxes, for comparing two layouts. |
| 205 | const boxesJS = `(() => { const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return [r.left, r.top, r.width, r.height].map(Math.round).join(","); }; |
| 206 | return JSON.stringify({Rail: b("[rst-shell-rail]"), Main: b("[rst-shell-main]")}); })()` |
| 207 | |
| 208 | // TestTheWideLayoutIsUnchanged: at 1280, in both directions, both views |
| 209 | // keep the old layout's boxes (the same markup rendered through the |
| 210 | // legacy layout is the golden), with no back control and no second h1. |
| 211 | // |
| 212 | // One deliberate change, asserted rather than tolerated: the sidebar |
| 213 | // rail's foot is no longer the language menu and the person laid out |
| 214 | // flat. It is one avatar button, the profile menu, and the language is |
| 215 | // inside its card, so the language menu that the legacy layout shows in |
| 216 | // the foot is not on screen until the avatar is opened. The rail and |
| 217 | // main boxes do not move, because the foot was always inside the rail. |
| 218 | func TestTheWideLayoutIsUnchanged(t *testing.T) { |
| 219 | for _, c := range []struct{ shell, dir string }{{"sidebar", "ltr"}, {"sidebar", "rtl"}, {"console", "ltr"}, {"console", "rtl"}} { |
| 220 | t.Run(c.shell+" "+c.dir, func(t *testing.T) { |
| 221 | shell := c.shell |
| 222 | pages := map[string]string{ |
| 223 | "/index": indexPage(t, "TestTheWideLayoutIsUnchanged", shell+"-"+c.dir+"-index"), |
| 224 | "/page": indexPage(t, "TestTheWideLayoutIsUnchanged", shell+"-"+c.dir+"-page"), |
| 225 | "/legacy": indexPage(t, "TestTheWideLayoutIsUnchanged", shell+"-"+c.dir+"-legacy"), |
| 226 | } |
| 227 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| 228 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 229 | defer cancel() |
| 230 | read := func(path string) (map[string]string, viewReading) { |
| 231 | var boxes map[string]string |
| 232 | var v viewReading |
| 233 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body")) |
| 234 | at(t, ctx, boxesJS, &boxes) |
| 235 | at(t, ctx, viewJS, &v) |
| 236 | return boxes, v |
| 237 | } |
| 238 | 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()})` |
| 239 | var foot struct{ Locale, Avatar bool } |
| 240 | golden, _ := read("/legacy") |
| 241 | at(t, ctx, footJS, &foot) |
| 242 | if shell == "sidebar" && (!foot.Locale || foot.Avatar) { |
| 243 | t.Errorf("the legacy sidebar's foot: language menu shown %v, avatar %v; the golden is the flat foot", foot.Locale, foot.Avatar) |
| 244 | } |
| 245 | for _, path := range []string{"/index", "/page"} { |
| 246 | got, v := read(path) |
| 247 | if shell == "sidebar" { |
| 248 | at(t, ctx, footJS, &foot) |
| 249 | if foot.Locale || !foot.Avatar { |
| 250 | 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) |
| 251 | } |
| 252 | } |
| 253 | if got["Rail"] != golden["Rail"] || got["Main"] != golden["Main"] { |
| 254 | 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"]) |
| 255 | } |
| 256 | if v.Back || v.Title != "" || v.H1s != 1 { |
| 257 | 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) |
| 258 | } |
| 259 | } |
| 260 | }) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // TestAnOldSidebarLayoutKeepsItsDrawer: an app that re-vendors |
| 265 | // tokens.css and keeps its old layout still opens its drawer at 390. |
| 266 | func TestAnOldSidebarLayoutKeepsItsDrawer(t *testing.T) { |
| 267 | pages := map[string]string{"/": legacyLayout(t, "sidebar")} |
| 268 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| 269 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 270 | defer cancel() |
| 271 | var before, after viewReading |
| 272 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-chrome] > summary", chromedp.ByQuery)) |
| 273 | at(t, ctx, viewJS, &before) |
| 274 | mustRun(t, ctx, chromedp.Click("[rst-shell-chrome] > summary", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-rail] [rst-shell-nav] a", chromedp.ByQuery)) |
| 275 | at(t, ctx, viewJS, &after) |
| 276 | if before.Rail || !after.Rail || !after.Main { |
| 277 | 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) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // TestTheIndexRowsRoundEachRunOfLinks: whatever the nav starts with, |
| 282 | // every row has its side and bottom borders, the first row of each run |
| 283 | // its top border and top corners, and the last row of each run its |
| 284 | // bottom corners. On a coarse pointer, where the touch block's 44px |
| 285 | // floor for nav links would otherwise win, every row is still 3rem. |
| 286 | func TestTheIndexRowsRoundEachRunOfLinks(t *testing.T) { |
| 287 | // Three sidebar indexes, each with its own nav: two groups (Sales |
| 288 | // with A and B, Settings with C), one link, and three links with |
| 289 | // no groups (C current). |
| 290 | rows := map[string]int{"/group-first": 3, "/one": 1, "/no-groups": 3} |
| 291 | pages := map[string]string{} |
| 292 | for path := range rows { |
| 293 | pages[path] = indexPage(t, "TestTheIndexRowsRoundEachRunOfLinks", path[1:]) |
| 294 | } |
| 295 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 296 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 297 | defer cancel() |
| 298 | for path := range rows { |
| 299 | var got struct { |
| 300 | Rows int |
| 301 | Bad []string |
| 302 | } |
| 303 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body")) |
| 304 | requirePointer(t, ctx, true) |
| 305 | at(t, ctx, `(() => { |
| 306 | const bad = [], links = document.querySelectorAll("[rst-shell-nav] > a"); |
| 307 | for (const a of links) { |
| 308 | const cs = getComputedStyle(a), prev = a.previousElementSibling, next = a.nextElementSibling; |
| 309 | const first = !prev || prev.hasAttribute("rst-shell-group"), last = !next || next.hasAttribute("rst-shell-group"); |
| 310 | const px = v => parseFloat(v) || 0; |
| 311 | if (px(cs.borderLeftWidth) !== 1 || px(cs.borderRightWidth) !== 1 || px(cs.borderBottomWidth) !== 1) bad.push(a.textContent + ": sides/bottom"); |
| 312 | if ((px(cs.borderTopWidth) === 1) !== first) bad.push(a.textContent + ": top border " + cs.borderTopWidth); |
| 313 | if ((px(cs.borderStartStartRadius) > 0) !== first) bad.push(a.textContent + ": top corner " + cs.borderStartStartRadius); |
| 314 | if ((px(cs.borderEndStartRadius) > 0) !== last) bad.push(a.textContent + ": bottom corner " + cs.borderEndStartRadius); |
| 315 | if (a.getBoundingClientRect().height < 47.5) bad.push(a.textContent + ": under 3rem (" + a.getBoundingClientRect().height + "px)"); |
| 316 | } |
| 317 | return JSON.stringify({Rows: [...links].filter(a => a.getClientRects().length > 0).length, Bad: bad}); |
| 318 | })()`, &got) |
| 319 | if got.Rows != rows[path] { |
| 320 | t.Fatalf("%s: %d index rows shown, want %d; the leg would be checking nothing", path, got.Rows, rows[path]) |
| 321 | } |
| 322 | if len(got.Bad) > 0 { |
| 323 | t.Errorf("%s: %v", path, got.Bad) |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // TestTheConsoleIndexRailFillsTheScreen: the console's phone index is |
| 329 | // the bar, then the rail, then the foot. The three share the window: a |
| 330 | // rail a whole window tall under the bar made every index scroll by the |
| 331 | // bar's and the foot's height, however short its nav. With a short nav |
| 332 | // they fill the window exactly and nothing scrolls, with a foot or |
| 333 | // without one; with a long nav the rail grows, the page scrolls, and |
| 334 | // every row can still be reached and hit. |
| 335 | func TestTheConsoleIndexRailFillsTheScreen(t *testing.T) { |
| 336 | // Three console indexes: a two-link nav with an About foot, the |
| 337 | // same nav with no foot, and thirty sections (nav-1 to nav-30) |
| 338 | // with the foot. |
| 339 | pages := map[string]string{ |
| 340 | "/short": indexPage(t, "TestTheConsoleIndexRailFillsTheScreen", "short"), |
| 341 | "/nofoot": indexPage(t, "TestTheConsoleIndexRailFillsTheScreen", "nofoot"), |
| 342 | "/long": indexPage(t, "TestTheConsoleIndexRailFillsTheScreen", "long"), |
| 343 | } |
| 344 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| 345 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 346 | defer cancel() |
| 347 | type geometry struct { |
| 348 | BarTop, BarH, RailTop, RailH, RailBottom, NavBottom float64 |
| 349 | FootShown bool |
| 350 | FootTop, FootBottom, DocH, VH float64 |
| 351 | } |
| 352 | read := func(path string) geometry { |
| 353 | t.Helper() |
| 354 | var g geometry |
| 355 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+path), chromedp.WaitReady("body")) |
| 356 | // Every box in document coordinates, so a scrolled page reads the |
| 357 | // same as one at the top. |
| 358 | at(t, ctx, `(() => { const b = s => { const r = document.querySelector(s).getBoundingClientRect(); return {top: r.top + scrollY, bottom: r.bottom + scrollY, h: r.height}; }; |
| 359 | const bar = b("[rst-shell-bar]"), rail = b("[rst-shell-rail]"), foot = b("[rst-shell-foot]"), nav = b("[rst-shell-nav]"); |
| 360 | return JSON.stringify({BarTop: bar.top, BarH: bar.h, RailTop: rail.top, RailH: rail.h, RailBottom: rail.bottom, NavBottom: nav.bottom, |
| 361 | FootShown: foot.h > 0, FootTop: foot.top, FootBottom: foot.bottom, DocH: document.documentElement.scrollHeight, VH: innerHeight}); })()`, &g) |
| 362 | return g |
| 363 | } |
| 364 | near := func(a, b float64) bool { return math.Abs(a-b) <= 1 } |
| 365 | |
| 366 | g := read("/short") |
| 367 | if g.NavBottom > g.VH/2 || !g.FootShown { |
| 368 | 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) |
| 369 | } |
| 370 | if !near(g.BarTop, 0) || !near(g.RailTop, g.BarTop+g.BarH) || !near(g.FootTop, g.RailBottom) || !near(g.FootBottom, g.VH) { |
| 371 | 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) |
| 372 | } |
| 373 | if !near(g.DocH, g.VH) { |
| 374 | t.Errorf("/short: the document is %.0fpx in a %.0fpx window; a short index does not scroll", g.DocH, g.VH) |
| 375 | } |
| 376 | |
| 377 | g = read("/nofoot") |
| 378 | if g.FootShown { |
| 379 | t.Fatal("/nofoot: an empty foot is drawn; the no-foot case has not arisen") |
| 380 | } |
| 381 | if !near(g.BarH+g.RailH, g.VH) || !near(g.DocH, g.VH) { |
| 382 | 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) |
| 383 | } |
| 384 | |
| 385 | g = read("/long") |
| 386 | if g.NavBottom < g.VH { |
| 387 | t.Fatalf("/long: thirty rows end at %.0f in a %.0fpx window; the long-nav case has not arisen", g.NavBottom, g.VH) |
| 388 | } |
| 389 | if !near(g.FootTop, g.RailBottom) || !near(g.DocH, g.FootBottom) { |
| 390 | 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) |
| 391 | } |
| 392 | var missed []string |
| 393 | at(t, ctx, `(() => { const out = []; |
| 394 | for (const a of document.querySelectorAll("[rst-shell-nav] a")) { |
| 395 | a.scrollIntoView({block: "center"}); |
| 396 | const r = a.getBoundingClientRect(), h = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); |
| 397 | if (!(h === a || a.contains(h))) out.push(a.id + " -> " + (h ? h.tagName : "nothing")); |
| 398 | } |
| 399 | return JSON.stringify(out); })()`, &missed) |
| 400 | if len(missed) > 0 { |
| 401 | t.Errorf("/long: rows that cannot be scrolled to and hit: %v", missed) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | // A view block with whitespace matches the index rules in the engine, |
| 406 | // not only in the template's output. |
| 407 | func TestAWhitespaceViewIsTheIndexInTheBrowser(t *testing.T) { |
| 408 | pages := map[string]string{"/": indexPage(t, "TestAWhitespaceViewIsTheIndexInTheBrowser", "")} |
| 409 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| 410 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 411 | defer cancel() |
| 412 | var v viewReading |
| 413 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body")) |
| 414 | at(t, ctx, viewJS, &v) |
| 415 | if !v.Rail || v.Main { |
| 416 | t.Errorf("a view written as \"\\n index\\n\" renders rail %v main %v; want the index", v.Rail, v.Main) |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | // TestAnOldConsoleLayoutKeepsItsRailOpen: an app that re-vendors |
| 421 | // fiesta.js and tokens.css but keeps a console layout from before |
| 422 | // the phone index still has a rail it can use at 390. Its rail is gated |
| 423 | // on the Menu's [open], so dismissing that Menu on an outside click, or |
| 424 | // covering the page with the card's tap-to-close layer, would close the |
| 425 | // whole navigation on a tap at a group label or the rail's empty space. |
| 426 | // The control: a dropdown in the page IS dismissed by the same click, |
| 427 | // so fiesta.js is running and saw it. It is in the page rather than |
| 428 | // in the bar's tail because the tail's open panel would lie over the |
| 429 | // rail and take the click itself. The page is the legacy console with |
| 430 | // a grouped nav (#group, Sales) and the Sort dropdown (#page-menu) in |
| 431 | // its content. |
| 432 | func TestAnOldConsoleLayoutKeepsItsRailOpen(t *testing.T) { |
| 433 | pages := map[string]string{"/": indexPage(t, "TestAnOldConsoleLayoutKeepsItsRailOpen", "")} |
| 434 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 435 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 436 | defer cancel() |
| 437 | type reading struct { |
| 438 | Menu, Dropdown, Rail bool |
| 439 | } |
| 440 | const read = `JSON.stringify({Menu: document.querySelector("[rst-shell-menu]").open, Dropdown: document.querySelector("#page-menu").open, |
| 441 | Rail: document.querySelector("#nav-invoices").getClientRects().length > 0})` |
| 442 | for _, c := range []struct { |
| 443 | where string |
| 444 | fx, fy float64 |
| 445 | sel string |
| 446 | }{ |
| 447 | {"a group label", 0.5, 0.5, "#group"}, |
| 448 | // The rail's padding under its last link: rail, and nothing else. |
| 449 | {"the rail's empty space", 0.5, 1, "[rst-shell-rail]"}, |
| 450 | } { |
| 451 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 452 | requirePointer(t, ctx, true) |
| 453 | mustRun(t, ctx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.WaitVisible("#nav-invoices", chromedp.ByQuery), |
| 454 | chromedp.Click("#page-menu > summary", chromedp.ByQuery), chromedp.WaitVisible("#page-menu [rst-dropdown-menu]", chromedp.ByQuery)) |
| 455 | dy := 0.0 |
| 456 | if c.fy == 1 { |
| 457 | dy = -6 |
| 458 | } |
| 459 | p := probe(t, ctx, c.sel, c.fx, c.fy, 0, dy) |
| 460 | mustRun(t, ctx, chromedp.MouseClickXY(p.X, p.Y)) |
| 461 | settleUntil(t, ctx, `!document.querySelector("#page-menu").open`) |
| 462 | var got reading |
| 463 | at(t, ctx, read, &got) |
| 464 | if !got.Menu || !got.Rail { |
| 465 | 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) |
| 466 | } |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | // backStripJS reads the back strip's bottom edge, the fragment target's |
| 471 | // top edge (both as the viewport sees them, after any landing scroll |
| 472 | // has settled), and the root's resolved scroll-padding-block-start, so |
| 473 | // one probe serves both the narrow leg and the wide one. -1 stands in |
| 474 | // for an element the page does not have, which the narrow leg's content |
| 475 | // page always does and the wide leg's assertion never looks at. |
| 476 | const backStripJS = `(() => { |
| 477 | const back = document.querySelector("[rst-shell-back]"), target = document.getElementById("target"); |
| 478 | return JSON.stringify({ |
| 479 | BackBottom: back ? Math.round(back.getBoundingClientRect().bottom) : -1, |
| 480 | TargetTop: target ? Math.round(target.getBoundingClientRect().top) : -1, |
| 481 | ScrollPadding: getComputedStyle(document.documentElement).scrollPaddingBlockStart, |
| 482 | }); |
| 483 | })()` |
| 484 | |
| 485 | type backStripReading struct { |
| 486 | BackBottom, TargetTop int |
| 487 | ScrollPadding string |
| 488 | } |
| 489 | |
| 490 | // TestAFragmentLinkLandsBelowTheBackStrip: the back strip is sticky at |
| 491 | // the top of a phone content page (tokens.css, just above), so the |
| 492 | // browser's native landing scroll for a #fragment link parks the |
| 493 | // target's top edge at the document root's scroll origin — which a |
| 494 | // sticky strip then covers, because a sticky element is not part of the |
| 495 | // flow a plain scroll-to-element measures against. A reader who follows |
| 496 | // a link to a heading finds the heading hidden under Back. |
| 497 | // |
| 498 | // Both shells, at 390 with a coarse pointer; and, because the fix must |
| 499 | // not reach past the media query that gates the strip itself, a second |
| 500 | // leg per shell at 1280 holding the root's scroll-padding unchanged. |
| 501 | // |
| 502 | // The page is each shell's content-page example (layout-sidebar-page, |
| 503 | // layout-console-page) with the target far enough down its slot to |
| 504 | // scroll to: the drive reads the back strip and the target, and |
| 505 | // nothing else about the chrome. |
| 506 | func TestAFragmentLinkLandsBelowTheBackStrip(t *testing.T) { |
| 507 | content := `<h1>Invoices</h1><div style="block-size: 150vh"></div><h2 id="target">Target</h2><div style="block-size: 150vh"></div>` |
| 508 | for _, shell := range []string{"sidebar", "console"} { |
| 509 | pages := map[string]string{"/page": shellPage(t, shell+"-page", content)} |
| 510 | |
| 511 | t.Run(shell+" at 390", func(t *testing.T) { |
| 512 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 513 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 514 | defer cancel() |
| 515 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/page#target"), chromedp.WaitVisible("#target", chromedp.ByQuery)) |
| 516 | requirePointer(t, ctx, true) |
| 517 | settled(t, ctx) |
| 518 | var got backStripReading |
| 519 | at(t, ctx, backStripJS, &got) |
| 520 | if got.BackBottom < 0 || got.TargetTop < 0 { |
| 521 | t.Fatalf("the back strip or #target is missing from the page: %+v", got) |
| 522 | } |
| 523 | if got.TargetTop < got.BackBottom { |
| 524 | 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) |
| 525 | } |
| 526 | // Exactly at the strip's edge, not merely below it: a scroll |
| 527 | // padding that kept an old strip height, or guessed a bigger |
| 528 | // one, leaves a gap or a hidden band whenever the strip grows. |
| 529 | if got.TargetTop > got.BackBottom+1 { |
| 530 | 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) |
| 531 | } |
| 532 | }) |
| 533 | |
| 534 | t.Run(shell+" at 1280 is unchanged", func(t *testing.T) { |
| 535 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }) |
| 536 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 537 | defer cancel() |
| 538 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/page#target"), chromedp.WaitReady("body")) |
| 539 | var got backStripReading |
| 540 | at(t, ctx, backStripJS, &got) |
| 541 | if got.ScrollPadding != "auto" && got.ScrollPadding != "0px" { |
| 542 | 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) |
| 543 | } |
| 544 | }) |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | // headerStripJS measures a phone header strip and the tap target in |
| 549 | // it: the strip's border box and the target's, as the viewport sees |
| 550 | // them, so a target that is 44px but pinned to the strip's top edge |
| 551 | // reads as off centre rather than passing. |
| 552 | const headerStripJS = `((strip, target) => { |
| 553 | const s = document.querySelector(strip), c = document.querySelector(target); |
| 554 | if (!s || !c || !s.checkVisibility() || !c.checkVisibility()) return JSON.stringify({Missing: true}); |
| 555 | const sr = s.getBoundingClientRect(), cr = c.getBoundingClientRect(); |
| 556 | return JSON.stringify({Top: sr.top, H: sr.height, TargetH: cr.height, Off: (cr.top + cr.height / 2) - (sr.top + sr.height / 2)}); |
| 557 | })(%q, %q)` |
| 558 | |
| 559 | type headerStripReading struct { |
| 560 | Missing bool |
| 561 | Top, H, TargetH float64 |
| 562 | Off float64 |
| 563 | } |
| 564 | |
| 565 | // TestThePhoneHeaderStripIs56Pixels: on a phone every strip a shell |
| 566 | // puts at the top of the screen is 56px, the height of a mobile app's |
| 567 | // top bar. At 44px the back strip was the bare tap target with no room |
| 568 | // round it, and read as cramped next to every native app's header. The |
| 569 | // tap target inside stays 44px and sits on the strip's centre line: a |
| 570 | // taller strip with the control at its top edge would make the strip's |
| 571 | // lower part a dead zone that looks tappable. The index's header row |
| 572 | // and a content page's back strip are both checked, so going from one |
| 573 | // to the other does not change the header's height under the slide. |
| 574 | // |
| 575 | // The pages are each shell's index and content-page examples |
| 576 | // (layout-sidebar and layout-sidebar-page, layout-console and |
| 577 | // layout-console-page), whose chrome carries every strip and target |
| 578 | // measured here, with shellContent in the slot. |
| 579 | func TestThePhoneHeaderStripIs56Pixels(t *testing.T) { |
| 580 | type strip struct{ where, page, strip, target string } |
| 581 | shells := map[string][]strip{ |
| 582 | "sidebar": { |
| 583 | {"the back strip", "/page", "[rst-shell-back]", "[rst-shell-back] > a"}, |
| 584 | {"the index's header row", "/", "[rst-shell-title]", "[rst-shell-profile] > summary"}, |
| 585 | }, |
| 586 | "console": { |
| 587 | {"the bar over a page", "/page", "[rst-shell-bar]", "[rst-shell-menu] > summary"}, |
| 588 | {"the back strip", "/page", "[rst-shell-back]", "[rst-shell-back] > a"}, |
| 589 | {"the index's header row", "/", "[rst-shell-bar]", "[rst-shell-menu] > summary"}, |
| 590 | }, |
| 591 | } |
| 592 | for shell, strips := range shells { |
| 593 | pages := map[string]string{ |
| 594 | "/": shellPage(t, shell, shellContent), |
| 595 | "/page": shellPage(t, shell+"-page", shellContent), |
| 596 | } |
| 597 | t.Run(shell, func(t *testing.T) { |
| 598 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 599 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 600 | defer cancel() |
| 601 | for _, s := range strips { |
| 602 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+s.page), chromedp.WaitReady("body")) |
| 603 | requirePointer(t, ctx, true) |
| 604 | settled(t, ctx) |
| 605 | var got headerStripReading |
| 606 | at(t, ctx, fmt.Sprintf(headerStripJS, s.strip, s.target), &got) |
| 607 | if got.Missing { |
| 608 | 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) |
| 609 | } |
| 610 | if math.Abs(got.H-56) > 0.5 || got.TargetH < 44 || math.Abs(got.Off) > 1 { |
| 611 | 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) |
| 612 | } |
| 613 | if s.page == "/" && math.Abs(got.Top) > 0.5 { |
| 614 | t.Errorf("%s starts %.1fpx down the page; want it at the top, where a content page's strip is", s.where, got.Top) |
| 615 | } |
| 616 | } |
| 617 | }) |
| 618 | } |
| 619 | } |
| 620 | |