//go:build browser package gallery import ( "context" "encoding/json" "fmt" "math" "testing" "time" "github.com/chromedp/cdproto/accessibility" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" "amadan.net/rastrillo/fiesta" ) // The display settings menu on a phone: the theme, scheme and language // controls as one cog menu at the top of every page. // prefsJS reads the display settings menu on a phone: where its button // sits (against the root's box, since tokens.css reserves a stable // scrollbar gutter that RTL puts on the left), whether the card is // open and shown, and for every theme link, the scheme menu's summary // and buttons, the language summary and every language, whether it can // be brought on screen inside the card and is then the topmost thing // at its centre. const prefsJS = `(() => { const q = s => document.querySelector(s), h = document.documentElement.getBoundingClientRect(); const rtl = document.documentElement.dir === "rtl", d = q(".ds-prefs"), s = d.querySelector("summary"), r = s.getBoundingClientRect(); const card = d.querySelector(":scope > [rst-dropdown-menu]"); 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")] .filter(el => el.checkVisibility()).map(el => { el.scrollIntoView({block: "nearest", inline: "nearest"}); const b = el.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); return {Text: el.textContent.trim(), On: b.top >= 0 && b.bottom <= innerHeight && b.left >= h.left && b.right <= h.right, Top: !!hit && (hit === el || el.contains(hit)), H: Math.round(b.height)}; }); const strip = q("[rst-shell-back]"), sr = strip && strip.checkVisibility() ? strip.getBoundingClientRect() : null; return JSON.stringify({Open: d.open, Shown: card.checkVisibility(), Top: Math.round(r.top), Mid: r.top + r.height / 2, 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), W: Math.round(r.width), H: Math.round(r.height), InStrip: !!strip && strip.contains(d), Controls: controls, Themes: card.querySelectorAll("[rst-seg-tabs] a").length, Schemes: card.querySelectorAll("[rst-scheme] [rst-dropdown-menu] button").length, Locales: card.querySelectorAll("[rst-locale] [rst-dropdown-menu] a").length, Focus: document.activeElement === s, Path: location.pathname, Hash: location.hash}); })()` type prefsControl struct { Text string On, Top bool H int } type prefsReading struct { Open, Shown, InStrip, Focus bool Top, End, W, H int Mid, StripH, StripMid float64 Themes, Schemes, Locales int Controls []prefsControl Path, Hash string } func readPrefs(t *testing.T, ctx context.Context, where string) prefsReading { t.Helper() var raw string mustRun(t, ctx, chromedp.Evaluate(prefsJS, &raw)) var p prefsReading if err := json.Unmarshal([]byte(raw), &p); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } return p } // tapAt taps the centre of sel the way a finger does: scrolled to only // if it is off screen, then a press and release at a point. chromedp's // selector click scrolls its target into view even when it is on // screen, and a button in the sticky strip sits inside the scroll // padding, so any scroll-into-view moves the page. func tapAt(t *testing.T, ctx context.Context, where, sel string) { t.Helper() var xy []float64 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 { t.Fatalf("%s: finding %s: %v", where, sel, err) } mustRun(t, ctx, chromedp.MouseClickXY(xy[0], xy[1])) } // axNameOf is the accessible name the browser computes for sel, read // off the accessibility tree rather than off an attribute. func axNameOf(t *testing.T, ctx context.Context, where, sel string) string { t.Helper() var nodes []*cdp.Node if err := chromedp.Run(ctx, chromedp.Nodes(sel, &nodes, chromedp.ByQuery)); err != nil || len(nodes) == 0 { t.Fatalf("%s: no node for %s: %v", where, sel, err) } var name string mustRun(t, ctx, chromedp.ActionFunc(func(c context.Context) error { ax, err := accessibility.GetPartialAXTree().WithBackendNodeID(nodes[0].BackendNodeID).WithFetchRelatives(false).Do(c) if err == nil && len(ax) > 0 && ax[0].Name != nil { _ = json.Unmarshal(ax[0].Name.Value, &name) } return err })) return name } // TestTheDisplaySettingsMenuOnAPhone: one menu at the top of every // page, in the index's header row and at the inline end of a content // page's back strip, in English and Arabic, scripts on and off, at 390 // on a coarse pointer. Its button is a 44px icon named with the // approved string. Opened, every theme link, the scheme menu and its // three buttons, and every language can be brought on screen in the // card and is above the page; the two nested menus open in place, one // at a time. An outside tap closes it and reaches nothing under it; // with scripts on, Escape closes it and hands focus back. func TestTheDisplaySettingsMenuOnAPhone(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() s := newTestSite(t, fiesta.FS(), mountPath) theme := fiesta.ThemeNames()[0] for _, scripts := range []bool{true, false} { c := ctx if !scripts { c = noScripts(t, ctx) } for _, locale := range []string{"en", "ar"} { for _, file := range []string{"index.html", "form.html"} { where := fmt.Sprintf("%s/%s/%s at 390px, scripts %v", theme, locale, file, scripts) mustRun(t, c, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, locale, file)), chromedp.WaitReady("body")) present(t, c, where, ".ds-prefs > summary") mustRun(t, c, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery)) requireCoarse(t, c) if !scripts { requireScriptsOff(t, c, where) } settleMotion(t, c, where) p := readPrefs(t, c, where) index := file == "index.html" if p.Open || p.Shown || p.W < 44 || p.H < 44 || p.End > 24 || index && p.Top > 32 || !index && !p.InStrip { 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)", where, !p.Open, p.Shown, p.W, p.H, p.Top, p.End, p.InStrip) } if index && math.Abs(p.Mid-28) > 1 || !index && (math.Abs(p.StripH-56) > 0.5 || math.Abs(p.Mid-p.StripMid) > 1) { 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) } if name, want := axNameOf(t, c, where, ".ds-prefs > summary"), s.say(locale, "gallery.prefs"); name != want { t.Errorf("%s: the button is named %q, want %q", where, name, want) } mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.Click(".ds-prefs [rst-locale] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-locale] [rst-dropdown-menu] a", chromedp.ByQuery)) settleMotion(t, c, where) open := readPrefs(t, c, where) // The language list open: the themes, both nested // summaries and every language; the scheme buttons sit // in the closed menu beside it. wantControls := open.Themes + 2 + open.Locales if !open.Open || !open.Shown || open.Themes != 3 || open.Schemes != 3 || open.Locales != 12 || len(open.Controls) != wantControls { 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", where, open.Open, open.Shown, open.Themes, open.Schemes, open.Locales, len(open.Controls), wantControls) } for _, ctl := range open.Controls { if !ctl.On || !ctl.Top || ctl.H < 44 { 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) } } // The scheme menu open in its place: its three buttons // reachable, and the language list folded away since // the two share a name. mustRun(t, c, chromedp.Click(".ds-prefs [rst-scheme] > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-scheme] [rst-dropdown-menu] button", chromedp.ByQuery)) settleMotion(t, c, where) schemes := readPrefs(t, c, where) if want := schemes.Themes + 2 + schemes.Schemes; len(schemes.Controls) != want { 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) } for _, ctl := range schemes.Controls { if !ctl.On || !ctl.Top || ctl.H < 44 { 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) } } var x float64 = 24 if locale == "ar" { x = 366 } mustRun(t, c, chromedp.MouseClickXY(x, 780)) until(t, c, where+", after an outside tap", `!document.querySelector(".ds-prefs").open`) if after := readPrefs(t, c, where); after.Path != open.Path || after.Hash != open.Hash { t.Errorf("%s: the outside tap went through to the page: %s%s -> %s%s", where, open.Path, open.Hash, after.Path, after.Hash) } if scripts { mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), chromedp.Focus(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) if p := readPrefs(t, c, where); p.Open || !p.Focus { t.Errorf("%s: Escape from a theme link: open %v, focus on the button %v", where, p.Open, p.Focus) } } else { mustRun(t, c, chromedp.Click(".ds-prefs > summary", chromedp.ByQuery), chromedp.WaitVisible(".ds-prefs [rst-seg-tabs] a", chromedp.ByQuery), chromedp.Click(".ds-prefs > summary", chromedp.ByQuery)) until(t, c, where+", toggled shut", `!document.querySelector(".ds-prefs").open`) } } } } } // TestTheDisplaySettingsKeepYourPlace: on a phone, a theme link in the // menu carries the section being read, as the desktop bar's does, so a // reader partway down a page who switches theme lands on the same // state. Then Back: the page comes back with the scheme chosen after // it, and every scheme menu on it marks that scheme. func TestTheDisplaySettingsKeepYourPlace(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() theme := fiesta.ThemeNames()[0] e := mustEntry(t, "signin") at := e.States[2].ID where := theme + "/en/signin.html at 390px" mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+pageAt(theme, "en", "signin.html")+"#"+at), chromedp.WaitReady("body")) present(t, ctx, where, ".ds-prefs > summary") mustRun(t, ctx, chromedp.WaitVisible(".ds-prefs > summary", chromedp.ByQuery)) until(t, ctx, where+", loaded", `document.readyState === "complete"`) eagerly(t, ctx, where) settleMotion(t, ctx, where) // The previews finish loading after the jump and move the state; // put it back on the reading line, where a reader would be. mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("`+at+`").scrollIntoView({block: "start"}); true`, nil)) settleMotion(t, ctx, where) tapAt(t, ctx, where, ".ds-prefs > summary") until(t, ctx, where+", the menu open", `document.querySelector(".ds-prefs").open`) settleMotion(t, ctx, where+", the menu open") tapAt(t, ctx, where, `.ds-prefs [rst-seg-tabs] a[href*="/signal/"]`) until(t, ctx, where+", switched to signal", `location.pathname.includes("/signal/") && location.hash === "#`+at+`"`) settleMotion(t, ctx, where+", switched to signal") tapAt(t, ctx, where, ".ds-prefs > summary") until(t, ctx, where+", the menu open on signal", `document.querySelector(".ds-prefs").open`) settleMotion(t, ctx, where+", the menu open on signal") tapAt(t, ctx, where, ".ds-prefs [rst-scheme] > summary") until(t, ctx, where+", the scheme menu open", `document.querySelector(".ds-prefs [rst-scheme]").open`) tapAt(t, ctx, where, `.ds-prefs [rst-scheme] form:has(input[value="dark"]) button`) until(t, ctx, where+", Dark chosen", `document.documentElement.getAttribute("data-theme") === "dark"`) mustRun(t, ctx, chromedp.Evaluate(`history.back(); true`, nil)) until(t, ctx, where+", back in Dark", `location.pathname.includes("/day/") && document.documentElement.getAttribute("data-theme") === "dark" && `+markedJS+`.every(m => m === "dark")`) }