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 "math"
10 "testing"
11 "time"
12
13 "github.com/chromedp/cdproto/accessibility"
14 "github.com/chromedp/cdproto/cdp"
15 "github.com/chromedp/chromedp"
16 "github.com/chromedp/chromedp/kb"
17
18 "amadan.net/rastrillo/fiesta"
19)
20
21// The display settings menu on a phone: the theme, scheme and language
22// controls as one cog menu at the top of every page.
23
24// prefsJS reads the display settings menu on a phone: where its button
25// sits (against the root's box, since tokens.css reserves a stable
26// scrollbar gutter that RTL puts on the left), whether the card is
27// open and shown, and for every theme link, the scheme menu's summary
28// and buttons, the language summary and every language, whether it can
29// be brought on screen inside the card and is then the topmost thing
30// at its centre.
31const prefsJS = `(() => {
32 const q = s => document.querySelector(s), h = document.documentElement.getBoundingClientRect();
33 const rtl = document.documentElement.dir === "rtl", d = q(".ds-prefs"), s = d.querySelector("summary"), r = s.getBoundingClientRect();
34 const card = d.querySelector(":scope > [rst-dropdown-menu]");
35 const controls = [...card.querySelectorAll("[rst-seg-tabs] a, [rst-scheme] > summary, [rst-scheme] [rst-dropdown-menu] button, [rst-locale] > summary, [rst-locale] [rst-dropdown-menu] a")]
36 .filter(el => el.checkVisibility()).map(el => {
37 el.scrollIntoView({block: "nearest", inline: "nearest"});
38 const b = el.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
39 return {Text: el.textContent.trim(), On: b.top >= 0 && b.bottom <= innerHeight && b.left >= h.left && b.right <= h.right,
40 Top: !!hit && (hit === el || el.contains(hit)), H: Math.round(b.height)};
41 });
42 const strip = q("[rst-shell-back]"), sr = strip && strip.checkVisibility() ? strip.getBoundingClientRect() : null;
43 return JSON.stringify({Open: d.open, Shown: card.checkVisibility(), Top: Math.round(r.top), Mid: r.top + r.height / 2,
44 StripH: sr ? sr.height : 0, StripMid: sr ? sr.top + sr.height / 2 : 0, End: Math.round(rtl ? r.left - h.left : h.right - r.right),
45 W: Math.round(r.width), H: Math.round(r.height), InStrip: !!strip && strip.contains(d), Controls: controls,
46 Themes: card.querySelectorAll("[rst-seg-tabs] a").length, Schemes: card.querySelectorAll("[rst-scheme] [rst-dropdown-menu] button").length,
47 Locales: card.querySelectorAll("[rst-locale] [rst-dropdown-menu] a").length,
48 Focus: document.activeElement === s, Path: location.pathname, Hash: location.hash});
49})()`
50
51type prefsControl struct {
52 Text string
53 On, Top bool
54 H int
55}
56
57type prefsReading struct {
58 Open, Shown, InStrip, Focus bool
59 Top, End, W, H int
60 Mid, StripH, StripMid float64
61 Themes, Schemes, Locales int
62 Controls []prefsControl
63 Path, Hash string
64}
65
66func readPrefs(t *testing.T, ctx context.Context, where string) prefsReading {
67 t.Helper()
68 var raw string
69 mustRun(t, ctx, chromedp.Evaluate(prefsJS, &raw))
70 var p prefsReading
71 if err := json.Unmarshal([]byte(raw), &p); err != nil {
72 t.Fatalf("%s: decoding %q: %v", where, raw, err)
73 }
74 return p
75}
76
77// tapAt taps the centre of sel the way a finger does: scrolled to only
78// if it is off screen, then a press and release at a point. chromedp's
79// selector click scrolls its target into view even when it is on
80// screen, and a button in the sticky strip sits inside the scroll
81// padding, so any scroll-into-view moves the page.
82func tapAt(t *testing.T, ctx context.Context, where, sel string) {
83 t.Helper()
84 var xy []float64
85 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const el = document.querySelector(`+"`"+sel+"`"+`); const o = el.getBoundingClientRect(); if (o.top < 0 || o.bottom > innerHeight) el.scrollIntoView({block: "nearest"}); const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()`, &xy)); err != nil || len(xy) != 2 {
86 t.Fatalf("%s: finding %s: %v", where, sel, err)
87 }
88 mustRun(t, ctx, chromedp.MouseClickXY(xy[0], xy[1]))
89}
90
91// axNameOf is the accessible name the browser computes for sel, read
92// off the accessibility tree rather than off an attribute.
93func axNameOf(t *testing.T, ctx context.Context, where, sel string) string {
94 t.Helper()
95 var nodes []*cdp.Node
96 if err := chromedp.Run(ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)); err != nil || len(nodes) == 0 {
97 t.Fatalf("%s: no node for %s: %v", where, sel, err)
98 }
99 var name string
100 mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error {
101 ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c)
102 if err == nil && len(ax) > 0 && ax[0].Name != nil {
103 _ = json.Unmarshal(ax[0].Name.Value, &name)
104 }
105 return err
106 }))
107 return name
108}
109
110// TestTheDisplaySettingsMenuOnAPhone: one menu at the top of every
111// page, in the index's header row and at the inline end of a content
112// page's back strip, in English and Arabic, scripts on and off, at 390
113// on a coarse pointer. Its button is a 44px icon named with the
114// approved string. Opened, every theme link, the scheme menu and its
115// three buttons, and every language can be brought on screen in the
116// card and is above the page; the two nested menus open in place, one
117// at a time. An outside tap closes it and reaches nothing under it;
118// with scripts on, Escape closes it and hands focus back.
119func TestTheDisplaySettingsMenuOnAPhone(t *testing.T) {
120 rig := phoneRig(t)
121 ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second)
122 defer cancel()
123 s := newTestSite(t, fiesta.FS(), mountPath)
124 theme := fiesta.ThemeNames()[0]
125 for _, scripts := range []bool{true, false} {
126 c := ctx
127 if !scripts {
128 c = noScripts(t, ctx)
129 }
130 for _, locale := range []string{"en", "ar"} {
131 for _, file := range []string{"index.html", "form.html"} {
132 where := fmt.Sprintf("%s/%s/%s at 390px, scripts %v", theme, locale, file, scripts)
133 mustRun(t, c, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady("body"))
134 present(t, c, where, ".ds-prefs > summary")
135 mustRun(t, c, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery))
136 requireCoarse(t, c)
137 if !scripts {
138 requireScriptsOff(t, c, where)
139 }
140 settleMotion(t, c, where)
141 p := readPrefs(t, c, where)
142 index := file == "index.html"
143 if p.Open || p.Shown || p.W < 44 || p.H < 44 || p.End > 24 || index && p.Top > 32 || !index && !p.InStrip {
144 t.Errorf("%s: closed %v shown %v, a %dx%dpx button %dpx from the top and %dpx from the inline end, in the back strip %v; want a closed 44px button at the top inline-end (in the strip on a content page)",
145 where, !p.Open, p.Shown, p.W, p.H, p.Top, p.End, p.InStrip)
146 }
147 if index && math.Abs(p.Mid-28) > 1 || !index && (math.Abs(p.StripH-56) > 0.5 || math.Abs(p.Mid-p.StripMid) > 1) {
148 t.Errorf("%s: the button's centre line is at %.1fpx; the back strip is %.1fpx tall with its centre at %.1fpx; want the button centred in a 56px header strip", where, p.Mid, p.StripH, p.StripMid)
149 }
150 if name, want := axNameOf(t, c, where, ".ds-prefs > summary"), s.say(locale, "gallery.prefs"); name != want {
151 t.Errorf("%s: the button is named %q, want %q", where, name, want)
152 }
153 mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] > summary", chromedp.ByQuery),
154 chromedp.Click(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] [rst-dropdown-menu] a", chromedp.ByQuery))
155 settleMotion(t, c, where)
156 open := readPrefs(t, c, where)
157 // The language list open: the themes, both nested
158 // summaries and every language; the scheme buttons sit
159 // in the closed menu beside it.
160 wantControls := open.Themes + 2 + open.Locales
161 if !open.Open || !open.Shown || open.Themes != 3 || open.Schemes != 3 || open.Locales != 12 || len(open.Controls) != wantControls {
162 t.Errorf("%s: open %v shown %v with %d themes, %d schemes, %d languages and %d controls reachable; want three themes, three schemes, twelve languages, %d reachable with the language list open",
163 where, open.Open, open.Shown, open.Themes, open.Schemes, open.Locales, len(open.Controls), wantControls)
164 }
165 for _, ctl := range open.Controls {
166 if !ctl.On || !ctl.Top || ctl.H < 44 {
167 t.Errorf("%s: %q in the open card: on screen %v, topmost %v, %dpx tall; want on screen, above the page, a 44px tap", where, ctl.Text, ctl.On, ctl.Top, ctl.H)
168 }
169 }
170 // The scheme menu open in its place: its three buttons
171 // reachable, and the language list folded away since
172 // the two share a name.
173 mustRun(t, c, chromedp.Click(".ds-prefs [rst-scheme] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-scheme] [rst-dropdown-menu] button", chromedp.ByQuery))
174 settleMotion(t, c, where)
175 schemes := readPrefs(t, c, where)
176 if want := schemes.Themes + 2 + schemes.Schemes; len(schemes.Controls) != want {
177 t.Errorf("%s: with the scheme menu open %d controls are reachable, want %d (the themes, both summaries and the three schemes)", where, len(schemes.Controls), want)
178 }
179 for _, ctl := range schemes.Controls {
180 if !ctl.On || !ctl.Top || ctl.H < 44 {
181 t.Errorf("%s: %q with the scheme menu open: on screen %v, topmost %v, %dpx tall", where, ctl.Text, ctl.On, ctl.Top, ctl.H)
182 }
183 }
184 var x float64 = 24
185 if locale == "ar" {
186 x = 366
187 }
188 mustRun(t, c, chromedp.MouseClickXY(x, 780))
189 until(t, c, where+", after an outside tap", `!document.querySelector(".ds-prefs").open`)
190 if after := readPrefs(t, c, where); after.Path != open.Path || after.Hash != open.Hash {
191 t.Errorf("%s: the outside tap went through to the page: %s%s -> %s%s", where, open.Path, open.Hash, after.Path, after.Hash)
192 }
193 if scripts {
194 mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery),
195 chromedp.Focus(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape))
196 if p := readPrefs(t, c, where); p.Open || !p.Focus {
197 t.Errorf("%s: Escape from a theme link: open %v, focus on the button %v", where, p.Open, p.Focus)
198 }
199 } else {
200 mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery),
201 chromedp.Click(".ds-prefs > summary", chromedp.ByQuery))
202 until(t, c, where+", toggled shut", `!document.querySelector(".ds-prefs").open`)
203 }
204 }
205 }
206 }
207}
208
209// TestTheDisplaySettingsKeepYourPlace: on a phone, a theme link in the
210// menu carries the section being read, as the desktop bar's does, so a
211// reader partway down a page who switches theme lands on the same
212// state. Then Back: the page comes back with the scheme chosen after
213// it, and every scheme menu on it marks that scheme.
214func TestTheDisplaySettingsKeepYourPlace(t *testing.T) {
215 rig := phoneRig(t)
216 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
217 defer cancel()
218 theme := fiesta.ThemeNames()[0]
219 e := mustEntry(t, "signin")
220 at := e.States[2].ID
221 where := theme + "/en/signin.html at 390px"
222 mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")+"#"+at), chromedp.WaitReady("body"))
223 present(t, ctx, where, ".ds-prefs > summary")
224 mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery))
225 until(t, ctx, where+", loaded", `document.readyState === "complete"`)
226 eagerly(t, ctx, where)
227 settleMotion(t, ctx, where)
228 // The previews finish loading after the jump and move the state;
229 // put it back on the reading line, where a reader would be.
230 mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("`+at+`").scrollIntoView({block: "start"}); true`, nil))
231 settleMotion(t, ctx, where)
232 tapAt(t, ctx, where, ".ds-prefs > summary")
233 until(t, ctx, where+", the menu open", `document.querySelector(".ds-prefs").open`)
234 settleMotion(t, ctx, where+", the menu open")
235 tapAt(t, ctx, where, `.ds-prefs [rst-seg-tabs] a[href*="/signal/"]`)
236 until(t, ctx, where+", switched to signal", `location.pathname.includes("/signal/") && location.hash === "#`+at+`"`)
237 settleMotion(t, ctx, where+", switched to signal")
238 tapAt(t, ctx, where, ".ds-prefs > summary")
239 until(t, ctx, where+", the menu open on signal", `document.querySelector(".ds-prefs").open`)
240 settleMotion(t, ctx, where+", the menu open on signal")
241 tapAt(t, ctx, where, ".ds-prefs [rst-scheme] > summary")
242 until(t, ctx, where+", the scheme menu open", `document.querySelector(".ds-prefs [rst-scheme]").open`)
243 tapAt(t, ctx, where, `.ds-prefs [rst-scheme] form:has(input[value="dark"]) button`)
244 until(t, ctx, where+", Dark chosen", `document.documentElement.getAttribute("data-theme") === "dark"`)
245 mustRun(t, ctx, chromedp.Evaluate(`history.back(); true`, nil))
246 until(t, ctx, where+", back in Dark", `location.pathname.includes("/day/") && document.documentElement.getAttribute("data-theme") === "dark" && `+markedJS+`.every(m => m === "dark")`)
247}
248