rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5import (
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.
26func 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.
34const 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.
40const 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.
57func settled(t *testing.T, ctx context.Context) {
58 t.Helper()
59 settleUntil(t, ctx, `document.readyState === "complete" && !document.activeViewTransition`)
60}
61
62type 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).
77func 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.
136func 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.
205const 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.
218func 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.
266func 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.
286func 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.
335func 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.
407func 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.
432func 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.
476const 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
485type 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.
506func 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.
552const 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
559type 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.
579func 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