| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 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. |
| 31 | const 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 | |
| 51 | type prefsControl struct { |
| 52 | Text string |
| 53 | On, Top bool |
| 54 | H int |
| 55 | } |
| 56 | |
| 57 | type 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 | |
| 66 | func 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. |
| 82 | func 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. |
| 93 | func 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. |
| 119 | func 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. |
| 214 | func 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 | |