| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "sort" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/chromedp/chromedp" |
| 14 | |
| 15 | "amadan.net/rastrillo/fiesta" |
| 16 | "amadan.net/rastrillo/fiesta/internal/axe" |
| 17 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 18 | ) |
| 19 | |
| 20 | // The demo application in an engine: its journeys with scripts off, |
| 21 | // its rows clickable edge to edge, and its four documents under axe. |
| 22 | |
| 23 | // demoURL is one of the demo's four documents in day/en, by view. |
| 24 | func demoURL(rig *harness.Rig, view string) string { |
| 25 | return rig.Origin + href(fiesta.ThemeNames()[0]+"/en/"+demoFile(view)) |
| 26 | } |
| 27 | |
| 28 | // TestTheDemoApplicationWorksWithNoScript is the demo's claim: four |
| 29 | // pages, each a real document, and with script execution disabled in |
| 30 | // the engine every journey still works. At 1280 the rail and the pages: |
| 31 | // land, follow the rail to the list, a row into the request, the back |
| 32 | // link out. At 390 the phone index: the rail is the page, a row opens |
| 33 | // the list with a back control and no rail, and the back control |
| 34 | // returns to the index with the row it left as the :target. |
| 35 | // |
| 36 | // At every stop exactly one view is on the page, read off computed |
| 37 | // style: a second visible view would be two page headers stacked, |
| 38 | // which reads as a broken build rather than as an app. Ported; the |
| 39 | // control that scripts are off is the gallery's own marker, read on a |
| 40 | // gallery page in the same tab, since the demo loads no gallery.js. |
| 41 | func TestTheDemoApplicationWorksWithNoScript(t *testing.T) { |
| 42 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 43 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 44 | defer cancel() |
| 45 | ctx = noScripts(t, ctx) |
| 46 | const views = `[...document.querySelectorAll(".app-view")].filter(v => getComputedStyle(v).display !== "none").map(v => v.id).join(",")` |
| 47 | shown := func(sel string) bool { |
| 48 | t.Helper() |
| 49 | var b bool |
| 50 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const e = document.querySelector(%q); return !!e && e.getClientRects().length > 0; })()`, sel), &b)) |
| 51 | return b |
| 52 | } |
| 53 | // goTo clicks sel and waits until the address ends with file and |
| 54 | // the destination's own section is in the document: the rail is in |
| 55 | // all four documents, so waiting for a rail link would pass in the |
| 56 | // page being left. It also waits out the narrow slide between |
| 57 | // pages, since a click sent during a view transition lands on its |
| 58 | // snapshot and does nothing. |
| 59 | goTo := func(sel, file, marker string) { |
| 60 | t.Helper() |
| 61 | mustRun(t, ctx, chromedp.Click(sel, chromedp.ByQuery)) |
| 62 | until(t, ctx, "clicking "+sel, fmt.Sprintf(`location.pathname.endsWith(%q) && !!document.querySelector(%q) && document.readyState === "complete" && !document.activeViewTransition`, "/"+file, marker)) |
| 63 | } |
| 64 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/index.html")), chromedp.WaitReady("body")) |
| 65 | requireScriptsOff(t, ctx, "the Overview in the same tab") |
| 66 | |
| 67 | var landed, list, detail, back string |
| 68 | mustRun(t, ctx, chromedp.Navigate(demoURL(rig, "index")), chromedp.WaitReady("body")) |
| 69 | mustRun(t, ctx, chromedp.Evaluate(views, &landed)) |
| 70 | if landed != "view-dashboard" { |
| 71 | t.Fatalf("landing on demo.html the page shows %q, want exactly view-dashboard; nothing to drive", landed) |
| 72 | } |
| 73 | goTo(`#nav-requests`, "demo-requests.html", "#view-requests") |
| 74 | mustRun(t, ctx, chromedp.Evaluate(views, &list)) |
| 75 | goTo(`#view-requests [rst-lrow] a.rst-nm`, "demo-request.html", "#view-request") |
| 76 | mustRun(t, ctx, chromedp.Evaluate(views, &detail)) |
| 77 | goTo(`#view-request [rst-back-nav] a`, "demo-requests.html", "#view-requests") |
| 78 | mustRun(t, ctx, chromedp.Evaluate(views, &back)) |
| 79 | for _, s := range []struct{ where, got, want string }{ |
| 80 | {"landing on demo.html", landed, "view-dashboard"}, {"the rail to the list", list, "view-requests"}, |
| 81 | {"a row into the request", detail, "view-request"}, {"the back link out", back, "view-requests"}, |
| 82 | } { |
| 83 | if s.got != s.want { |
| 84 | t.Errorf("%s: the page shows %q, want exactly %q", s.where, s.got, s.want) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(demoURL(rig, "index")), chromedp.WaitReady("body")) |
| 89 | if !shown("#nav-requests") || shown("[rst-shell-main]") { |
| 90 | t.Errorf("demo.html at 390 is not the index (rail shown %v, main shown %v)", shown("#nav-requests"), shown("[rst-shell-main]")) |
| 91 | } |
| 92 | goTo("#nav-requests", "demo-requests.html", "#view-requests") |
| 93 | if !shown("[rst-shell-back] a") || shown("#nav-requests") { |
| 94 | t.Errorf("the list at 390: back control shown %v, rail shown %v; want the back control and no rail", shown("[rst-shell-back] a"), shown("#nav-requests")) |
| 95 | } |
| 96 | goTo("[rst-shell-back] a", "demo.html", "#view-dashboard") |
| 97 | var target bool |
| 98 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-requests").matches(":target")`, &target)) |
| 99 | if !target { |
| 100 | t.Error("back on the index, the Requests row is not the :target; the scriptless focus return has nothing to start from") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // TestTheDemoRequestsRowsAreClickableEdgeToEdge is the whole-row |
| 105 | // target's demo leg. The Requests list is a list grid with a status |
| 106 | // pill in every row, written with no markup of its own for the |
| 107 | // whole-row rule, so it is the check that the rule reaches a real |
| 108 | // screen from tokens.css alone. At 1280 with a mouse and 390 with a |
| 109 | // coarse pointer, a click at the row's far edge opens the request; at |
| 110 | // 1280, where the status and updated columns are shown, so does a click |
| 111 | // on the pill and on the time. The destination is read off the request |
| 112 | // page's own section being in the document, which only the click can |
| 113 | // produce. Ported. |
| 114 | func TestTheDemoRequestsRowsAreClickableEdgeToEdge(t *testing.T) { |
| 115 | for _, leg := range []struct { |
| 116 | name string |
| 117 | w, h int64 |
| 118 | coarse bool |
| 119 | }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} { |
| 120 | t.Run(leg.name, func(t *testing.T) { |
| 121 | var opts []harness.Option |
| 122 | if leg.coarse { |
| 123 | opts = append(opts, harness.WithCoarsePointer()) |
| 124 | } |
| 125 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, opts...) |
| 126 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 127 | defer cancel() |
| 128 | url := demoURL(rig, "requests") |
| 129 | mustRun(t, ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| 130 | var rows int |
| 131 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll('#view-requests [rst-lrow]:not([rst-lrow~="head"])').length`, &rows)) |
| 132 | if rows == 0 { |
| 133 | t.Fatalf("%s has no request rows to click", url) |
| 134 | } |
| 135 | var coarse bool |
| 136 | mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &coarse)) |
| 137 | if coarse != leg.coarse { |
| 138 | t.Fatalf("(pointer: coarse) is %v, this leg needs %v; the rig is not the device this leg claims to measure", coarse, leg.coarse) |
| 139 | } |
| 140 | const row = `#view-requests [rst-lrow]:not([rst-lrow~="head"])` |
| 141 | for _, c := range []struct { |
| 142 | where, sel string |
| 143 | fx, dx float64 |
| 144 | wideOnly bool |
| 145 | }{ |
| 146 | {"the first row's far edge", row, 1, -12, false}, |
| 147 | // Both cells are rst-m-hide: below 800px they have no box. |
| 148 | {"the first row's status pill", row + ` [rst-status]`, 0.5, 0, true}, |
| 149 | {"the first row's updated time", row + ` .rst-cell-mut`, 0.5, 0, true}, |
| 150 | } { |
| 151 | if c.wideOnly && leg.coarse { |
| 152 | continue |
| 153 | } |
| 154 | mustRun(t, ctx, chromedp.Navigate(url)) |
| 155 | until(t, ctx, leg.name, `!!document.getElementById("view-requests") && document.readyState === "complete" && !document.activeViewTransition`) |
| 156 | var p struct { |
| 157 | X, Y float64 |
| 158 | Hit string |
| 159 | } |
| 160 | at(t, ctx, fmt.Sprintf(`(() => { const el = document.querySelector(%q); el.scrollIntoView({block: "center"}); const r = el.getBoundingClientRect(); |
| 161 | const x = r.left + r.width * %v + %v, y = r.top + r.height / 2, h = document.elementFromPoint(x, y), link = document.querySelector(%q); |
| 162 | return JSON.stringify({X: x, Y: y, Hit: h === link ? "the row's link" : h ? h.tagName + " " + h.className : "nothing"}); })()`, c.sel, c.fx, c.dx, row+` > a.rst-nm`), &p) |
| 163 | if p.Hit != "the row's link" { |
| 164 | t.Errorf("%s: at %s the element under the pointer is %s, want the row's link", leg.name, c.where, p.Hit) |
| 165 | } |
| 166 | mustRun(t, ctx, chromedp.MouseClickXY(p.X, p.Y)) |
| 167 | if !demoPoll(ctx, `location.pathname.endsWith("/demo-request.html") && !!document.getElementById("view-request")`) { |
| 168 | t.Errorf("%s: a click at %s (%.0f, %.0f) did not open the request", leg.name, c.where, p.X, p.Y) |
| 169 | } |
| 170 | } |
| 171 | }) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | // demoPoll reports whether a page expression became true within 10s. |
| 176 | func demoPoll(ctx context.Context, expr string) bool { |
| 177 | deadline := time.Now().Add(10 * time.Second) |
| 178 | for time.Now().Before(deadline) { |
| 179 | var yes bool |
| 180 | if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes { |
| 181 | return true |
| 182 | } |
| 183 | time.Sleep(100 * time.Millisecond) |
| 184 | } |
| 185 | return false |
| 186 | } |
| 187 | |
| 188 | // TestTheDemoApplicationPassesAxe: the demo's four documents in day, |
| 189 | // with data-theme light and dark, at 1280, under the WCAG 2.2 AA tag |
| 190 | // set. Expected: no violations; the rules axe could not decide are |
| 191 | // logged. The body's ground is read back after each flip and the two |
| 192 | // must differ, so a dark scan of a page still painted light cannot pass |
| 193 | // as two scans. |
| 194 | func TestTheDemoApplicationPassesAxe(t *testing.T) { |
| 195 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 196 | ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) |
| 197 | defer cancel() |
| 198 | theme := fiesta.ThemeNames()[0] |
| 199 | incomplete := map[string]int{} |
| 200 | scans, failed := 0, 0 |
| 201 | for _, view := range demoViews { |
| 202 | file := demoFile(view) |
| 203 | where := theme + "/en/" + file |
| 204 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(where)), chromedp.WaitReady("body")) |
| 205 | settleMotion(t, ctx, where) |
| 206 | var views int |
| 207 | mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".app-view").length`, &views)) |
| 208 | if views != 1 { |
| 209 | t.Fatalf("%s shows %d of the demo's views, want one; the scan would read something else", where, views) |
| 210 | } |
| 211 | ground := map[string]string{} |
| 212 | for _, scheme := range []string{"light", "dark"} { |
| 213 | var bg string |
| 214 | mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) |
| 215 | ground[scheme] = bg |
| 216 | res, err := axe.Scan(ctx, "", axe.DefaultTags()) |
| 217 | if err != nil { |
| 218 | t.Fatalf("%s %s: %v", where, scheme, err) |
| 219 | } |
| 220 | scans++ |
| 221 | for _, id := range res.Incomplete { |
| 222 | incomplete[id]++ |
| 223 | } |
| 224 | failed += report(t, where+" "+scheme, res.Violations) |
| 225 | } |
| 226 | if ground["light"] == ground["dark"] { |
| 227 | t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) |
| 228 | } |
| 229 | } |
| 230 | if scans != 2*len(demoViews) { |
| 231 | t.Fatalf("%d scans, want %d", scans, 2*len(demoViews)) |
| 232 | } |
| 233 | var ids []string |
| 234 | for id, n := range incomplete { |
| 235 | ids = append(ids, fmt.Sprintf("%s x%d", id, n)) |
| 236 | } |
| 237 | sort.Strings(ids) |
| 238 | t.Logf("%d scans, %d violations; needs review, axe could not decide: %v", scans, failed, ids) |
| 239 | } |
| 240 | |