//go:build browser package gallery import ( "context" "encoding/json" "fmt" "math" "net/http" "strings" "testing" "time" "github.com/chromedp/cdproto/input" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // The pinned bar: what it reserves, what lands under it, what paints // over it, its language menu, the controls' heights, the prev/next // pair under main, and the scheme a returning visitor sees before the // first paint. // focusTopJS is the top of whatever has focus, or of the element a // fragment targets, in the page's coordinates. Inside a preview frame // the element's offset is in the frame's own pixels and the frame may // be drawn scaled, so the offset is multiplied by the frame's rendered // width over its layout width before the frame's top is added. const focusTopJS = `((el) => { let top = 0, k = 1; el = el || document.activeElement; while (el && el.tagName === "IFRAME") { const r = el.getBoundingClientRect(); top += r.top; k *= r.width / el.offsetWidth; const d = el.contentDocument; if (!d || !d.activeElement || d.activeElement === d.body) break; el = d.activeElement; } const r = el.getBoundingClientRect(); const main = document.getElementById("main"), outer = document.activeElement; return JSON.stringify({Top: el.tagName === "IFRAME" ? top : top + r.top * k, What: el.tagName.toLowerCase() + (el.id ? "#" + el.id : ""), Bar: document.querySelector(".ds-top").getBoundingClientRect().bottom, End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2, InMain: !!outer && outer !== document.body && main.contains(outer)}); })` type focusReading struct { Top, Bar float64 What string End bool // InMain says whether what has focus is inside main (a frame's // own controls count through their frame): the Tab walks stop // when focus leaves it for the bar, the rail or the browser. InMain bool } func readFocus(t *testing.T, ctx context.Context, where, target string) focusReading { t.Helper() var raw string mustRun(t, ctx, chromedp.Evaluate(focusTopJS+"("+target+")", &raw)) var r focusReading if err := json.Unmarshal([]byte(raw), &r); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } return r } // TestFocusIsNeverUnderThePinnedBar: focus and fragment targets stop // below the pinned bar, never under it (WCAG 2.4.11): forty Tabs on // from the first control in main and forty back, the skip link's // target, and every rail fragment of the page. The sign-in page, whose // twelve framed forms give the walk its forty stops inside main, and // Tokens, whose groups the rail links by fragment. A walk stops where // focus leaves main for the chrome or the browser, and the forward // walk is held to at least twenty stops so it cannot stop short. A // target near the end of the page may stop lower, because the browser // clamps the scroll at the end, but never higher. func TestFocusIsNeverUnderThePinnedBar(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() theme := fiesta.ThemeNames()[0] fragments := 0 for _, file := range []string{"signin.html", "tokens.html"} { for _, locale := range []string{"en", "ar"} { for _, w := range []int64{1440, 800} { where := fmt.Sprintf("%s/%s/%s at %dpx", theme, locale, file, w) mustRun(t, ctx, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady(`main`, chromedp.ByQuery)) present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail") eagerly(t, ctx, where) under := func(how string, r focusReading) { if r.Top < r.Bar-1 { t.Errorf("%s: %s puts %s at %.1fpx, under the bar ending at %.1fpx", where, how, r.What, r.Top, r.Bar) } } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("main [rst-page]").querySelector("a[href], button, summary, input:not([type=hidden]), iframe").focus(); true`, nil)) stops := 0 for i := 0; i < 40; i++ { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab)) r := readFocus(t, ctx, where, "") if !r.InMain { break } stops++ under(fmt.Sprintf("tab %d", i+1), r) } if file == "signin.html" && stops < 20 { t.Errorf("%s: the forward walk left main after %d stops; the page was chosen for its forty", where, stops) } for i := 0; i < 40; i++ { mustRun(t, ctx, chromedp.KeyEvent(kb.Tab, chromedp.KeyModifiers(input.ModifierShift))) r := readFocus(t, ctx, where, "") if !r.InMain { break } under(fmt.Sprintf("shift+tab %d", i+1), r) } mustRun(t, ctx, chromedp.Evaluate(`scrollTo(0, 2000); document.querySelector("[rst-skip]").click(); true`, nil)) under("the skip link", readFocus(t, ctx, where, `document.getElementById("main")`)) var hrefs []string mustRun(t, ctx, chromedp.Evaluate(`[...document.querySelectorAll("#ds-nav a[href*='#']")].filter(a => a.pathname === location.pathname).map(a => a.hash.slice(1))`, &hrefs)) for _, id := range hrefs { fragments++ mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil)) r := readFocus(t, ctx, where, fmt.Sprintf(`document.getElementById(%q)`, id)) under("#"+id, r) if !r.End && r.Top > r.Bar+40 { t.Errorf("%s: #%s lands at %.1fpx, far below the bar's %.1fpx with the page not at its end; the scroll padding is not what the fragment stopped at", where, id, r.Top, r.Bar) } } } } } if fragments == 0 { t.Fatal("the rail links no fragment of any page read; the fragment leg checked nothing") } } // stripBadJS scans down the page in steps of at most half the strip's // own height, sampling roughly every 32px across the strip's width at // its top, middle and bottom edges, and reports every sampled point // where elementFromPoint lands outside the strip. const stripBadJS = `((sel) => { const strip = document.querySelector(sel); const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2); const bad = []; for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); const r = strip.getBoundingClientRect(); for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) { for (let x = r.left; x <= r.right; x += 32) { const hit = document.elementFromPoint(x, yy); if (!hit || !strip.contains(hit)) bad.push({X: Math.round(x), Y: Math.round(yy), Hit: hit ? hit.tagName + "." + hit.className : "nothing"}); } } } return JSON.stringify(bad); })` // stripH2PointsJS finds, at the same scroll steps and sample points, // every point a
heading's own box covers, by geometry: the // control for the scan's density. const stripH2PointsJS = `((sel) => { const strip = document.querySelector(sel); const h = strip.getBoundingClientRect().height, step = Math.max(1, h / 2); const pts = []; for (let y = 0; y < document.documentElement.scrollHeight; y += step) { scrollTo(0, y); const r = strip.getBoundingClientRect(); const h2s = [...document.querySelectorAll("main h2")].map(h2 => h2.getBoundingClientRect()); for (const yy of [r.top + 2, (r.top + r.bottom) / 2, r.bottom - 2]) { for (let x = r.left; x <= r.right; x += 32) { if (h2s.some(hr => x >= hr.left && x <= hr.right && yy >= hr.top && yy <= hr.bottom)) pts.push({X: Math.round(x), Y: Math.round(yy)}); } } } return JSON.stringify(pts); })` type stripPoint struct { X, Y float64 Hit string } func evalStripPoints(t *testing.T, ctx context.Context, where, js, selector string) []stripPoint { t.Helper() var raw string mustRun(t, ctx, chromedp.Evaluate(js+"("+fmt.Sprintf("%q", selector)+")", &raw)) var pts []stripPoint if err := json.Unmarshal([]byte(raw), &pts); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } return pts } // TestNothingPaintsOverTheBarOrTheBackStrip: nothing in the content // paints over the pinned chrome: at 1440px the bar, and at 390px the // shell's back strip, are what elementFromPoint finds at their edges // everywhere down a page of frames. A second pass raises every
// heading above the strip and checks the scan catches every point a // heading's box covers: the control for the scan's own density. func TestNothingPaintsOverTheBarOrTheBackStrip(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) defer cancel() theme := fiesta.ThemeNames()[0] for _, c := range []struct { w int64 strip string }{{1440, ".ds-top"}, {390, "[rst-shell-back]"}} { where := fmt.Sprintf("signin.html at %dpx", c.w) mustRun(t, ctx, chromedp.EmulateViewport(c.w, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")), chromedp.WaitReady("body")) present(t, ctx, where, "[rst-shell-sidebar] > .ds-rail") mustRun(t, ctx, chromedp.WaitVisible(c.strip, chromedp.ByQuery)) eagerly(t, ctx, where) before := evalStripPoints(t, ctx, where, stripBadJS, c.strip) if len(before) > 0 { if len(before) > 6 { before = before[:6] } t.Errorf("%s: something paints over %s: %v", where, c.strip, before) } h2Points := evalStripPoints(t, ctx, where, stripH2PointsJS, c.strip) if len(h2Points) == 0 { t.Fatalf("%s: no heading passes under %s at any scroll step, so nothing checks the scan can see one", where, c.strip) } mustRun(t, ctx, chromedp.Evaluate(`document.head.insertAdjacentHTML("beforeend", ""); true`, nil)) after := evalStripPoints(t, ctx, where, stripBadJS, c.strip) caught := make(map[[2]int]bool, len(after)) for _, p := range after { caught[[2]int{int(p.X), int(p.Y)}] = true } var missed []stripPoint for _, p := range h2Points { if !caught[[2]int{int(p.X), int(p.Y)}] { missed = append(missed, p) } } if len(missed) > 0 { if len(missed) > 6 { missed = missed[:6] } t.Errorf("%s: a heading raised above %s went uncaught at %d of %d points its own box covers: %v", where, c.strip, len(missed), len(h2Points), missed) } } } // barFit reads the bar's box, the union of what is inside it, whether // that content shares one line, and the height the content alone // needs. The bar's own box is fixed at --ds-bar-h, so it cannot // measure its own overflow; its children can. const barFit = `(() => { const bar = document.querySelector(".ds-top"), b = bar.getBoundingClientRect(); let top = Infinity, bottom = -Infinity, left = Infinity, right = -Infinity, maxTop = -Infinity, minBottom = Infinity; for (const el of bar.querySelectorAll(".ds-top__brand, .ds-top__controls > *")) { const r = el.getBoundingClientRect(); if (!r.width && !r.height) continue; top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom); left = Math.min(left, r.left); right = Math.max(right, r.right); maxTop = Math.max(maxTop, r.top); minBottom = Math.min(minBottom, r.bottom); } const prevStyle = bar.getAttribute("style"); bar.style.blockSize = "auto"; const need = bar.getBoundingClientRect().height; if (prevStyle === null) bar.removeAttribute("style"); else bar.setAttribute("style", prevStyle); return JSON.stringify({Top: b.top, Bottom: b.bottom, Left: b.left, Right: b.right, UTop: top, UBottom: bottom, ULeft: left, URight: right, Over: bar.scrollWidth - bar.clientWidth, Need: need, MaxTop: maxTop, MinBottom: minBottom}); })()` type barReading struct { Top, Bottom, Left, Right, UTop, UBottom, ULeft, URight, Over, Need, MaxTop, MinBottom float64 } // barHSlack is how far above the measured maximum --ds-bar-h may sit: // values are rounded up to the next quarter rem (4px), so up to 4px of // slack is the rounding itself rather than a reservation nobody needs. const barHSlack = 4.5 // TestThePinnedBarFitsItsReservation: every theme and locale, scripts // on and off, at both edges of both bands: what is in the bar lies // inside it, nothing in it overflows sideways, it is still at the top // after scrolling to the end, and from 1024px it never wraps. A // failure names the height the band needs, which is the number to // write into --ds-bar-h; the last check does the comparison the other // way, catching a reservation nobody's content has needed since. func TestThePinnedBarFitsItsReservation(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) defer cancel() need := map[string]float64{} reservation := map[string]float64{} for _, scripts := range []bool{true, false} { tab := ctx if !scripts { tab = noScripts(t, ctx) } for _, theme := range fiesta.ThemeNames() { for _, locale := range Locales { url := rig.Origin + pageAt(theme, locale, "form.html") mustRun(t, tab, chromedp.EmulateViewport(1440, 900), chromedp.Navigate(url), chromedp.WaitReady("body")) present(t, tab, theme+"/"+locale, "[rst-shell-sidebar] > .ds-rail") if !scripts { requireScriptsOff(t, tab, fmt.Sprintf("%s/%s scripts off", theme, locale)) } for _, w := range []int64{1440, 1024, 1023, 800} { where := fmt.Sprintf("%s/%s at %dpx, scripts %v", theme, locale, w, scripts) band := "1024 and up" if w < 1024 { band = "800 to 1023" } var raw string var after float64 mustRun(t, tab, chromedp.EmulateViewport(w, 900), chromedp.Evaluate(`scrollTo(0, 0)`, nil), chromedp.Evaluate(barFit, &raw), chromedp.Evaluate(`scrollTo(0, document.documentElement.scrollHeight); document.querySelector(".ds-top").getBoundingClientRect().top`, &after), ) var r barReading if err := json.Unmarshal([]byte(raw), &r); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } need[band] = max(need[band], r.Need) reservation[band] = r.Bottom - r.Top if r.UTop < r.Top-0.5 || r.UBottom > r.Bottom+0.5 || r.ULeft < r.Left-0.5 || r.URight > r.Right+0.5 { t.Errorf("%s: the bar's contents [%v…%v × %v…%v] spill out of its box [%v…%v × %v…%v]; this band needs --ds-bar-h of at least %.0fpx", where, r.ULeft, r.URight, r.UTop, r.UBottom, r.Left, r.Right, r.Top, r.Bottom, r.Need) } if r.Over > 0.5 { t.Errorf("%s: the bar overflows sideways by %.1fpx", where, r.Over) } if after < -0.5 || after > 0.5 { t.Errorf("%s: scrolled to the end, the bar's top is at %.1fpx, not pinned at 0", where, after) } if w >= 1024 && r.MaxTop >= r.MinBottom-0.5 { t.Errorf("%s: the brand and the controls do not share one line (the latest top %.1fpx is not above the earliest bottom %.1fpx); the bar has wrapped", where, r.MaxTop, r.MinBottom) } } } } } for _, band := range []string{"1024 and up", "800 to 1023"} { if over := reservation[band] - need[band]; over > barHSlack { t.Errorf("--ds-bar-h for %s reserves %.1fpx but the measured maximum content needs only %.1fpx, %.1fpx more than this drive's rounding slack", band, reservation[band], need[band], over) } } t.Logf("tallest bar contents per band (the --ds-bar-h each needs): %v", need) } // withoutAnchorPositioning replaces the page's tokens.css with a copy // whose anchor-positioning @supports condition can never hold, which // is what an engine without the feature gets: the menu panel // positioned absolutely under its summary instead of fixed against it. const withoutAnchorPositioning = `(async () => { const link = document.querySelector('link[href*="/tokens."][href$=".css"]'); const css = await (await fetch(link.href)).text(); const style = document.createElement("style"); style.textContent = css.replaceAll("@supports (position-area: block-end) and (position-try-fallbacks: flip-block)", "@supports (position-area: fiesta-no-such-value)"); link.replaceWith(style); return true; })()` // requireAnchorPositioning is the control for a leg that says which // positioning the panel got: fixed with anchor positioning, not // without. func requireAnchorPositioning(t *testing.T, ctx context.Context, where, selector string, want bool) { t.Helper() var pos string mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+fmt.Sprintf("%q", selector)+`)).position`, &pos)) if (pos == "fixed") != want { t.Fatalf("%s: the menu panel is position: %s; this leg needs anchor positioning %v", where, pos, want) } } // TestTheBarsLanguageMenuStaysOnScreen: the bar's language menu, // opened, lies inside the viewport and every language is reachable by // keyboard: left to right and right to left, with anchor positioning // and without, with scripts and without, at both edges of both bands. // On the sign-in page, whose twelve frames make it tall enough at // every width to draw the scrollbar the measurement is taken against. func TestTheBarsLanguageMenuStaysOnScreen(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, harness.WithScrollbars()) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() theme := fiesta.ThemeNames()[0] for _, scripts := range []bool{true, false} { tab := ctx if !scripts { tab = noScripts(t, ctx) } for _, locale := range []string{"en", "ar"} { for _, anchored := range []bool{true, false} { for _, w := range []int64{1440, 1024, 1023, 800} { where := fmt.Sprintf("%s/%s at %dpx, scripts %v, anchor positioning %v", theme, locale, w, scripts, anchored) mustRun(t, tab, chromedp.EmulateViewport(w, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "signin.html")), chromedp.WaitReady("body")) present(t, tab, where, "[rst-shell-sidebar] > .ds-rail") if !scripts { requireScriptsOff(t, tab, where) } if !anchored { mustRun(t, tab, chromedp.Evaluate(withoutAnchorPositioning, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) } requireAnchorPositioning(t, tab, where, ".ds-top__controls [rst-locale] > [rst-dropdown-menu]", anchored) requireDrawnScrollbar(t, tab, where) var raw string mustRun(t, tab, chromedp.Evaluate(`(() => { const d = document.querySelector(".ds-top__controls [rst-locale]"); d.open = true; const p = d.querySelector("[rst-dropdown-menu]").getBoundingClientRect(); d.querySelector("summary").focus(); return JSON.stringify({L: p.left, R: p.right, T: p.top, B: p.bottom, W: document.documentElement.clientWidth, H: document.documentElement.clientHeight}); })()`, &raw)) var p struct{ L, R, T, B, W, H float64 } if err := json.Unmarshal([]byte(raw), &p); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } if p.L < -0.5 || p.T < -0.5 || p.R > p.W+0.5 || p.B > p.H+0.5 { t.Errorf("%s: the open panel [%v…%v × %v…%v] is not inside the %vx%v client box", where, p.L, p.R, p.T, p.B, p.W, p.H) } for i, want := range Locales { var got string mustRun(t, tab, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(`(() => { const a = document.activeElement, r = a.getBoundingClientRect(); const de = document.documentElement, inside = r.top >= 0 && r.bottom <= de.clientHeight && r.left >= 0 && r.right <= de.clientWidth; return a.closest(".ds-top__controls [rst-locale] [rst-dropdown-menu]") && inside ? a.lang : "not a visible language: " + a.outerHTML.slice(0, 60); })()`, &got)) if got != want { t.Errorf("%s: tab %d lands on %s, want the %s link on screen", where, i+1, got, want) break } } } } } } } // TestTheGallerysOwnControlsAreExactlyTheFloor: the gallery's own // controls carry ds- classes the system's floors cannot match, so each // reads --rst-target itself. Held exactly, not "at least": each control // is the larger of the floor and its own height with the floor lifted, // so the floor never adds to a control already over it. The rail's // section headings, the filter box and the prev/next links with a // mouse at 1280px; the prev/next links at 600px; the display settings // button and the prev/next links on a coarse pointer at 390px, where // the floor is 44 and the rail is not drawn. The preview widget's tabs // and Copy buttons are the widget's own gate. func TestTheGallerysOwnControlsAreExactlyTheFloor(t *testing.T) { const read = `(() => { const floor = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize); const h = e => Math.round(e.getBoundingClientRect().height * 10) / 10; const each = sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => { const got = h(e); e.style.minBlockSize = "0px"; const own = h(e); e.style.minBlockSize = ""; return {Got: got, Want: Math.max(floor, own), W: e.getBoundingClientRect().width}; }); return JSON.stringify({Floor: floor, Heads: each(".ds-nav > details > summary"), Filter: each(".ds-search input"), Steps: each(".ds-updown__prev, .ds-updown__next"), Prefs: each(".ds-prefs > summary")}); })()` theme := fiesta.ThemeNames()[0] for _, c := range []struct { name string rig func(*testing.T) *harness.Rig w, h int64 floor float64 rail bool }{ {"a mouse at 1280px", func(t *testing.T) *harness.Rig { return harness.New(t, func(string) http.Handler { return treeHandler(t) }) }, 1280, 800, 32, true}, {"a mouse at 600px", func(t *testing.T) *harness.Rig { return harness.New(t, func(string) http.Handler { return treeHandler(t) }) }, 600, 800, 44, false}, {"a coarse pointer at 390px", func(t *testing.T) *harness.Rig { return phoneRig(t) }, 390, 844, 44, false}, } { rig := c.rig(t) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) var raw string mustRun(t, ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), chromedp.WaitReady("body")) present(t, ctx, c.name, ".ds-updown") mustRun(t, ctx, chromedp.Evaluate(read, &raw)) cancel() type reading struct{ Got, Want, W float64 } var got struct { Floor float64 Heads, Filter, Steps, Prefs []reading } if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("%s: decoding %s: %v", c.name, raw, err) } if got.Floor != c.floor { t.Errorf("%s: --rst-target is %vpx, want %v", c.name, got.Floor, c.floor) } if len(got.Steps) == 0 || c.rail && (len(got.Heads) == 0 || len(got.Filter) == 0) || !c.rail && c.floor == 44 && c.w == 390 && len(got.Prefs) == 0 { t.Fatalf("%s: read %d rail headings, %d filter boxes, %d prev/next links and %d display settings buttons; the selectors no longer match the page", c.name, len(got.Heads), len(got.Filter), len(got.Steps), len(got.Prefs)) } for what, rs := range map[string][]reading{"a rail section heading": got.Heads, "the filter box": got.Filter, "a prev/next link": got.Steps, "the display settings button": got.Prefs} { for _, h := range rs { if math.Abs(h.Got-h.Want) > 0.05 { t.Errorf("%s: %s is %vpx tall, want %v, the floor or its own height", c.name, what, h.Got, h.Want) break } } } } } // TestTheThemeTabsSitInLineWithTheOtherControls: the theme switcher is // the system's seg-tabs, which leaves a block's gap under itself. In // the bar's centred row that margin would lift the tabs above the // menus beside them; in the phone card's column it would more than // double the space under them. func TestTheThemeTabsSitInLineWithTheOtherControls(t *testing.T) { theme := fiesta.ThemeNames()[0] t.Run("the bar at 1280", func(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() for _, locale := range []string{"en", "ar"} { where := theme + "/" + locale + "/index.html at 1280" mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) present(t, ctx, where, ".ds-top__controls [rst-scheme]") var mid [2]float64 mustRun(t, ctx, chromedp.Evaluate(`(() => { const m = s => { const r = document.querySelector(".ds-top__controls " + s).getBoundingClientRect(); return r.top + r.height / 2; }; return [m("[rst-seg-tabs]"), m("[rst-scheme] > summary")]; })()`, &mid)) if math.Abs(mid[0]-mid[1]) > 1 { t.Errorf("%s: the theme tabs' centre line is at %.1fpx and the scheme menu's at %.1fpx; want them level", where, mid[0], mid[1]) } } }) t.Run("the phone card at 390", func(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() for _, locale := range []string{"en", "ar"} { where := theme + "/" + locale + "/index.html at 390" mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) present(t, ctx, where, ".ds-prefs > summary") mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery), chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs]", chromedp.ByQuery)) requireCoarse(t, ctx) settleMotion(t, ctx, where) var gaps []float64 mustRun(t, ctx, chromedp.Evaluate(`(() => { const kids = [...document.querySelector(".ds-prefs > [rst-dropdown-menu]").children].filter(k => k.checkVisibility()).map(k => k.getBoundingClientRect()); return kids.slice(1).map((r, i) => r.top - kids[i].bottom); })()`, &gaps)) if len(gaps) < 2 { t.Fatalf("%s: the card holds %d groups; it needs three for the gaps between them to be compared", where, len(gaps)+1) } for i, g := range gaps[1:] { if math.Abs(g-gaps[0]) > 0.5 { t.Errorf("%s: the gap under the theme tabs is %.1fpx and gap %d is %.1fpx; want every group the same distance apart", where, gaps[0], i+2, g) } } } }) } // TestThePrevNextPairSitsAtTheEndsOfItsRow: previous is flush at the // inline start and never crosses the middle; next is flush at the // inline end and begins at or past the middle, so a missing previous // leaves its space. The two ends and one middle page of the sequence, // in both directions. func TestThePrevNextPairSitsAtTheEndsOfItsRow(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 240*time.Second) defer cancel() const measure = `(() => { const box = document.querySelector(".ds-updown"); if (!box) return JSON.stringify({error: "no prev/next strip on this page"}); const dir = getComputedStyle(document.documentElement).direction; const b = box.getBoundingClientRect(); const read = (sel) => { const el = box.querySelector(sel); if (!el) return null; const r = el.getBoundingClientRect(); return {startGap: Math.round(dir === "rtl" ? b.right - r.right : r.left - b.left), endGap: Math.round(dir === "rtl" ? r.left - b.left : b.right - r.right), size: Math.round(r.width)}; }; return JSON.stringify({dir: dir, width: Math.round(b.width), prev: read(".ds-updown__prev"), next: read(".ds-updown__next")}); })()` type edge struct { StartGap int `json:"startGap"` EndGap int `json:"endGap"` Size int `json:"size"` } type strip struct { Error string `json:"error"` Dir string `json:"dir"` Width int `json:"width"` Prev *edge `json:"prev"` Next *edge `json:"next"` } s := newTestSite(t, fiesta.FS(), mountPath) theme := fiesta.ThemeNames()[0] for _, locale := range []string{"en", "ar"} { seq := s.sequence(theme, locale) for _, pos := range []int{0, 1, len(seq) - 1} { name := strings.TrimPrefix(seq[pos].Href, mountPath+"/") var raw string mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+seq[pos].Href), chromedp.WaitReady("body")) present(t, ctx, name, ".ds-updown") mustRun(t, ctx, chromedp.Evaluate(measure, &raw)) var got strip if err := json.Unmarshal([]byte(raw), &got); err != nil { t.Fatalf("%s: reading the measurement: %v", name, err) } if got.Error != "" { t.Errorf("%s: %s", name, got.Error) continue } if got.Dir != Dir(locale) { t.Errorf("%s: the page renders dir=%q, want %q", name, got.Dir, Dir(locale)) continue } if got.Width < 200 { t.Errorf("%s: the strip is %dpx wide; there is nothing to measure", name, got.Width) continue } half := got.Width / 2 if (got.Prev != nil) != (pos > 0) { t.Errorf("%s: previous link present=%v, want %v", name, got.Prev != nil, pos > 0) } if (got.Next != nil) != (pos < len(seq)-1) { t.Errorf("%s: next link present=%v, want %v", name, got.Next != nil, pos < len(seq)-1) } if p := got.Prev; p != nil { if p.StartGap > 2 { t.Errorf("%s: previous starts %dpx from the strip's inline start, want 0 (%s, strip %dpx)", name, p.StartGap, got.Dir, got.Width) } if p.StartGap+p.Size > half+2 { t.Errorf("%s: previous runs %dpx into a %dpx strip, past its half (%d)", name, p.StartGap+p.Size, got.Width, half) } } if n := got.Next; n != nil { if n.EndGap > 2 { t.Errorf("%s: next ends %dpx from the strip's inline end, want 0 (%s, strip %dpx)", name, n.EndGap, got.Dir, got.Width) } if start := got.Width - n.EndGap - n.Size; start < half-2 { t.Errorf("%s: next begins %dpx into a %dpx strip, before its half (%d); the missing previous has not left its space", name, start, got.Width, half) } } if got.Prev == nil && got.Next == nil { t.Errorf("%s: the strip is empty", name) } } } } // markedJS reads every scheme menu on the page: for each, the value of // the one button carrying aria-current="true", or "" for none and // "many" for more than one. const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => { const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true"); return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : ""; })` // TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint: a returning // visitor with Dark stored sees a dark page from the first frame, even // with the theme stylesheet slow to arrive, and after load the Dark // button carries aria-current in both the bar's and the display // settings menu's scheme menu while System carries none. fiesta.js // skips its own restore when data-theme is already on the root, so // the marking is the gallery script's. The control is a fresh visit, // where System is marked in both. func TestAStoredSchemeIsOnTheGalleryPageBeforeFirstPaint(t *testing.T) { theme := fiesta.ThemeNames()[0] slow := func(t *testing.T) http.Handler { inner := treeHandler(t) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/assets/css/themes/") { time.Sleep(500 * time.Millisecond) } inner.ServeHTTP(w, r) }) } for _, c := range []struct{ name, store, want string }{{"Dark stored", `localStorage.setItem("fiesta-scheme", "dark");`, "dark"}, {"a fresh visit", "", "system"}} { rig := harness.New(t, func(string) http.Handler { return slow(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) mustRun(t, ctx, addInit(c.store+` requestAnimationFrame(() => { window.__firstFrame = document.documentElement.getAttribute("data-theme") || "system"; });`), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "tokens.html")), chromedp.WaitReady("body"), ) present(t, ctx, c.name, ".ds-prefs [rst-scheme]") until(t, ctx, c.name, `document.readyState === "complete" && window.__firstFrame !== undefined`) var first string var marked []string mustRun(t, ctx, chromedp.Evaluate(`window.__firstFrame`, &first), chromedp.Evaluate(markedJS, &marked)) cancel() if first != c.want { t.Errorf("%s: the first frame painted with data-theme %q, want %q", c.name, first, c.want) } if len(marked) != 2 { t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", c.name, len(marked)) } for i, m := range marked { if m != c.want { t.Errorf("%s: scheme menu %d marks %q current, want %q", c.name, i, m, c.want) } } } } // refusedStorage makes every read and write of localStorage throw, as // a browser with site data blocked does. const refusedStorage = `(() => { const refuse = () => { throw new DOMException("site data is blocked", "SecurityError"); }; for (const name of ["getItem", "setItem", "removeItem"]) Storage.prototype[name] = refuse; })();` // TestASchemeChosenWithStorageRefusedIsTheOneMarked: with localStorage // refusing every read and write, choosing Dark in the bar's menu still // applies Dark to the page, and both copies of the scheme menu, the // bar's and the display settings menu's, mark Dark current. Nothing // can be remembered, so the marks follow the scheme on the root, not // the stored one. func TestASchemeChosenWithStorageRefusedIsTheOneMarked(t *testing.T) { where := "storage refused" rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, addInit(refusedStorage), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+pageAt(fiesta.ThemeNames()[0], "en", "tokens.html")), chromedp.WaitReady("body"), ) present(t, ctx, where, ".ds-prefs [rst-scheme]") until(t, ctx, where, `document.readyState === "complete"`) var refused bool mustRun(t, ctx, chromedp.Evaluate(`(() => { try { localStorage.getItem("fiesta-scheme"); return false; } catch (e) { return true; } })()`, &refused)) if !refused { t.Fatalf("%s: localStorage still answers; the drive would be testing working storage", where) } chooseScheme(t, ctx, "dark") // gallery.js marks both copies one frame after the choice; two // frames later the marks are settled whichever way they went. var theme string var marked []string mustRun(t, ctx, chromedp.Evaluate(`new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(done))))`, nil, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), chromedp.Evaluate(`document.documentElement.getAttribute("data-theme") || "system"`, &theme), chromedp.Evaluate(markedJS, &marked), ) if theme != "dark" { t.Errorf("%s: after choosing Dark the page is on %q", where, theme) } if len(marked) != 2 { t.Fatalf("%s: %d scheme menus on the page, want 2 (the bar's and the display settings menu's)", where, len(marked)) } for i, m := range marked { if m != "dark" { t.Errorf("%s: scheme menu %d marks %q current after Dark was chosen, want %q", where, i, m, "dark") } } } // TestTheFrameMirrorsRightToLeft: right to left, the shell's grid puts // the rail on the right and the bar over main on the left, with no rule // of the gallery's: the grid lines are numbered, not sided. Port of // Rastrillo's drive, on an example page of day/ar at 1280px, kept with // the bar's drives as Rastrillo keeps it. func TestTheFrameMirrorsRightToLeft(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() where := "day/ar form-layout at 1280px" openPage(t, ctx, where, pageURL(rig, "ar", "form-layout.html"), 1280, 900) var r struct { RailLeft, MainRight, BarBottom, MainTop, BarLeft, BarRight, MainLeft, BrandLeft, ControlsRight float64 } at(t, ctx, `(() => { const b = s => document.querySelector(s).getBoundingClientRect(); const rail = b("[rst-shell-rail]"), bar = b(".ds-top"), main = b("main"); const brand = b(".ds-top__brand"), controls = b(".ds-top__controls"); return JSON.stringify({RailLeft: rail.left, MainRight: main.right, BarBottom: bar.bottom, MainTop: main.top, BarLeft: bar.left, BarRight: bar.right, MainLeft: main.left, BrandLeft: brand.left, ControlsRight: controls.right}); })()`, &r) if r.RailLeft < r.MainRight-0.5 { t.Errorf("%s: the rail starts at %.1fpx, left of main's right edge %.1fpx; it belongs on the right", where, r.RailLeft, r.MainRight) } if r.BarBottom > r.MainTop+0.5 || r.BarLeft < r.MainLeft-0.5 || r.BarRight > r.MainRight+0.5 { t.Errorf("%s: the bar [%v…%v, bottom %v] is not above main [%v…%v, top %v]", where, r.BarLeft, r.BarRight, r.BarBottom, r.MainLeft, r.MainRight, r.MainTop) } // The brand is first in source order and justify-content: // space-between puts the first child at the inline start, which in // Arabic is the bar's right side, not its left: a rule keyed to // "left" rather than to the writing direction would swap these two. if r.BrandLeft < r.ControlsRight-0.5 { t.Errorf("%s: the brand's left edge is at %.1fpx, not right of the controls' right edge %.1fpx; the brand belongs at the inline start and the controls at the end", where, r.BrandLeft, r.ControlsRight) } }