rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package gallery
4
5import (
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.
28const 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.
33const 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.
48func 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.
189func 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.
221func 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.
254func 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