//go:build browser package gallery import ( "context" "fmt" "net/http" "sort" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/axe" "amadan.net/rastrillo/fiesta/internal/harness" ) // The demo application in an engine: its journeys with scripts off, // its rows clickable edge to edge, and its four documents under axe. // demoURL is one of the demo's four documents in day/en, by view. func demoURL(rig *harness.Rig, view string) string { return rig.Origin + href(fiesta.ThemeNames()[0]+"/en/"+demoFile(view)) } // TestTheDemoApplicationWorksWithNoScript is the demo's claim: four // pages, each a real document, and with script execution disabled in // the engine every journey still works. At 1280 the rail and the pages: // land, follow the rail to the list, a row into the request, the back // link out. At 390 the phone index: the rail is the page, a row opens // the list with a back control and no rail, and the back control // returns to the index with the row it left as the :target. // // At every stop exactly one view is on the page, read off computed // style: a second visible view would be two page headers stacked, // which reads as a broken build rather than as an app. Ported; the // control that scripts are off is the gallery's own marker, read on a // gallery page in the same tab, since the demo loads no gallery.js. func TestTheDemoApplicationWorksWithNoScript(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() ctx = noScripts(t, ctx) const views = `[...document.querySelectorAll(".app-view")].filter(v => getComputedStyle(v).display !== "none").map(v => v.id).join(",")` shown := func(sel string) bool { t.Helper() var b bool mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(() => { const e = document.querySelector(%q); return !!e && e.getClientRects().length > 0; })()`, sel), &b)) return b } // goTo clicks sel and waits until the address ends with file and // the destination's own section is in the document: the rail is in // all four documents, so waiting for a rail link would pass in the // page being left. It also waits out the narrow slide between // pages, since a click sent during a view transition lands on its // snapshot and does nothing. goTo := func(sel, file, marker string) { t.Helper() mustRun(t, ctx, chromedp.Click(sel, chromedp.ByQuery)) until(t, ctx, "clicking "+sel, fmt.Sprintf(`location.pathname.endsWith(%q) && !!document.querySelector(%q) && document.readyState === "complete" && !document.activeViewTransition`, "/"+file, marker)) } mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(fiesta.ThemeNames()[0]+"/en/index.html")), chromedp.WaitReady("body")) requireScriptsOff(t, ctx, "the Overview in the same tab") var landed, list, detail, back string mustRun(t, ctx, chromedp.Navigate(demoURL(rig, "index")), chromedp.WaitReady("body")) mustRun(t, ctx, chromedp.Evaluate(views, &landed)) if landed != "view-dashboard" { t.Fatalf("landing on demo.html the page shows %q, want exactly view-dashboard; nothing to drive", landed) } goTo(`#nav-requests`, "demo-requests.html", "#view-requests") mustRun(t, ctx, chromedp.Evaluate(views, &list)) goTo(`#view-requests [rst-lrow] a.rst-nm`, "demo-request.html", "#view-request") mustRun(t, ctx, chromedp.Evaluate(views, &detail)) goTo(`#view-request [rst-back-nav] a`, "demo-requests.html", "#view-requests") mustRun(t, ctx, chromedp.Evaluate(views, &back)) for _, s := range []struct{ where, got, want string }{ {"landing on demo.html", landed, "view-dashboard"}, {"the rail to the list", list, "view-requests"}, {"a row into the request", detail, "view-request"}, {"the back link out", back, "view-requests"}, } { if s.got != s.want { t.Errorf("%s: the page shows %q, want exactly %q", s.where, s.got, s.want) } } mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(demoURL(rig, "index")), chromedp.WaitReady("body")) if !shown("#nav-requests") || shown("[rst-shell-main]") { t.Errorf("demo.html at 390 is not the index (rail shown %v, main shown %v)", shown("#nav-requests"), shown("[rst-shell-main]")) } goTo("#nav-requests", "demo-requests.html", "#view-requests") if !shown("[rst-shell-back] a") || shown("#nav-requests") { 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")) } goTo("[rst-shell-back] a", "demo.html", "#view-dashboard") var target bool mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("nav-requests").matches(":target")`, &target)) if !target { t.Error("back on the index, the Requests row is not the :target; the scriptless focus return has nothing to start from") } } // TestTheDemoRequestsRowsAreClickableEdgeToEdge is the whole-row // target's demo leg. The Requests list is a list grid with a status // pill in every row, written with no markup of its own for the // whole-row rule, so it is the check that the rule reaches a real // screen from tokens.css alone. At 1280 with a mouse and 390 with a // coarse pointer, a click at the row's far edge opens the request; at // 1280, where the status and updated columns are shown, so does a click // on the pill and on the time. The destination is read off the request // page's own section being in the document, which only the click can // produce. Ported. func TestTheDemoRequestsRowsAreClickableEdgeToEdge(t *testing.T) { for _, leg := range []struct { name string w, h int64 coarse bool }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} { t.Run(leg.name, func(t *testing.T) { var opts []harness.Option if leg.coarse { opts = append(opts, harness.WithCoarsePointer()) } rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }, opts...) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() url := demoURL(rig, "requests") mustRun(t, ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(url), chromedp.WaitReady("body")) var rows int mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll('#view-requests [rst-lrow]:not([rst-lrow~="head"])').length`, &rows)) if rows == 0 { t.Fatalf("%s has no request rows to click", url) } var coarse bool mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &coarse)) if coarse != leg.coarse { t.Fatalf("(pointer: coarse) is %v, this leg needs %v; the rig is not the device this leg claims to measure", coarse, leg.coarse) } const row = `#view-requests [rst-lrow]:not([rst-lrow~="head"])` for _, c := range []struct { where, sel string fx, dx float64 wideOnly bool }{ {"the first row's far edge", row, 1, -12, false}, // Both cells are rst-m-hide: below 800px they have no box. {"the first row's status pill", row + ` [rst-status]`, 0.5, 0, true}, {"the first row's updated time", row + ` .rst-cell-mut`, 0.5, 0, true}, } { if c.wideOnly && leg.coarse { continue } mustRun(t, ctx, chromedp.Navigate(url)) until(t, ctx, leg.name, `!!document.getElementById("view-requests") && document.readyState === "complete" && !document.activeViewTransition`) var p struct { X, Y float64 Hit string } at(t, ctx, fmt.Sprintf(`(() => { const el = document.querySelector(%q); el.scrollIntoView({block: "center"}); const r = el.getBoundingClientRect(); const x = r.left + r.width * %v + %v, y = r.top + r.height / 2, h = document.elementFromPoint(x, y), link = document.querySelector(%q); 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) if p.Hit != "the row's link" { t.Errorf("%s: at %s the element under the pointer is %s, want the row's link", leg.name, c.where, p.Hit) } mustRun(t, ctx, chromedp.MouseClickXY(p.X, p.Y)) if !demoPoll(ctx, `location.pathname.endsWith("/demo-request.html") && !!document.getElementById("view-request")`) { t.Errorf("%s: a click at %s (%.0f, %.0f) did not open the request", leg.name, c.where, p.X, p.Y) } } }) } } // demoPoll reports whether a page expression became true within 10s. func demoPoll(ctx context.Context, expr string) bool { deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { var yes bool if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes { return true } time.Sleep(100 * time.Millisecond) } return false } // TestTheDemoApplicationPassesAxe: the demo's four documents in day, // with data-theme light and dark, at 1280, under the WCAG 2.2 AA tag // set. Expected: no violations; the rules axe could not decide are // logged. The body's ground is read back after each flip and the two // must differ, so a dark scan of a page still painted light cannot pass // as two scans. func TestTheDemoApplicationPassesAxe(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 5*time.Minute) defer cancel() theme := fiesta.ThemeNames()[0] incomplete := map[string]int{} scans, failed := 0, 0 for _, view := range demoViews { file := demoFile(view) where := theme + "/en/" + file mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(where)), chromedp.WaitReady("body")) settleMotion(t, ctx, where) var views int mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".app-view").length`, &views)) if views != 1 { t.Fatalf("%s shows %d of the demo's views, want one; the scan would read something else", where, views) } ground := map[string]string{} for _, scheme := range []string{"light", "dark"} { var bg string mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) ground[scheme] = bg res, err := axe.Scan(ctx, "", axe.DefaultTags()) if err != nil { t.Fatalf("%s %s: %v", where, scheme, err) } scans++ for _, id := range res.Incomplete { incomplete[id]++ } failed += report(t, where+" "+scheme, res.Violations) } if ground["light"] == ground["dark"] { t.Fatalf("%s: the body paints %s under both schemes, so the flip to dark did not take", where, ground["light"]) } } if scans != 2*len(demoViews) { t.Fatalf("%d scans, want %d", scans, 2*len(demoViews)) } var ids []string for id, n := range incomplete { ids = append(ids, fmt.Sprintf("%s x%d", id, n)) } sort.Strings(ids) t.Logf("%d scans, %d violations; needs review, axe could not decide: %v", scans, failed, ids) }