| 1 | //go:build browser |
| 2 | |
| 3 | // The accessibility gate. |
| 4 | // |
| 5 | // "And everything is WCAG 2.2 AA, right?" This file is the half of |
| 6 | // that answer a machine can give. It serves what Render produces in a |
| 7 | // real Chromium, the exact bytes fiesta gallery would write, injects |
| 8 | // the vendored axe-core, and runs it over every example state in every |
| 9 | // theme and both schemes with the WCAG 2.2 AA tag set. Any violation |
| 10 | // fails the build, printing rule id, impact and the offending |
| 11 | // selector, so a regression names itself. |
| 12 | // |
| 13 | // Run it with: |
| 14 | // |
| 15 | // make browser.internal.gallery~1of2 browser.internal.gallery~2of2 |
| 16 | // |
| 17 | // Three honest limits, written down here rather than implied: |
| 18 | // |
| 19 | // - Automated scanning catches roughly half of the WCAG success |
| 20 | // criteria, the machine-checkable half. The rest (meaningful |
| 21 | // alternative text, sensible reading order, whether a label says |
| 22 | // something true) is a person's job. A green run here is a floor, |
| 23 | // not a certificate. |
| 24 | // - axe checks what is on the screen. Every state is scanned as its |
| 25 | // own document, which is where its markup lives; the gallery page |
| 26 | // around it is measured by the reflow drive and not scanned by |
| 27 | // axe, and the keyboard walks open the state documents on their |
| 28 | // own too. |
| 29 | // - Two criteria axe cannot see at all get their own drives: |
| 30 | // 1.4.10 Reflow (a 320px viewport that does not scroll sideways) |
| 31 | // and 2.4.7/2.1.2 (keyboard_browser_test.go's walks, which prove |
| 32 | // focus is visible at every stop and can always move on). |
| 33 | package gallery |
| 34 | |
| 35 | import ( |
| 36 | "context" |
| 37 | "encoding/json" |
| 38 | "fmt" |
| 39 | "net/http" |
| 40 | "path" |
| 41 | "sort" |
| 42 | "strings" |
| 43 | "sync" |
| 44 | "testing" |
| 45 | "time" |
| 46 | |
| 47 | "github.com/chromedp/cdproto/cdp" |
| 48 | "github.com/chromedp/cdproto/emulation" |
| 49 | "github.com/chromedp/cdproto/runtime" |
| 50 | "github.com/chromedp/chromedp" |
| 51 | |
| 52 | "amadan.net/rastrillo/fiesta" |
| 53 | "amadan.net/rastrillo/fiesta/example" |
| 54 | "amadan.net/rastrillo/fiesta/internal/axe" |
| 55 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 56 | ) |
| 57 | |
| 58 | // mountPath is where the drives serve the tree: under a directory |
| 59 | // rather than at the root, so every absolute URL the render writes is |
| 60 | // exercised against a mount a developer's machine would not need. |
| 61 | const mountPath = "/gallery" |
| 62 | |
| 63 | // tree serves the rendered tree under mountPath, with extra files |
| 64 | // beside it, and reports every path it was asked for and did not have |
| 65 | // when the test ends: a frame whose stylesheet 404s is a frame the |
| 66 | // drive measured unstyled. requests records every request, for the |
| 67 | // drive that proves a frame requests nothing. |
| 68 | type tree struct { |
| 69 | files map[string][]byte |
| 70 | mu sync.Mutex |
| 71 | missing []string |
| 72 | requests []string |
| 73 | } |
| 74 | |
| 75 | func newTree(t *testing.T, extra map[string][]byte) *tree { |
| 76 | t.Helper() |
| 77 | files := render(t, mountPath) |
| 78 | for name, body := range extra { |
| 79 | files[name] = body |
| 80 | } |
| 81 | tr := &tree{files: files} |
| 82 | t.Cleanup(func() { |
| 83 | tr.mu.Lock() |
| 84 | defer tr.mu.Unlock() |
| 85 | if len(tr.missing) > 0 { |
| 86 | t.Errorf("the tree was asked for %d file(s) it does not have: %v", len(tr.missing), tr.missing) |
| 87 | } |
| 88 | }) |
| 89 | return tr |
| 90 | } |
| 91 | |
| 92 | var contentTypes = map[string]string{ |
| 93 | ".html": "text/html; charset=utf-8", |
| 94 | ".css": "text/css; charset=utf-8", |
| 95 | ".js": "text/javascript; charset=utf-8", |
| 96 | ".svg": "image/svg+xml", |
| 97 | } |
| 98 | |
| 99 | func (tr *tree) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 100 | tr.mu.Lock() |
| 101 | tr.requests = append(tr.requests, r.Method+" "+r.URL.RequestURI()) |
| 102 | tr.mu.Unlock() |
| 103 | if !strings.HasPrefix(r.URL.Path, mountPath+"/") || r.Method != http.MethodGet { |
| 104 | http.NotFound(w, r) |
| 105 | return |
| 106 | } |
| 107 | name := strings.TrimPrefix(r.URL.Path, mountPath+"/") |
| 108 | body, ok := tr.files[name] |
| 109 | if !ok { |
| 110 | tr.mu.Lock() |
| 111 | tr.missing = append(tr.missing, r.URL.Path) |
| 112 | tr.mu.Unlock() |
| 113 | http.NotFound(w, r) |
| 114 | return |
| 115 | } |
| 116 | if ct, ok := contentTypes[path.Ext(name)]; ok { |
| 117 | w.Header().Set("Content-Type", ct) |
| 118 | } |
| 119 | w.Write(body) |
| 120 | } |
| 121 | |
| 122 | // seen returns the requests so far. |
| 123 | func (tr *tree) seen() []string { |
| 124 | tr.mu.Lock() |
| 125 | defer tr.mu.Unlock() |
| 126 | return append([]string(nil), tr.requests...) |
| 127 | } |
| 128 | |
| 129 | // href is the URL of one file of the tree at the mount. |
| 130 | func href(rel string) string { return mountPath + "/" + rel } |
| 131 | |
| 132 | // frameFile is the path of one state's frame document within the tree. |
| 133 | func frameFile(theme, locale, name, state string) string { |
| 134 | return theme + "/" + locale + "/frames/" + name + "/" + state + ".html" |
| 135 | } |
| 136 | |
| 137 | func mustRun(t *testing.T, ctx context.Context, actions ...chromedp.Action) { |
| 138 | t.Helper() |
| 139 | if err := chromedp.Run(ctx, actions...); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // at evaluates a JS expression returning a JSON string into v. |
| 145 | func at(t *testing.T, ctx context.Context, js string, v any, opts ...chromedp.EvaluateOption) { |
| 146 | t.Helper() |
| 147 | var raw string |
| 148 | if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw, opts...)); err != nil { |
| 149 | t.Fatalf("evaluating: %v\n%s", err, js) |
| 150 | } |
| 151 | if err := json.Unmarshal([]byte(raw), v); err != nil { |
| 152 | t.Fatalf("decoding %q: %v", raw, err) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) } |
| 157 | |
| 158 | // settleJS takes every frame off lazy loading and reports how many |
| 159 | // there are and how many gallery.js has painted. |
| 160 | const settleJS = `(() => { |
| 161 | const f = [...document.querySelectorAll(".ds-view__frame")]; |
| 162 | f.forEach(x => { if (x.loading !== "eager") x.loading = "eager"; }); |
| 163 | return JSON.stringify({total: f.length, painted: f.filter(x => x.hasAttribute("data-gallery-painted")).length}); |
| 164 | })()` |
| 165 | |
| 166 | // painted loads every frame on the page and waits until gallery.js |
| 167 | // has painted them all, so a reading that follows is of documents |
| 168 | // that are there. It is Fiesta's own wait, on the painted mark; the |
| 169 | // ported drives' eagerly, in rig_browser_test.go, waits on each |
| 170 | // frame's document settling instead. |
| 171 | func painted(t *testing.T, ctx context.Context, where string) int { |
| 172 | t.Helper() |
| 173 | deadline := time.Now().Add(60 * time.Second) |
| 174 | for { |
| 175 | var got struct{ Total, Painted int } |
| 176 | at(t, ctx, settleJS, &got) |
| 177 | if got.Total == 0 { |
| 178 | t.Fatalf("%s: no frames on the page", where) |
| 179 | } |
| 180 | if got.Painted == got.Total { |
| 181 | t.Logf("%s: %d frames loaded and painted", where, got.Total) |
| 182 | return got.Total |
| 183 | } |
| 184 | if !time.Now().Before(deadline) { |
| 185 | t.Fatalf("%s: %d of %d frames painted after 60s", where, got.Painted, got.Total) |
| 186 | } |
| 187 | time.Sleep(50 * time.Millisecond) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // report prints one scan's violations, one block each, and returns how |
| 192 | // many there were. |
| 193 | func report(t *testing.T, where string, vs []axe.Violation) int { |
| 194 | t.Helper() |
| 195 | for _, v := range vs { |
| 196 | nodes := v.Nodes |
| 197 | if len(nodes) > 5 { |
| 198 | nodes = append(nodes[:5], fmt.Sprintf("… and %d more", len(v.Nodes)-5)) |
| 199 | } |
| 200 | t.Errorf("%s: %s (%s): %s\n %s", where, v.ID, v.Impact, v.Help, strings.Join(nodes, "\n ")) |
| 201 | } |
| 202 | return len(vs) |
| 203 | } |
| 204 | |
| 205 | // axeLegs are TestEveryExamplePassesAxe's legs: the themes in English, |
| 206 | // and day in Arabic. Every theme is scanned by one of them. |
| 207 | var axeLegs = []struct{ theme, locale string }{{"day", "en"}, {"plain", "en"}, {"signal", "en"}, {"day", "ar"}} |
| 208 | |
| 209 | // TestEveryExamplePassesAxe is the gate: every frame document frameFiles |
| 210 | // names, scanned in day, plain and signal with data-theme light and |
| 211 | // dark, and in Arabic day, with the WCAG 2.2 AA tag set. Expected: no |
| 212 | // violations. The rules axe could not decide are logged per leg, never |
| 213 | // silently dropped: they are the seam between what this scan holds and |
| 214 | // what a person reviews. Preservation: part 3's gate, iterating |
| 215 | // frameFiles so a document a later task adds is scanned the day it |
| 216 | // lands. |
| 217 | // |
| 218 | // It is one test per leg, so the suite's slices can run the legs on |
| 219 | // shards of their own: TestEveryExamplePassesAxeDayEn and the three |
| 220 | // after it. Each checks that every theme is scanned by some leg, and |
| 221 | // that its own leg scanned something, which the one test checked of |
| 222 | // all four together. |
| 223 | func TestEveryExamplePassesAxeDayEn(t *testing.T) { examplesPassAxe(t, 0) } |
| 224 | func TestEveryExamplePassesAxePlainEn(t *testing.T) { examplesPassAxe(t, 1) } |
| 225 | func TestEveryExamplePassesAxeSignalEn(t *testing.T) { examplesPassAxe(t, 2) } |
| 226 | func TestEveryExamplePassesAxeDayAr(t *testing.T) { examplesPassAxe(t, 3) } |
| 227 | |
| 228 | // examplesPassAxe is TestEveryExamplePassesAxe's leg i. |
| 229 | func examplesPassAxe(t *testing.T, i int) { |
| 230 | if len(axeLegs) != 4 { |
| 231 | t.Fatalf("axeLegs has %d legs and there are four tests, one per leg; a leg needs a test of its own", len(axeLegs)) |
| 232 | } |
| 233 | for _, theme := range fiesta.ThemeNames() { |
| 234 | found := false |
| 235 | for _, l := range axeLegs { |
| 236 | found = found || l.theme == theme |
| 237 | } |
| 238 | if !found { |
| 239 | t.Fatalf("theme %s is scanned by no leg", theme) |
| 240 | } |
| 241 | } |
| 242 | rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| 243 | ctx, cancel := context.WithTimeout(rig.Context(), 18*time.Minute) |
| 244 | defer cancel() |
| 245 | s := newTestSite(t, fiesta.FS(), mountPath) |
| 246 | m := s.m |
| 247 | |
| 248 | scans, failed := 0, 0 |
| 249 | for _, leg := range axeLegs[i : i+1] { |
| 250 | name := leg.theme + " " + leg.locale |
| 251 | incomplete := map[string]int{} |
| 252 | start := time.Now() |
| 253 | for _, e := range m.Examples { |
| 254 | for _, f := range s.frameFiles(e) { |
| 255 | where := name + " " + strings.TrimSuffix(strings.TrimPrefix(f, "frames/"), ".html") |
| 256 | mustRun(t, ctx, |
| 257 | chromedp.EmulateViewport(1280, 900), |
| 258 | chromedp.Navigate(rig.Origin+href(leg.theme+"/"+leg.locale+"/"+f)), |
| 259 | chromedp.WaitReady("body"), |
| 260 | ) |
| 261 | // The body's ground is read back after each flip, and the |
| 262 | // two must differ: a dark scan of a page still painted |
| 263 | // light would be the light scan twice. |
| 264 | ground := map[string]string{} |
| 265 | for _, scheme := range []string{"light", "dark"} { |
| 266 | var bg string |
| 267 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| 268 | ground[scheme] = bg |
| 269 | res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| 270 | if err != nil { |
| 271 | t.Fatalf("%s %s: %v", where, scheme, err) |
| 272 | } |
| 273 | scans++ |
| 274 | for _, id := range res.Incomplete { |
| 275 | incomplete[id]++ |
| 276 | } |
| 277 | failed += report(t, where+" "+scheme, res.Violations) |
| 278 | } |
| 279 | if ground["light"] == ground["dark"] { |
| 280 | t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | var ids []string |
| 285 | for id, n := range incomplete { |
| 286 | ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| 287 | } |
| 288 | sort.Strings(ids) |
| 289 | t.Logf("%s: scanned in %s; needs review, axe could not decide: %v", name, time.Since(start).Round(time.Second), ids) |
| 290 | } |
| 291 | if scans == 0 { |
| 292 | t.Fatal("no document was scanned") |
| 293 | } |
| 294 | t.Logf("%d scans, %d violations", scans, failed) |
| 295 | rig.Screen("body", "the last frame document") |
| 296 | } |
| 297 | |
| 298 | // measureJS reports whether the document scrolls sideways, and the |
| 299 | // elements that reach past either edge. Both edges, because an LTR |
| 300 | // page spills past the right and an RTL page past the left. The edge |
| 301 | // is the root's own laid-out box, not clientWidth, which over a stable |
| 302 | // gutter differs by the scrollbar's width wherever none is drawn. |
| 303 | const measureJS = `(() => { |
| 304 | const de = document.documentElement; |
| 305 | const w = Math.round(de.getBoundingClientRect().width); |
| 306 | const over = []; |
| 307 | document.querySelectorAll("body *").forEach(el => { |
| 308 | const r = el.getBoundingClientRect(); |
| 309 | if (r.width === 0) return; |
| 310 | if (r.right > w + 1 || r.left < -1) { |
| 311 | const cls = (el.className || "").toString().trim().split(/\s+/)[0]; |
| 312 | over.push(el.tagName.toLowerCase() + (cls ? "." + cls : "") + " [" + Math.round(r.left) + "…" + Math.round(r.right) + "]"); |
| 313 | } |
| 314 | }); |
| 315 | return JSON.stringify({doc: de.scrollWidth, client: w, body: document.body.scrollWidth, over: over.slice(0, 8)}); |
| 316 | })()` |
| 317 | |
| 318 | // TestTheGalleryHasNoHorizontalScrollAt320 is WCAG 2.2 AA 1.4.10 |
| 319 | // Reflow on the gallery's own pages: content at a 320 CSS-pixel |
| 320 | // viewport must not need horizontal scrolling. 320x640 is the |
| 321 | // criterion's own number, 1280 CSS px at 400% zoom, so a desktop |
| 322 | // browser rather than mobile emulation. A frame is a scroll container |
| 323 | // by design and may scroll inside its box; the page may not. |
| 324 | func TestTheGalleryHasNoHorizontalScrollAt320(t *testing.T) { |
| 325 | rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| 326 | ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) |
| 327 | defer cancel() |
| 328 | m := manifest(t) |
| 329 | theme := fiesta.ThemeNames()[0] |
| 330 | |
| 331 | pages := []string{theme + "/en/index.html", theme + "/en/tokens.html", theme + "/en/icons.html", theme + "/ar/index.html"} |
| 332 | for _, e := range m.Examples { |
| 333 | pages = append(pages, theme+"/en/"+e.Name+".html") |
| 334 | } |
| 335 | pages = append(pages, theme+"/ar/field.html") |
| 336 | for _, p := range pages { |
| 337 | mustRun(t, ctx, |
| 338 | chromedp.ActionFunc(func(c context.Context) error { |
| 339 | return emulation.SetDeviceMetricsOverride(320, 640, 1, false).Do(c) |
| 340 | }), |
| 341 | chromedp.Navigate(rig.Origin+href(p)), |
| 342 | chromedp.WaitReady("body"), |
| 343 | ) |
| 344 | var got struct { |
| 345 | Doc, Client, Body int |
| 346 | Over []string |
| 347 | } |
| 348 | at(t, ctx, measureJS, &got) |
| 349 | if got.Doc > got.Client || got.Body > got.Client { |
| 350 | t.Errorf("%s scrolls sideways at 320px (WCAG 1.4.10): document %dpx, body %dpx in a %dpx page\n widest: %s", |
| 351 | p, got.Doc, got.Body, got.Client, strings.Join(got.Over, "\n ")) |
| 352 | } |
| 353 | } |
| 354 | t.Logf("%d pages measured", len(pages)) |
| 355 | rig.Screen("main", "the last page") |
| 356 | } |
| 357 | |
| 358 | // submitJS submits every form in every frame on the page, by its |
| 359 | // submit button where it has one, and reports how many submit events |
| 360 | // the frames saw and whether every frame kept its window: a mark set |
| 361 | // on a window before the submits is still there after only if no |
| 362 | // navigation replaced it. |
| 363 | const submitJS = `(async () => { |
| 364 | const frames = [...document.querySelectorAll(".ds-view__frame")]; |
| 365 | let events = 0, forms = 0; |
| 366 | for (const f of frames) { |
| 367 | const w = f.contentWindow, d = f.contentDocument; |
| 368 | w.__mark = 1; |
| 369 | w.addEventListener("submit", () => { events++; }, true); |
| 370 | for (const form of d.querySelectorAll("form")) { |
| 371 | forms++; |
| 372 | const b = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])'); |
| 373 | if (b) b.click(); else form.requestSubmit(); |
| 374 | } |
| 375 | } |
| 376 | await new Promise(r => setTimeout(r, 300)); |
| 377 | const kept = frames.filter(f => f.contentWindow.__mark === 1 && f.contentDocument.readyState === "complete").length; |
| 378 | return JSON.stringify({frames: frames.length, forms, events, kept}); |
| 379 | })()` |
| 380 | |
| 381 | // TestAFrameSubmitsNothing: submitting each form in a few frames makes |
| 382 | // no request beyond the frame's own assets, and the frame keeps its |
| 383 | // document. Three example pages whose states hold forms of different |
| 384 | // kinds: a confirmation, a select inside a field, and a search bar. |
| 385 | func TestAFrameSubmitsNothing(t *testing.T) { |
| 386 | var tr *tree |
| 387 | rig := harness.New(t, func(string) http.Handler { tr = newTree(t, nil); return tr }) |
| 388 | ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) |
| 389 | defer cancel() |
| 390 | theme := fiesta.ThemeNames()[0] |
| 391 | |
| 392 | total := 0 |
| 393 | for _, name := range []string{"confirm-form", "field-select", "list-bar-search"} { |
| 394 | mustRun(t, ctx, |
| 395 | chromedp.EmulateViewport(1280, 900), |
| 396 | chromedp.Navigate(rig.Origin+href(theme+"/en/"+name+".html")), |
| 397 | chromedp.WaitReady("body"), |
| 398 | ) |
| 399 | painted(t, ctx, name) |
| 400 | before := len(tr.seen()) |
| 401 | var got struct{ Frames, Forms, Events, Kept int } |
| 402 | at(t, ctx, submitJS, &got, awaitPromise) |
| 403 | if got.Forms == 0 || got.Events == 0 { |
| 404 | t.Errorf("%s: %d forms submitted and %d submit events seen; the drive submitted nothing", name, got.Forms, got.Events) |
| 405 | } |
| 406 | if got.Kept != got.Frames { |
| 407 | t.Errorf("%s: %d of %d frames kept their document through the submits", name, got.Kept, got.Frames) |
| 408 | } |
| 409 | after := tr.seen() |
| 410 | if len(after) > before { |
| 411 | t.Errorf("%s: the submits made %d request(s):\n %s", name, len(after)-before, strings.Join(after[before:], "\n ")) |
| 412 | } |
| 413 | total += got.Events |
| 414 | t.Logf("%s: %d forms in %d frames, %d submit events, no request", name, got.Forms, got.Frames, got.Events) |
| 415 | } |
| 416 | if total == 0 { |
| 417 | t.Fatal("no submit event was seen on any page") |
| 418 | } |
| 419 | rig.Screen("main", "the last page") |
| 420 | } |
| 421 | |
| 422 | // schemeJS reports the parent's scheme and, for every frame, its |
| 423 | // scheme attribute and the colour painted behind its content: the body |
| 424 | // where it paints one, else the root. |
| 425 | const schemeJS = `(() => { |
| 426 | const paint = d => { |
| 427 | const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor; |
| 428 | return b === "rgba(0, 0, 0, 0)" ? r : b; |
| 429 | }; |
| 430 | const frames = [...document.querySelectorAll(".ds-view__frame")].map(f => ({ |
| 431 | theme: f.contentDocument.documentElement.getAttribute("data-theme") || "", |
| 432 | bg: paint(f.contentDocument), |
| 433 | })); |
| 434 | let stored = ""; |
| 435 | try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {} |
| 436 | return JSON.stringify({theme: document.documentElement.getAttribute("data-theme") || "", stored, frames}); |
| 437 | })()` |
| 438 | |
| 439 | // luminance is the relative luminance of an rgb() string, for telling |
| 440 | // a dark canvas from a light one. |
| 441 | func luminance(t *testing.T, rgb string) float64 { |
| 442 | t.Helper() |
| 443 | var r, g, b float64 |
| 444 | if _, err := fmt.Sscanf(rgb, "rgb(%g, %g, %g)", &r, &g, &b); err != nil { |
| 445 | t.Fatalf("%q is not an rgb() colour: %v", rgb, err) |
| 446 | } |
| 447 | lin := func(c float64) float64 { |
| 448 | c /= 255 |
| 449 | if c <= 0.03928 { |
| 450 | return c / 12.92 |
| 451 | } |
| 452 | return ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055) * ((c + 0.055) / 1.055) |
| 453 | } |
| 454 | return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b) |
| 455 | } |
| 456 | |
| 457 | // chooseScheme picks one scheme from the header's menu, as a reader |
| 458 | // would: open the menu, press the button. A page carries two copies of |
| 459 | // the menu, the pinned bar's and the phone's display settings', and |
| 460 | // one of them is hidden at any width, so the drive finds the visible |
| 461 | // copy and keeps every query inside it; chromedp.Click on the bare |
| 462 | // selector waits on the first copy in the document, the hidden one at |
| 463 | // a desktop width. |
| 464 | func chooseScheme(t *testing.T, ctx context.Context, scheme string) { |
| 465 | t.Helper() |
| 466 | var menus []*cdp.Node |
| 467 | var at int |
| 468 | mustRun(t, ctx, |
| 469 | chromedp.Nodes(`[rst-scheme]`, &menus, chromedp.ByQueryAll), |
| 470 | chromedp.Evaluate(`[...document.querySelectorAll("[rst-scheme]")].findIndex(m => m.checkVisibility())`, &at), |
| 471 | ) |
| 472 | if at < 0 || at >= len(menus) { |
| 473 | t.Fatalf("none of the %d copies of the scheme menu is visible", len(menus)) |
| 474 | } |
| 475 | menu := chromedp.FromNode(menus[at]) |
| 476 | btn := fmt.Sprintf(`input[value=%q] ~ button`, scheme) |
| 477 | mustRun(t, ctx, |
| 478 | chromedp.Click(`summary`, chromedp.ByQuery, menu), |
| 479 | chromedp.WaitVisible(btn, chromedp.ByQuery, menu), |
| 480 | chromedp.Click(btn, chromedp.ByQuery, menu), |
| 481 | ) |
| 482 | } |
| 483 | |
| 484 | // TestTheSchemeMenuRepaintsTheFrames: choosing Dark in the parent |
| 485 | // gives every loaded frame data-theme="dark" and a dark computed |
| 486 | // background, and a frame loaded afterwards starts dark. The later |
| 487 | // frame is added outside gallery.js's reach, so only frame.js can have |
| 488 | // painted it. |
| 489 | func TestTheSchemeMenuRepaintsTheFrames(t *testing.T) { |
| 490 | rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| 491 | ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) |
| 492 | defer cancel() |
| 493 | theme := fiesta.ThemeNames()[0] |
| 494 | |
| 495 | mustRun(t, ctx, |
| 496 | chromedp.EmulateViewport(1280, 900), |
| 497 | chromedp.Navigate(rig.Origin+href(theme+"/en/field.html")), |
| 498 | chromedp.WaitReady("body"), |
| 499 | ) |
| 500 | n := painted(t, ctx, "field") |
| 501 | |
| 502 | type reading struct { |
| 503 | Theme, Stored string |
| 504 | Frames []struct{ Theme, Bg string } |
| 505 | } |
| 506 | var before reading |
| 507 | at(t, ctx, schemeJS, &before) |
| 508 | if before.Theme != "" || before.Stored != "" { |
| 509 | t.Fatalf("before any choice the page is on %q with %q stored; the drive would be testing a remembered choice", before.Theme, before.Stored) |
| 510 | } |
| 511 | for i, f := range before.Frames { |
| 512 | if f.Theme != "" { |
| 513 | t.Errorf("frame %d starts on %q, want the OS's scheme", i, f.Theme) |
| 514 | } |
| 515 | if l := luminance(t, f.Bg); l < 0.5 { |
| 516 | t.Errorf("frame %d starts dark (%s); the reading after Dark would prove nothing", i, f.Bg) |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | chooseScheme(t, ctx, "dark") |
| 521 | deadline := time.Now().Add(10 * time.Second) |
| 522 | var after reading |
| 523 | for { |
| 524 | at(t, ctx, schemeJS, &after) |
| 525 | dark := 0 |
| 526 | for _, f := range after.Frames { |
| 527 | if f.Theme == "dark" { |
| 528 | dark++ |
| 529 | } |
| 530 | } |
| 531 | if dark == len(after.Frames) || !time.Now().Before(deadline) { |
| 532 | break |
| 533 | } |
| 534 | time.Sleep(50 * time.Millisecond) |
| 535 | } |
| 536 | if after.Theme != "dark" { |
| 537 | t.Errorf("after choosing Dark the page is on %q", after.Theme) |
| 538 | } |
| 539 | if after.Stored != "dark" { |
| 540 | t.Errorf("after choosing Dark the page stored %q; the header's menu is the standalone state, which remembers", after.Stored) |
| 541 | } |
| 542 | if len(after.Frames) != n { |
| 543 | t.Fatalf("%d frames read, %d loaded", len(after.Frames), n) |
| 544 | } |
| 545 | for i, f := range after.Frames { |
| 546 | if f.Theme != "dark" { |
| 547 | t.Errorf("frame %d is on %q after Dark was chosen", i, f.Theme) |
| 548 | } |
| 549 | if l := luminance(t, f.Bg); l > 0.2 { |
| 550 | t.Errorf("frame %d paints %s after Dark was chosen, which is not dark", i, f.Bg) |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | // A frame loaded afterwards, outside gallery.js's class, so the |
| 555 | // attribute it starts with is frame.js's doing alone. |
| 556 | later := fmt.Sprintf(`(async () => { |
| 557 | const f = document.createElement("iframe"); |
| 558 | f.src = %q; |
| 559 | const loaded = new Promise(r => f.addEventListener("load", r)); |
| 560 | document.querySelector("main").append(f); |
| 561 | await loaded; |
| 562 | const d = f.contentDocument; |
| 563 | const b = getComputedStyle(d.body).backgroundColor, r = getComputedStyle(d.documentElement).backgroundColor; |
| 564 | return JSON.stringify({theme: d.documentElement.getAttribute("data-theme") || "", bg: b === "rgba(0, 0, 0, 0)" ? r : b}); |
| 565 | })()`, href(frameFile(theme, "en", "badge", mustDefault(t, "badge")))) |
| 566 | var fresh struct{ Theme, Bg string } |
| 567 | at(t, ctx, later, &fresh, awaitPromise) |
| 568 | if fresh.Theme != "dark" { |
| 569 | t.Errorf("a frame loaded after the choice starts on %q, want dark", fresh.Theme) |
| 570 | } |
| 571 | if l := luminance(t, fresh.Bg); l > 0.2 { |
| 572 | t.Errorf("a frame loaded after the choice paints %s, which is not dark", fresh.Bg) |
| 573 | } |
| 574 | rig.Screen("main", "the field page in Dark") |
| 575 | } |
| 576 | |
| 577 | // placeJS reports where the reader's focus and scroll are on the |
| 578 | // gallery page, and how many of its frames hold a field that asked for |
| 579 | // autofocus. |
| 580 | const placeJS = `(() => { |
| 581 | const asked = [...document.querySelectorAll(".ds-view__frame")].filter(f => f.contentDocument.querySelector("[autofocus]")).length; |
| 582 | const a = document.activeElement; |
| 583 | return JSON.stringify({active: a ? a.tagName.toLowerCase() : "", scrollY: Math.round(scrollY), asked}); |
| 584 | })()` |
| 585 | |
| 586 | // TestFramesKeepTheReadersPlace: on the signin example's page, whose |
| 587 | // states ask for autofocus, loading every frame leaves focus on the |
| 588 | // parent and the page where it was. A browser grants a same-origin |
| 589 | // frame's autofocus, so without frame.js's hold the first frame to |
| 590 | // load would take the reader's focus and scroll the page to itself. |
| 591 | func TestFramesKeepTheReadersPlace(t *testing.T) { |
| 592 | rig := harness.New(t, func(string) http.Handler { return newTree(t, nil) }) |
| 593 | ctx, cancel := context.WithTimeout(rig.Context(), 2*time.Minute) |
| 594 | defer cancel() |
| 595 | theme := fiesta.ThemeNames()[0] |
| 596 | |
| 597 | mustRun(t, ctx, |
| 598 | chromedp.EmulateViewport(1280, 900), |
| 599 | chromedp.Navigate(rig.Origin+href(theme+"/en/signin.html")), |
| 600 | chromedp.WaitReady("body"), |
| 601 | ) |
| 602 | painted(t, ctx, "signin") |
| 603 | // Autofocus lands at a rendering update after load, so the reading |
| 604 | // waits two frames. |
| 605 | mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) |
| 606 | var got struct { |
| 607 | Active string |
| 608 | ScrollY, Asked int |
| 609 | } |
| 610 | at(t, ctx, placeJS, &got) |
| 611 | if got.Asked == 0 { |
| 612 | t.Fatal("no frame on the signin page asks for autofocus; the check below would prove nothing") |
| 613 | } |
| 614 | if got.Active != "body" { |
| 615 | t.Errorf("after the frames loaded, focus is on the parent's %s, not its body: a frame took it", got.Active) |
| 616 | } |
| 617 | if got.ScrollY != 0 { |
| 618 | t.Errorf("after the frames loaded, the page is scrolled to %dpx: a frame's autofocus moved it", got.ScrollY) |
| 619 | } |
| 620 | t.Logf("signin: %d frames asked for autofocus; focus is on the parent's %s at %dpx", got.Asked, got.Active, got.ScrollY) |
| 621 | rig.Screen("main", "the signin page") |
| 622 | } |
| 623 | |
| 624 | // mustDefault is the manifest's default state for name. |
| 625 | func mustDefault(t *testing.T, name string) string { |
| 626 | t.Helper() |
| 627 | state, ok := manifest(t).Default(name) |
| 628 | if !ok || state == "" { |
| 629 | t.Fatalf("the manifest has no default state for %s", name) |
| 630 | } |
| 631 | return state |
| 632 | } |
| 633 | |
| 634 | // statesOf lists one family's states in manifest order, as |
| 635 | // name/state pairs. |
| 636 | func statesOf(m *example.Manifest, family string) [][2]string { |
| 637 | var out [][2]string |
| 638 | for _, e := range m.Examples { |
| 639 | if e.Family != family { |
| 640 | continue |
| 641 | } |
| 642 | for _, s := range e.States { |
| 643 | out = append(out, [2]string{e.Name, s.ID}) |
| 644 | } |
| 645 | } |
| 646 | return out |
| 647 | } |
| 648 | |