//go:build browser package gallery import ( "context" "encoding/json" "fmt" "slices" "sort" "strings" "testing" "time" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/axe" ) // The phone index and the way back: below 800px the Overview is the // shell's index page and every other page a content page with a back // strip, as the sidebar shell does it for an app. // boxOf says whether the first element matching sel takes up room. const boxOf = `(sel => { const e = document.querySelector(sel); if (!e) return false; const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; })` // focusID names whatever has focus the way the expected sequences // below are written: an input or a row by id, a link by its href, a // summary by its tag. const focusID = `(() => { const a = document.activeElement; if (!a || a === document.body) return "body"; if (a.id) return "#" + a.id; if (a.tagName === "A") return a.getAttribute("href"); return a.tagName.toLowerCase(); })()` // TestThePhoneIndexAndTheWayBack: at 390px the Overview is the index // (the rail is the page; main and the bar have no box; the one visible // h1 is the gallery's name; the rows are the page kinds in table order // then Demos, each a 44px target), Tab runs the display settings // button, the filter and the rows, a row opens its page as a content // page whose Back names its row, Back returns to the index with that // row focused, and a theme link in the menu lands on the other theme's // index. Scripts off: Back is the link, the row is the :target, and // the next Tab lands on the row after it. func TestThePhoneIndexAndTheWayBack(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() s := newTestSite(t, fiesta.FS(), mountPath) theme := fiesta.ThemeNames()[0] for _, locale := range []string{"en", "ar"} { where := theme + "/" + locale + " at 390px" mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) present(t, ctx, where, ".ds-index") mustRun(t, ctx, chromedp.WaitVisible(`.ds-index`, chromedp.ByQuery)) requireCoarse(t, ctx) var raw string mustRun(t, ctx, chromedp.Evaluate(`(() => { const box = `+boxOf+`; const h1s = [...document.querySelectorAll("h1")].filter(h => h.getBoundingClientRect().height > 0).map(h => h.textContent.trim()); const rows = [...document.querySelectorAll(".ds-index > a")].map(a => ({T: a.textContent.trim(), H: a.getBoundingClientRect().height, ID: a.id})); return JSON.stringify({Rail: box("[rst-shell-rail]"), Main: box("main"), Bar: box(".ds-top"), Lead: box(".ds-index-lead"), H1: h1s, Group: (document.querySelector(".ds-index > [rst-shell-group]") || {}).textContent || "", Rows: rows}); })()`, &raw)) var ix struct { Rail, Main, Bar, Lead bool H1 []string Group string Rows []struct { T, ID string H float64 } } if err := json.Unmarshal([]byte(raw), &ix); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } if !ix.Rail || ix.Main || ix.Bar || !ix.Lead { t.Errorf("%s: rail %v, main %v, bar %v, lead %v; the index is the rail and its lead alone", where, ix.Rail, ix.Main, ix.Bar, ix.Lead) } if len(ix.H1) != 1 || ix.H1[0] != s.say(locale, "gallery.brand") { t.Errorf("%s: visible h1s %q, want exactly the index title", where, ix.H1) } var want []string for _, pk := range s.pageKinds() { if pk.Kind != "overview" { want = append(want, s.titleOf(locale, pk)) } } if ix.Group != s.say(locale, "gallery.nav.demos") { t.Errorf("%s: the rows' group label is %q, want Demos", where, ix.Group) } if len(ix.Rows) < len(want) { t.Fatalf("%s: %d rows, want at least %d", where, len(ix.Rows), len(want)) } for i, w := range want { if ix.Rows[i].T != w { t.Errorf("%s: row %d is %q, want %q", where, i, ix.Rows[i].T, w) } } for _, r := range ix.Rows { if r.H < 44 { t.Errorf("%s: the row %q is %.1fpx tall, under a 44px target", where, r.T, r.H) } } var seq, expect []string mustRun(t, ctx, chromedp.Evaluate(`document.activeElement && document.activeElement.blur(); scrollTo(0, 0); true`, nil), chromedp.Evaluate(`["summary", "#ds-filter", ...[...document.querySelectorAll(".ds-index > a")].map(a => a.id ? "#" + a.id : a.getAttribute("href"))]`, &expect)) for range expect { var at string mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(focusID, &at)) seq = append(seq, at) } if strings.Join(seq, " ") != strings.Join(expect, " ") { t.Errorf("%s: Tab order\n got %v\nwant %v", where, seq, expect) } var filter struct { IconIn bool Gap float64 } mustRun(t, ctx, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter").getBoundingClientRect(), g = document.querySelector(".ds-search > .icon").getBoundingClientRect(); const rtl = document.documentElement.dir === "rtl", row = document.querySelector(".ds-index > a").getBoundingClientRect(); const start = rtl ? i.right - g.right : g.left - i.left; return JSON.stringify({IconIn: g.width > 0 && g.top >= i.top && g.bottom <= i.bottom && start >= 0 && start < 16, Gap: row.top - i.bottom}); })()`, &raw)) if err := json.Unmarshal([]byte(raw), &filter); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } if !filter.IconIn || filter.Gap < 8 { t.Errorf("%s: the search icon is inside the field at its inline start %v, and the field is %.1fpx above the first row; want the icon there and a gap of at least 8px", where, filter.IconIn, filter.Gap) } mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-form").click(); true`, nil)) until(t, ctx, where+", Form loaded", `location.pathname.endsWith("/form.html") && document.readyState === "complete"`) var page string mustRun(t, ctx, chromedp.Evaluate(`(() => { const box = `+boxOf+`; const back = document.querySelector("[rst-skip] + [rst-shell-back] > a"); return JSON.stringify({Back: !!back && box("[rst-shell-back]"), Href: back ? back.getAttribute("href") : "", Rail: box("[rst-shell-rail]"), Bar: box(".ds-top"), Switch: [...document.querySelectorAll("[rst-scheme] button, [rst-seg-tabs] a, [rst-locale]")].some(e => e.checkVisibility() && e.getBoundingClientRect().width > 0)}); })()`, &page)) var cp struct { Back, Rail, Bar, Switch bool Href string } if err := json.Unmarshal([]byte(page), &cp); err != nil { t.Fatalf("%s: decoding %q: %v", where, page, err) } if !cp.Back || cp.Rail || cp.Bar || cp.Switch { t.Errorf("%s: Form shows back %v, rail %v, bar %v, a switcher %v; a content page is the way back and the content", where, cp.Back, cp.Rail, cp.Bar, cp.Switch) } if want := pageAt(theme, locale, "index.html") + "#nav-form"; cp.Href != want { t.Errorf("%s: Back goes to %q, want %q", where, cp.Href, want) } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-shell-back] a").click(); true`, nil)) until(t, ctx, where+", back on the index", `location.pathname.endsWith("/index.html") && document.activeElement && document.activeElement.id === "nav-form"`) until(t, ctx, where+", the slide over", `document.readyState === "complete"`) settleMotion(t, ctx, where+", the slide over") tapAt(t, ctx, where, ".ds-prefs > summary") until(t, ctx, where+", the menu open", `document.querySelector(".ds-prefs").open`) tapAt(t, ctx, where, `.ds-prefs [rst-seg-tabs] a[href*="/signal/"]`) until(t, ctx, where+", switched to signal", `location.pathname.includes("/signal/") && (() => { const r = document.querySelector(".ds-prefs > summary").getBoundingClientRect(); return r.height > 0 && r.top >= 0 && r.top < innerHeight; })()`) } off := noScripts(t, ctx) where := theme + "/en at 390px, scripts off" mustRun(t, off, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) present(t, off, where, "[rst-shell-back]") mustRun(t, off, chromedp.WaitVisible(`[rst-shell-back]`, chromedp.ByQuery)) requireScriptsOff(t, off, where) mustRun(t, off, chromedp.Evaluate(`document.querySelector("[rst-shell-back] a").click(); true`, nil)) until(t, off, where, `location.hash === "#nav-form" && document.querySelector(":target") && document.querySelector(":target").id === "nav-form"`) requireScriptsOff(t, off, where+", back on the index") var next, after string mustRun(t, off, chromedp.Evaluate(`document.getElementById("nav-form").nextElementSibling.id || document.getElementById("nav-form").nextElementSibling.getAttribute("href")`, &after), chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(focusID, &next)) if strings.TrimPrefix(next, "#") != after { t.Errorf("%s: the Tab after Back lands on %s, want the row after Form, %s", where, next, after) } } // TestTheIndexTitleIsVisible: the Overview names the gallery where a // reader can see it: as the index's heading on a phone, and as the // bar's brand on a desktop. An h1 that has a box is not enough: the // shell hides a brand span that has no element in it, so the heading // could render as an empty row with its title hidden. Read as painted // text, a range with a width, in two scripts. func TestTheIndexTitleIsVisible(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() s := newTestSite(t, fiesta.FS(), mountPath) theme := fiesta.ThemeNames()[0] const painted = `(sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => { const r = document.createRange(); r.selectNodeContents(e); const box = r.getBoundingClientRect(); return box.width > 0 && box.height > 0 ? e.innerText.trim() : ""; }).filter(Boolean))` for _, locale := range []string{"en", "ar"} { want := s.say(locale, "gallery.brand") for _, c := range []struct { w, h int sel string }{{390, 844, "h1"}, {1280, 800, ".ds-top__brand"}} { where := fmt.Sprintf("%s/%s index at %dpx", theme, locale, c.w) var got []string mustRun(t, ctx, chromedp.EmulateViewport(int64(c.w), int64(c.h)), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) present(t, ctx, where, c.sel) mustRun(t, ctx, chromedp.Evaluate(painted+`("`+c.sel+`")`, &got)) if !slices.Contains(got, want) { t.Errorf("%s: painted %s text %q, want %q among it", where, c.sel, got, want) } } } } // scanWithout runs axe over the page with the named rules disabled, // loading the engine where the page has none. The WCAG 2.2 AA tag set // otherwise, as axe.Scan uses. func scanWithout(t *testing.T, ctx context.Context, where string, rules ...string) []axe.Violation { t.Helper() var loaded bool mustRun(t, ctx, chromedp.Evaluate(`typeof window.axe !== "undefined"`, &loaded)) if !loaded { mustRun(t, ctx, chromedp.Evaluate(axe.Script(), nil)) } tags, _ := json.Marshal(axe.DefaultTags()) off, _ := json.Marshal(rules) var raw string mustRun(t, ctx, chromedp.Evaluate(`(async (tags, rules) => { const off = {}; rules.forEach(r => { off[r] = {enabled: false}; }); const res = await window.axe.run(document, {runOnly: {type: "tag", values: tags}, resultTypes: ["violations"], iframes: false, rules: off}); return JSON.stringify({passed: res.passes.length, violations: res.violations.map(v => ({ID: v.id, Impact: v.impact, Help: v.help, Nodes: v.nodes.map(n => n.target.map(t => Array.isArray(t) ? t.join(" >> ") : String(t)).join(" >> "))}))}); })(`+string(tags)+`, `+string(off)+`)`, &raw, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) var out struct { Passed int Violations []axe.Violation } if err := json.Unmarshal([]byte(raw), &out); err != nil { t.Fatalf("%s: decoding axe's result: %v", where, err) } if out.Passed < 5 { t.Fatalf("%s: %d rules ran to completion; the scan saw nothing", where, out.Passed) } return out.Violations } // TestA11yScansThePhoneViews: axe on both phone views, every theme and // both schemes, motion settled first; and again with the display // settings open and the language list inside it. target-size is off // for the open card: the page outside it is under the summary's // light-dismiss layer, which axe cannot see. func TestA11yScansThePhoneViews(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) defer cancel() total := 0 incomplete := map[string]int{} for _, theme := range fiesta.ThemeNames() { for _, view := range []struct{ name, file, ready string }{{"index", "index.html", ".ds-index"}, {"content page", "form.html", "[rst-shell-back]"}} { for _, scheme := range []string{"light", "dark"} { where := fmt.Sprintf("%s/en %s at 390px (%s)", theme, view.name, scheme) mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", view.file)), chromedp.WaitReady("body")) present(t, ctx, where, view.ready) mustRun(t, ctx, chromedp.WaitVisible(view.ready, chromedp.ByQuery)) requireCoarse(t, ctx) settleMotion(t, ctx, where) mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); true`, scheme), nil)) res, err := axe.Scan(ctx, "", nil) if err != nil { t.Fatalf("%s: %v", where, err) } for _, id := range res.Incomplete { incomplete[id]++ } total += report(t, where, res.Violations) where += ", display settings open" mustRun(t, ctx, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.Click(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] [rst-dropdown-menu] a", chromedp.ByQuery)) settleMotion(t, ctx, where) total += report(t, where, scanWithout(t, ctx, where, "target-size")) } } } var ids []string for id, n := range incomplete { ids = append(ids, fmt.Sprintf("%s x%d", id, n)) } sort.Strings(ids) t.Logf("needs review, axe could not decide: %v", ids) if total == 0 { t.Logf("clean: both phone views, menu closed and open, in %d themes and both schemes", len(fiesta.ThemeNames())) } }