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 gallery
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "math"
10 "net/http"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/chromedp/cdproto/input"
16 "github.com/chromedp/cdproto/runtime"
17 "github.com/chromedp/chromedp"
18 "github.com/chromedp/chromedp/kb"
19
20 "amadan.net/rastrillo/fiesta"
21 "amadan.net/rastrillo/fiesta/internal/harness"
22)
23
24// The pinned bar: what it reserves, what lands under it, what paints
25// over it, its language menu, the controls' heights, the prev/next
26// pair under main, and the scheme a returning visitor sees before the
27// first paint.
28
29// focusTopJS is the top of whatever has focus, or of the element a
30// fragment targets, in the page's coordinates. Inside a preview frame
31// the element's offset is in the frame's own pixels and the frame may
32// be drawn scaled, so the offset is multiplied by the frame's rendered
33// width over its layout width before the frame's top is added.
34const focusTopJS = `((el) => {
35 let top = 0, k = 1;
36 el = el || document.activeElement;
37 while (el && el.tagName === "IFRAME") {
38 const r = el.getBoundingClientRect();
39 top += r.top; k *= r.width / el.offsetWidth;
40 const d = el.contentDocument;
41 if (!d || !d.activeElement || d.activeElement === d.body) break;
42 el = d.activeElement;
43 }
44 const r = el.getBoundingClientRect();
45 const main = document.getElementById("main"), outer = document.activeElement;
46 return JSON.stringify({Top: el.tagName === "IFRAME" ? top : top + r.top * k, What: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""),
47 Bar: document.querySelector(".ds-top").getBoundingClientRect().bottom,
48 End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2,
49 InMain: !!outer && outer !== document.body && main.contains(outer)});
50})`
51
52type focusReading struct {
53 Top, Bar float64
54 What string
55 End bool
56 // InMain says whether what has focus is inside main (a frame's
57 // own controls count through their frame): the Tab walks stop
58 // when focus leaves it for the bar, the rail or the browser.
59 InMain bool
60}
61
62func readFocus(t *testing.T, ctx context.Context, where, target string) focusReading {
63 t.Helper()
64 var raw string
65 mustRun(t, ctx, chromedp.Evaluate(focusTopJS+"("+target+")", &raw))
66 var r focusReading
67 if err := json.Unmarshal([]byte(raw), &r); err != nil {
68 t.Fatalf("%s: decoding %q: %v", where, raw, err)
69 }
70 return r
71}
72
73// TestFocusIsNeverUnderThePinnedBar: focus and fragment targets stop
74// below the pinned bar, never under it (WCAG 2.4.11): forty Tabs on
75// from the first control in main and forty back, the skip link's
76// target, and every rail fragment of the page. The sign-in page, whose
77// twelve framed forms give the walk its forty stops inside main, and
78// Tokens, whose groups the rail links by fragment. A walk stops where
79// focus leaves main for the chrome or the browser, and the forward
80// walk is held to at least twenty stops so it cannot stop short. A
81// target near the end of the page may stop lower, because the browser
82// clamps the scroll at the end, but never higher.
83func TestFocusIsNeverUnderThePinnedBar(t *testing.T) {
84 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
85 ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
86 defer cancel()
87 theme := fiesta.ThemeNames()[0]
88 fragments := 0
89 for _, file := range []string{"signin.html", "tokens.html"} {
90 for _, locale := range []string{"en", "ar"} {
91 for _, w := range []int64{1440, 800} {
92 where := fmt.Sprintf("%s/%s/%s at %dpx", theme, locale, file, w)
93 mustRun(t, ctx, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady(`main`, chromedp.ByQuery))
94 present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail")
95 eagerly(t, ctx, where)
96 under := func(how string, r focusReading) {
97 if r.Top < r.Bar-1 {
98 t.Errorf("%s: %s puts %s at %.1fpx, under the bar ending at %.1fpx", where, how, r.What, r.Top, r.Bar)
99 }
100 }
101 mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("main [rst-page]").querySelector("a[href], button, summary, input:not([type=hidden]), iframe").focus(); true`, nil))
102 stops := 0
103 for i := 0; i < 40; i++ {
104 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab))
105 r := readFocus(t, ctx, where, "")
106 if !r.InMain {
107 break
108 }
109 stops++
110 under(fmt.Sprintf("tab %d", i+1), r)
111 }
112 if file == "signin.html" && stops < 20 {
113 t.Errorf("%s: the forward walk left main after %d stops; the page was chosen for its forty", where, stops)
114 }
115 for i := 0; i < 40; i++ {
116 mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift)))
117 r := readFocus(t, ctx, where, "")
118 if !r.InMain {
119 break
120 }
121 under(fmt.Sprintf("shift+tab %d", i+1), r)
122 }
123 mustRun(t, ctx, chromedp.Evaluate(`scrollTo(0, 2000); document.querySelector("[rst-skip]").click(); true`, nil))
124 under("the skip link", readFocus(t, ctx, where, `document.getElementById("main")`))
125 var hrefs []string
126 mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll("#ds-nav a[href*='#']")].filter(a => a.pathname === location.pathname).map(a => a.hash.slice(1))`, &hrefs))
127 for _, id := range hrefs {
128 fragments++
129 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil))
130 r := readFocus(t, ctx, where, fmt.Sprintf(`document.getElementById(%q)`, id))
131 under("#"+id, r)
132 if !r.End && r.Top > r.Bar+40 {
133 t.Errorf("%s: #%s lands at %.1fpx, far below the bar's %.1fpx with the page not at its end; the scroll padding is not what the fragment stopped at", where, id, r.Top, r.Bar)
134 }
135 }
136 }
137 }
138 }
139 if fragments == 0 {
140 t.Fatal("the rail links no fragment of any page read; the fragment leg checked nothing")
141 }
142}
143
144// stripBadJS scans down the page in steps of at most half the strip's
145// own height, sampling roughly every 32px across the strip's width at
146// its top, middle and bottom edges, and reports every sampled point
147// where elementFromPoint lands outside the strip.
148const stripBadJS = `((sel) => {
149 const strip = document.querySelector(sel);
150 const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2);
151 const bad = [];
152 for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
153 scrollTo(0, y);
154 const r = strip.getBoundingClientRect();
155 for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) {
156 for (let x = r.left; x <= r.right; x += 32) {
157 const hit = document.elementFromPoint(x, yy);
158 if (!hit || !strip.contains(hit)) bad.push({X: Math.round(x), Y: Math.round(yy), Hit: hit ? hit.tagName + "." + hit.className : "nothing"});
159 }
160 }
161 }
162 return JSON.stringify(bad);
163})`
164
165// stripH2PointsJS finds, at the same scroll steps and sample points,
166// every point a <main> heading's own box covers, by geometry: the
167// control for the scan's density.
168const stripH2PointsJS = `((sel) => {
169 const strip = document.querySelector(sel);
170 const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2);
171 const pts = [];
172 for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
173 scrollTo(0, y);
174 const r = strip.getBoundingClientRect();
175 const h2s = [...document.querySelectorAll("main h2")].map(h2 => h2.getBoundingClientRect());
176 for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) {
177 for (let x = r.left; x <= r.right; x += 32) {
178 if (h2s.some(hr => x >= hr.left && x <= hr.right && yy >= hr.top && yy <= hr.bottom)) pts.push({X: Math.round(x), Y: Math.round(yy)});
179 }
180 }
181 }
182 return JSON.stringify(pts);
183})`
184
185type stripPoint struct {
186 X, Y float64
187 Hit string
188}
189
190func evalStripPoints(t *testing.T, ctx context.Context, where, js, selector string) []stripPoint {
191 t.Helper()
192 var raw string
193 mustRun(t, ctx, chromedp.Evaluate(js+"("+fmt.Sprintf("%q", selector)+")", &raw))
194 var pts []stripPoint
195 if err := json.Unmarshal([]byte(raw), &pts); err != nil {
196 t.Fatalf("%s: decoding %q: %v", where, raw, err)
197 }
198 return pts
199}
200
201// TestNothingPaintsOverTheBarOrTheBackStrip: nothing in the content
202// paints over the pinned chrome: at 1440px the bar, and at 390px the
203// shell's back strip, are what elementFromPoint finds at their edges
204// everywhere down a page of frames. A second pass raises every <main>
205// heading above the strip and checks the scan catches every point a
206// heading's box covers: the control for the scan's own density.
207func TestNothingPaintsOverTheBarOrTheBackStrip(t *testing.T) {
208 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
209 ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second)
210 defer cancel()
211 theme := fiesta.ThemeNames()[0]
212 for _, c := range []struct {
213 w int64
214 strip string
215 }{{1440, ".ds-top"}, {390, "[rst-shell-back]"}} {
216 where := fmt.Sprintf("signin.html at %dpx", c.w)
217 mustRun(t, ctx, chromedp.EmulateViewport(c.w, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")), chromedp.WaitReady("body"))
218 present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail")
219 mustRun(t, ctx, chromedp.WaitVisible(c.strip, chromedp.ByQuery))
220 eagerly(t, ctx, where)
221 before := evalStripPoints(t, ctx, where, stripBadJS, c.strip)
222 if len(before) > 0 {
223 if len(before) > 6 {
224 before = before[:6]
225 }
226 t.Errorf("%s: something paints over %s: %v", where, c.strip, before)
227 }
228 h2Points := evalStripPoints(t, ctx, where, stripH2PointsJS, c.strip)
229 if len(h2Points) == 0 {
230 t.Fatalf("%s: no heading passes under %s at any scroll step, so nothing checks the scan can see one", where, c.strip)
231 }
232 mustRun(t, ctx, chromedp.Evaluate(`document.head.insertAdjacentHTML("beforeend", "<style>main h2{position:relative;z-index:11}</style>"); true`, nil))
233 after := evalStripPoints(t, ctx, where, stripBadJS, c.strip)
234 caught := make(map[[2]int]bool, len(after))
235 for _, p := range after {
236 caught[[2]int{int(p.X), int(p.Y)}] = true
237 }
238 var missed []stripPoint
239 for _, p := range h2Points {
240 if !caught[[2]int{int(p.X), int(p.Y)}] {
241 missed = append(missed, p)
242 }
243 }
244 if len(missed) > 0 {
245 if len(missed) > 6 {
246 missed = missed[:6]
247 }
248 t.Errorf("%s: a heading raised above %s went uncaught at %d of %d points its own box covers: %v", where, c.strip, len(missed), len(h2Points), missed)
249 }
250 }
251}
252
253// barFit reads the bar's box, the union of what is inside it, whether
254// that content shares one line, and the height the content alone
255// needs. The bar's own box is fixed at --ds-bar-h, so it cannot
256// measure its own overflow; its children can.
257const barFit = `(() => {
258 const bar = document.querySelector(".ds-top"), b = bar.getBoundingClientRect();
259 let top = Infinity, bottom = -Infinity, left = Infinity, right = -Infinity, maxTop = -Infinity, minBottom = Infinity;
260 for (const el of bar.querySelectorAll(".ds-top__brand, .ds-top__controls > *")) {
261 const r = el.getBoundingClientRect();
262 if (!r.width && !r.height) continue;
263 top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom);
264 left = Math.min(left, r.left); right = Math.max(right, r.right);
265 maxTop = Math.max(maxTop, r.top); minBottom = Math.min(minBottom, r.bottom);
266 }
267 const prevStyle = bar.getAttribute("style");
268 bar.style.blockSize = "auto";
269 const need = bar.getBoundingClientRect().height;
270 if (prevStyle === null) bar.removeAttribute("style"); else bar.setAttribute("style", prevStyle);
271 return JSON.stringify({Top: b.top, Bottom: b.bottom, Left: b.left, Right: b.right,
272 UTop: top, UBottom: bottom, ULeft: left, URight: right, Over: bar.scrollWidth - bar.clientWidth,
273 Need: need, MaxTop: maxTop, MinBottom: minBottom});
274})()`
275
276type barReading struct {
277 Top, Bottom, Left, Right, UTop, UBottom, ULeft, URight, Over, Need, MaxTop, MinBottom float64
278}
279
280// barHSlack is how far above the measured maximum --ds-bar-h may sit:
281// values are rounded up to the next quarter rem (4px), so up to 4px of
282// slack is the rounding itself rather than a reservation nobody needs.
283const barHSlack = 4.5
284
285// TestThePinnedBarFitsItsReservation: every theme and locale, scripts
286// on and off, at both edges of both bands: what is in the bar lies
287// inside it, nothing in it overflows sideways, it is still at the top
288// after scrolling to the end, and from 1024px it never wraps. A
289// failure names the height the band needs, which is the number to
290// write into --ds-bar-h; the last check does the comparison the other
291// way, catching a reservation nobody's content has needed since.
292func TestThePinnedBarFitsItsReservation(t *testing.T) {
293 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
294 ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second)
295 defer cancel()
296 need := map[string]float64{}
297 reservation := map[string]float64{}
298 for _, scripts := range []bool{true, false} {
299 tab := ctx
300 if !scripts {
301 tab = noScripts(t, ctx)
302 }
303 for _, theme := range fiesta.ThemeNames() {
304 for _, locale := range Locales {
305 url := rig.Origin + pageAt(theme, locale, "form.html")
306 mustRun(t, tab, chromedp.EmulateViewport(1440, 900), chromedp.Navigate(url), chromedp.WaitReady("body"))
307 present(t, tab, theme+"/"+locale, "[rst-shell-sidebar] > .ds-rail")
308 if !scripts {
309 requireScriptsOff(t, tab, fmt.Sprintf("%s/%s scripts off", theme, locale))
310 }
311 for _, w := range []int64{1440, 1024, 1023, 800} {
312 where := fmt.Sprintf("%s/%s at %dpx, scripts %v", theme, locale, w, scripts)
313 band := "1024 and up"
314 if w < 1024 {
315 band = "800 to 1023"
316 }
317 var raw string
318 var after float64
319 mustRun(t, tab,
320 chromedp.EmulateViewport(w, 900),
321 chromedp.Evaluate(`scrollTo(0, 0)`, nil),
322 chromedp.Evaluate(barFit, &raw),
323 chromedp.Evaluate(`scrollTo(0, document.documentElement.scrollHeight); document.querySelector(".ds-top").getBoundingClientRect().top`, &after),
324 )
325 var r barReading
326 if err := json.Unmarshal([]byte(raw), &r); err != nil {
327 t.Fatalf("%s: decoding %q: %v", where, raw, err)
328 }
329 need[band] = max(need[band], r.Need)
330 reservation[band] = r.Bottom - r.Top
331 if r.UTop < r.Top-0.5 || r.UBottom > r.Bottom+0.5 || r.ULeft < r.Left-0.5 || r.URight > r.Right+0.5 {
332 t.Errorf("%s: the bar's contents [%v…%v × %v…%v] spill out of its box [%v…%v × %v…%v]; this band needs --ds-bar-h of at least %.0fpx", where, r.ULeft, r.URight, r.UTop, r.UBottom, r.Left, r.Right, r.Top, r.Bottom, r.Need)
333 }
334 if r.Over > 0.5 {
335 t.Errorf("%s: the bar overflows sideways by %.1fpx", where, r.Over)
336 }
337 if after < -0.5 || after > 0.5 {
338 t.Errorf("%s: scrolled to the end, the bar's top is at %.1fpx, not pinned at 0", where, after)
339 }
340 if w >= 1024 && r.MaxTop >= r.MinBottom-0.5 {
341 t.Errorf("%s: the brand and the controls do not share one line (the latest top %.1fpx is not above the earliest bottom %.1fpx); the bar has wrapped", where, r.MaxTop, r.MinBottom)
342 }
343 }
344 }
345 }
346 }
347 for _, band := range []string{"1024 and up", "800 to 1023"} {
348 if over := reservation[band] - need[band]; over > barHSlack {
349 t.Errorf("--ds-bar-h for %s reserves %.1fpx but the measured maximum content needs only %.1fpx, %.1fpx more than this drive's rounding slack", band, reservation[band], need[band], over)
350 }
351 }
352 t.Logf("tallest bar contents per band (the --ds-bar-h each needs): %v", need)
353}
354
355// withoutAnchorPositioning replaces the page's tokens.css with a copy
356// whose anchor-positioning @supports condition can never hold, which
357// is what an engine without the feature gets: the menu panel
358// positioned absolutely under its summary instead of fixed against it.
359const withoutAnchorPositioning = `(async () => {
360 const link = document.querySelector('link[href*="/tokens."][href$=".css"]');
361 const css = await (await fetch(link.href)).text();
362 const style = document.createElement("style");
363 style.textContent = css.replaceAll("@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: fiesta-no-such-value)");
364 link.replaceWith(style);
365 return true;
366})()`
367
368// requireAnchorPositioning is the control for a leg that says which
369// positioning the panel got: fixed with anchor positioning, not
370// without.
371func requireAnchorPositioning(t *testing.T, ctx context.Context, where, selector string, want bool) {
372 t.Helper()
373 var pos string
374 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+fmt.Sprintf("%q", selector)+`)).position`, &pos))
375 if (pos == "fixed") != want {
376 t.Fatalf("%s: the menu panel is position: %s; this leg needs anchor positioning %v", where, pos, want)
377 }
378}
379
380// TestTheBarsLanguageMenuStaysOnScreen: the bar's language menu,
381// opened, lies inside the viewport and every language is reachable by
382// keyboard: left to right and right to left, with anchor positioning
383// and without, with scripts and without, at both edges of both bands.
384// On the sign-in page, whose twelve frames make it tall enough at
385// every width to draw the scrollbar the measurement is taken against.
386func TestTheBarsLanguageMenuStaysOnScreen(t *testing.T) {
387 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars())
388 ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
389 defer cancel()
390 theme := fiesta.ThemeNames()[0]
391 for _, scripts := range []bool{true, false} {
392 tab := ctx
393 if !scripts {
394 tab = noScripts(t, ctx)
395 }
396 for _, locale := range []string{"en", "ar"} {
397 for _, anchored := range []bool{true, false} {
398 for _, w := range []int64{1440, 1024, 1023, 800} {
399 where := fmt.Sprintf("%s/%s at %dpx, scripts %v, anchor positioning %v", theme, locale, w, scripts, anchored)
400 mustRun(t, tab, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "signin.html")), chromedp.WaitReady("body"))
401 present(t, tab, where, "[rst-shell-sidebar] > .ds-rail")
402 if !scripts {
403 requireScriptsOff(t, tab, where)
404 }
405 if !anchored {
406 mustRun(t, tab, chromedp.Evaluate(withoutAnchorPositioning, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }))
407 }
408 requireAnchorPositioning(t, tab, where, ".ds-top__controls [rst-locale] > [rst-dropdown-menu]", anchored)
409 requireDrawnScrollbar(t, tab, where)
410 var raw string
411 mustRun(t, tab, chromedp.Evaluate(`(() => {
412 const d = document.querySelector(".ds-top__controls [rst-locale]");
413 d.open = true;
414 const p = d.querySelector("[rst-dropdown-menu]").getBoundingClientRect();
415 d.querySelector("summary").focus();
416 return JSON.stringify({L: p.left, R: p.right, T: p.top, B: p.bottom, W: document.documentElement.clientWidth, H: document.documentElement.clientHeight});
417 })()`, &raw))
418 var p struct{ L, R, T, B, W, H float64 }
419 if err := json.Unmarshal([]byte(raw), &p); err != nil {
420 t.Fatalf("%s: decoding %q: %v", where, raw, err)
421 }
422 if p.L < -0.5 || p.T < -0.5 || p.R > p.W+0.5 || p.B > p.H+0.5 {
423 t.Errorf("%s: the open panel [%v…%v × %v…%v] is not inside the %vx%v client box", where, p.L, p.R, p.T, p.B, p.W, p.H)
424 }
425 for i, want := range Locales {
426 var got string
427 mustRun(t, tab, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`(() => {
428 const a = document.activeElement, r = a.getBoundingClientRect();
429 const de = document.documentElement, inside = r.top >= 0 && r.bottom <= de.clientHeight && r.left >= 0 && r.right <= de.clientWidth;
430 return a.closest(".ds-top__controls [rst-locale] [rst-dropdown-menu]") && inside ? a.lang : "not a visible language: " + a.outerHTML.slice(0, 60);
431 })()`, &got))
432 if got != want {
433 t.Errorf("%s: tab %d lands on %s, want the %s link on screen", where, i+1, got, want)
434 break
435 }
436 }
437 }
438 }
439 }
440 }
441}
442
443// TestTheGallerysOwnControlsAreExactlyTheFloor: the gallery's own
444// controls carry ds- classes the system's floors cannot match, so each
445// reads --rst-target itself. Held exactly, not "at least": each control
446// is the larger of the floor and its own height with the floor lifted,
447// so the floor never adds to a control already over it. The rail's
448// section headings, the filter box and the prev/next links with a
449// mouse at 1280px; the prev/next links at 600px; the display settings
450// button and the prev/next links on a coarse pointer at 390px, where
451// the floor is 44 and the rail is not drawn. The preview widget's tabs
452// and Copy buttons are the widget's own gate.
453func TestTheGallerysOwnControlsAreExactlyTheFloor(t *testing.T) {
454 const read = `(() => {
455 const floor = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize);
456 const h = e => Math.round(e.getBoundingClientRect().height * 10) / 10;
457 const each = sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => {
458 const got = h(e); e.style.minBlockSize = "0px"; const own = h(e); e.style.minBlockSize = "";
459 return {Got: got, Want: Math.max(floor, own), W: e.getBoundingClientRect().width}; });
460 return JSON.stringify({Floor: floor, Heads: each(".ds-nav > details > summary"), Filter: each(".ds-search input"), Steps: each(".ds-updown__prev, .ds-updown__next"), Prefs: each(".ds-prefs > summary")});
461 })()`
462 theme := fiesta.ThemeNames()[0]
463 for _, c := range []struct {
464 name string
465 rig func(*testing.T) *harness.Rig
466 w, h int64
467 floor float64
468 rail bool
469 }{
470 {"a mouse at 1280px", func(t *testing.T) *harness.Rig {
471 return harness.New(t, func(string) http.Handler { return treeHandler(t) })
472 }, 1280, 800, 32, true},
473 {"a mouse at 600px", func(t *testing.T) *harness.Rig {
474 return harness.New(t, func(string) http.Handler { return treeHandler(t) })
475 }, 600, 800, 44, false},
476 {"a coarse pointer at 390px", func(t *testing.T) *harness.Rig { return phoneRig(t) }, 390, 844, 44, false},
477 } {
478 rig := c.rig(t)
479 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
480 var raw string
481 mustRun(t, ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), chromedp.WaitReady("body"))
482 present(t, ctx, c.name, ".ds-updown")
483 mustRun(t, ctx, chromedp.Evaluate(read, &raw))
484 cancel()
485 type reading struct{ Got, Want, W float64 }
486 var got struct {
487 Floor float64
488 Heads, Filter, Steps, Prefs []reading
489 }
490 if err := json.Unmarshal([]byte(raw), &got); err != nil {
491 t.Fatalf("%s: decoding %s: %v", c.name, raw, err)
492 }
493 if got.Floor != c.floor {
494 t.Errorf("%s: --rst-target is %vpx, want %v", c.name, got.Floor, c.floor)
495 }
496 if len(got.Steps) == 0 || c.rail && (len(got.Heads) == 0 || len(got.Filter) == 0) || !c.rail && c.floor == 44 && c.w == 390 && len(got.Prefs) == 0 {
497 t.Fatalf("%s: read %d rail headings, %d filter boxes, %d prev/next links and %d display settings buttons; the selectors no longer match the page", c.name, len(got.Heads), len(got.Filter), len(got.Steps), len(got.Prefs))
498 }
499 for what, rs := range map[string][]reading{"a rail section heading": got.Heads, "the filter box": got.Filter, "a prev/next link": got.Steps, "the display settings button": got.Prefs} {
500 for _, h := range rs {
501 if math.Abs(h.Got-h.Want) > 0.05 {
502 t.Errorf("%s: %s is %vpx tall, want %v, the floor or its own height", c.name, what, h.Got, h.Want)
503 break
504 }
505 }
506 }
507 }
508}
509
510// TestTheThemeTabsSitInLineWithTheOtherControls: the theme switcher is
511// the system's seg-tabs, which leaves a block's gap under itself. In
512// the bar's centred row that margin would lift the tabs above the
513// menus beside them; in the phone card's column it would more than
514// double the space under them.
515func TestTheThemeTabsSitInLineWithTheOtherControls(t *testing.T) {
516 theme := fiesta.ThemeNames()[0]
517 t.Run("the bar at 1280", func(t *testing.T) {
518 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
519 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
520 defer cancel()
521 for _, locale := range []string{"en", "ar"} {
522 where := theme + "/" + locale + "/index.html at 1280"
523 mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body"))
524 present(t, ctx, where, ".ds-top__controls [rst-scheme]")
525 var mid [2]float64
526 mustRun(t, ctx, chromedp.Evaluate(`(() => { const m = s => { const r = document.querySelector(".ds-top__controls " + s).getBoundingClientRect(); return r.top + r.height / 2; };
527 return [m("[rst-seg-tabs]"), m("[rst-scheme] > summary")]; })()`, &mid))
528 if math.Abs(mid[0]-mid[1]) > 1 {
529 t.Errorf("%s: the theme tabs' centre line is at %.1fpx and the scheme menu's at %.1fpx; want them level", where, mid[0], mid[1])
530 }
531 }
532 })
533 t.Run("the phone card at 390", func(t *testing.T) {
534 rig := phoneRig(t)
535 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
536 defer cancel()
537 for _, locale := range []string{"en", "ar"} {
538 where := theme + "/" + locale + "/index.html at 390"
539 mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body"))
540 present(t, ctx, where, ".ds-prefs > summary")
541 mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery), chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs]", chromedp.ByQuery))
542 requireCoarse(t, ctx)
543 settleMotion(t, ctx, where)
544 var gaps []float64
545 mustRun(t, ctx, chromedp.Evaluate(`(() => { const kids = [...document.querySelector(".ds-prefs > [rst-dropdown-menu]").children].filter(k => k.checkVisibility()).map(k => k.getBoundingClientRect());
546 return kids.slice(1).map((r, i) => r.top - kids[i].bottom); })()`, &gaps))
547 if len(gaps) < 2 {
548 t.Fatalf("%s: the card holds %d groups; it needs three for the gaps between them to be compared", where, len(gaps)+1)
549 }
550 for i, g := range gaps[1:] {
551 if math.Abs(g-gaps[0]) > 0.5 {
552 t.Errorf("%s: the gap under the theme tabs is %.1fpx and gap %d is %.1fpx; want every group the same distance apart", where, gaps[0], i+2, g)
553 }
554 }
555 }
556 })
557}
558
559// TestThePrevNextPairSitsAtTheEndsOfItsRow: previous is flush at the
560// inline start and never crosses the middle; next is flush at the
561// inline end and begins at or past the middle, so a missing previous
562// leaves its space. The two ends and one middle page of the sequence,
563// in both directions.
564func TestThePrevNextPairSitsAtTheEndsOfItsRow(t *testing.T) {
565 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
566 ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second)
567 defer cancel()
568 const measure = `(() => {
569 const box = document.querySelector(".ds-updown");
570 if (!box) return JSON.stringify({error: "no prev/next strip on this page"});
571 const dir = getComputedStyle(document.documentElement).direction;
572 const b = box.getBoundingClientRect();
573 const read = (sel) => {
574 const el = box.querySelector(sel);
575 if (!el) return null;
576 const r = el.getBoundingClientRect();
577 return {startGap: Math.round(dir === "rtl" ? b.right - r.right : r.left - b.left), endGap: Math.round(dir === "rtl" ? r.left - b.left : b.right - r.right), size: Math.round(r.width)};
578 };
579 return JSON.stringify({dir: dir, width: Math.round(b.width), prev: read(".ds-updown__prev"), next: read(".ds-updown__next")});
580 })()`
581 type edge struct {
582 StartGap int `json:"startGap"`
583 EndGap int `json:"endGap"`
584 Size int `json:"size"`
585 }
586 type strip struct {
587 Error string `json:"error"`
588 Dir string `json:"dir"`
589 Width int `json:"width"`
590 Prev *edge `json:"prev"`
591 Next *edge `json:"next"`
592 }
593 s := newTestSite(t, fiesta.FS(), mountPath)
594 theme := fiesta.ThemeNames()[0]
595 for _, locale := range []string{"en", "ar"} {
596 seq := s.sequence(theme, locale)
597 for _, pos := range []int{0, 1, len(seq) - 1} {
598 name := strings.TrimPrefix(seq[pos].Href, mountPath+"/")
599 var raw string
600 mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+seq[pos].Href), chromedp.WaitReady("body"))
601 present(t, ctx, name, ".ds-updown")
602 mustRun(t, ctx, chromedp.Evaluate(measure, &raw))
603 var got strip
604 if err := json.Unmarshal([]byte(raw), &got); err != nil {
605 t.Fatalf("%s: reading the measurement: %v", name, err)
606 }
607 if got.Error != "" {
608 t.Errorf("%s: %s", name, got.Error)
609 continue
610 }
611 if got.Dir != Dir(locale) {
612 t.Errorf("%s: the page renders dir=%q, want %q", name, got.Dir, Dir(locale))
613 continue
614 }
615 if got.Width < 200 {
616 t.Errorf("%s: the strip is %dpx wide; there is nothing to measure", name, got.Width)
617 continue
618 }
619 half := got.Width / 2
620 if (got.Prev != nil) != (pos > 0) {
621 t.Errorf("%s: previous link present=%v, want %v", name, got.Prev != nil, pos > 0)
622 }
623 if (got.Next != nil) != (pos < len(seq)-1) {
624 t.Errorf("%s: next link present=%v, want %v", name, got.Next != nil, pos < len(seq)-1)
625 }
626 if p := got.Prev; p != nil {
627 if p.StartGap > 2 {
628 t.Errorf("%s: previous starts %dpx from the strip's inline start, want 0 (%s, strip %dpx)", name, p.StartGap, got.Dir, got.Width)
629 }
630 if p.StartGap+p.Size > half+2 {
631 t.Errorf("%s: previous runs %dpx into a %dpx strip, past its half (%d)", name, p.StartGap+p.Size, got.Width, half)
632 }
633 }
634 if n := got.Next; n != nil {
635 if n.EndGap > 2 {
636 t.Errorf("%s: next ends %dpx from the strip's inline end, want 0 (%s, strip %dpx)", name, n.EndGap, got.Dir, got.Width)
637 }
638 if start := got.Width - n.EndGap - n.Size; start < half-2 {
639 t.Errorf("%s: next begins %dpx into a %dpx strip, before its half (%d); the missing previous has not left its space", name, start, got.Width, half)
640 }
641 }
642 if got.Prev == nil && got.Next == nil {
643 t.Errorf("%s: the strip is empty", name)
644 }
645 }
646 }
647}
648
649// markedJS reads every scheme menu on the page: for each, the value of
650// the one button carrying aria-current="true", or "" for none and
651// "many" for more than one.
652const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => {
653 const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true");
654 return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : "";
655})`
656
657// TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint: a returning
658// visitor with Dark stored sees a dark page from the first frame, even
659// with the theme stylesheet slow to arrive, and after load the Dark
660// button carries aria-current in both the bar's and the display
661// settings menu's scheme menu while System carries none. fiesta.js
662// skips its own restore when data-theme is already on the root, so
663// the marking is the gallery script's. The control is a fresh visit,
664// where System is marked in both.
665func TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint(t *testing.T) {
666 theme := fiesta.ThemeNames()[0]
667 slow := func(t *testing.T) http.Handler {
668 inner := treeHandler(t)
669 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
670 if strings.Contains(r.URL.Path, "/assets/css/themes/") {
671 time.Sleep(500 * time.Millisecond)
672 }
673 inner.ServeHTTP(w, r)
674 })
675 }
676 for _, c := range []struct{ name, store, want string }{{"Dark stored", `localStorage.setItem("fiesta-scheme", "dark");`, "dark"}, {"a fresh visit", "", "system"}} {
677 rig := harness.New(t, func(string) http.Handler { return slow(t) })
678 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
679 mustRun(t, ctx,
680 addInit(c.store+` requestAnimationFrame(() => { window.__firstFrame = document.documentElement.getAttribute("data-theme") || "system"; });`),
681 chromedp.EmulateViewport(1280, 900),
682 chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")),
683 chromedp.WaitReady("body"),
684 )
685 present(t, ctx, c.name, ".ds-prefs [rst-scheme]")
686 until(t, ctx, c.name, `document.readyState === "complete" && window.__firstFrame !== undefined`)
687 var first string
688 var marked []string
689 mustRun(t, ctx, chromedp.Evaluate(`window.__firstFrame`, &first), chromedp.Evaluate(markedJS, &marked))
690 cancel()
691 if first != c.want {
692 t.Errorf("%s: the first frame painted with data-theme %q, want %q", c.name, first, c.want)
693 }
694 if len(marked) != 2 {
695 t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", c.name, len(marked))
696 }
697 for i, m := range marked {
698 if m != c.want {
699 t.Errorf("%s: scheme menu %d marks %q current, want %q", c.name, i, m, c.want)
700 }
701 }
702 }
703}
704
705// refusedStorage makes every read and write of localStorage throw, as
706// a browser with site data blocked does.
707const refusedStorage = `(() => {
708 const refuse = () => { throw new DOMException("site data is blocked", "SecurityError"); };
709 for (const name of ["getItem", "setItem", "removeItem"]) Storage.prototype[name] = refuse;
710})();`
711
712// TestASchemeChosenWithStorageRefusedIsTheOneMarked: with localStorage
713// refusing every read and write, choosing Dark in the bar's menu still
714// applies Dark to the page, and both copies of the scheme menu, the
715// bar's and the display settings menu's, mark Dark current. Nothing
716// can be remembered, so the marks follow the scheme on the root, not
717// the stored one.
718func TestASchemeChosenWithStorageRefusedIsTheOneMarked(t *testing.T) {
719 where := "storage refused"
720 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
721 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
722 defer cancel()
723 mustRun(t, ctx,
724 addInit(refusedStorage),
725 chromedp.EmulateViewport(1280, 900),
726 chromedp.Navigate(rig.Origin+pageAt(fiesta.ThemeNames()[0], "en", "tokens.html")),
727 chromedp.WaitReady("body"),
728 )
729 present(t, ctx, where, ".ds-prefs [rst-scheme]")
730 until(t, ctx, where, `document.readyState === "complete"`)
731 var refused bool
732 mustRun(t, ctx, chromedp.Evaluate(`(() => { try { localStorage.getItem("fiesta-scheme"); return false; } catch (e) { return true; } })()`, &refused))
733 if !refused {
734 t.Fatalf("%s: localStorage still answers; the drive would be testing working storage", where)
735 }
736
737 chooseScheme(t, ctx, "dark")
738 // gallery.js marks both copies one frame after the choice; two
739 // frames later the marks are settled whichever way they went.
740 var theme string
741 var marked []string
742 mustRun(t, ctx,
743 chromedp.Evaluate(`new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))))`, nil,
744 func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),
745 chromedp.Evaluate(`document.documentElement.getAttribute("data-theme") || "system"`, &theme),
746 chromedp.Evaluate(markedJS, &marked),
747 )
748 if theme != "dark" {
749 t.Errorf("%s: after choosing Dark the page is on %q", where, theme)
750 }
751 if len(marked) != 2 {
752 t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", where, len(marked))
753 }
754 for i, m := range marked {
755 if m != "dark" {
756 t.Errorf("%s: scheme menu %d marks %q current after Dark was chosen, want %q", where, i, m, "dark")
757 }
758 }
759}
760
761// TestTheFrameMirrorsRightToLeft: right to left, the shell's grid puts
762// the rail on the right and the bar over main on the left, with no rule
763// of the gallery's: the grid lines are numbered, not sided. Port of
764// Rastrillo's drive, on an example page of day/ar at 1280px, kept with
765// the bar's drives as Rastrillo keeps it.
766func TestTheFrameMirrorsRightToLeft(t *testing.T) {
767 rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
768 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
769 defer cancel()
770 where := "day/ar form-layout at 1280px"
771 openPage(t, ctx, where, pageURL(rig, "ar", "form-layout.html"), 1280, 900)
772 var r struct {
773 RailLeft, MainRight, BarBottom, MainTop, BarLeft, BarRight, MainLeft, BrandLeft, ControlsRight float64
774 }
775 at(t, ctx, `(() => {
776 const b = s => document.querySelector(s).getBoundingClientRect();
777 const rail = b("[rst-shell-rail]"), bar = b(".ds-top"), main = b("main");
778 const brand = b(".ds-top__brand"), controls = b(".ds-top__controls");
779 return JSON.stringify({RailLeft: rail.left, MainRight: main.right, BarBottom: bar.bottom, MainTop: main.top, BarLeft: bar.left, BarRight: bar.right, MainLeft: main.left, BrandLeft: brand.left, ControlsRight: controls.right});
780 })()`, &r)
781 if r.RailLeft < r.MainRight-0.5 {
782 t.Errorf("%s: the rail starts at %.1fpx, left of main's right edge %.1fpx; it belongs on the right", where, r.RailLeft, r.MainRight)
783 }
784 if r.BarBottom > r.MainTop+0.5 || r.BarLeft < r.MainLeft-0.5 || r.BarRight > r.MainRight+0.5 {
785 t.Errorf("%s: the bar [%v…%v, bottom %v] is not above main [%v…%v, top %v]", where, r.BarLeft, r.BarRight, r.BarBottom, r.MainLeft, r.MainRight, r.MainTop)
786 }
787 // The brand is first in source order and justify-content:
788 // space-between puts the first child at the inline start, which in
789 // Arabic is the bar's right side, not its left: a rule keyed to
790 // "left" rather than to the writing direction would swap these two.
791 if r.BrandLeft < r.ControlsRight-0.5 {
792 t.Errorf("%s: the brand's left edge is at %.1fpx, not right of the controls' right edge %.1fpx; the brand belongs at the inline start and the controls at the end", where, r.BrandLeft, r.ControlsRight)
793 }
794}
795