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 "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.
24func 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.
41func 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.
114func 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.
176func 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.
194func 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