| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "slices" |
| 10 | "sort" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/chromedp/cdproto/runtime" |
| 16 | "github.com/chromedp/chromedp" |
| 17 | "github.com/chromedp/chromedp/kb" |
| 18 | |
| 19 | "amadan.net/rastrillo/fiesta" |
| 20 | "amadan.net/rastrillo/fiesta/internal/axe" |
| 21 | ) |
| 22 | |
| 23 | // The phone index and the way back: below 800px the Overview is the |
| 24 | // shell's index page and every other page a content page with a back |
| 25 | // strip, as the sidebar shell does it for an app. |
| 26 | |
| 27 | // boxOf says whether the first element matching sel takes up room. |
| 28 | const boxOf = `(sel => { const e = document.querySelector(sel); if (!e) return false; const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; })` |
| 29 | |
| 30 | // focusID names whatever has focus the way the expected sequences |
| 31 | // below are written: an input or a row by id, a link by its href, a |
| 32 | // summary by its tag. |
| 33 | const focusID = `(() => { const a = document.activeElement; |
| 34 | if (!a || a === document.body) return "body"; |
| 35 | if (a.id) return "#" + a.id; |
| 36 | if (a.tagName === "A") return a.getAttribute("href"); |
| 37 | return a.tagName.toLowerCase(); })()` |
| 38 | |
| 39 | // TestThePhoneIndexAndTheWayBack: at 390px the Overview is the index |
| 40 | // (the rail is the page; main and the bar have no box; the one visible |
| 41 | // h1 is the gallery's name; the rows are the page kinds in table order |
| 42 | // then Demos, each a 44px target), Tab runs the display settings |
| 43 | // button, the filter and the rows, a row opens its page as a content |
| 44 | // page whose Back names its row, Back returns to the index with that |
| 45 | // row focused, and a theme link in the menu lands on the other theme's |
| 46 | // index. Scripts off: Back is the link, the row is the :target, and |
| 47 | // the next Tab lands on the row after it. |
| 48 | func TestThePhoneIndexAndTheWayBack(t *testing.T) { |
| 49 | rig := phoneRig(t) |
| 50 | ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| 51 | defer cancel() |
| 52 | s := newTestSite(t, fiesta.FS(), mountPath) |
| 53 | theme := fiesta.ThemeNames()[0] |
| 54 | for _, locale := range []string{"en", "ar"} { |
| 55 | where := theme + "/" + locale + " at 390px" |
| 56 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| 57 | present(t, ctx, where, ".ds-index") |
| 58 | mustRun(t, ctx, chromedp.WaitVisible(`.ds-index`, chromedp.ByQuery)) |
| 59 | requireCoarse(t, ctx) |
| 60 | var raw string |
| 61 | mustRun(t, ctx, chromedp.Evaluate(`(() => { |
| 62 | const box = `+boxOf+`; |
| 63 | const h1s = [...document.querySelectorAll("h1")].filter(h => h.getBoundingClientRect().height > 0).map(h => h.textContent.trim()); |
| 64 | const rows = [...document.querySelectorAll(".ds-index > a")].map(a => ({T: a.textContent.trim(), H: a.getBoundingClientRect().height, ID: a.id})); |
| 65 | return JSON.stringify({Rail: box("[rst-shell-rail]"), Main: box("main"), Bar: box(".ds-top"), Lead: box(".ds-index-lead"), H1: h1s, |
| 66 | Group: (document.querySelector(".ds-index > [rst-shell-group]") || {}).textContent || "", Rows: rows}); |
| 67 | })()`, &raw)) |
| 68 | var ix struct { |
| 69 | Rail, Main, Bar, Lead bool |
| 70 | H1 []string |
| 71 | Group string |
| 72 | Rows []struct { |
| 73 | T, ID string |
| 74 | H float64 |
| 75 | } |
| 76 | } |
| 77 | if err := json.Unmarshal([]byte(raw), &ix); err != nil { |
| 78 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 79 | } |
| 80 | if !ix.Rail || ix.Main || ix.Bar || !ix.Lead { |
| 81 | 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) |
| 82 | } |
| 83 | if len(ix.H1) != 1 || ix.H1[0] != s.say(locale, "gallery.brand") { |
| 84 | t.Errorf("%s: visible h1s %q, want exactly the index title", where, ix.H1) |
| 85 | } |
| 86 | var want []string |
| 87 | for _, pk := range s.pageKinds() { |
| 88 | if pk.Kind != "overview" { |
| 89 | want = append(want, s.titleOf(locale, pk)) |
| 90 | } |
| 91 | } |
| 92 | if ix.Group != s.say(locale, "gallery.nav.demos") { |
| 93 | t.Errorf("%s: the rows' group label is %q, want Demos", where, ix.Group) |
| 94 | } |
| 95 | if len(ix.Rows) < len(want) { |
| 96 | t.Fatalf("%s: %d rows, want at least %d", where, len(ix.Rows), len(want)) |
| 97 | } |
| 98 | for i, w := range want { |
| 99 | if ix.Rows[i].T != w { |
| 100 | t.Errorf("%s: row %d is %q, want %q", where, i, ix.Rows[i].T, w) |
| 101 | } |
| 102 | } |
| 103 | for _, r := range ix.Rows { |
| 104 | if r.H < 44 { |
| 105 | t.Errorf("%s: the row %q is %.1fpx tall, under a 44px target", where, r.T, r.H) |
| 106 | } |
| 107 | } |
| 108 | var seq, expect []string |
| 109 | mustRun(t, ctx, chromedp.Evaluate(`document.activeElement && document.activeElement.blur(); scrollTo(0, 0); true`, nil), |
| 110 | chromedp.Evaluate(`["summary", "#ds-filter", ...[...document.querySelectorAll(".ds-index > a")].map(a => a.id ? "#" + a.id : a.getAttribute("href"))]`, &expect)) |
| 111 | for range expect { |
| 112 | var at string |
| 113 | mustRun(t, ctx, chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(focusID, &at)) |
| 114 | seq = append(seq, at) |
| 115 | } |
| 116 | if strings.Join(seq, " ") != strings.Join(expect, " ") { |
| 117 | t.Errorf("%s: Tab order\n got %v\nwant %v", where, seq, expect) |
| 118 | } |
| 119 | var filter struct { |
| 120 | IconIn bool |
| 121 | Gap float64 |
| 122 | } |
| 123 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const i = document.getElementById("ds-filter").getBoundingClientRect(), g = document.querySelector(".ds-search > .icon").getBoundingClientRect(); |
| 124 | const rtl = document.documentElement.dir === "rtl", row = document.querySelector(".ds-index > a").getBoundingClientRect(); |
| 125 | const start = rtl ? i.right - g.right : g.left - i.left; |
| 126 | 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)) |
| 127 | if err := json.Unmarshal([]byte(raw), &filter); err != nil { |
| 128 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 129 | } |
| 130 | if !filter.IconIn || filter.Gap < 8 { |
| 131 | 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) |
| 132 | } |
| 133 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-form").click(); true`, nil)) |
| 134 | until(t, ctx, where+", Form loaded", `location.pathname.endsWith("/form.html") && document.readyState === "complete"`) |
| 135 | var page string |
| 136 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const box = `+boxOf+`; |
| 137 | const back = document.querySelector("[rst-skip] + [rst-shell-back] > a"); |
| 138 | return JSON.stringify({Back: !!back && box("[rst-shell-back]"), Href: back ? back.getAttribute("href") : "", |
| 139 | Rail: box("[rst-shell-rail]"), Bar: box(".ds-top"), |
| 140 | Switch: [...document.querySelectorAll("[rst-scheme] button, [rst-seg-tabs] a, [rst-locale]")].some(e => e.checkVisibility() && e.getBoundingClientRect().width > 0)}); |
| 141 | })()`, &page)) |
| 142 | var cp struct { |
| 143 | Back, Rail, Bar, Switch bool |
| 144 | Href string |
| 145 | } |
| 146 | if err := json.Unmarshal([]byte(page), &cp); err != nil { |
| 147 | t.Fatalf("%s: decoding %q: %v", where, page, err) |
| 148 | } |
| 149 | if !cp.Back || cp.Rail || cp.Bar || cp.Switch { |
| 150 | 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) |
| 151 | } |
| 152 | if want := pageAt(theme, locale, "index.html") + "#nav-form"; cp.Href != want { |
| 153 | t.Errorf("%s: Back goes to %q, want %q", where, cp.Href, want) |
| 154 | } |
| 155 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-shell-back] a").click(); true`, nil)) |
| 156 | until(t, ctx, where+", back on the index", `location.pathname.endsWith("/index.html") && document.activeElement && document.activeElement.id === "nav-form"`) |
| 157 | until(t, ctx, where+", the slide over", `document.readyState === "complete"`) |
| 158 | settleMotion(t, ctx, where+", the slide over") |
| 159 | tapAt(t, ctx, where, ".ds-prefs > summary") |
| 160 | until(t, ctx, where+", the menu open", `document.querySelector(".ds-prefs").open`) |
| 161 | tapAt(t, ctx, where, `.ds-prefs [rst-seg-tabs] a[href*="/signal/"]`) |
| 162 | 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; })()`) |
| 163 | } |
| 164 | |
| 165 | off := noScripts(t, ctx) |
| 166 | where := theme + "/en at 390px, scripts off" |
| 167 | mustRun(t, off, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "form.html")), chromedp.WaitReady("body")) |
| 168 | present(t, off, where, "[rst-shell-back]") |
| 169 | mustRun(t, off, chromedp.WaitVisible(`[rst-shell-back]`, chromedp.ByQuery)) |
| 170 | requireScriptsOff(t, off, where) |
| 171 | mustRun(t, off, chromedp.Evaluate(`document.querySelector("[rst-shell-back] a").click(); true`, nil)) |
| 172 | until(t, off, where, `location.hash === "#nav-form" && document.querySelector(":target") && document.querySelector(":target").id === "nav-form"`) |
| 173 | requireScriptsOff(t, off, where+", back on the index") |
| 174 | var next, after string |
| 175 | mustRun(t, off, |
| 176 | chromedp.Evaluate(`document.getElementById("nav-form").nextElementSibling.id || document.getElementById("nav-form").nextElementSibling.getAttribute("href")`, &after), |
| 177 | chromedp.KeyEvent(kb.Tab), chromedp.Evaluate(focusID, &next)) |
| 178 | if strings.TrimPrefix(next, "#") != after { |
| 179 | t.Errorf("%s: the Tab after Back lands on %s, want the row after Form, %s", where, next, after) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // TestTheIndexTitleIsVisible: the Overview names the gallery where a |
| 184 | // reader can see it: as the index's heading on a phone, and as the |
| 185 | // bar's brand on a desktop. An h1 that has a box is not enough: the |
| 186 | // shell hides a brand span that has no element in it, so the heading |
| 187 | // could render as an empty row with its title hidden. Read as painted |
| 188 | // text, a range with a width, in two scripts. |
| 189 | func TestTheIndexTitleIsVisible(t *testing.T) { |
| 190 | rig := phoneRig(t) |
| 191 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 192 | defer cancel() |
| 193 | s := newTestSite(t, fiesta.FS(), mountPath) |
| 194 | theme := fiesta.ThemeNames()[0] |
| 195 | const painted = `(sel => [...document.querySelectorAll(sel)].filter(e => e.checkVisibility()).map(e => { |
| 196 | const r = document.createRange(); r.selectNodeContents(e); |
| 197 | const box = r.getBoundingClientRect(); |
| 198 | return box.width > 0 && box.height > 0 ? e.innerText.trim() : ""; |
| 199 | }).filter(Boolean))` |
| 200 | for _, locale := range []string{"en", "ar"} { |
| 201 | want := s.say(locale, "gallery.brand") |
| 202 | for _, c := range []struct { |
| 203 | w, h int |
| 204 | sel string |
| 205 | }{{390, 844, "h1"}, {1280, 800, ".ds-top__brand"}} { |
| 206 | where := fmt.Sprintf("%s/%s index at %dpx", theme, locale, c.w) |
| 207 | var got []string |
| 208 | mustRun(t, ctx, chromedp.EmulateViewport(int64(c.w), int64(c.h)), chromedp.Navigate(rig.Origin+pageAt(theme, locale, "index.html")), chromedp.WaitReady("body")) |
| 209 | present(t, ctx, where, c.sel) |
| 210 | mustRun(t, ctx, chromedp.Evaluate(painted+`("`+c.sel+`")`, &got)) |
| 211 | if !slices.Contains(got, want) { |
| 212 | t.Errorf("%s: painted %s text %q, want %q among it", where, c.sel, got, want) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // scanWithout runs axe over the page with the named rules disabled, |
| 219 | // loading the engine where the page has none. The WCAG 2.2 AA tag set |
| 220 | // otherwise, as axe.Scan uses. |
| 221 | func scanWithout(t *testing.T, ctx context.Context, where string, rules ...string) []axe.Violation { |
| 222 | t.Helper() |
| 223 | var loaded bool |
| 224 | mustRun(t, ctx, chromedp.Evaluate(`typeof window.axe !== "undefined"`, &loaded)) |
| 225 | if !loaded { |
| 226 | mustRun(t, ctx, chromedp.Evaluate(axe.Script(), nil)) |
| 227 | } |
| 228 | tags, _ := json.Marshal(axe.DefaultTags()) |
| 229 | off, _ := json.Marshal(rules) |
| 230 | var raw string |
| 231 | mustRun(t, ctx, chromedp.Evaluate(`(async (tags, rules) => { |
| 232 | const off = {}; rules.forEach(r => { off[r] = {enabled: false}; }); |
| 233 | const res = await window.axe.run(document, {runOnly: {type: "tag", values: tags}, resultTypes: ["violations"], iframes: false, rules: off}); |
| 234 | 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(" >> "))}))}); |
| 235 | })(`+string(tags)+`, `+string(off)+`)`, &raw, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) |
| 236 | var out struct { |
| 237 | Passed int |
| 238 | Violations []axe.Violation |
| 239 | } |
| 240 | if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| 241 | t.Fatalf("%s: decoding axe's result: %v", where, err) |
| 242 | } |
| 243 | if out.Passed < 5 { |
| 244 | t.Fatalf("%s: %d rules ran to completion; the scan saw nothing", where, out.Passed) |
| 245 | } |
| 246 | return out.Violations |
| 247 | } |
| 248 | |
| 249 | // TestA11yScansThePhoneViews: axe on both phone views, every theme and |
| 250 | // both schemes, motion settled first; and again with the display |
| 251 | // settings open and the language list inside it. target-size is off |
| 252 | // for the open card: the page outside it is under the summary's |
| 253 | // light-dismiss layer, which axe cannot see. |
| 254 | func TestA11yScansThePhoneViews(t *testing.T) { |
| 255 | rig := phoneRig(t) |
| 256 | ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) |
| 257 | defer cancel() |
| 258 | total := 0 |
| 259 | incomplete := map[string]int{} |
| 260 | for _, theme := range fiesta.ThemeNames() { |
| 261 | for _, view := range []struct{ name, file, ready string }{{"index", "index.html", ".ds-index"}, {"content page", "form.html", "[rst-shell-back]"}} { |
| 262 | for _, scheme := range []string{"light", "dark"} { |
| 263 | where := fmt.Sprintf("%s/en %s at 390px (%s)", theme, view.name, scheme) |
| 264 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", view.file)), chromedp.WaitReady("body")) |
| 265 | present(t, ctx, where, view.ready) |
| 266 | mustRun(t, ctx, chromedp.WaitVisible(view.ready, chromedp.ByQuery)) |
| 267 | requireCoarse(t, ctx) |
| 268 | settleMotion(t, ctx, where) |
| 269 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); true`, scheme), nil)) |
| 270 | res, err := axe.Scan(ctx, "", nil) |
| 271 | if err != nil { |
| 272 | t.Fatalf("%s: %v", where, err) |
| 273 | } |
| 274 | for _, id := range res.Incomplete { |
| 275 | incomplete[id]++ |
| 276 | } |
| 277 | total += report(t, where, res.Violations) |
| 278 | where += ", display settings open" |
| 279 | mustRun(t, ctx, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), |
| 280 | chromedp.Click(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] [rst-dropdown-menu] a", chromedp.ByQuery)) |
| 281 | settleMotion(t, ctx, where) |
| 282 | total += report(t, where, scanWithout(t, ctx, where, "target-size")) |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | var ids []string |
| 287 | for id, n := range incomplete { |
| 288 | ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| 289 | } |
| 290 | sort.Strings(ids) |
| 291 | t.Logf("needs review, axe could not decide: %v", ids) |
| 292 | if total == 0 { |
| 293 | t.Logf("clean: both phone views, menu closed and open, in %d themes and both schemes", len(fiesta.ThemeNames())) |
| 294 | } |
| 295 | } |
| 296 | |