//go:build browser // The landing page in a real Chromium, served as the edge serves it: // the site.Build tree over the committed releases, through the same // handler every www drive uses. Each drive is one journey a reader // makes: a phone, a browser with scripts off, a returning visitor, a // click through the sample, a copy of the install line. package www_test import ( "bytes" "context" "encoding/json" "fmt" "image" imagepng "image/png" "math" "net/http" "strings" "testing" "time" "github.com/chromedp/cdproto/browser" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/input" cdppage "github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/axe" "amadan.net/rastrillo/fiesta/internal/harness" "amadan.net/rastrillo/fiesta/internal/www" ) // siteRig boots a browser on the built site, and hands back the // handler, for a drive that delays a file or reads the request log. func siteRig(t *testing.T, opts ...harness.Option) (*harness.Rig, *siteHandler) { t.Helper() var h *siteHandler rig := harness.New(t, func(string) http.Handler { h = newSiteHandler(t, builtSite(t)) h.noStore = true return h }, opts...) return rig, h } func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) { t.Helper() if err := chromedp.Run(ctx, actions...); err != nil { t.Fatal(err) } } // at evaluates a JS expression returning a JSON string into v. func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) { t.Helper() var raw string if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil { t.Fatalf("evaluating: %v\n%s", err, js) } if err := json.Unmarshal([]byte(raw), v); err != nil { t.Fatalf("decoding %q: %v", raw, err) } } func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } // onNewDocument runs js in every document the page opens from now on, // before any of the page's own scripts. func onNewDocument(t *testing.T, ctx context.Context, js string) { t.Helper() mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { _, err := cdppage.AddScriptToEvaluateOnNewDocument(js).Do(ctx) return err })) } // soon waits up to ten seconds for sel to be visible: long enough on a // loaded box, short enough that a page without the element fails by // its name rather than by the test's deadline. func soon(sel string) chromedp.Action { return chromedp.ActionFunc(func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := chromedp.WaitVisible(sel, chromedp.ByQuery).Do(ctx); err != nil { return fmt.Errorf("%s never became visible: %w", sel, err) } return nil }) } // open loads the English landing page at the given viewport, waits // for it to load, and requires the sample to be on it: a page without // the sample is not the landing page, and the drive fails here by name. func open(t *testing.T, ctx context.Context, rig *harness.Rig, w, h int64) { t.Helper() openPath(t, ctx, rig, "/", w, h) } // openPath is open at any path: the landing page in another language // lives at //. func openPath(t *testing.T, ctx context.Context, rig *harness.Rig, p string, w, h int64) { t.Helper() mustRun(t, ctx, chromedp.EmulateViewport(w, h), chromedp.Navigate(rig.Origin+p), chromedp.WaitReady("body"), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) var present bool mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("sample")`, &present)) if !present { t.Fatalf("the page at %s has no #sample; this is not the landing page", p) } } // catalog is the English catalog, for the words a drive expects to see. func catalog(t *testing.T) map[string]string { t.Helper() s, err := www.Strings(siteSource(t).FS(), "en") if err != nil { t.Fatal(err) } return s } // visibleJS reports, for a selector, how many matches there are and // how many are rendered (have a box on the page). const visibleJS = `((sel) => { const all = [...document.querySelectorAll(sel)]; return JSON.stringify({Total: all.length, Shown: all.filter(e => e.getClientRects().length > 0).length}); })` type shown struct{ Total, Shown int } func visible(t *testing.T, ctx context.Context, sel string) shown { t.Helper() var s shown at(t, ctx, visibleJS+fmt.Sprintf("(%q)", sel), &s) return s } // themeURL is the stylesheet each theme choice loads. var themeURL = map[string]string{"day": "/1/fiesta.css", "plain": "/1/fiesta-plain.css", "signal": "/1/fiesta-signal.css"} // chooseTheme presses one theme tab and waits for its stylesheet to be // the one in effect: the link's href swapped, and a sheet at that URL // with rules in document.styleSheets, which it joins only once loaded // (the link's own .sheet can still be the old one meanwhile). func chooseTheme(t *testing.T, ctx context.Context, theme string) { t.Helper() mustRun(t, ctx, soon(`.site-themebar`), chromedp.Click(fmt.Sprintf(`[data-site-theme=%q]`, theme), chromedp.ByQuery)) deadline := time.Now().Add(10 * time.Second) for { var ok bool 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)) if ok { return } if !time.Now().Before(deadline) { t.Fatalf("the %s stylesheet never became the page's theme", theme) } time.Sleep(50 * time.Millisecond) } } // chooseScheme picks one scheme from the theme bar's menu, as a reader // would: open the menu, press the button. func chooseScheme(t *testing.T, ctx context.Context, scheme string) { t.Helper() btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme) mustRun(t, ctx, chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery), soon(btn), chromedp.Click(btn, chromedp.ByQuery), ) } // currentScheme is the scheme button carrying aria-current, "" for none. func currentScheme(t *testing.T, ctx context.Context) string { t.Helper() var s string mustRun(t, ctx, chromedp.Evaluate(`(() => { const b = document.querySelector("[rst-scheme] button[aria-current]"); return b ? b.form.elements.scheme.value : ""; })()`, &s)) return s } // TestTheLandingPassesAxe: the whole page, in Day, Plain and Signal, // each light and dark, with the WCAG 2.2 AA tag set; then the Arabic // and Japanese pages in Day, light and dark, for a right-to-left // script and one with no spaces between words. Each theme is chosen // from the theme bar, as a reader would, and each scheme is set on // the root, the way fiesta.js sets it. func TestTheLandingPassesAxe(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) defer cancel() scans, failed := 0, 0 scan := func(lang, theme string) { t.Helper() chooseTheme(t, ctx, theme) ground := map[string]string{} for _, scheme := range []string{"light", "dark"} { var bg string mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) ground[scheme] = bg res, err := axe.Scan(ctx, "", axe.DefaultTags()) if err != nil { t.Fatalf("%s %s %s: %v", lang, theme, scheme, err) } scans++ for _, v := range res.Violations { failed++ nodes := v.Nodes if len(nodes) > 5 { nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5)) } t.Errorf("%s %s %s: %s (%s): %s\n %s", lang, theme, scheme, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n ")) } t.Logf("%s %s %s: needs review, axe could not decide: %v", lang, theme, scheme, res.Incomplete) } if ground["light"] == ground["dark"] { t.Fatalf("%s %s: the body paints %s under both schemes, so the flip to dark did not take", lang, theme, ground["light"]) } mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.removeAttribute("data-theme")`, nil)) } open(t, ctx, rig, 1280, 900) for _, theme := range []string{"day", "plain", "signal"} { scan("en", theme) } rig.Screen("body", "the landing page") for _, lang := range []string{"ar", "ja"} { openPath(t, ctx, rig, pageURL(lang), 1280, 900) var got string mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got)) if got != lang { t.Fatalf("the page at %s is in %q; want %s", pageURL(lang), got, lang) } scan(lang, "day") rig.Screen("body", "the landing page in "+lang) } t.Logf("%d scans, %d violations", scans, failed) } // measureJS reports whether the document scrolls sideways, and the // elements that reach past either edge. const measureJS = `(() => { const de = document.documentElement; const w = Math.round(de.getBoundingClientRect().width); const over = []; document.querySelectorAll("body *").forEach(el => { const r = el.getBoundingClientRect(); if (r.width === 0) return; if (r.right > w + 1 || r.left < -1) { const cls = (el.className || "").toString().trim().split(/\s+/)[0]; over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]"); } }); return JSON.stringify({Doc: de.scrollWidth, Client: w, Body: document.body.scrollWidth, Over: over.slice(0, 8)}); })()` // windowJS measures the sample window's own content: the window's box // against the viewport, and every shown element inside it against the // window's box, since the window clips its overflow and would hide a // pane that reached past its edge. An element inside a box that // scrolls sideways on purpose (the sidebar's row on a phone) is left // to its scroller, which must itself fit. const windowJS = `(() => { const w = document.querySelector(".site-window"), wr = w.getBoundingClientRect(), vw = document.documentElement.clientWidth; 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; }; const over = []; w.querySelectorAll("*").forEach(el => { const r = el.getBoundingClientRect(); if (r.width === 0 || scrolls(el)) return; if (r.right > wr.right + 1 || r.left < wr.left - 1) { const cls = (el.className || "").toString().trim().split(/\s+/)[0]; over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]"); } }); return JSON.stringify({Left: wr.left, Right: wr.right, VW: vw, Over: over.slice(0, 8)}); })()` // TestTheLandingHasNoHorizontalScrollAt320 is WCAG 1.4.10 Reflow on // the landing page, in every one of its eleven languages: at a 320 // CSS-pixel viewport nothing scrolls sideways, on the page as it opens // and with every sample pane and every course shown, and nothing // inside the sample window reaches past the window's edge, where its // clipping would hide it. Each page's content is required first, so a // stub with a sample and nothing else cannot pass. The bunting is // allowed to be wider than the page: it is clipped by its own box, and // the check is the document's scroll width, which clipping keeps. func TestTheLandingHasNoHorizontalScrollAt320(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) defer cancel() mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) })) for _, lang := range www.Languages { mustRun(t, ctx, chromedp.Navigate(rig.Origin+pageURL(lang)), chromedp.WaitReady("body"), chromedp.Evaluate(`new Promise(r => document.readyState === "complete" ? r() : addEventListener("load", r, {once: true}))`, nil, awaitPromise)) var got string mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.lang`, &got)) if got != lang { 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) } for _, c := range []struct { sel string want int }{ {"#headline", 1}, {"#install code", 1}, {".site-themebar", 1}, {"#sample [rst-side-nav] a", 5}, {"#sample .site-pane", 5}, {"#sample .site-pane h2", 5}, {"#sample .site-course", 3}, {"#reasons [rst-card]", 6}, {"#footer nav", 4}, {"#bunting svg", 1}, } { if s := visible(t, ctx, c.sel); s.Total != c.want { 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) } } mustRun(t, ctx, soon(".site-themebar")) measure := func(where string) { t.Helper() where = lang + ", " + where var page struct { Doc, Client, Body int Over []string } at(t, ctx, measureJS, &page) if page.Doc > page.Client || page.Body > page.Client { t.Errorf("%s: the page scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s", where, page.Doc, page.Body, page.Client, strings.Join(page.Over, "\n ")) } var win struct { Left, Right, VW float64 Over []string } at(t, ctx, windowJS, &win) if win.Left < -1 || win.Right > win.VW+1 { t.Errorf("%s: the sample window spans %.0f…%.0f in a %.0fpx viewport", where, win.Left, win.Right, win.VW) } if len(win.Over) > 0 { 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 ")) } } measure("as the page opens") for _, pane := range []string{"pane-guests", "pane-menu", "pane-music", "pane-settings", "pane-overview"} { showPane(t, ctx, pane) if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != pane { t.Fatalf("%s: after pressing %s the sample shows %v", lang, pane, p.Panes) } measure(pane) if pane != "pane-menu" { continue } for _, course := range []string{"course-mains", "course-desserts", "course-starters"} { mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, course), chromedp.ByQuery)) if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != course { t.Fatalf("%s: after pressing %s the Menu pane shows %v", lang, course, p.Courses) } measure(course) } } rig.Screen("#sample", "the page at 320 in "+lang) } } // rtlJS measures the top bar: the brand's box and the last item's, the // document's direction, and the install code's computed direction. const rtlJS = `(() => { const b = document.querySelector(".site-brand").getBoundingClientRect(), items = document.querySelectorAll(".site-nav > *"), l = items[items.length - 1].getBoundingClientRect(), code = document.querySelector("#install code"); 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")}); })()` // TestArabicReadsRightToLeft: the Arabic page is dir="rtl" and lays // out that way, so the top bar's brand sits to the right of its last // item, by bounding box; and the install line's code stays left to // right, since a URL reads that way in any language. func TestArabicReadsRightToLeft(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() openPath(t, ctx, rig, pageURL("ar"), 1280, 900) var g struct { Dir, BodyDir, CodeDir, CodeAttr string BrandLeft, BrandRight, LastLeft, LastRight, BrandTop, BrandBottom, LastTop, LastBottom float64 } at(t, ctx, rtlJS, &g) if g.Dir != "rtl" || g.BodyDir != "rtl" { t.Errorf("the Arabic page has dir=%q and the body's computed direction is %q; want rtl for both", g.Dir, g.BodyDir) } // On the same row: the two boxes overlap vertically. Their tops // differ by a pixel, since Arabic and Latin glyphs sit differently // in a centred flex row, so they are not compared exactly. if g.BrandBottom <= g.LastTop || g.LastBottom <= g.BrandTop { 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) } if g.BrandLeft < g.LastRight { 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) } if g.CodeAttr != "ltr" || g.CodeDir != "ltr" { 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) } rig.Screen("#headline", "the Arabic page") } // TestTheLandingReadsWithNoScript: with JavaScript off the page still // reads in full: the headline, the link box, every sample pane's // heading and the six reasons are on the screen; the theme bar and // the Copy button are not; and submitting the sample's Settings form // leaves the reader on the page, at the sample. func TestTheLandingReadsWithNoScript(t *testing.T) { rig, h := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), soon("#reasons")) var ran bool mustRun(t, ctx, chromedp.Evaluate(`!!document.getElementById("site-theme")`, &ran)) if ran { t.Fatal("the head's script wrote the theme link; scripts are not off, and this drive proves nothing") } for sel, want := range map[string]int{ "#headline": 1, "#install code": 1, "#sample .site-pane h2": 5, "#reasons [rst-card]": 6, } { if s := visible(t, ctx, sel); s.Total != want || s.Shown != want { t.Errorf("%s: %d on the page, %d shown; want %d shown with scripts off", sel, s.Total, s.Shown, want) } } for _, sel := range []string{".site-themebar", "[rst-snippet-copy]"} { if s := visible(t, ctx, sel); s.Total != 1 || s.Shown != 0 { t.Errorf("%s: %d on the page, %d shown; want 1 hidden with scripts off", sel, s.Total, s.Shown) } } h.Seen(true) // The submit is a navigation to a new document. Nothing may ask the // page anything until that document has loaded: a question sent // while the old one is being replaced is answered "Inspected target // navigated or closed", which is how this drive failed on a loaded // runner when it polled the location instead. loaded := navigationLoaded(ctx) mustRun(t, ctx, chromedp.Click(`#pane-settings button[type="submit"]`, chromedp.ByQuery)) select { case <-loaded: case <-time.After(30 * time.Second): t.Fatal("submitting Settings with scripts off loaded no new document within 30s") } var loc string mustRun(t, ctx, chromedp.Location(&loc), soon("#reasons")) if !strings.HasPrefix(loc, rig.Origin+"/?") || !strings.HasSuffix(loc, "#sample") { t.Errorf("after submitting Settings with scripts off the page is at %s; want the same page with a query, at #sample", loc) } for _, p := range h.Seen(false) { if p != "/" && !strings.HasPrefix(p, "/1/") && !strings.HasPrefix(p, "/static/") { t.Errorf("the submit asked the server for %s", p) } } rig.Screen("#reasons", "the page after the submit") } // navigationLoaded is closed once the main frame has navigated to a new // document and that document's load event has fired, in that order: // a load event of the document being left does not count. Call it // before the action that navigates, so neither event can be missed. func navigationLoaded(ctx context.Context) <-chan struct{} { done := make(chan struct{}) lctx, cancel := context.WithCancel(ctx) // The listener runs on chromedp's one event goroutine, so these // need no lock; fired keeps a late event after cancel from closing // done twice. navigated, fired := false, false chromedp.ListenTarget(lctx, func(ev any) { switch e := ev.(type) { case *cdppage.EventFrameNavigated: if e.Frame.ParentID == "" { navigated = true } case *cdppage.EventLoadEventFired: if navigated && !fired { fired = true cancel() close(done) } } }) return done } // firstFrameJS queues a reading from the head, before any stylesheet // or script of the page's own: the first animation frame's theme // variables, stylesheets, scheme and root attribute, which is what the // first paint showed. const firstFrameJS = `requestAnimationFrame(() => { const cs = getComputedStyle(document.documentElement); window.__first = { Bg: cs.getPropertyValue("--rst-bg").trim(), Sheets: [...document.styleSheets].map(s => s.href ? new URL(s.href).pathname : "inline"), Scheme: cs.colorScheme, Theme: document.documentElement.getAttribute("data-theme") || "", Ready: document.readyState, }; })` type firstFrame struct { Bg, Scheme, Theme, Ready string Sheets []string } func rootBg(t *testing.T, ctx context.Context) string { t.Helper() var bg string mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.documentElement).getPropertyValue("--rst-bg").trim()`, &bg)) return bg } // TestTheThemeComesBackBeforeFirstPaint: a returning visitor who chose // Plain sees Plain from the first frame, with the stylesheet delayed // half a second by the server so a page that painted before it would // be caught; Day's stylesheet is never requested. With Dark stored as // well, the first frame is dark. func TestTheThemeComesBackBeforeFirstPaint(t *testing.T) { rig, h := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) day := rootBg(t, ctx) mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "plain"); true`, nil)) h.Delay("/1/fiesta-plain.css", 500*time.Millisecond) onNewDocument(t, ctx, firstFrameJS) h.Seen(true) start := time.Now() open(t, ctx, rig, 1280, 900) var first firstFrame mustRun(t, ctx, chromedp.Evaluate(`window.__first`, &first)) // The steady state, waited for: a page that came back in Plain // late would otherwise fail here, before the first frame is judged. plain := rootBg(t, ctx) for deadline := time.Now().Add(3 * time.Second); plain == day && time.Now().Before(deadline); plain = rootBg(t, ctx) { time.Sleep(50 * time.Millisecond) } if plain == day || plain == "" { t.Fatalf("after the reload --rst-bg is %q, the same as Day's; Plain did not come back at all", plain) } if first.Bg != plain { t.Errorf("the first frame had --rst-bg %q (sheets %v, ready %s); want Plain's %q", first.Bg, first.Sheets, first.Ready, plain) } if !contains(first.Sheets, "/1/fiesta-plain.css") { t.Errorf("the first frame's stylesheets were %v; Plain's was not among them", first.Sheets) } for _, p := range h.Seen(false) { if p == "/1/fiesta.css" { t.Error("Day's stylesheet was requested on a visit that had Plain stored") } } if time.Since(start) < 500*time.Millisecond { t.Errorf("the reload took %s, less than the stylesheet's delay; the delay was not in the page's way", time.Since(start)) } mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil)) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, chromedp.Evaluate(`window.__first`, &first)) if first.Scheme != "dark" || first.Theme != "dark" { 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) } if first.Bg != plain { t.Errorf("with Dark and Plain stored the first frame had --rst-bg %q; want Plain's %q", first.Bg, plain) } mustRun(t, ctx, chromedp.Evaluate(`localStorage.clear(); true`, nil)) rig.Screen("#sample", "the page in Plain, dark") } func contains(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false } // throwingStorage is a localStorage that throws on every touch, as a // browser with storage blocked does. const throwingStorage = `Object.defineProperty(window, "localStorage", { get() { throw new Error("storage is blocked"); } });` // TestTheSchemeMenuMarksTheRestoredScheme: with Dark stored, a reload // marks Dark current in the scheme menu. A fresh visit, a reload after // choosing System, and a storage that throws each mark System. func TestTheSchemeMenuMarksTheRestoredScheme(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if got := currentScheme(t, ctx); got != "system" { t.Fatalf("a fresh visit marks %q; want system", got) } mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil)) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if got := currentScheme(t, ctx); got != "dark" { t.Errorf("with dark stored a reload marks %q; want dark", got) } chooseScheme(t, ctx, "system") open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if got := currentScheme(t, ctx); got != "system" { t.Errorf("after choosing System a reload marks %q; want system", got) } var stored string mustRun(t, ctx, chromedp.Evaluate(`localStorage.getItem("fiesta-scheme") || ""`, &stored)) if stored != "" { t.Errorf("after choosing System the storage still holds %q", stored) } mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil)) onNewDocument(t, ctx, throwingStorage) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if got := currentScheme(t, ctx); got != "system" { t.Errorf("with a storage that throws the page marks %q; want system", got) } rig.Screen("#sample", "the page with storage blocked") } // themeLink reads the theme stylesheet's href and the theme tab marked // current. func themeLink(t *testing.T, ctx context.Context) (href, current string) { t.Helper() var got struct{ Href, Current string } 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) return got.Href, got.Current } // TestABrokenStoredThemeFallsBackToDay: a stored theme that is not // day, plain or signal, and a storage that throws, both load Day's // stylesheet and mark Day. The broken values include the names every // object inherits, which a lookup in a plain object would accept. func TestABrokenStoredThemeFallsBackToDay(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) for _, stored := range []string{"bogus", "constructor", "__proto__", "toString", "DAY", ""} { mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`localStorage.setItem("fiesta-theme", %q); true`, stored), nil)) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if href, cur := themeLink(t, ctx); href != "/1/fiesta.css" || cur != "day" { t.Errorf("with %q stored the page loads %q and marks %q; want /1/fiesta.css and day", stored, href, cur) } } mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-theme", "plain"); true`, nil)) onNewDocument(t, ctx, throwingStorage) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if href, cur := themeLink(t, ctx); href != "/1/fiesta.css" || cur != "day" { t.Errorf("with a storage that throws the page loads %q and marks %q; want /1/fiesta.css and day", href, cur) } rig.Screen("#sample", "the page in Day") } // paneJS reports which sample panes and which course lists are shown, // and which sidebar link and course tab are current. const paneJS = `(() => JSON.stringify({ Panes: [...document.querySelectorAll("#sample .site-pane")].filter(p => p.getClientRects().length > 0).map(p => p.id), Current: [...document.querySelectorAll("#sample [rst-side-nav] a[aria-current]")].map(a => a.getAttribute("href")), Courses: [...document.querySelectorAll("#sample .site-course")].filter(p => p.getClientRects().length > 0).map(p => p.id), Tab: [...document.querySelectorAll("#sample [data-site-course][aria-current]")].map(a => a.getAttribute("href")), }))()` type panes struct{ Panes, Current, Courses, Tab []string } func readPanes(t *testing.T, ctx context.Context) panes { t.Helper() var p panes at(t, ctx, paneJS, &p) return p } // showPane presses one sidebar link of the sample. func showPane(t *testing.T, ctx context.Context, id string) { t.Helper() mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [rst-side-nav] a[href="#%s"]`, id), chromedp.ByQuery)) } // TestEverySamplePaneAndCourseSwitches: the sample opens on Overview; // pressing Menu, then Guests, shows only that pane and marks its link; // each course tab shows only its course. func TestEverySamplePaneAndCourseSwitches(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != "pane-overview" || strings.Join(p.Current, " ") != "#pane-overview" { t.Fatalf("the sample opens showing %v with %v current; want pane-overview alone", p.Panes, p.Current) } showPane(t, ctx, "pane-menu") p := readPanes(t, ctx) if strings.Join(p.Panes, " ") != "pane-menu" || strings.Join(p.Current, " ") != "#pane-menu" { t.Errorf("after Menu the sample shows %v with %v current; want pane-menu alone", p.Panes, p.Current) } if strings.Join(p.Courses, " ") != "course-starters" || strings.Join(p.Tab, " ") != "#course-starters" { t.Errorf("the Menu pane opens showing courses %v with %v current; want course-starters alone", p.Courses, p.Tab) } for _, c := range []string{"course-mains", "course-desserts", "course-starters"} { mustRun(t, ctx, chromedp.Click(fmt.Sprintf(`#sample [data-site-course][href="#%s"]`, c), chromedp.ByQuery)) if p := readPanes(t, ctx); strings.Join(p.Courses, " ") != c || strings.Join(p.Tab, " ") != "#"+c { t.Errorf("after the %s tab the pane shows %v with %v current; want %s alone", c, p.Courses, p.Tab, c) } } showPane(t, ctx, "pane-guests") if p := readPanes(t, ctx); strings.Join(p.Panes, " ") != "pane-guests" || strings.Join(p.Current, " ") != "#pane-guests" { t.Errorf("after Guests the sample shows %v with %v current; want pane-guests alone", p.Panes, p.Current) } var loc string mustRun(t, ctx, chromedp.Location(&loc)) if loc != rig.Origin+"/" { t.Errorf("switching panes moved the page to %s", loc) } rig.Screen("#pane-guests", "the Guests pane") } // paneTopJS measures the pane that is showing: where it starts below // the top of the column that holds every pane, and where its heading // starts below the pane's own top. Both, because a gap above a pane // can be its margin, outside its box, or its padding, inside it. const 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(); return JSON.stringify({ID: p.id, Pane: r.top - c.top, Heading: h.top - r.top}); })()` // TestEverySamplePaneStartsLikeTheFirst: each sample pane, shown on its // own, starts its heading as high in the window as Overview does, on a // desktop and on a phone. A divider written between neighbouring panes // still matches when the pane before is hidden, which drew a gap and a // rule above every pane but the first. func TestEverySamplePaneStartsLikeTheFirst(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() type top struct { ID string Pane, Heading float64 } for _, w := range []int64{1280, 390} { open(t, ctx, rig, w, 900) mustRun(t, ctx, soon(".site-themebar")) var first top at(t, ctx, paneTopJS, &first) if first.ID != "pane-overview" { t.Fatalf("at %d the sample opens on %s; want pane-overview", w, first.ID) } for _, id := range []string{"pane-guests", "pane-menu", "pane-music", "pane-settings"} { showPane(t, ctx, id) var got top at(t, ctx, paneTopJS, &got) if got.ID != id { t.Fatalf("at %d, after pressing %s the sample shows %s", w, id, got.ID) } if math.Abs(got.Pane-first.Pane) > 0.5 || math.Abs(got.Heading-first.Heading) > 0.5 { 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", w, id, got.Pane, got.Heading, first.Pane, first.Heading) } } rig.Screen("#pane-settings", fmt.Sprintf("the last pane at %d", w)) } } // liveText is the text of the live region inside sel. func liveText(t *testing.T, ctx context.Context, sel string) string { t.Helper() var s string mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const l = document.querySelector(%q + " .site-live"); return l ? l.textContent : ""; })()`, sel), &s)) return s } // TestTheSampleFormsStayOnThePage: submitting Settings and Search // changes no URL, makes no request, and announces the local message // in each form's live region. func TestTheSampleFormsStayOnThePage(t *testing.T) { rig, h := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) words := catalog(t) h.Seen(true) showPane(t, ctx, "pane-settings") if got := liveText(t, ctx, "#pane-settings"); got != "" { t.Fatalf("the Settings live region already says %q", got) } mustRun(t, ctx, chromedp.Click(`#pane-settings button[type="submit"]`, chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond)) if got := liveText(t, ctx, "#pane-settings"); got != words["sample.saved"] { t.Errorf("after Save the live region says %q; want %q", got, words["sample.saved"]) } showPane(t, ctx, "pane-guests") mustRun(t, ctx, chromedp.Click(`#pane-guests input[type="search"]`, chromedp.ByQuery), chromedp.SendKeys(`#pane-guests input[type="search"]`, "Ana\n", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond)) if got := liveText(t, ctx, "#pane-guests"); got != words["sample.searched"] { t.Errorf("after a search the live region says %q; want %q", got, words["sample.searched"]) } var loc string mustRun(t, ctx, chromedp.Location(&loc)) if loc != rig.Origin+"/" { t.Errorf("the submits moved the page to %s", loc) } if seen := h.Seen(false); len(seen) > 0 { t.Errorf("the submits made %d request(s): %v", len(seen), seen) } var roles int mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll('#sample .site-live[role="status"]').length`, &roles)) if roles < 2 { t.Errorf("%d live regions in the sample carry role=status; want one per form", roles) } rig.Screen("#pane-guests", "the Guests pane after a search") } // switchJS reads the sample's switch: its checkbox, the change events // since the page counted, the knob's translateX, its track's box and // the knob's travel, as check's drive reads them. const switchJS = `((id) => { const l = document.getElementById(id), t = l.querySelector("[rst-switch-track]"), a = getComputedStyle(t, "::after"); const m = new DOMMatrix(a.transform === "none" ? "" : a.transform), r = t.getBoundingClientRect(); return JSON.stringify({Checked: l.querySelector("input").checked, Changes: (window.changes || {})[id] || 0, Dragging: l.hasAttribute("data-rst-drag"), X: m.m41, L: r.left, R: r.right, Y: r.top + r.height / 2, K: parseFloat(a.width), Run: r.width - r.height}); })` type switchReading struct { Checked, Dragging bool Changes int X, L, R, Y, K, Run float64 } func readSwitch(t *testing.T, ctx context.Context, id string) switchReading { t.Helper() var s switchReading at(t, ctx, switchJS+fmt.Sprintf("(%q)", id), &s) return s } // TestTheSampleSwitchDrags: the sample's reminders switch, on to begin // with, can be dragged off: the knob follows the pointer and on release // snaps to the off end, with one change event. check's drag journey, // on the landing page's own markup through the release's fiesta.js. func TestTheSampleSwitchDrags(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) showPane(t, ctx, "pane-guests") const id = "sample-reminders" // The switch is below the fold of a 900px viewport; a press at its // box would land on nothing until it is scrolled on screen. 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), chromedp.ScrollIntoView("#"+id, chromedp.ByQuery), chromedp.Sleep(100*time.Millisecond)) s := readSwitch(t, ctx, id) if !s.Checked || s.Run <= 0 { t.Fatalf("the switch starts checked=%v with a travel of %.1f; the drag below proves nothing", s.Checked, s.Run) } mouse := func(typ input.MouseType, x, y float64) chromedp.Action { return input.DispatchMouseEvent(typ, x, y).WithButton(input.Left).WithClickCount(1) } // On, the knob rests at the inline end: 2px in, half a knob back. x := s.R - 2 - s.K/2 mustRun(t, ctx, mouse(input.MousePressed, x, s.Y)) for _, o := range []float64{0.2, 0.4, 0.6, 0.8} { mustRun(t, ctx, mouse(input.MouseMoved, x-o*s.Run, s.Y)) } mid := readSwitch(t, ctx, id) if !mid.Dragging || mid.X > 0.3*s.Run { 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) } mustRun(t, ctx, mouse(input.MouseReleased, x-0.8*s.Run, s.Y), chromedp.Sleep(350*time.Millisecond)) if s := readSwitch(t, ctx, id); s.Checked || s.Changes != 1 || s.X > 0.5 || s.Dragging { 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) } rig.Screen("#pane-guests", "the Guests pane after the drag") } // copyJS reads the Copy button and its live region. const copyJS = `(() => { const b = document.querySelector("[rst-snippet-copy]"), l = document.querySelector("#install [rst-snippet-status]"); 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()}); })()` type copyReading struct { Present, Shown bool Label, Live, Role, Selected string } func readCopy(t *testing.T, ctx context.Context) copyReading { t.Helper() var c copyReading at(t, ctx, copyJS, &c) return c } // TestCopy: a granted clipboard takes the link, twice in a row, with // the label reading Copied for a moment and the live region saying // so; a clipboard that refuses leaves the link selected and the live // region saying the copy failed; with no clipboard API the button // stays hidden; and every announcement goes through a status region. func TestCopy(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() words := catalog(t) // The clipboard API refuses a document without focus, and a // headless page has none unless the browser is told it does. mustRun(t, ctx, browser.GrantPermissions([]browser.PermissionType{browser.PermissionTypeClipboardReadWrite, browser.PermissionTypeClipboardSanitizedWrite}).WithOrigin(rig.Origin), emulation.SetFocusEmulationEnabled(true)) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon("[rst-snippet-copy]")) t.Run("granted", func(t *testing.T) { for i := 1; i <= 2; i++ { mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText("")`, nil, awaitPromise), chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond)) var held string mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.readText()`, &held, awaitPromise)) if held != installLink { t.Errorf("copy %d: the clipboard holds %q; want the link tag", i, held) } if c := readCopy(t, ctx); c.Label != words["copied"] || c.Live != words["copied"] || c.Role != "status" { 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"]) } if i == 1 { mustRun(t, ctx, chromedp.Sleep(2200*time.Millisecond)) if c := readCopy(t, ctx); c.Label != words["copy"] { t.Errorf("two seconds on, the label reads %q; want %q back", c.Label, words["copy"]) } } } }) t.Run("refused", func(t *testing.T) { mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText = () => Promise.reject(new Error("refused")); getSelection().removeAllRanges(); true`, nil), chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery), chromedp.Sleep(200*time.Millisecond)) if c := readCopy(t, ctx); c.Live != words["copy.failed"] || c.Selected != installLink { 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"]) } }) t.Run("no clipboard API", func(t *testing.T) { onNewDocument(t, ctx, `Object.defineProperty(navigator, "clipboard", { value: undefined });`) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, soon(".site-themebar")) if c := readCopy(t, ctx); !c.Present || c.Shown { t.Errorf("with no clipboard API the Copy button is present=%v shown=%v; want present and hidden", c.Present, c.Shown) } }) rig.Screen("#install", "the install section") } // buntingJS reads the bunting: whether a canvas is there and shown, // whether the still SVG is shown, and the canvas's pixels as a data // URL, for telling two moments apart. const buntingJS = `(() => { const row = document.getElementById("bunting"), c = row.querySelector("canvas"), svg = row.querySelector("svg"); const shown = e => !!e && e.getClientRects().length > 0 && getComputedStyle(e).visibility !== "hidden"; return JSON.stringify({Canvas: !!c, CanvasShown: shown(c), SvgShown: shown(svg), Pixels: c ? c.toDataURL() : ""}); })()` type bunting struct { Canvas, CanvasShown, SvgShown bool Pixels string } func readBunting(t *testing.T, ctx context.Context) bunting { t.Helper() var b bunting at(t, ctx, buntingJS, &b) return b } // reduceMotion emulates prefers-reduced-motion: reduce when on, and // clears the emulation when off. func reduceMotion(t *testing.T, ctx context.Context, on bool) { t.Helper() if on { mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}})) } else { mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "no-preference"}})) } } // waitBunting polls until the bunting reads as fn wants, or fails. func waitBunting(t *testing.T, ctx context.Context, what string, fn func(bunting) bool) bunting { t.Helper() deadline := time.Now().Add(10 * time.Second) for { b := readBunting(t, ctx) if fn(b) { return b } if !time.Now().Before(deadline) { t.Fatalf("%s: never happened; canvas %v (shown %v), svg shown %v", what, b.Canvas, b.CanvasShown, b.SvgShown) } time.Sleep(50 * time.Millisecond) } } // TestTheBuntingMovesOnlyWhenMotionIsAllowed: under normal motion the // canvas is drawn and its pixels differ across 600 ms, the control. // Under reduced motion there is no moving canvas and the still SVG // shows. Reduced motion turned on mid-visit stops the canvas and shows // the SVG; turned off again, the canvas comes back. func TestTheBuntingMovesOnlyWhenMotionIsAllowed(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() defer func() { rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second) defer rcancel() _ = chromedp.Run(rctx, emulation.SetEmulatedMedia()) }() moving := func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown } still := func(b bunting) bool { return !b.CanvasShown && b.SvgShown } reduceMotion(t, ctx, false) open(t, ctx, rig, 1280, 900) before := waitBunting(t, ctx, "the canvas under normal motion", moving) mustRun(t, ctx, chromedp.Sleep(600*time.Millisecond)) after := readBunting(t, ctx) if before.Pixels == after.Pixels { 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") } reduceMotion(t, ctx, true) open(t, ctx, rig, 1280, 900) mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond)) if b := readBunting(t, ctx); !still(b) { 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) } reduceMotion(t, ctx, false) open(t, ctx, rig, 1280, 900) waitBunting(t, ctx, "the canvas after motion was allowed again", moving) reduceMotion(t, ctx, true) waitBunting(t, ctx, "the still SVG after reduced motion was turned on mid-visit", still) mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond)) if b := readBunting(t, ctx); !still(b) { 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) } reduceMotion(t, ctx, false) waitBunting(t, ctx, "the canvas after reduced motion was turned off again", moving) rig.Screen("#headline", "the page under normal motion") } // ropeJS reads the rope's colour two ways: the most opaque pixel in // the canvas's top rows, where only the rope and its ties are drawn, // and the token the still SVG's rope is stroked with. const ropeJS = `(() => { const c = document.querySelector("#bunting canvas"); if (!c) return JSON.stringify({Alpha: -1}); const d = c.getContext("2d").getImageData(0, 0, c.width, 6).data; let best = -1, rope = null; 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]]; } const token = (getComputedStyle(document.querySelector(".site-span")).stroke.match(/\d+/g) || []).slice(0, 3).map(Number); return JSON.stringify({Alpha: best, Rope: rope, Token: token}); })()` type rope struct { Alpha int Rope, Token []int } // near is whether two colours are within a few steps per channel, the // slack antialiasing and rounding need. func near(a, b []int) bool { if len(a) != 3 || len(b) != 3 { return false } for i := range a { if d := a[i] - b[i]; d > 6 || d < -6 { return false } } return true } // ropeMatches polls until the canvas's rope is the token's colour, or // fails naming both. func ropeMatches(t *testing.T, ctx context.Context, when string) rope { t.Helper() deadline := time.Now().Add(5 * time.Second) for { var r rope at(t, ctx, ropeJS, &r) if r.Alpha > 0 && near(r.Rope, r.Token) { return r } if !time.Now().Before(deadline) { 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) } time.Sleep(50 * time.Millisecond) } } // TestTheBuntingFollowsTheTheme: the rope is a Fiesta token, and the // three themes give it three colours; choosing Signal repaints the // canvas in Signal's once its stylesheet has loaded. func TestTheBuntingFollowsTheTheme(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() open(t, ctx, rig, 1280, 900) waitBunting(t, ctx, "the canvas under normal motion", func(b bunting) bool { return b.Canvas && b.CanvasShown }) day := ropeMatches(t, ctx, "in Day") chooseTheme(t, ctx, "signal") signal := ropeMatches(t, ctx, "after choosing Signal") if near(day.Token, signal.Token) { t.Fatalf("Day's rope token %v and Signal's %v are the same colour; this drive proves nothing", day.Token, signal.Token) } mustRun(t, ctx, chromedp.Evaluate(`localStorage.clear(); true`, nil)) rig.Screen("#headline", "the page in Signal") } // capture screenshots one rectangle of the page, in CSS pixels. func capture(t *testing.T, ctx context.Context, x, y, w, h float64) image.Image { t.Helper() var png []byte mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { var err error png, err = cdppage.CaptureScreenshot().WithClip(&cdppage.Viewport{X: x, Y: y, Width: w, Height: h, Scale: 1}).Do(ctx) return err })) img, err := imagepng.Decode(bytes.NewReader(png)) if err != nil { t.Fatalf("decoding the screenshot: %v", err) } return img } // rgb reads a pixel as 8-bit channels. func rgb(img image.Image, x, y int) [3]int { r, g, b, _ := img.At(x, y).RGBA() return [3]int{int(r >> 8), int(g >> 8), int(b >> 8)} } // apart is the largest channel difference between two colours. func apart(a, b [3]int) int { d := 0 for i := range a { if x := a[i] - b[i]; x > d { d = x } else if -x > d { d = -x } } return d } // topInk is the first row from the top of column x whose pixel is not // the background, or -1 when the column is clear: where the rope // crosses the column, since nothing hangs above the rope. func topInk(img image.Image, x int, bg [3]int) int { for y := 0; y < img.Bounds().Dy(); y++ { if apart(rgb(img, x, y), bg) > 40 { return y } } return -1 } // meanApart is the mean over every pixel of the largest channel // difference between two pictures of the same size. func meanApart(a, b image.Image) float64 { w, h := a.Bounds().Dx(), a.Bounds().Dy() sum := 0 for y := 0; y < h; y++ { for x := 0; x < w; x++ { sum += apart(rgb(a, x, y), rgb(b, x, y)) } } return float64(sum) / float64(w*h) } // TestTheStillBuntingMatchesTheMovingOne: the canvas hung at rest, // before any wind, and the still SVG in the markup are the same // picture. With the script's freeze hook set the canvas never moves; // the bunting's strip is screenshotted with the canvas showing, then // with the SVG showing in its place, and the two are compared: the // rope's height at thirty columns across the page within two pixels, // and the mean pixel difference over the strip small (the canvas's // cloth carries a weave the SVG's does not, and edges antialias apart, // so it is never zero). A flat rope under a sagging one, or pennants // hung at a different height, fails both. func TestTheStillBuntingMatchesTheMovingOne(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() onNewDocument(t, ctx, `window.siteBuntingStill = true;`) open(t, ctx, rig, 1280, 900) waitBunting(t, ctx, "the canvas hung at rest", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown }) var strip struct{ Top, Height, Width float64 } 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) if strip.Height < 100 || strip.Width < 1000 { 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) } moving := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height) mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("bunting").classList.remove("site-bunting-live"); document.querySelector("#bunting canvas").style.visibility = "hidden"; true`, nil)) still := waitBunting(t, ctx, "the SVG shown in the canvas's place", func(b bunting) bool { return b.SvgShown && !b.CanvasShown }) _ = still stillImg := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height) w, h := moving.Bounds().Dx(), moving.Bounds().Dy() if w != stillImg.Bounds().Dx() || h != stillImg.Bounds().Dy() { t.Fatalf("the two screenshots are %dx%d and %dx%d", w, h, stillImg.Bounds().Dx(), stillImg.Bounds().Dy()) } bg := rgb(moving, w-1, h-1) off, columns := 0, 0 for x := 20; x < w-20; x += 40 { a, b := topInk(moving, x, bg), topInk(stillImg, x, bg) if a < 0 || b < 0 { t.Errorf("column %d: the rope was not found (canvas row %d, SVG row %d)", x, a, b) continue } columns++ if d := a - b; d > 2 || d < -2 { off++ t.Errorf("column %d: the canvas's rope is at row %d and the SVG's at row %d", x, a, b) } } mean := meanApart(moving, stillImg) t.Logf("%d columns sampled, %d off by more than 2px; mean pixel difference %.2f", columns, off, mean) if columns < 20 { t.Fatalf("only %d columns had a rope; the strip was not the bunting", columns) } if mean > 4 { t.Errorf("the canvas at rest and the still SVG differ by %.2f per pixel on average; want under 4", mean) } rig.Screen("#headline", "the page with the still bunting") } // motionJS gives a drive what it needs to scroll the page as a reader // does and watch the canvas while it happens: rest() keeps the // canvas's pixels as they are now, off() is the percentage of them // that have plainly changed since (a channel moved by more than 48, so // the cloth moved rather than turned a shade in the light: the // slightest tilt reshades a whole pennant), glide(to, ms) scrolls // there at an even speed, one step a frame, as a trackpad does, and // shake(n, by, ms) is n quick trips down by that much and back, each // leg taking ms. The page's own scroll-behaviour is overridden: a // smooth scroll would hand the timing to the browser. const motionJS = `window.siteMotion = (() => { const canvas = () => document.querySelector("#bunting canvas"), frame = () => new Promise(r => requestAnimationFrame(r)); const pixels = () => { const c = canvas(); return c.getContext("2d").getImageData(0, 0, c.width, c.height).data; }; let base = null; const m = { frame, rest() { base = pixels(); return true; }, off() { const d = pixels(); let n = 0; 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++; return 100 * n / (d.length / 4); }, async glide(to, ms, each) { const from = scrollY, t0 = performance.now(); for (;;) { await frame(); const u = Math.min(1, (performance.now() - t0) / ms); scrollTo({ top: from + (to - from) * u, behavior: "instant" }); if (each) each(); if (u >= 1) return; } }, async shake(n, by, ms, each) { const home = scrollY; for (let i = 0; i < n; i++) { await m.glide(home + by, ms, each); await m.glide(home, ms, each); } }, // motion integrates off() over the ms from now, in percent // seconds: how much of the canvas moved from rest, and for how // long, in one number. Each sample stands for the time since the // last, and none counts past the deadline, even mid-gesture, so a // slower frame rate samples less often but weighs each sample // more, and the number does not depend on it. async motion(ms, during) { const t0 = performance.now(), end = t0 + ms; let sum = 0, last = t0; const each = () => { const now = Math.min(performance.now(), end); if (now > last) { sum += m.off() * (now - last) / 1000; last = now; } }; if (during) await during(each); while (performance.now() < end) { await frame(); each(); } return sum; }, }; return m; })(); true` // buntingProbe is what siteBuntingProbe reports: the cloth's extremes // since the page opened, in the SVG's units, and whether the frame // loop is running. type buntingProbe struct { MinY, MaxY, MinX, MaxX, MaxZ, Stretch float64 Height, From, To float64 Finite, Running bool Builds int } func probeBunting(t *testing.T, ctx context.Context) buntingProbe { t.Helper() var p buntingProbe at(t, ctx, `window.siteBuntingProbe()`, &p) return p } // openStill opens the landing page with the breeze held off and the // probe on, and waits for the canvas to hang still with its loop // asleep: the cloth at rest, so whatever moves it next is the drive's. func openStill(t *testing.T, ctx context.Context, rig *harness.Rig) { t.Helper() onNewDocument(t, ctx, `window.siteBuntingWindless = true; window.siteBuntingProbe = true;`) open(t, ctx, rig, 1280, 900) waitBunting(t, ctx, "the canvas", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown }) settle(t, ctx, "the canvas before any scrolling", 10*time.Second) mustRun(t, ctx, chromedp.Evaluate(motionJS, nil)) } // settle waits for the canvas's frame loop to go to sleep, which it // does once the cloth is still, and fails after within. func settle(t *testing.T, ctx context.Context, what string, within time.Duration) time.Duration { t.Helper() start := time.Now() for { if p := probeBunting(t, ctx); !p.Running { return time.Since(start) } if time.Since(start) > within { t.Fatalf("%s: the cloth was still moving %v later", what, within) } time.Sleep(50 * time.Millisecond) } } // TestTheBuntingFollowsTheScrollBack: a reader scrolls the row off // screen, reads a little, and scrolls back up quickly. The air the // page moved through is still moving when the row comes back, so the // cloth lifts at once: within 300 ms of the row's return the canvas // is well away from its rest. Then it settles, and the loop sleeps, // within a few seconds. The breeze is held off so the scroll is the // only thing moving the cloth. Settled means the loop has gone to // sleep and the canvas is back within 1 of its rest. func TestTheBuntingFollowsTheScrollBack(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() openStill(t, ctx, rig) mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil)) var back struct{ Peak, Returned float64 } at(t, ctx, `(async () => { const m = siteMotion, row = document.getElementById("bunting"); await m.glide(700, 250); await new Promise(r => setTimeout(r, 1200)); let returned = -1, peak = 0; const each = () => { if (returned < 0 && row.getBoundingClientRect().bottom > 0) returned = performance.now(); if (returned >= 0 && performance.now() - returned <= 300) peak = Math.max(peak, m.off()); }; await m.glide(0, 250, each); while (performance.now() - returned <= 300) { await m.frame(); each(); } return JSON.stringify({Peak: peak, Returned: returned}); })()`, &back, awaitPromise) if back.Returned < 0 { t.Fatal("the row never came back on screen") } took := settle(t, ctx, "the cloth after the scroll back", 8*time.Second) var after float64 at(t, ctx, `JSON.stringify(siteMotion.off())`, &after) 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) if back.Peak < buntingScrollBack { 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) } if after > 1 { t.Errorf("the settled canvas is %.2f%% off its rest before the scroll; want under 1%%", after) } } // buntingScrollBack is the percentage of the canvas, in motionJS's // off(), that must move from rest within 300 ms of a quick scroll // back: before the cloth felt the air it was under 1, with it near 10. const buntingScrollBack = 3.0 // TestAShakeMovesTheBuntingMoreThanAScroll: near the top of the page, // three quick trips 80 px down and back, 100 ms a leg, are the frame // jolting, and the cloth must dance; one slow trip down the same 80 px // and back, 800 ms a leg, is a gentle current, and the cloth only // sways. Each is measured over the same three seconds from its start, // with the breeze held off and the cloth at rest before each. 80 px // keeps the row on screen; much further and it leaves, and the loop // sleeps. func TestAShakeMovesTheBuntingMoreThanAScroll(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() openStill(t, ctx, rig) var slow, shake float64 mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil)) at(t, ctx, `siteMotion.motion(3000, each => siteMotion.shake(1, 80, 800, each)).then(JSON.stringify)`, &slow, awaitPromise) settle(t, ctx, "the cloth after the slow scroll", 8*time.Second) mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest()`, nil)) at(t, ctx, `siteMotion.motion(3000, each => siteMotion.shake(3, 80, 100, each)).then(JSON.stringify)`, &shake, awaitPromise) 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)) if slow <= 0 { t.Errorf("the slow scroll did not move the cloth at all") } if shake < 1.8*slow { 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) } } // TestTheBuntingNeverLeavesItsRow: a violent shake, far faster than a // hand, with the breeze blowing, never throws the cloth out of the // canvas or tears it. The probe reports every point's extremes from // the page opening to the end of the shake: each one finite, inside // the canvas top to bottom and between the end tacks, and no link // stretched past half again its length. func TestTheBuntingNeverLeavesItsRow(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() onNewDocument(t, ctx, `window.siteBuntingProbe = true;`) open(t, ctx, rig, 1280, 900) waitBunting(t, ctx, "the canvas", func(b bunting) bool { return b.Canvas && b.CanvasShown && !b.SvgShown }) mustRun(t, ctx, chromedp.Evaluate(motionJS, nil)) mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest(); siteMotion.motion(1500, each => siteMotion.shake(25, 90, 1, each))`, nil, awaitPromise)) 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)) p := probeBunting(t, ctx) 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) if !p.Finite { t.Fatal("a point of the cloth went to NaN or infinity") } if p.MinY < 0 || p.MaxY > p.Height { t.Errorf("the cloth reached y %.1f to %.1f; the canvas shows 0 to %.1f", p.MinY, p.MaxY, p.Height) } if p.MinX < p.From || p.MaxX > p.To { 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) } if p.Stretch > 1.5 { t.Errorf("a link stretched to %.2f times its length; the cloth tore", p.Stretch) } } // TestAScrollAfterIdleWakesTheBunting: a reader leaves the page for // twenty seconds, then turns the wheel once, 80 px, with the row on // screen. One scroll event that long after the last is a slow scroll // by its average speed, but the air it moves is real, so the cloth // must wake and lift. func TestAScrollAfterIdleWakesTheBunting(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() openStill(t, ctx, rig) mustRun(t, ctx, chromedp.Evaluate(`scrollTo({top: 80, behavior: "instant"}); true`, nil)) mustRun(t, ctx, chromedp.Sleep(20*time.Second)) settle(t, ctx, "the cloth after twenty idle seconds", time.Second) var peak float64 at(t, ctx, `(async () => { const m = siteMotion; m.rest(); scrollTo({top: 0, behavior: "instant"}); let peak = 0; const t0 = performance.now(); while (performance.now() - t0 < 1000) { await m.frame(); peak = Math.max(peak, m.off()); } return JSON.stringify(peak); })()`, &peak, awaitPromise) t.Logf("one 80 px scroll after twenty idle seconds moved %.2f%% of the canvas within a second", peak) if peak < 1 { t.Errorf("one 80 px scroll after twenty idle seconds moved %.2f%% of the canvas; want at least 1%%: the cloth never woke", peak) } } // TestTheBuntingHangsTheSameAfterAShake: a rebuild (a resize, or // motion allowed again) hangs the row with the calm pre-roll, which // must be the rest pose whatever the cloth was doing: the flutter a // shake leaves behind must not leak into it, or the first frame after // the rebuild is not the still SVG. The viewport grows a pixel taller // right after a shake, which rebuilds the same row; the pose it hangs // is held to the one hung when the page opened. func TestTheBuntingHangsTheSameAfterAShake(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() openStill(t, ctx, rig) var before, after []float64 at(t, ctx, `window.siteBuntingProbe.hung()`, &before) builds := probeBunting(t, ctx).Builds mustRun(t, ctx, chromedp.Evaluate(`siteMotion.rest(); siteMotion.shake(3, 80, 100)`, nil, awaitPromise)) mustRun(t, ctx, chromedp.EmulateViewport(1280, 901)) deadline := time.Now().Add(5 * time.Second) for probeBunting(t, ctx).Builds == builds { if time.Now().After(deadline) { t.Fatal("resizing the viewport never rebuilt the row") } time.Sleep(20 * time.Millisecond) } at(t, ctx, `window.siteBuntingProbe.hung()`, &after) if len(before) == 0 || len(before) != len(after) { t.Fatalf("the row hung %d coordinates before the shake and %d after; the rebuild was not the same row", len(before), len(after)) } worst := 0.0 for i := range before { worst = max(worst, math.Abs(after[i]-before[i])) } t.Logf("the pose hung after the shake is at most %.4f units from the one hung before", worst) if worst > 0.01 { 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) } } // TestReducedMotionIgnoresScrolling: under reduced motion there is no // canvas, and scrolling and shaking the page leave the still SVG // exactly as it was. func TestReducedMotionIgnoresScrolling(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() defer func() { rctx, rcancel := context.WithTimeout(rig.Context(), 10*time.Second) defer rcancel() _ = chromedp.Run(rctx, emulation.SetEmulatedMedia()) }() reduceMotion(t, ctx, true) open(t, ctx, rig, 1280, 900) still := func(b bunting) bool { return !b.Canvas && b.SvgShown } waitBunting(t, ctx, "the still SVG under reduced motion", still) var strip struct{ Top, Height, Width float64 } at(t, ctx, `(() => { const r = document.getElementById("bunting").getBoundingClientRect(); return JSON.stringify({Top: r.top, Height: r.height, Width: r.width}); })()`, &strip) before := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height) mustRun(t, ctx, chromedp.Evaluate(motionJS, nil)) 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)) if b := readBunting(t, ctx); !still(b) { 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) } after := capture(t, ctx, 0, strip.Top, strip.Width, strip.Height) if d := meanApart(before, after); d != 0 { t.Errorf("the still SVG changed by %.2f per pixel after scrolling under reduced motion; want no change", d) } } // withoutAnchorSupport is what anchor.js's feature test sees in a // browser without anchor positioning: CSS.supports says no to it. const withoutAnchorSupport = `(() => { const s = CSS.supports.bind(CSS); CSS.supports = (...a) => !/position-area|position-try/.test(a.join(" ")) && s(...a); })()` // menuJS opens the language menu and measures its panel against the // viewport. const menuJS = `(async () => { const d = document.querySelector(".site-lang"); d.querySelector("summary").click(); await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); const p = d.querySelector(":scope > [rst-dropdown-menu]").getBoundingClientRect(); 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")}); })()` // TestTheLanguageMenuOpensOnAPhone: at 320px the language menu opens // with its panel inside the viewport, with the browser's own anchor // positioning and with anchor.js standing in for it. func TestTheLanguageMenuOpensOnAPhone(t *testing.T) { for _, leg := range []struct { name string anchor bool }{{"anchor positioning", true}, {"anchor.js", false}} { t.Run(leg.name, func(t *testing.T) { rig, _ := siteRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) defer cancel() if !leg.anchor { onNewDocument(t, ctx, withoutAnchorSupport) } mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) }), chromedp.Navigate(rig.Origin+"/"), soon(".site-lang")) var g struct { Open, Flipped bool Left, Right, Top, Bottom, VW, VH float64 } at(t, ctx, menuJS, &g, awaitPromise) if !g.Open { t.Fatal("the language menu did not open") } if g.Flipped != !leg.anchor { t.Errorf("anchor.js marked the root %v; want %v on this leg", g.Flipped, !leg.anchor) } if g.Left < -1 || g.Right > g.VW+1 || g.Top < -1 || g.Bottom > g.VH+1 { 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) } rig.Screen(".site-lang", "the language menu open") }) } }