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
3// The landing page in a real Chromium, served as the edge serves it:
4// the site.Build tree over the committed releases, through the same
5// handler every www drive uses. Each drive is one journey a reader
6// makes: a phone, a browser with scripts off, a returning visitor, a
7// click through the sample, a copy of the install line.
8package www_test
9
10import (
11 "bytes"
12 "context"
13 "encoding/json"
14 "fmt"
15 "image"
16 imagepng "image/png"
17 "math"
18 "net/http"
19 "strings"
20 "testing"
21 "time"
22
23 "github.com/chromedp/cdproto/browser"
24 "github.com/chromedp/cdproto/emulation"
25 "github.com/chromedp/cdproto/input"
26 cdppage "github.com/chromedp/cdproto/page"
27 "github.com/chromedp/cdproto/runtime"
28 "github.com/chromedp/chromedp"
29
30 "amadan.net/rastrillo/fiesta/internal/axe"
31 "amadan.net/rastrillo/fiesta/internal/harness"
32 "amadan.net/rastrillo/fiesta/internal/www"
33)
34
35// siteRig boots a browser on the built site, and hands back the
36// handler, for a drive that delays a file or reads the request log.
37func siteRig(t *testing.T, opts ...harness.Option) (*harness.Rig, *siteHandler) {
38 t.Helper()
39 var h *siteHandler
40 rig := harness.New(t, func(string) http.Handler {
41 h = newSiteHandler(t, builtSite(t))
42 h.noStore = true
43 return h
44 }, opts...)
45 return rig, h
46}
47
48func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) {
49 t.Helper()
50 if err := chromedp.Run(ctx, actions...); err != nil {
51 t.Fatal(err)
52 }
53}
54
55// at evaluates a JS expression returning a JSON string into v.
56func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) {
57 t.Helper()
58 var raw string
59 if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil {
60 t.Fatalf("evaluating: %v\n%s", err, js)
61 }
62 if err := json.Unmarshal([]byte(raw), v); err != nil {
63 t.Fatalf("decoding %q: %v", raw, err)
64 }
65}
66
67func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }
68
69// onNewDocument runs js in every document the page opens from now on,
70// before any of the page's own scripts.
71func onNewDocument(t *testing.T, ctx context.Context, js string) {
72 t.Helper()
73 mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
74 _, err := cdppage.AddScriptToEvaluateOnNewDocument(js).Do(ctx)
75 return err
76 }))
77}
78
79// soon waits up to ten seconds for sel to be visible: long enough on a
80// loaded box, short enough that a page without the element fails by
81// its name rather than by the test's deadline.
82func soon(sel string) chromedp.Action {
83 return chromedp.ActionFunc(func(ctx context.Context) error {
84 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
85 defer cancel()
86 if err := chromedp.WaitVisible(sel, chromedp.ByQuery).Do(ctx); err != nil {
87 return fmt.Errorf("%s never became visible: %w", sel, err)
88 }
89 return nil
90 })
91}
92
93// open loads the English landing page at the given viewport, waits
94// for it to load, and requires the sample to be on it: a page without
95// the sample is not the landing page, and the drive fails here by name.
96func open(t *testing.T, ctx context.Context, rig *harness.Rig, w, h int64) {
97 t.Helper()
98 openPath(t, ctx, rig, "/", w, h)
99}
100
101// openPath is open at any path: the landing page in another language
102// lives at /<code>/.
103func openPath(t *testing.T, ctx context.Context, rig *harness.Rig, p string, w, h int64) {
104 t.Helper()
105 mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(rig.Origin+p), chromedp.WaitReady("body"),
106 chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise))
107 var present bool
108 mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("sample")`, &present))
109 if !present {
110 t.Fatalf("the page at %s has no #sample; this is not the landing page", p)
111 }
112}
113
114// catalog is the English catalog, for the words a drive expects to see.
115func catalog(t *testing.T) map[string]string {
116 t.Helper()
117 s, err := www.Strings(siteSource(t).FS(), "en")
118 if err != nil {
119 t.Fatal(err)
120 }
121 return s
122}
123
124// visibleJS reports, for a selector, how many matches there are and
125// how many are rendered (have a box on the page).
126const visibleJS = `((sel) => { const all = [...document.querySelectorAll(sel)]; return JSON.stringify({Total: all.length, Shown: all.filter(e => e.getClientRects().length > 0).length}); })`
127
128type shown struct{ Total, Shown int }
129
130func visible(t *testing.T, ctx context.Context, sel string) shown {
131 t.Helper()
132 var s shown
133 at(t, ctx, visibleJS+fmt.Sprintf("(%q)", sel), &s)
134 return s
135}
136
137// themeURL is the stylesheet each theme choice loads.
138var themeURL = map[string]string{"day": "/1/fiesta.css", "plain": "/1/fiesta-plain.css", "signal": "/1/fiesta-signal.css"}
139
140// chooseTheme presses one theme tab and waits for its stylesheet to be
141// the one in effect: the link's href swapped, and a sheet at that URL
142// with rules in document.styleSheets, which it joins only once loaded
143// (the link's own .sheet can still be the old one meanwhile).
144func chooseTheme(t *testing.T, ctx context.Context, theme string) {
145 t.Helper()
146 mustRun(t, ctx, soon(`.site-themebar`),
147 chromedp.Click(fmt.Sprintf(`[data-site-theme=%q]`, theme), chromedp.ByQuery))
148 deadline := time.Now().Add(10 * time.Second)
149 for {
150 var ok bool
151 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.getElementById("site-theme"), url = %q; return !!l && l.getAttribute("href") === url && [...document.styleSheets].some(s => s.href && new URL(s.href).pathname === url && s.cssRules.length > 0); })()`, themeURL[theme]), &ok))
152 if ok {
153 return
154 }
155 if !time.Now().Before(deadline) {
156 t.Fatalf("the %s stylesheet never became the page's theme", theme)
157 }
158 time.Sleep(50 * time.Millisecond)
159 }
160}
161
162// chooseScheme picks one scheme from the theme bar's menu, as a reader
163// would: open the menu, press the button.
164func chooseScheme(t *testing.T, ctx context.Context, scheme string) {
165 t.Helper()
166 btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme)
167 mustRun(t, ctx,
168 chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery),
169 soon(btn),
170 chromedp.Click(btn, chromedp.ByQuery),
171 )
172}
173
174// currentScheme is the scheme button carrying aria-current, "" for none.
175func currentScheme(t *testing.T, ctx context.Context) string {
176 t.Helper()
177 var s string
178 mustRun(t, ctx, chromedp.Evaluate(`(() => { const b = document.querySelector("[rst-scheme] button[aria-current]"); return b ? b.form.elements.scheme.value : ""; })()`, &s))
179 return s
180}
181
182// TestTheLandingPassesAxe: the whole page, in Day, Plain and Signal,
183// each light and dark, with the WCAG 2.2 AA tag set; then the Arabic
184// and Japanese pages in Day, light and dark, for a right-to-left
185// script and one with no spaces between words. Each theme is chosen
186// from the theme bar, as a reader would, and each scheme is set on
187// the root, the way fiesta.js sets it.
188func TestTheLandingPassesAxe(t *testing.T) {
189 rig, _ := siteRig(t)
190 ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
191 defer cancel()
192 scans, failed := 0, 0
193 scan := func(lang, theme string) {
194 t.Helper()
195 chooseTheme(t, ctx, theme)
196 ground := map[string]string{}
197 for _, scheme := range []string{"light", "dark"} {
198 var bg string
199 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg))
200 ground[scheme] = bg
201 res, err := axe.Scan(ctx, "", axe.DefaultTags())
202 if err != nil {
203 t.Fatalf("%s %s %s: %v", lang, theme, scheme, err)
204 }
205 scans++
206 for _, v := range res.Violations {
207 failed++
208 nodes := v.Nodes
209 if len(nodes) > 5 {
210 nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5))
211 }
212 t.Errorf("%s %s %s: %s (%s): %s\n %s", lang, theme, scheme, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n "))
213 }
214 t.Logf("%s %s %s: needs review, axe could not decide: %v", lang, theme, scheme, res.Incomplete)
215 }
216 if ground["light"] == ground["dark"] {
217 t.Fatalf("%s %s: the body paints %s under both schemes, so the flip to dark did not take", lang, theme, ground["light"])
218 }
219 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.removeAttribute("data-theme")`, nil))
220 }
221 open(t, ctx, rig, 1280, 900)
222 for _, theme := range []string{"day", "plain", "signal"} {
223 scan("en", theme)
224 }
225 rig.Screen("body", "the landing page")
226 for _, lang := range []string{"ar", "ja"} {
227 openPath(t, ctx, rig, pageURL(lang), 1280, 900)
228 var got string
229 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got))
230 if got != lang {
231 t.Fatalf("the page at %s is in %q; want %s", pageURL(lang), got, lang)
232 }
233 scan(lang, "day")
234 rig.Screen("body", "the landing page in "+lang)
235 }
236 t.Logf("%d scans, %d violations", scans, failed)
237}
238
239// measureJS reports whether the document scrolls sideways, and the
240// elements that reach past either edge.
241const measureJS = `(() => {
242 const de = document.documentElement;
243 const w = Math.round(de.getBoundingClientRect().width);
244 const over = [];
245 document.querySelectorAll("body *").forEach(el => {
246 const r = el.getBoundingClientRect();
247 if (r.width === 0) return;
248 if (r.right > w + 1 || r.left < -1) {
249 const cls = (el.className || "").toString().trim().split(/\s+/)[0];
250 over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]");
251 }
252 });
253 return JSON.stringify({Doc: de.scrollWidth, Client: w, Body: document.body.scrollWidth, Over: over.slice(0, 8)});
254})()`
255
256// windowJS measures the sample window's own content: the window's box
257// against the viewport, and every shown element inside it against the
258// window's box, since the window clips its overflow and would hide a
259// pane that reached past its edge. An element inside a box that
260// scrolls sideways on purpose (the sidebar's row on a phone) is left
261// to its scroller, which must itself fit.
262const windowJS = `(() => {
263 const w = document.querySelector(".site-window"), wr = w.getBoundingClientRect(), vw = document.documentElement.clientWidth;
264 const scrolls = el => { for (let p = el.parentElement; p && p !== w; p = p.parentElement) { const o = getComputedStyle(p).overflowX; if (o === "auto" || o === "scroll") return true; } return false; };
265 const over = [];
266 w.querySelectorAll("*").forEach(el => {
267 const r = el.getBoundingClientRect();
268 if (r.width === 0 || scrolls(el)) return;
269 if (r.right > wr.right + 1 || r.left < wr.left - 1) {
270 const cls = (el.className || "").toString().trim().split(/\s+/)[0];
271 over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]");
272 }
273 });
274 return JSON.stringify({Left: wr.left, Right: wr.right, VW: vw, Over: over.slice(0, 8)});
275})()`
276
277// TestTheLandingHasNoHorizontalScrollAt320 is WCAG 1.4.10 Reflow on
278// the landing page, in every one of its eleven languages: at a 320
279// CSS-pixel viewport nothing scrolls sideways, on the page as it opens
280// and with every sample pane and every course shown, and nothing
281// inside the sample window reaches past the window's edge, where its
282// clipping would hide it. Each page's content is required first, so a
283// stub with a sample and nothing else cannot pass. The bunting is
284// allowed to be wider than the page: it is clipped by its own box, and
285// the check is the document's scroll width, which clipping keeps.
286func TestTheLandingHasNoHorizontalScrollAt320(t *testing.T) {
287 rig, _ := siteRig(t)
288 ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute)
289 defer cancel()
290 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
291 return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c)
292 }))
293 for _, lang := range www.Languages {
294 mustRun(t, ctx, chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"),
295 chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise))
296 var got string
297 mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got))
298 if got != lang {
299 t.Fatalf("%s: the page is in %q; want %s. This is not the landing page in %s, and measuring it would prove nothing", pageURL(lang), got, lang, lang)
300 }
301 for _, c := range []struct {
302 sel string
303 want int
304 }{
305 {"#headline", 1},
306 {"#install code", 1},
307 {".site-themebar", 1},
308 {"#sample [rst-side-nav] a", 5},
309 {"#sample .site-pane", 5},
310 {"#sample .site-pane h2", 5},
311 {"#sample .site-course", 3},
312 {"#reasons [rst-card]", 6},
313 {"#footer nav", 4},
314 {"#bunting svg", 1},
315 } {
316 if s := visible(t, ctx, c.sel); s.Total != c.want {
317 t.Fatalf("%s: %s: %d on the page; want %d. This is not the landing page, and measuring it would prove nothing", pageURL(lang), c.sel, s.Total, c.want)
318 }
319 }
320 mustRun(t, ctx, soon(".site-themebar"))
321
322 measure := func(where string) {
323 t.Helper()
324 where = lang + ", " + where
325 var page struct {
326 Doc, Client, Body int
327 Over []string
328 }
329 at(t, ctx, measureJS, &page)
330 if page.Doc > page.Client || page.Body > page.Client {
331 t.Errorf("%s: the page scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s",
332 where, page.Doc, page.Body, page.Client, strings.Join(page.Over, "\n "))
333 }
334 var win struct {
335 Left, Right, VW float64
336 Over []string
337 }
338 at(t, ctx, windowJS, &win)
339 if win.Left < -1 || win.Right > win.VW+1 {
340 t.Errorf("%s: the sample window spans %.0f…%.0f in a %.0fpx viewport", where, win.Left, win.Right, win.VW)
341 }
342 if len(win.Over) > 0 {
343 t.Errorf("%s: %d element(s) inside the sample reach past the window's edge, where it clips them:\n %s", where, len(win.Over), strings.Join(win.Over, "\n "))
344 }
345 }
346 measure("as the page opens")
347 for _, pane := range []string{"pane-guests", "pane-menu", "pane-music", "pane-settings", "pane-overview"} {
348 showPane(t, ctx, pane)
349 if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != pane {
350 t.Fatalf("%s: after pressing %s the sample shows %v", lang, pane, p.Panes)
351 }
352 measure(pane)
353 if pane != "pane-menu" {
354 continue
355 }
356 for _, course := range []string{"course-mains", "course-desserts", "course-starters"} {
357 mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, course), chromedp.ByQuery))
358 if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != course {
359 t.Fatalf("%s: after pressing %s the Menu pane shows %v", lang, course, p.Courses)
360 }
361 measure(course)
362 }
363 }
364 rig.Screen("#sample", "the page at 320 in "+lang)
365 }
366}
367
368// rtlJS measures the top bar: the brand's box and the last item's, the
369// document's direction, and the install code's computed direction.
370const rtlJS = `(() => { const b = document.querySelector(".site-brand").getBoundingClientRect(), items = document.querySelectorAll(".site-nav > *"), l = items[items.length - 1].getBoundingClientRect(), code = document.querySelector("#install code");
371 return JSON.stringify({Dir: document.documentElement.dir, BodyDir: getComputedStyle(document.body).direction, BrandLeft: b.left, BrandRight: b.right, LastLeft: l.left, LastRight: l.right, BrandTop: b.top, BrandBottom: b.bottom, LastTop: l.top, LastBottom: l.bottom, CodeDir: getComputedStyle(code).direction, CodeAttr: code.getAttribute("dir")}); })()`
372
373// TestArabicReadsRightToLeft: the Arabic page is dir="rtl" and lays
374// out that way, so the top bar's brand sits to the right of its last
375// item, by bounding box; and the install line's code stays left to
376// right, since a URL reads that way in any language.
377func TestArabicReadsRightToLeft(t *testing.T) {
378 rig, _ := siteRig(t)
379 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
380 defer cancel()
381 openPath(t, ctx, rig, pageURL("ar"), 1280, 900)
382 var g struct {
383 Dir, BodyDir, CodeDir, CodeAttr string
384 BrandLeft, BrandRight, LastLeft, LastRight, BrandTop, BrandBottom, LastTop, LastBottom float64
385 }
386 at(t, ctx, rtlJS, &g)
387 if g.Dir != "rtl" || g.BodyDir != "rtl" {
388 t.Errorf("the Arabic page has dir=%q and the body's computed direction is %q; want rtl for both", g.Dir, g.BodyDir)
389 }
390 // On the same row: the two boxes overlap vertically. Their tops
391 // differ by a pixel, since Arabic and Latin glyphs sit differently
392 // in a centred flex row, so they are not compared exactly.
393 if g.BrandBottom <= g.LastTop || g.LastBottom <= g.BrandTop {
394 t.Errorf("the top bar wrapped: the brand spans y=%.0f…%.0f and the last item y=%.0f…%.0f, so left and right cannot be compared", g.BrandTop, g.BrandBottom, g.LastTop, g.LastBottom)
395 }
396 if g.BrandLeft < g.LastRight {
397 t.Errorf("the brand spans %.0f…%.0f and the top bar's last item %.0f…%.0f; right to left, the brand is to the right of everything", g.BrandLeft, g.BrandRight, g.LastLeft, g.LastRight)
398 }
399 if g.CodeAttr != "ltr" || g.CodeDir != "ltr" {
400 t.Errorf("the install code has dir=%q and a computed direction of %q; want ltr for both, so the URL reads as a URL", g.CodeAttr, g.CodeDir)
401 }
402 rig.Screen("#headline", "the Arabic page")
403}
404
405// TestTheLandingReadsWithNoScript: with JavaScript off the page still
406// reads in full: the headline, the link box, every sample pane's
407// heading and the six reasons are on the screen; the theme bar and
408// the Copy button are not; and submitting the sample's Settings form
409// leaves the reader on the page, at the sample.
410func TestTheLandingReadsWithNoScript(t *testing.T) {
411 rig, h := siteRig(t)
412 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
413 defer cancel()
414 mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(1280, 900),
415 chromedp.Navigate(rig.Origin+"/"), soon("#reasons"))
416 var ran bool
417 mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("site-theme")`, &ran))
418 if ran {
419 t.Fatal("the head's script wrote the theme link; scripts are not off, and this drive proves nothing")
420 }
421 for sel, want := range map[string]int{
422 "#headline": 1,
423 "#install code": 1,
424 "#sample .site-pane h2": 5,
425 "#reasons [rst-card]": 6,
426 } {
427 if s := visible(t, ctx, sel); s.Total != want || s.Shown != want {
428 t.Errorf("%s: %d on the page, %d shown; want %d shown with scripts off", sel, s.Total, s.Shown, want)
429 }
430 }
431 for _, sel := range []string{".site-themebar", "[rst-snippet-copy]"} {
432 if s := visible(t, ctx, sel); s.Total != 1 || s.Shown != 0 {
433 t.Errorf("%s: %d on the page, %d shown; want 1 hidden with scripts off", sel, s.Total, s.Shown)
434 }
435 }
436 h.Seen(true)
437 // The submit is a navigation to a new document. Nothing may ask the
438 // page anything until that document has loaded: a question sent
439 // while the old one is being replaced is answered "Inspected target
440 // navigated or closed", which is how this drive failed on a loaded
441 // runner when it polled the location instead.
442 loaded := navigationLoaded(ctx)
443 mustRun(t, ctx, chromedp.Click(`#pane-settings button[type="submit"]`, chromedp.ByQuery))
444 select {
445 case <-loaded:
446 case <-time.After(30 * time.Second):
447 t.Fatal("submitting Settings with scripts off loaded no new document within 30s")
448 }
449 var loc string
450 mustRun(t, ctx, chromedp.Location(&loc), soon("#reasons"))
451 if !strings.HasPrefix(loc, rig.Origin+"/?") || !strings.HasSuffix(loc, "#sample") {
452 t.Errorf("after submitting Settings with scripts off the page is at %s; want the same page with a query, at #sample", loc)
453 }
454 for _, p := range h.Seen(false) {
455 if p != "/" && !strings.HasPrefix(p, "/1/") && !strings.HasPrefix(p, "/static/") {
456 t.Errorf("the submit asked the server for %s", p)
457 }
458 }
459 rig.Screen("#reasons", "the page after the submit")
460}
461
462// navigationLoaded is closed once the main frame has navigated to a new
463// document and that document's load event has fired, in that order:
464// a load event of the document being left does not count. Call it
465// before the action that navigates, so neither event can be missed.
466func navigationLoaded(ctx context.Context) <-chan struct{} {
467 done := make(chan struct{})
468 lctx, cancel := context.WithCancel(ctx)
469 // The listener runs on chromedp's one event goroutine, so these
470 // need no lock; fired keeps a late event after cancel from closing
471 // done twice.
472 navigated, fired := false, false
473 chromedp.ListenTarget(lctx, func(ev any) {
474 switch e := ev.(type) {
475 case *cdppage.EventFrameNavigated:
476 if e.Frame.ParentID == "" {
477 navigated = true
478 }
479 case *cdppage.EventLoadEventFired:
480 if navigated && !fired {
481 fired = true
482 cancel()
483 close(done)
484 }
485 }
486 })
487 return done
488}
489
490// firstFrameJS queues a reading from the head, before any stylesheet
491// or script of the page's own: the first animation frame's theme
492// variables, stylesheets, scheme and root attribute, which is what the
493// first paint showed.
494const firstFrameJS = `requestAnimationFrame(() => {
495 const cs = getComputedStyle(document.documentElement);
496 window.__first = {
497 Bg: cs.getPropertyValue("--rst-bg").trim(),
498 Sheets: [...document.styleSheets].map(s => s.href ? new URL(s.href).pathname : "inline"),
499 Scheme: cs.colorScheme,
500 Theme: document.documentElement.getAttribute("data-theme") || "",
501 Ready: document.readyState,
502 };
503})`
504
505type firstFrame struct {
506 Bg, Scheme, Theme, Ready string
507 Sheets []string
508}
509
510func rootBg(t *testing.T, ctx context.Context) string {
511 t.Helper()
512 var bg string
513 mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.documentElement).getPropertyValue("--rst-bg").trim()`, &bg))
514 return bg
515}
516
517// TestTheThemeComesBackBeforeFirstPaint: a returning visitor who chose
518// Plain sees Plain from the first frame, with the stylesheet delayed
519// half a second by the server so a page that painted before it would
520// be caught; Day's stylesheet is never requested. With Dark stored as
521// well, the first frame is dark.
522func TestTheThemeComesBackBeforeFirstPaint(t *testing.T) {
523 rig, h := siteRig(t)
524 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
525 defer cancel()
526 open(t, ctx, rig, 1280, 900)
527 day := rootBg(t, ctx)
528 mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "plain"); true`, nil))
529 h.Delay("/1/fiesta-plain.css", 500*time.Millisecond)
530 onNewDocument(t, ctx, firstFrameJS)
531
532 h.Seen(true)
533 start := time.Now()
534 open(t, ctx, rig, 1280, 900)
535 var first firstFrame
536 mustRun(t, ctx, chromedp.Evaluate(`window.__first`, &first))
537 // The steady state, waited for: a page that came back in Plain
538 // late would otherwise fail here, before the first frame is judged.
539 plain := rootBg(t, ctx)
540 for deadline := time.Now().Add(3 * time.Second); plain == day && time.Now().Before(deadline); plain = rootBg(t, ctx) {
541 time.Sleep(50 * time.Millisecond)
542 }
543 if plain == day || plain == "" {
544 t.Fatalf("after the reload --rst-bg is %q, the same as Day's; Plain did not come back at all", plain)
545 }
546 if first.Bg != plain {
547 t.Errorf("the first frame had --rst-bg %q (sheets %v, ready %s); want Plain's %q", first.Bg, first.Sheets, first.Ready, plain)
548 }
549 if !contains(first.Sheets, "/1/fiesta-plain.css") {
550 t.Errorf("the first frame's stylesheets were %v; Plain's was not among them", first.Sheets)
551 }
552 for _, p := range h.Seen(false) {
553 if p == "/1/fiesta.css" {
554 t.Error("Day's stylesheet was requested on a visit that had Plain stored")
555 }
556 }
557 if time.Since(start) < 500*time.Millisecond {
558 t.Errorf("the reload took %s, less than the stylesheet's delay; the delay was not in the page's way", time.Since(start))
559 }
560
561 mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil))
562 open(t, ctx, rig, 1280, 900)
563 mustRun(t, ctx, chromedp.Evaluate(`window.__first`, &first))
564 if first.Scheme != "dark" || first.Theme != "dark" {
565 t.Errorf("with Dark stored the first frame's color-scheme was %q on data-theme=%q; want dark on dark", first.Scheme, first.Theme)
566 }
567 if first.Bg != plain {
568 t.Errorf("with Dark and Plain stored the first frame had --rst-bg %q; want Plain's %q", first.Bg, plain)
569 }
570 mustRun(t, ctx, chromedp.Evaluate(`localStorage.clear(); true`, nil))
571 rig.Screen("#sample", "the page in Plain, dark")
572}
573
574func contains(list []string, s string) bool {
575 for _, x := range list {
576 if x == s {
577 return true
578 }
579 }
580 return false
581}
582
583// throwingStorage is a localStorage that throws on every touch, as a
584// browser with storage blocked does.
585const throwingStorage = `Object.defineProperty(window, "localStorage", { get() { throw new Error("storage is blocked"); } });`
586
587// TestTheSchemeMenuMarksTheRestoredScheme: with Dark stored, a reload
588// marks Dark current in the scheme menu. A fresh visit, a reload after
589// choosing System, and a storage that throws each mark System.
590func TestTheSchemeMenuMarksTheRestoredScheme(t *testing.T) {
591 rig, _ := siteRig(t)
592 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
593 defer cancel()
594 open(t, ctx, rig, 1280, 900)
595 mustRun(t, ctx, soon(".site-themebar"))
596 if got := currentScheme(t, ctx); got != "system" {
597 t.Fatalf("a fresh visit marks %q; want system", got)
598 }
599
600 mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil))
601 open(t, ctx, rig, 1280, 900)
602 mustRun(t, ctx, soon(".site-themebar"))
603 if got := currentScheme(t, ctx); got != "dark" {
604 t.Errorf("with dark stored a reload marks %q; want dark", got)
605 }
606
607 chooseScheme(t, ctx, "system")
608 open(t, ctx, rig, 1280, 900)
609 mustRun(t, ctx, soon(".site-themebar"))
610 if got := currentScheme(t, ctx); got != "system" {
611 t.Errorf("after choosing System a reload marks %q; want system", got)
612 }
613 var stored string
614 mustRun(t, ctx, chromedp.Evaluate(`localStorage.getItem("fiesta-scheme") || ""`, &stored))
615 if stored != "" {
616 t.Errorf("after choosing System the storage still holds %q", stored)
617 }
618
619 mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil))
620 onNewDocument(t, ctx, throwingStorage)
621 open(t, ctx, rig, 1280, 900)
622 mustRun(t, ctx, soon(".site-themebar"))
623 if got := currentScheme(t, ctx); got != "system" {
624 t.Errorf("with a storage that throws the page marks %q; want system", got)
625 }
626 rig.Screen("#sample", "the page with storage blocked")
627}
628
629// themeLink reads the theme stylesheet's href and the theme tab marked
630// current.
631func themeLink(t *testing.T, ctx context.Context) (href, current string) {
632 t.Helper()
633 var got struct{ Href, Current string }
634 at(t, ctx, `(() => { const l = document.getElementById("site-theme"), a = document.querySelector('[data-site-theme][aria-pressed="true"]'); return JSON.stringify({Href: l ? l.getAttribute("href") : "", Current: a ? a.dataset.siteTheme : ""}); })()`, &got)
635 return got.Href, got.Current
636}
637
638// TestABrokenStoredThemeFallsBackToDay: a stored theme that is not
639// day, plain or signal, and a storage that throws, both load Day's
640// stylesheet and mark Day. The broken values include the names every
641// object inherits, which a lookup in a plain object would accept.
642func TestABrokenStoredThemeFallsBackToDay(t *testing.T) {
643 rig, _ := siteRig(t)
644 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
645 defer cancel()
646 open(t, ctx, rig, 1280, 900)
647 for _, stored := range []string{"bogus", "constructor", "__proto__", "toString", "DAY", ""} {
648 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`localStorage.setItem("fiesta-theme", %q); true`, stored), nil))
649 open(t, ctx, rig, 1280, 900)
650 mustRun(t, ctx, soon(".site-themebar"))
651 if href, cur := themeLink(t, ctx); href != "/1/fiesta.css" || cur != "day" {
652 t.Errorf("with %q stored the page loads %q and marks %q; want /1/fiesta.css and day", stored, href, cur)
653 }
654 }
655
656 mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "plain"); true`, nil))
657 onNewDocument(t, ctx, throwingStorage)
658 open(t, ctx, rig, 1280, 900)
659 mustRun(t, ctx, soon(".site-themebar"))
660 if href, cur := themeLink(t, ctx); href != "/1/fiesta.css" || cur != "day" {
661 t.Errorf("with a storage that throws the page loads %q and marks %q; want /1/fiesta.css and day", href, cur)
662 }
663 rig.Screen("#sample", "the page in Day")
664}
665
666// paneJS reports which sample panes and which course lists are shown,
667// and which sidebar link and course tab are current.
668const paneJS = `(() => JSON.stringify({
669 Panes: [...document.querySelectorAll("#sample .site-pane")].filter(p => p.getClientRects().length > 0).map(p => p.id),
670 Current: [...document.querySelectorAll("#sample [rst-side-nav] a[aria-current]")].map(a => a.getAttribute("href")),
671 Courses: [...document.querySelectorAll("#sample .site-course")].filter(p => p.getClientRects().length > 0).map(p => p.id),
672 Tab: [...document.querySelectorAll("#sample [data-site-course][aria-current]")].map(a => a.getAttribute("href")),
673}))()`
674
675type panes struct{ Panes, Current, Courses, Tab []string }
676
677func readPanes(t *testing.T, ctx context.Context) panes {
678 t.Helper()
679 var p panes
680 at(t, ctx, paneJS, &p)
681 return p
682}
683
684// showPane presses one sidebar link of the sample.
685func showPane(t *testing.T, ctx context.Context, id string) {
686 t.Helper()
687 mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [rst-side-nav] a[href="#%s"]`, id), chromedp.ByQuery))
688}
689
690// TestEverySamplePaneAndCourseSwitches: the sample opens on Overview;
691// pressing Menu, then Guests, shows only that pane and marks its link;
692// each course tab shows only its course.
693func TestEverySamplePaneAndCourseSwitches(t *testing.T) {
694 rig, _ := siteRig(t)
695 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
696 defer cancel()
697 open(t, ctx, rig, 1280, 900)
698 mustRun(t, ctx, soon(".site-themebar"))
699 if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != "pane-overview" || strings.Join(p.Current, " ") != "#pane-overview" {
700 t.Fatalf("the sample opens showing %v with %v current; want pane-overview alone", p.Panes, p.Current)
701 }
702 showPane(t, ctx, "pane-menu")
703 p := readPanes(t, ctx)
704 if strings.Join(p.Panes, " ") != "pane-menu" || strings.Join(p.Current, " ") != "#pane-menu" {
705 t.Errorf("after Menu the sample shows %v with %v current; want pane-menu alone", p.Panes, p.Current)
706 }
707 if strings.Join(p.Courses, " ") != "course-starters" || strings.Join(p.Tab, " ") != "#course-starters" {
708 t.Errorf("the Menu pane opens showing courses %v with %v current; want course-starters alone", p.Courses, p.Tab)
709 }
710 for _, c := range []string{"course-mains", "course-desserts", "course-starters"} {
711 mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, c), chromedp.ByQuery))
712 if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != c || strings.Join(p.Tab, " ") != "#"+c {
713 t.Errorf("after the %s tab the pane shows %v with %v current; want %s alone", c, p.Courses, p.Tab, c)
714 }
715 }
716 showPane(t, ctx, "pane-guests")
717 if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != "pane-guests" || strings.Join(p.Current, " ") != "#pane-guests" {
718 t.Errorf("after Guests the sample shows %v with %v current; want pane-guests alone", p.Panes, p.Current)
719 }
720 var loc string
721 mustRun(t, ctx, chromedp.Location(&loc))
722 if loc != rig.Origin+"/" {
723 t.Errorf("switching panes moved the page to %s", loc)
724 }
725 rig.Screen("#pane-guests", "the Guests pane")
726}
727
728// paneTopJS measures the pane that is showing: where it starts below
729// the top of the column that holds every pane, and where its heading
730// starts below the pane's own top. Both, because a gap above a pane
731// can be its margin, outside its box, or its padding, inside it.
732const paneTopJS = `(() => { const p = [...document.querySelectorAll("#sample .site-pane")].find(p => p.getClientRects().length > 0), c = p.parentElement.getBoundingClientRect(), r = p.getBoundingClientRect(), h = p.querySelector("h2").getBoundingClientRect();
733 return JSON.stringify({ID: p.id, Pane: r.top - c.top, Heading: h.top - r.top}); })()`
734
735// TestEverySamplePaneStartsLikeTheFirst: each sample pane, shown on its
736// own, starts its heading as high in the window as Overview does, on a
737// desktop and on a phone. A divider written between neighbouring panes
738// still matches when the pane before is hidden, which drew a gap and a
739// rule above every pane but the first.
740func TestEverySamplePaneStartsLikeTheFirst(t *testing.T) {
741 rig, _ := siteRig(t)
742 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
743 defer cancel()
744 type top struct {
745 ID string
746 Pane, Heading float64
747 }
748 for _, w := range []int64{1280, 390} {
749 open(t, ctx, rig, w, 900)
750 mustRun(t, ctx, soon(".site-themebar"))
751 var first top
752 at(t, ctx, paneTopJS, &first)
753 if first.ID != "pane-overview" {
754 t.Fatalf("at %d the sample opens on %s; want pane-overview", w, first.ID)
755 }
756 for _, id := range []string{"pane-guests", "pane-menu", "pane-music", "pane-settings"} {
757 showPane(t, ctx, id)
758 var got top
759 at(t, ctx, paneTopJS, &got)
760 if got.ID != id {
761 t.Fatalf("at %d, after pressing %s the sample shows %s", w, id, got.ID)
762 }
763 if math.Abs(got.Pane-first.Pane) > 0.5 || math.Abs(got.Heading-first.Heading) > 0.5 {
764 t.Errorf("at %d, %s starts %.0fpx down its column with its heading %.0fpx below that; Overview starts at %.0f with its heading at %.0f",
765 w, id, got.Pane, got.Heading, first.Pane, first.Heading)
766 }
767 }
768 rig.Screen("#pane-settings", fmt.Sprintf("the last pane at %d", w))
769 }
770}
771
772// liveText is the text of the live region inside sel.
773func liveText(t *testing.T, ctx context.Context, sel string) string {
774 t.Helper()
775 var s string
776 mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.querySelector(%q + " .site-live"); return l ? l.textContent : ""; })()`, sel), &s))
777 return s
778}
779
780// TestTheSampleFormsStayOnThePage: submitting Settings and Search
781// changes no URL, makes no request, and announces the local message
782// in each form's live region.
783func TestTheSampleFormsStayOnThePage(t *testing.T) {
784 rig, h := siteRig(t)
785 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
786 defer cancel()
787 open(t, ctx, rig, 1280, 900)
788 mustRun(t, ctx, soon(".site-themebar"))
789 words := catalog(t)
790 h.Seen(true)
791
792 showPane(t, ctx, "pane-settings")
793 if got := liveText(t, ctx, "#pane-settings"); got != "" {
794 t.Fatalf("the Settings live region already says %q", got)
795 }
796 mustRun(t, ctx, chromedp.Click(`#pane-settings button[type="submit"]`, chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
797 if got := liveText(t, ctx, "#pane-settings"); got != words["sample.saved"] {
798 t.Errorf("after Save the live region says %q; want %q", got, words["sample.saved"])
799 }
800
801 showPane(t, ctx, "pane-guests")
802 mustRun(t, ctx, chromedp.Click(`#pane-guests input[type="search"]`, chromedp.ByQuery),
803 chromedp.SendKeys(`#pane-guests input[type="search"]`, "Ana\n", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
804 if got := liveText(t, ctx, "#pane-guests"); got != words["sample.searched"] {
805 t.Errorf("after a search the live region says %q; want %q", got, words["sample.searched"])
806 }
807
808 var loc string
809 mustRun(t, ctx, chromedp.Location(&loc))
810 if loc != rig.Origin+"/" {
811 t.Errorf("the submits moved the page to %s", loc)
812 }
813 if seen := h.Seen(false); len(seen) > 0 {
814 t.Errorf("the submits made %d request(s): %v", len(seen), seen)
815 }
816 var roles int
817 mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll('#sample .site-live[role="status"]').length`, &roles))
818 if roles < 2 {
819 t.Errorf("%d live regions in the sample carry role=status; want one per form", roles)
820 }
821 rig.Screen("#pane-guests", "the Guests pane after a search")
822}
823
824// switchJS reads the sample's switch: its checkbox, the change events
825// since the page counted, the knob's translateX, its track's box and
826// the knob's travel, as check's drive reads them.
827const switchJS = `((id) => { const l = document.getElementById(id), t = l.querySelector("[rst-switch-track]"), a = getComputedStyle(t, "::after");
828 const m = new DOMMatrix(a.transform === "none" ? "" : a.transform), r = t.getBoundingClientRect();
829 return JSON.stringify({Checked: l.querySelector("input").checked, Changes: (window.changes || {})[id] || 0, Dragging: l.hasAttribute("data-rst-drag"),
830 X: m.m41, L: r.left, R: r.right, Y: r.top + r.height / 2, K: parseFloat(a.width), Run: r.width - r.height}); })`
831
832type switchReading struct {
833 Checked, Dragging bool
834 Changes int
835 X, L, R, Y, K, Run float64
836}
837
838func readSwitch(t *testing.T, ctx context.Context, id string) switchReading {
839 t.Helper()
840 var s switchReading
841 at(t, ctx, switchJS+fmt.Sprintf("(%q)", id), &s)
842 return s
843}
844
845// TestTheSampleSwitchDrags: the sample's reminders switch, on to begin
846// with, can be dragged off: the knob follows the pointer and on release
847// snaps to the off end, with one change event. check's drag journey,
848// on the landing page's own markup through the release's fiesta.js.
849func TestTheSampleSwitchDrags(t *testing.T) {
850 rig, _ := siteRig(t)
851 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
852 defer cancel()
853 open(t, ctx, rig, 1280, 900)
854 mustRun(t, ctx, soon(".site-themebar"))
855 showPane(t, ctx, "pane-guests")
856 const id = "sample-reminders"
857 // The switch is below the fold of a 900px viewport; a press at its
858 // box would land on nothing until it is scrolled on screen.
859 mustRun(t, ctx, chromedp.Evaluate(`window.changes = {}; document.addEventListener("change", e => { const l = e.target.closest("[rst-switch]"); if (l) window.changes[l.id] = (window.changes[l.id] || 0) + 1; }); true`, nil),
860 chromedp.ScrollIntoView("#"+id, chromedp.ByQuery), chromedp.Sleep(100*time.Millisecond))
861 s := readSwitch(t, ctx, id)
862 if !s.Checked || s.Run <= 0 {
863 t.Fatalf("the switch starts checked=%v with a travel of %.1f; the drag below proves nothing", s.Checked, s.Run)
864 }
865 mouse := func(typ input.MouseType, x, y float64) chromedp.Action {
866 return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1)
867 }
868 // On, the knob rests at the inline end: 2px in, half a knob back.
869 x := s.R - 2 - s.K/2
870 mustRun(t, ctx, mouse(input.MousePressed, x, s.Y))
871 for _, o := range []float64{0.2, 0.4, 0.6, 0.8} {
872 mustRun(t, ctx, mouse(input.MouseMoved, x-o*s.Run, s.Y))
873 }
874 mid := readSwitch(t, ctx, id)
875 if !mid.Dragging || mid.X > 0.3*s.Run {
876 t.Errorf("80%% into a drag off, the knob is at %.1f (dragging %v); it should follow the pointer to about %.1f", mid.X, mid.Dragging, 0.2*s.Run)
877 }
878 mustRun(t, ctx, mouse(input.MouseReleased, x-0.8*s.Run, s.Y), chromedp.Sleep(350*time.Millisecond))
879 if s := readSwitch(t, ctx, id); s.Checked || s.Changes != 1 || s.X > 0.5 || s.Dragging {
880 t.Errorf("released past the middle: checked %v, %d change events, knob at %.1f; want off, one event, at 0", s.Checked, s.Changes, s.X)
881 }
882 rig.Screen("#pane-guests", "the Guests pane after the drag")
883}
884
885// copyJS reads the Copy button and its live region.
886const copyJS = `(() => { const b = document.querySelector("[rst-snippet-copy]"), l = document.querySelector("#install [rst-snippet-status]");
887 return JSON.stringify({Present: !!b, Shown: !!b && b.getClientRects().length > 0, Label: b ? b.textContent.trim() : "", Live: l ? l.textContent : "", Role: l ? l.getAttribute("role") : "", Selected: getSelection().toString()}); })()`
888
889type copyReading struct {
890 Present, Shown bool
891 Label, Live, Role, Selected string
892}
893
894func readCopy(t *testing.T, ctx context.Context) copyReading {
895 t.Helper()
896 var c copyReading
897 at(t, ctx, copyJS, &c)
898 return c
899}
900
901// TestCopy: a granted clipboard takes the link, twice in a row, with
902// the label reading Copied for a moment and the live region saying
903// so; a clipboard that refuses leaves the link selected and the live
904// region saying the copy failed; with no clipboard API the button
905// stays hidden; and every announcement goes through a status region.
906func TestCopy(t *testing.T) {
907 rig, _ := siteRig(t)
908 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
909 defer cancel()
910 words := catalog(t)
911 // The clipboard API refuses a document without focus, and a
912 // headless page has none unless the browser is told it does.
913 mustRun(t, ctx, browser.GrantPermissions([]browser.PermissionType{browser.PermissionTypeClipboardReadWrite, browser.PermissionTypeClipboardSanitizedWrite}).WithOrigin(rig.Origin),
914 emulation.SetFocusEmulationEnabled(true))
915 open(t, ctx, rig, 1280, 900)
916 mustRun(t, ctx, soon("[rst-snippet-copy]"))
917
918 t.Run("granted", func(t *testing.T) {
919 for i := 1; i <= 2; i++ {
920 mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText("")`, nil, awaitPromise),
921 chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
922 var held string
923 mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.readText()`, &held, awaitPromise))
924 if held != installLink {
925 t.Errorf("copy %d: the clipboard holds %q; want the link tag", i, held)
926 }
927 if c := readCopy(t, ctx); c.Label != words["copied"] || c.Live != words["copied"] || c.Role != "status" {
928 t.Errorf("copy %d: the label reads %q and the live region (role %q) says %q; want %q in both, in a status region", i, c.Label, c.Role, c.Live, words["copied"])
929 }
930 if i == 1 {
931 mustRun(t, ctx, chromedp.Sleep(2200*time.Millisecond))
932 if c := readCopy(t, ctx); c.Label != words["copy"] {
933 t.Errorf("two seconds on, the label reads %q; want %q back", c.Label, words["copy"])
934 }
935 }
936 }
937 })
938
939 t.Run("refused", func(t *testing.T) {
940 mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText = () => Promise.reject(new Error("refused")); getSelection().removeAllRanges(); true`, nil),
941 chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond))
942 if c := readCopy(t, ctx); c.Live != words["copy.failed"] || c.Selected != installLink {
943 t.Errorf("after a refused copy the live region says %q and the selection is %q; want %q and the link tag", c.Live, c.Selected, words["copy.failed"])
944 }
945 })
946
947 t.Run("no clipboard API", func(t *testing.T) {
948 onNewDocument(t, ctx, `Object.defineProperty(navigator, "clipboard", { value: undefined });`)
949 open(t, ctx, rig, 1280, 900)
950 mustRun(t, ctx, soon(".site-themebar"))
951 if c := readCopy(t, ctx); !c.Present || c.Shown {
952 t.Errorf("with no clipboard API the Copy button is present=%v shown=%v; want present and hidden", c.Present, c.Shown)
953 }
954 })
955 rig.Screen("#install", "the install section")
956}
957
958// buntingJS reads the bunting: whether a canvas is there and shown,
959// whether the still SVG is shown, and the canvas's pixels as a data
960// URL, for telling two moments apart.
961const buntingJS = `(() => { const row = document.getElementById("bunting"), c = row.querySelector("canvas"), svg = row.querySelector("svg");
962 const shown = e => !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden";
963 return JSON.stringify({Canvas: !!c, CanvasShown: shown(c), SvgShown: shown(svg), Pixels: c ? c.toDataURL() : ""}); })()`
964
965type bunting struct {
966 Canvas, CanvasShown, SvgShown bool
967 Pixels string
968}
969
970func readBunting(t *testing.T, ctx context.Context) bunting {
971 t.Helper()
972 var b bunting
973 at(t, ctx, buntingJS, &b)
974 return b
975}
976
977// reduceMotion emulates prefers-reduced-motion: reduce when on, and
978// clears the emulation when off.
979func reduceMotion(t *testing.T, ctx context.Context, on bool) {
980 t.Helper()
981 if on {
982 mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}}))
983 } else {
984 mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "no-preference"}}))
985 }
986}
987
988// waitBunting polls until the bunting reads as fn wants, or fails.
989func waitBunting(t *testing.T, ctx context.Context, what string, fn func(bunting) bool) bunting {
990 t.Helper()
991 deadline := time.Now().Add(10 * time.Second)
992 for {
993 b := readBunting(t, ctx)
994 if fn(b) {
995 return b
996 }
997 if !time.Now().Before(deadline) {
998 t.Fatalf("%s: never happened; canvas %v (shown %v), svg shown %v", what, b.Canvas, b.CanvasShown, b.SvgShown)
999 }
1000 time.Sleep(50 * time.Millisecond)
1001 }
1002}
1003
1004// TestTheBuntingMovesOnlyWhenMotionIsAllowed: under normal motion the
1005// canvas is drawn and its pixels differ across 600 ms, the control.
1006// Under reduced motion there is no moving canvas and the still SVG
1007// shows. Reduced motion turned on mid-visit stops the canvas and shows
1008// the SVG; turned off again, the canvas comes back.
1009func TestTheBuntingMovesOnlyWhenMotionIsAllowed(t *testing.T) {
1010 rig, _ := siteRig(t)
1011 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1012 defer cancel()
1013 defer func() {
1014 rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second)
1015 defer rcancel()
1016 _ = chromedp.Run(rctx, emulation.SetEmulatedMedia())
1017 }()
1018 moving := func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown }
1019 still := func(b bunting) bool { return !b.CanvasShown && b.SvgShown }
1020
1021 reduceMotion(t, ctx, false)
1022 open(t, ctx, rig, 1280, 900)
1023 before := waitBunting(t, ctx, "the canvas under normal motion", moving)
1024 mustRun(t, ctx, chromedp.Sleep(600*time.Millisecond))
1025 after := readBunting(t, ctx)
1026 if before.Pixels == after.Pixels {
1027 t.Error("the canvas pixels are the same 600 ms apart; the bunting is not moving, so the reduced-motion check below would prove nothing")
1028 }
1029
1030 reduceMotion(t, ctx, true)
1031 open(t, ctx, rig, 1280, 900)
1032 mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond))
1033 if b := readBunting(t, ctx); !still(b) {
1034 t.Errorf("under reduced motion the canvas is there=%v shown=%v and the SVG shown=%v; want no moving canvas and the SVG", b.Canvas, b.CanvasShown, b.SvgShown)
1035 }
1036
1037 reduceMotion(t, ctx, false)
1038 open(t, ctx, rig, 1280, 900)
1039 waitBunting(t, ctx, "the canvas after motion was allowed again", moving)
1040 reduceMotion(t, ctx, true)
1041 waitBunting(t, ctx, "the still SVG after reduced motion was turned on mid-visit", still)
1042 mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond))
1043 if b := readBunting(t, ctx); !still(b) {
1044 t.Errorf("300 ms after reduced motion came on, canvas shown=%v and the SVG shown=%v; want the SVG alone", b.CanvasShown, b.SvgShown)
1045 }
1046 reduceMotion(t, ctx, false)
1047 waitBunting(t, ctx, "the canvas after reduced motion was turned off again", moving)
1048 rig.Screen("#headline", "the page under normal motion")
1049}
1050
1051// ropeJS reads the rope's colour two ways: the most opaque pixel in
1052// the canvas's top rows, where only the rope and its ties are drawn,
1053// and the token the still SVG's rope is stroked with.
1054const ropeJS = `(() => {
1055 const c = document.querySelector("#bunting canvas");
1056 if (!c) return JSON.stringify({Alpha: -1});
1057 const d = c.getContext("2d").getImageData(0, 0, c.width, 6).data;
1058 let best = -1, rope = null;
1059 for (let i = 0; i < d.length; i += 4) if (d[i + 3] > best) { best = d[i + 3]; rope = [d[i], d[i + 1], d[i + 2]]; }
1060 const token = (getComputedStyle(document.querySelector(".site-span")).stroke.match(/\d+/g) || []).slice(0, 3).map(Number);
1061 return JSON.stringify({Alpha: best, Rope: rope, Token: token});
1062})()`
1063
1064type rope struct {
1065 Alpha int
1066 Rope, Token []int
1067}
1068
1069// near is whether two colours are within a few steps per channel, the
1070// slack antialiasing and rounding need.
1071func near(a, b []int) bool {
1072 if len(a) != 3 || len(b) != 3 {
1073 return false
1074 }
1075 for i := range a {
1076 if d := a[i] - b[i]; d > 6 || d < -6 {
1077 return false
1078 }
1079 }
1080 return true
1081}
1082
1083// ropeMatches polls until the canvas's rope is the token's colour, or
1084// fails naming both.
1085func ropeMatches(t *testing.T, ctx context.Context, when string) rope {
1086 t.Helper()
1087 deadline := time.Now().Add(5 * time.Second)
1088 for {
1089 var r rope
1090 at(t, ctx, ropeJS, &r)
1091 if r.Alpha > 0 && near(r.Rope, r.Token) {
1092 return r
1093 }
1094 if !time.Now().Before(deadline) {
1095 t.Fatalf("%s: the canvas's rope is %v (alpha %d) but the token is %v; the bunting was not repainted", when, r.Rope, r.Alpha, r.Token)
1096 }
1097 time.Sleep(50 * time.Millisecond)
1098 }
1099}
1100
1101// TestTheBuntingFollowsTheTheme: the rope is a Fiesta token, and the
1102// three themes give it three colours; choosing Signal repaints the
1103// canvas in Signal's once its stylesheet has loaded.
1104func TestTheBuntingFollowsTheTheme(t *testing.T) {
1105 rig, _ := siteRig(t)
1106 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1107 defer cancel()
1108 open(t, ctx, rig, 1280, 900)
1109 waitBunting(t, ctx, "the canvas under normal motion", func(b bunting) bool { return b.Canvas && b.CanvasShown })
1110 day := ropeMatches(t, ctx, "in Day")
1111 chooseTheme(t, ctx, "signal")
1112 signal := ropeMatches(t, ctx, "after choosing Signal")
1113 if near(day.Token, signal.Token) {
1114 t.Fatalf("Day's rope token %v and Signal's %v are the same colour; this drive proves nothing", day.Token, signal.Token)
1115 }
1116 mustRun(t, ctx, chromedp.Evaluate(`localStorage.clear(); true`, nil))
1117 rig.Screen("#headline", "the page in Signal")
1118}
1119
1120// capture screenshots one rectangle of the page, in CSS pixels.
1121func capture(t *testing.T, ctx context.Context, x, y, w, h float64) image.Image {
1122 t.Helper()
1123 var png []byte
1124 mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error {
1125 var err error
1126 png, err = cdppage.CaptureScreenshot().WithClip(&cdppage.Viewport{X: x, Y: y, Width: w, Height: h, Scale: 1}).Do(ctx)
1127 return err
1128 }))
1129 img, err := imagepng.Decode(bytes.NewReader(png))
1130 if err != nil {
1131 t.Fatalf("decoding the screenshot: %v", err)
1132 }
1133 return img
1134}
1135
1136// rgb reads a pixel as 8-bit channels.
1137func rgb(img image.Image, x, y int) [3]int {
1138 r, g, b, _ := img.At(x, y).RGBA()
1139 return [3]int{int(r >> 8), int(g >> 8), int(b >> 8)}
1140}
1141
1142// apart is the largest channel difference between two colours.
1143func apart(a, b [3]int) int {
1144 d := 0
1145 for i := range a {
1146 if x := a[i] - b[i]; x > d {
1147 d = x
1148 } else if -x > d {
1149 d = -x
1150 }
1151 }
1152 return d
1153}
1154
1155// topInk is the first row from the top of column x whose pixel is not
1156// the background, or -1 when the column is clear: where the rope
1157// crosses the column, since nothing hangs above the rope.
1158func topInk(img image.Image, x int, bg [3]int) int {
1159 for y := 0; y < img.Bounds().Dy(); y++ {
1160 if apart(rgb(img, x, y), bg) > 40 {
1161 return y
1162 }
1163 }
1164 return -1
1165}
1166
1167// meanApart is the mean over every pixel of the largest channel
1168// difference between two pictures of the same size.
1169func meanApart(a, b image.Image) float64 {
1170 w, h := a.Bounds().Dx(), a.Bounds().Dy()
1171 sum := 0
1172 for y := 0; y < h; y++ {
1173 for x := 0; x < w; x++ {
1174 sum += apart(rgb(a, x, y), rgb(b, x, y))
1175 }
1176 }
1177 return float64(sum) / float64(w*h)
1178}
1179
1180// TestTheStillBuntingMatchesTheMovingOne: the canvas hung at rest,
1181// before any wind, and the still SVG in the markup are the same
1182// picture. With the script's freeze hook set the canvas never moves;
1183// the bunting's strip is screenshotted with the canvas showing, then
1184// with the SVG showing in its place, and the two are compared: the
1185// rope's height at thirty columns across the page within two pixels,
1186// and the mean pixel difference over the strip small (the canvas's
1187// cloth carries a weave the SVG's does not, and edges antialias apart,
1188// so it is never zero). A flat rope under a sagging one, or pennants
1189// hung at a different height, fails both.
1190func TestTheStillBuntingMatchesTheMovingOne(t *testing.T) {
1191 rig, _ := siteRig(t)
1192 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1193 defer cancel()
1194 onNewDocument(t, ctx, `window.siteBuntingStill = true;`)
1195 open(t, ctx, rig, 1280, 900)
1196 waitBunting(t, ctx, "the canvas hung at rest", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown })
1197 var strip struct{ Top, Height, Width float64 }
1198 at(t, ctx, `(() => { const r = document.getElementById("bunting").getBoundingClientRect(), c = document.querySelector("#bunting canvas").getBoundingClientRect(); return JSON.stringify({Top: r.top, Height: c.height, Width: r.width}); })()`, &strip)
1199 if strip.Height < 100 || strip.Width < 1000 {
1200 t.Fatalf("the bunting's strip is %.0f by %.0f; this drive expects the canvas's full height across the page", strip.Width, strip.Height)
1201 }
1202 moving := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
1203 mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("bunting").classList.remove("site-bunting-live"); document.querySelector("#bunting canvas").style.visibility = "hidden"; true`, nil))
1204 still := waitBunting(t, ctx, "the SVG shown in the canvas's place", func(b bunting) bool { return b.SvgShown && !b.CanvasShown })
1205 _ = still
1206 stillImg := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
1207
1208 w, h := moving.Bounds().Dx(), moving.Bounds().Dy()
1209 if w != stillImg.Bounds().Dx() || h != stillImg.Bounds().Dy() {
1210 t.Fatalf("the two screenshots are %dx%d and %dx%d", w, h, stillImg.Bounds().Dx(), stillImg.Bounds().Dy())
1211 }
1212 bg := rgb(moving, w-1, h-1)
1213 off, columns := 0, 0
1214 for x := 20; x < w-20; x += 40 {
1215 a, b := topInk(moving, x, bg), topInk(stillImg, x, bg)
1216 if a < 0 || b < 0 {
1217 t.Errorf("column %d: the rope was not found (canvas row %d, SVG row %d)", x, a, b)
1218 continue
1219 }
1220 columns++
1221 if d := a - b; d > 2 || d < -2 {
1222 off++
1223 t.Errorf("column %d: the canvas's rope is at row %d and the SVG's at row %d", x, a, b)
1224 }
1225 }
1226 mean := meanApart(moving, stillImg)
1227 t.Logf("%d columns sampled, %d off by more than 2px; mean pixel difference %.2f", columns, off, mean)
1228 if columns < 20 {
1229 t.Fatalf("only %d columns had a rope; the strip was not the bunting", columns)
1230 }
1231 if mean > 4 {
1232 t.Errorf("the canvas at rest and the still SVG differ by %.2f per pixel on average; want under 4", mean)
1233 }
1234 rig.Screen("#headline", "the page with the still bunting")
1235}
1236
1237// motionJS gives a drive what it needs to scroll the page as a reader
1238// does and watch the canvas while it happens: rest() keeps the
1239// canvas's pixels as they are now, off() is the percentage of them
1240// that have plainly changed since (a channel moved by more than 48, so
1241// the cloth moved rather than turned a shade in the light: the
1242// slightest tilt reshades a whole pennant), glide(to, ms) scrolls
1243// there at an even speed, one step a frame, as a trackpad does, and
1244// shake(n, by, ms) is n quick trips down by that much and back, each
1245// leg taking ms. The page's own scroll-behaviour is overridden: a
1246// smooth scroll would hand the timing to the browser.
1247const motionJS = `window.siteMotion = (() => {
1248 const canvas = () => document.querySelector("#bunting canvas"), frame = () => new Promise(r => requestAnimationFrame(r));
1249 const pixels = () => { const c = canvas(); return c.getContext("2d").getImageData(0, 0, c.width, c.height).data; };
1250 let base = null;
1251 const m = {
1252 frame,
1253 rest() { base = pixels(); return true; },
1254 off() {
1255 const d = pixels();
1256 let n = 0;
1257 for (let i = 0; i < d.length; i += 4) if (Math.max(Math.abs(d[i] - base[i]), Math.abs(d[i + 1] - base[i + 1]), Math.abs(d[i + 2] - base[i + 2]), Math.abs(d[i + 3] - base[i + 3])) > 48) n++;
1258 return 100 * n / (d.length / 4);
1259 },
1260 async glide(to, ms, each) {
1261 const from = scrollY, t0 = performance.now();
1262 for (;;) {
1263 await frame();
1264 const u = Math.min(1, (performance.now() - t0) / ms);
1265 scrollTo({ top: from + (to - from) * u, behavior: "instant" });
1266 if (each) each();
1267 if (u >= 1) return;
1268 }
1269 },
1270 async shake(n, by, ms, each) {
1271 const home = scrollY;
1272 for (let i = 0; i < n; i++) { await m.glide(home + by, ms, each); await m.glide(home, ms, each); }
1273 },
1274 // motion integrates off() over the ms from now, in percent
1275 // seconds: how much of the canvas moved from rest, and for how
1276 // long, in one number. Each sample stands for the time since the
1277 // last, and none counts past the deadline, even mid-gesture, so a
1278 // slower frame rate samples less often but weighs each sample
1279 // more, and the number does not depend on it.
1280 async motion(ms, during) {
1281 const t0 = performance.now(), end = t0 + ms;
1282 let sum = 0, last = t0;
1283 const each = () => {
1284 const now = Math.min(performance.now(), end);
1285 if (now > last) { sum += m.off() * (now - last) / 1000; last = now; }
1286 };
1287 if (during) await during(each);
1288 while (performance.now() < end) { await frame(); each(); }
1289 return sum;
1290 },
1291 };
1292 return m;
1293})(); true`
1294
1295// buntingProbe is what siteBuntingProbe reports: the cloth's extremes
1296// since the page opened, in the SVG's units, and whether the frame
1297// loop is running.
1298type buntingProbe struct {
1299 MinY, MaxY, MinX, MaxX, MaxZ, Stretch float64
1300 Height, From, To float64
1301 Finite, Running bool
1302 Builds int
1303}
1304
1305func probeBunting(t *testing.T, ctx context.Context) buntingProbe {
1306 t.Helper()
1307 var p buntingProbe
1308 at(t, ctx, `window.siteBuntingProbe()`, &p)
1309 return p
1310}
1311
1312// openStill opens the landing page with the breeze held off and the
1313// probe on, and waits for the canvas to hang still with its loop
1314// asleep: the cloth at rest, so whatever moves it next is the drive's.
1315func openStill(t *testing.T, ctx context.Context, rig *harness.Rig) {
1316 t.Helper()
1317 onNewDocument(t, ctx, `window.siteBuntingWindless = true; window.siteBuntingProbe = true;`)
1318 open(t, ctx, rig, 1280, 900)
1319 waitBunting(t, ctx, "the canvas", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown })
1320 settle(t, ctx, "the canvas before any scrolling", 10*time.Second)
1321 mustRun(t, ctx, chromedp.Evaluate(motionJS, nil))
1322}
1323
1324// settle waits for the canvas's frame loop to go to sleep, which it
1325// does once the cloth is still, and fails after within.
1326func settle(t *testing.T, ctx context.Context, what string, within time.Duration) time.Duration {
1327 t.Helper()
1328 start := time.Now()
1329 for {
1330 if p := probeBunting(t, ctx); !p.Running {
1331 return time.Since(start)
1332 }
1333 if time.Since(start) > within {
1334 t.Fatalf("%s: the cloth was still moving %v later", what, within)
1335 }
1336 time.Sleep(50 * time.Millisecond)
1337 }
1338}
1339
1340// TestTheBuntingFollowsTheScrollBack: a reader scrolls the row off
1341// screen, reads a little, and scrolls back up quickly. The air the
1342// page moved through is still moving when the row comes back, so the
1343// cloth lifts at once: within 300 ms of the row's return the canvas
1344// is well away from its rest. Then it settles, and the loop sleeps,
1345// within a few seconds. The breeze is held off so the scroll is the
1346// only thing moving the cloth. Settled means the loop has gone to
1347// sleep and the canvas is back within 1 of its rest.
1348func TestTheBuntingFollowsTheScrollBack(t *testing.T) {
1349 rig, _ := siteRig(t)
1350 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1351 defer cancel()
1352 openStill(t, ctx, rig)
1353 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil))
1354 var back struct{ Peak, Returned float64 }
1355 at(t, ctx, `(async () => {
1356 const m = siteMotion, row = document.getElementById("bunting");
1357 await m.glide(700, 250);
1358 await new Promise(r => setTimeout(r, 1200));
1359 let returned = -1, peak = 0;
1360 const each = () => {
1361 if (returned < 0 && row.getBoundingClientRect().bottom > 0) returned = performance.now();
1362 if (returned >= 0 && performance.now() - returned <= 300) peak = Math.max(peak, m.off());
1363 };
1364 await m.glide(0, 250, each);
1365 while (performance.now() - returned <= 300) { await m.frame(); each(); }
1366 return JSON.stringify({Peak: peak, Returned: returned});
1367})()`, &back, awaitPromise)
1368 if back.Returned < 0 {
1369 t.Fatal("the row never came back on screen")
1370 }
1371 took := settle(t, ctx, "the cloth after the scroll back", 8*time.Second)
1372 var after float64
1373 at(t, ctx, `JSON.stringify(siteMotion.off())`, &after)
1374 t.Logf("within 300 ms of the row's return %.2f%% of the canvas had moved from rest; it settled %v later, %.2f%% off rest", back.Peak, took.Round(10*time.Millisecond), after)
1375 if back.Peak < buntingScrollBack {
1376 t.Errorf("within 300 ms of the row coming back at most %.2f%% of the canvas had moved from rest; want at least %.0f%%: the scroll back did not reach the cloth", back.Peak, buntingScrollBack)
1377 }
1378 if after > 1 {
1379 t.Errorf("the settled canvas is %.2f%% off its rest before the scroll; want under 1%%", after)
1380 }
1381}
1382
1383// buntingScrollBack is the percentage of the canvas, in motionJS's
1384// off(), that must move from rest within 300 ms of a quick scroll
1385// back: before the cloth felt the air it was under 1, with it near 10.
1386const buntingScrollBack = 3.0
1387
1388// TestAShakeMovesTheBuntingMoreThanAScroll: near the top of the page,
1389// three quick trips 80 px down and back, 100 ms a leg, are the frame
1390// jolting, and the cloth must dance; one slow trip down the same 80 px
1391// and back, 800 ms a leg, is a gentle current, and the cloth only
1392// sways. Each is measured over the same three seconds from its start,
1393// with the breeze held off and the cloth at rest before each. 80 px
1394// keeps the row on screen; much further and it leaves, and the loop
1395// sleeps.
1396func TestAShakeMovesTheBuntingMoreThanAScroll(t *testing.T) {
1397 rig, _ := siteRig(t)
1398 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1399 defer cancel()
1400 openStill(t, ctx, rig)
1401 var slow, shake float64
1402 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil))
1403 at(t, ctx, `siteMotion.motion(3000, each => siteMotion.shake(1, 80, 800, each)).then(JSON.stringify)`, &slow, awaitPromise)
1404 settle(t, ctx, "the cloth after the slow scroll", 8*time.Second)
1405 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil))
1406 at(t, ctx, `siteMotion.motion(3000, each => siteMotion.shake(3, 80, 100, each)).then(JSON.stringify)`, &shake, awaitPromise)
1407 t.Logf("motion over three seconds, off() integrated over time: the slow scroll %.2f, the shake %.2f %%·s (%.1f times)", slow, shake, shake/max(slow, 1e-9))
1408 if slow <= 0 {
1409 t.Errorf("the slow scroll did not move the cloth at all")
1410 }
1411 if shake < 1.8*slow {
1412 t.Errorf("the shake moved the canvas %.2f %%·s and the slow scroll %.2f; want the shake at least 1.8 times the scroll", shake, slow)
1413 }
1414}
1415
1416// TestTheBuntingNeverLeavesItsRow: a violent shake, far faster than a
1417// hand, with the breeze blowing, never throws the cloth out of the
1418// canvas or tears it. The probe reports every point's extremes from
1419// the page opening to the end of the shake: each one finite, inside
1420// the canvas top to bottom and between the end tacks, and no link
1421// stretched past half again its length.
1422func TestTheBuntingNeverLeavesItsRow(t *testing.T) {
1423 rig, _ := siteRig(t)
1424 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1425 defer cancel()
1426 onNewDocument(t, ctx, `window.siteBuntingProbe = true;`)
1427 open(t, ctx, rig, 1280, 900)
1428 waitBunting(t, ctx, "the canvas", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown })
1429 mustRun(t, ctx, chromedp.Evaluate(motionJS, nil))
1430 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest(); siteMotion.motion(1500, each => siteMotion.shake(25, 90, 1, each))`, nil, awaitPromise))
1431 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.motion(1500, each => siteMotion.glide(3000, 1).then(() => siteMotion.glide(0, 1)).then(() => siteMotion.shake(10, 120, 1, each)))`, nil, awaitPromise))
1432 p := probeBunting(t, ctx)
1433 t.Logf("extremes: y %.1f to %.1f of %.1f, x %.1f to %.1f between %.1f and %.1f, |z| %.1f, stretch %.3f", p.MinY, p.MaxY, p.Height, p.MinX, p.MaxX, p.From, p.To, p.MaxZ, p.Stretch)
1434 if !p.Finite {
1435 t.Fatal("a point of the cloth went to NaN or infinity")
1436 }
1437 if p.MinY < 0 || p.MaxY > p.Height {
1438 t.Errorf("the cloth reached y %.1f to %.1f; the canvas shows 0 to %.1f", p.MinY, p.MaxY, p.Height)
1439 }
1440 if p.MinX < p.From || p.MaxX > p.To {
1441 t.Errorf("the cloth reached x %.1f to %.1f; the rope's end tacks are at %.1f and %.1f", p.MinX, p.MaxX, p.From, p.To)
1442 }
1443 if p.Stretch > 1.5 {
1444 t.Errorf("a link stretched to %.2f times its length; the cloth tore", p.Stretch)
1445 }
1446}
1447
1448// TestAScrollAfterIdleWakesTheBunting: a reader leaves the page for
1449// twenty seconds, then turns the wheel once, 80 px, with the row on
1450// screen. One scroll event that long after the last is a slow scroll
1451// by its average speed, but the air it moves is real, so the cloth
1452// must wake and lift.
1453func TestAScrollAfterIdleWakesTheBunting(t *testing.T) {
1454 rig, _ := siteRig(t)
1455 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1456 defer cancel()
1457 openStill(t, ctx, rig)
1458 mustRun(t, ctx, chromedp.Evaluate(`scrollTo({top: 80, behavior: "instant"}); true`, nil))
1459 mustRun(t, ctx, chromedp.Sleep(20*time.Second))
1460 settle(t, ctx, "the cloth after twenty idle seconds", time.Second)
1461 var peak float64
1462 at(t, ctx, `(async () => {
1463 const m = siteMotion;
1464 m.rest();
1465 scrollTo({top: 0, behavior: "instant"});
1466 let peak = 0;
1467 const t0 = performance.now();
1468 while (performance.now() - t0 < 1000) { await m.frame(); peak = Math.max(peak, m.off()); }
1469 return JSON.stringify(peak);
1470})()`, &peak, awaitPromise)
1471 t.Logf("one 80 px scroll after twenty idle seconds moved %.2f%% of the canvas within a second", peak)
1472 if peak < 1 {
1473 t.Errorf("one 80 px scroll after twenty idle seconds moved %.2f%% of the canvas; want at least 1%%: the cloth never woke", peak)
1474 }
1475}
1476
1477// TestTheBuntingHangsTheSameAfterAShake: a rebuild (a resize, or
1478// motion allowed again) hangs the row with the calm pre-roll, which
1479// must be the rest pose whatever the cloth was doing: the flutter a
1480// shake leaves behind must not leak into it, or the first frame after
1481// the rebuild is not the still SVG. The viewport grows a pixel taller
1482// right after a shake, which rebuilds the same row; the pose it hangs
1483// is held to the one hung when the page opened.
1484func TestTheBuntingHangsTheSameAfterAShake(t *testing.T) {
1485 rig, _ := siteRig(t)
1486 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1487 defer cancel()
1488 openStill(t, ctx, rig)
1489 var before, after []float64
1490 at(t, ctx, `window.siteBuntingProbe.hung()`, &before)
1491 builds := probeBunting(t, ctx).Builds
1492 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest(); siteMotion.shake(3, 80, 100)`, nil, awaitPromise))
1493 mustRun(t, ctx, chromedp.EmulateViewport(1280, 901))
1494 deadline := time.Now().Add(5 * time.Second)
1495 for probeBunting(t, ctx).Builds == builds {
1496 if time.Now().After(deadline) {
1497 t.Fatal("resizing the viewport never rebuilt the row")
1498 }
1499 time.Sleep(20 * time.Millisecond)
1500 }
1501 at(t, ctx, `window.siteBuntingProbe.hung()`, &after)
1502 if len(before) == 0 || len(before) != len(after) {
1503 t.Fatalf("the row hung %d coordinates before the shake and %d after; the rebuild was not the same row", len(before), len(after))
1504 }
1505 worst := 0.0
1506 for i := range before {
1507 worst = max(worst, math.Abs(after[i]-before[i]))
1508 }
1509 t.Logf("the pose hung after the shake is at most %.4f units from the one hung before", worst)
1510 if worst > 0.01 {
1511 t.Errorf("the pose hung after a shake is %.2f units from the rest pose; want under 0.01: the shake leaked into the calm pre-roll", worst)
1512 }
1513}
1514
1515// TestReducedMotionIgnoresScrolling: under reduced motion there is no
1516// canvas, and scrolling and shaking the page leave the still SVG
1517// exactly as it was.
1518func TestReducedMotionIgnoresScrolling(t *testing.T) {
1519 rig, _ := siteRig(t)
1520 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1521 defer cancel()
1522 defer func() {
1523 rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second)
1524 defer rcancel()
1525 _ = chromedp.Run(rctx, emulation.SetEmulatedMedia())
1526 }()
1527 reduceMotion(t, ctx, true)
1528 open(t, ctx, rig, 1280, 900)
1529 still := func(b bunting) bool { return !b.Canvas && b.SvgShown }
1530 waitBunting(t, ctx, "the still SVG under reduced motion", still)
1531 var strip struct{ Top, Height, Width float64 }
1532 at(t, ctx, `(() => { const r = document.getElementById("bunting").getBoundingClientRect(); return JSON.stringify({Top: r.top, Height: r.height, Width: r.width}); })()`, &strip)
1533 before := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
1534 mustRun(t, ctx, chromedp.Evaluate(motionJS, nil))
1535 mustRun(t, ctx, chromedp.Evaluate(`(async () => { await siteMotion.glide(700, 250); await siteMotion.glide(0, 250); await siteMotion.shake(3, 60, 70); await new Promise(r => setTimeout(r, 300)); return true; })()`, nil, awaitPromise))
1536 if b := readBunting(t, ctx); !still(b) {
1537 t.Errorf("after scrolling under reduced motion the canvas is there=%v and the SVG shown=%v; want the SVG alone", b.Canvas, b.SvgShown)
1538 }
1539 after := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height)
1540 if d := meanApart(before, after); d != 0 {
1541 t.Errorf("the still SVG changed by %.2f per pixel after scrolling under reduced motion; want no change", d)
1542 }
1543}
1544
1545// withoutAnchorSupport is what anchor.js's feature test sees in a
1546// browser without anchor positioning: CSS.supports says no to it.
1547const withoutAnchorSupport = `(() => { const s = CSS.supports.bind(CSS); CSS.supports = (...a) => !/position-area|position-try/.test(a.join(" ")) && s(...a); })()`
1548
1549// menuJS opens the language menu and measures its panel against the
1550// viewport.
1551const menuJS = `(async () => { const d = document.querySelector(".site-lang"); d.querySelector("summary").click();
1552 await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
1553 const p = d.querySelector(":scope > [rst-dropdown-menu]").getBoundingClientRect();
1554 return JSON.stringify({Open: d.open, Left: p.left, Right: p.right, Top: p.top, Bottom: p.bottom, VW: document.documentElement.clientWidth, VH: innerHeight, Flipped: document.documentElement.hasAttribute("data-rst-flip-menus")}); })()`
1555
1556// TestTheLanguageMenuOpensOnAPhone: at 320px the language menu opens
1557// with its panel inside the viewport, with the browser's own anchor
1558// positioning and with anchor.js standing in for it.
1559func TestTheLanguageMenuOpensOnAPhone(t *testing.T) {
1560 for _, leg := range []struct {
1561 name string
1562 anchor bool
1563 }{{"anchor positioning", true}, {"anchor.js", false}} {
1564 t.Run(leg.name, func(t *testing.T) {
1565 rig, _ := siteRig(t)
1566 ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute)
1567 defer cancel()
1568 if !leg.anchor {
1569 onNewDocument(t, ctx, withoutAnchorSupport)
1570 }
1571 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
1572 return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c)
1573 }), chromedp.Navigate(rig.Origin+"/"), soon(".site-lang"))
1574 var g struct {
1575 Open, Flipped bool
1576 Left, Right, Top, Bottom, VW, VH float64
1577 }
1578 at(t, ctx, menuJS, &g, awaitPromise)
1579 if !g.Open {
1580 t.Fatal("the language menu did not open")
1581 }
1582 if g.Flipped != !leg.anchor {
1583 t.Errorf("anchor.js marked the root %v; want %v on this leg", g.Flipped, !leg.anchor)
1584 }
1585 if g.Left < -1 || g.Right > g.VW+1 || g.Top < -1 || g.Bottom > g.VH+1 {
1586 t.Errorf("the language menu's panel spans %.0f…%.0f by %.0f…%.0f in a %.0fx%.0f viewport; it is off the screen", g.Left, g.Right, g.Top, g.Bottom, g.VW, g.VH)
1587 }
1588 rig.Screen(".site-lang", "the language menu open")
1589 })
1590 }
1591}
1592