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 check
4
5import (
6 "context"
7 "fmt"
8 "net/http"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/chromedp/cdproto/emulation"
14 "github.com/chromedp/chromedp"
15 "github.com/chromedp/chromedp/kb"
16
17 "amadan.net/rastrillo/fiesta/internal/harness"
18)
19
20// profilePage is one page of this file's drives as rastrillo rendered it,
21// by the drive's name and the page's case: a layout with the drives'
22// standard nav (Invoices, Orders, Team) and language menu
23// (#rail-locale, English and Gaeilge), and the blocks rastrillo's
24// profileDefs filled for the profile menu: the app's name as the
25// brand, Grace Hopper as the person, the account's links (#acct-profile
26// and #acct-out), and a heading with a link in the page (#main-link).
27func profilePage(t *testing.T, test, kase string) string {
28 t.Helper()
29 return fixture(t, "profile_browser_test.go", test, kase)
30}
31
32// profileShell is where each shell keeps the profile menu: the sidebar's
33// own <details rst-shell-profile> in the rail's foot, and the console's
34// Menu, whose summary shows the avatar on the index and whose card is
35// the tail beside it. The two cards read the same: the name and email,
36// the Language menu, then the account's links laid out flat.
37type profileShell struct {
38 trigger, card, account string
39}
40
41var profileShells = map[string]profileShell{
42 "sidebar": {"[rst-shell-profile] > summary", "[rst-shell-profile] > [rst-dropdown-menu]", ""},
43 "console": {"[rst-shell-menu] > summary", "[rst-shell-tail]", ""},
44}
45
46// profileJS reads the index's header row and the card: the heading's
47// text, size and height, where the avatar sits against the root's box
48// (tokens.css reserves a stable scrollbar gutter, which RTL puts on the
49// left, so the viewport's edge is not the page's), and for each element
50// the card should show, whether it is on screen and the topmost thing
51// at its centre: in the card and above the page.
52const profileJS = `((trigger, card) => {
53 const q = s => document.querySelector(s), h = document.documentElement.getBoundingClientRect();
54 const rtl = document.documentElement.dir === "rtl", sum = q(trigger), r = sum.getBoundingClientRect();
55 const title = q("[rst-shell-title]"), t = title.getBoundingClientRect(), foot = q("[rst-shell-rail-foot]");
56 const nav = [...document.querySelectorAll("[rst-shell-nav] > a")].pop().getBoundingClientRect();
57 const seen = sel => [...document.querySelectorAll(sel)].filter(el => el.checkVisibility()).map(el => {
58 const b = el.getBoundingClientRect(), hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2);
59 return {Sel: sel, Text: el.textContent.trim(), On: b.width > 0 && b.top >= 0 && b.bottom <= innerHeight && b.left >= h.left && b.right <= h.right, Top: !!hit && (hit === el || el.contains(hit))};
60 });
61 const c = q(card), cb = c.getBoundingClientRect();
62 return JSON.stringify({
63 Open: sum.parentElement.open, Title: title.innerText.trim(), TitleH: Math.round(t.height), TitleSize: parseFloat(getComputedStyle(title).fontSize),
64 TitleOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
65 AvTop: Math.round(r.top), AvEnd: Math.round(rtl ? r.left - h.left : h.right - r.right), AvW: Math.round(r.width), AvH: Math.round(r.height),
66 FootBelowNav: !!foot && foot.getBoundingClientRect().top > nav.bottom,
67 CardShown: c.checkVisibility(), CardTop: Math.round(cb.top), AvBottom: Math.round(r.bottom),
68 Items: [].concat(seen("[rst-shell-who] [rst-person-name]"), seen("[rst-shell-who] [rst-person-email]"),
69 seen("#acct-profile"), seen("#acct-out"), seen("#rail-locale [rst-dropdown-menu] a")),
70 Focus: document.activeElement === sum,
71 });
72})(%q, %q)`
73
74type profileItem struct {
75 Sel, Text string
76 On, Top bool
77}
78
79type profileReading struct {
80 Open, CardShown, FootBelowNav, Focus bool
81 Title string
82 TitleH, TitleOverflow int
83 TitleSize float64
84 AvTop, AvEnd, AvW, AvH int
85 CardTop, AvBottom int
86 Items []profileItem
87}
88
89// requireCardItems holds an open card's readings to what it must show:
90// the name and the email, both account links and both languages, each
91// present with its own text, on screen and the topmost thing at its
92// centre. Asserting only the ones that were found would pass a card
93// that had lost an item.
94func requireCardItems(t *testing.T, where string, items []profileItem) {
95 t.Helper()
96 want := map[string]string{"[rst-shell-who] [rst-person-name]": "Grace Hopper", "[rst-shell-who] [rst-person-email]": "grace@example.com", "#acct-profile": "Profile", "#acct-out": "Sign out"}
97 got := map[string]bool{}
98 locales := map[string]bool{}
99 for _, it := range items {
100 if !it.On || !it.Top {
101 t.Errorf("%s: %s (%q) in the open card: on screen %v, topmost %v; want it on screen and above the page", where, it.Sel, it.Text, it.On, it.Top)
102 }
103 if w, ok := want[it.Sel]; ok && it.Text != w {
104 t.Errorf("%s: %s reads %q, want %q", where, it.Sel, it.Text, w)
105 }
106 got[it.Sel] = true
107 if it.Sel == "#rail-locale [rst-dropdown-menu] a" {
108 locales[it.Text] = true
109 }
110 }
111 for sel := range want {
112 if !got[sel] {
113 t.Errorf("%s: the open card never showed %s", where, sel)
114 }
115 }
116 if !locales["English"] || !locales["Gaeilge"] {
117 t.Errorf("%s: the card showed the languages %v, want English and Gaeilge", where, locales)
118 }
119}
120
121func readProfile(t *testing.T, ctx context.Context, p profileShell) profileReading {
122 t.Helper()
123 var r profileReading
124 at(t, ctx, fmt.Sprintf(profileJS, p.trigger, p.card), &r)
125 return r
126}
127
128// TestTheProfileMenuOnThePhoneIndex is the phone index of both shells,
129// LTR and RTL, scripts on and off, at 390 on a coarse pointer. The
130// heading is the app's name, about 21px, on one line however long the
131// name; the avatar is a 44px button at the top inline-end, named with
132// the person's name; nothing is left at the foot. Opened, the card
133// shows the name, the email, the account's links and every language,
134// each on screen and above the page. An outside tap closes it without
135// reaching the page, with and without scripts; with scripts Escape
136// closes it and hands focus back to the avatar.
137func TestTheProfileMenuOnThePhoneIndex(t *testing.T) {
138 const long = "Harbour Freight Forwarding and Customs Brokerage"
139 for _, shell := range []string{"sidebar", "console"} {
140 for _, dir := range []string{"ltr", "rtl"} {
141 for _, scripts := range []bool{true, false} {
142 shell, dir, scripts := shell, dir, scripts
143 t.Run(fmt.Sprintf("%s %s scripts %v", shell, dir, scripts), func(t *testing.T) {
144 p := profileShells[shell]
145 // Both indexes: the brand Harbour, and the long brand.
146 pages := map[string]string{
147 "/": profilePage(t, "TestTheProfileMenuOnThePhoneIndex", shell+"-"+dir+"-index"),
148 "/long": profilePage(t, "TestTheProfileMenuOnThePhoneIndex", shell+"-"+dir+"-long"),
149 "/go/": sizingDoc("landed", `<p id="landed">landed</p>`),
150 }
151 rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer())
152 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
153 defer cancel()
154 acts := []chromedp.Action{chromedp.EmulateViewport(390, 844)}
155 if !scripts {
156 acts = append(acts, emulation.SetScriptExecutionDisabled(true))
157 }
158 mustRun(t, ctx, append(acts, chromedp.Navigate(rig.Origin+"/long"), chromedp.WaitVisible(p.trigger, chromedp.ByQuery))...)
159 requirePointer(t, ctx, true)
160 lr := readProfile(t, ctx, p)
161 if lr.Title != long || lr.TitleH > 56 || lr.TitleOverflow > 0 {
162 t.Errorf("a long brand: heading %q, %dpx tall, the page %dpx wider than the screen; want the whole name, on one line, eliding inside the screen", lr.Title, lr.TitleH, lr.TitleOverflow)
163 }
164
165 mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(p.trigger, chromedp.ByQuery))
166 settled(t, ctx)
167 closed := readProfile(t, ctx, p)
168 if closed.Title != "Harbour" || closed.TitleH > 56 || closed.TitleSize < 19 || closed.TitleSize > 23 {
169 t.Errorf("the heading is %q at %.1fpx, %dpx tall; want the brand, Harbour, at about 21px on one line in the 56px header row", closed.Title, closed.TitleSize, closed.TitleH)
170 }
171 if closed.AvTop > 32 || closed.AvEnd > 24 || closed.AvW < 44 || closed.AvH < 44 {
172 t.Errorf("the avatar is %dx%dpx, %dpx from the top and %dpx from the inline end; want a 44px tap at the top inline-end", closed.AvW, closed.AvH, closed.AvTop, closed.AvEnd)
173 }
174 if closed.FootBelowNav {
175 t.Error("the rail's foot is still under the nav on the phone index")
176 }
177 if closed.Open || closed.CardShown {
178 t.Error("the card is open before anyone opened it")
179 }
180 if name := axName(ctx, t, p.trigger); name != "Grace Hopper" {
181 t.Errorf("the avatar's accessible name is %q, want the person's name", name)
182 }
183
184 // Opened, both shells' cards read in one order with the
185 // language closed: the name, the email, the Language menu,
186 // then the account's links, flat, with no Account menu to
187 // open first.
188 mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery))
189 var order []string
190 at(t, ctx, `JSON.stringify([...document.querySelectorAll("[rst-shell-who] [rst-person-name], [rst-shell-who] [rst-person-email], #rail-locale > summary, #acct-profile, #acct-out")]
191 .filter(e => e.checkVisibility()).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top).map(e => e.id || e.getAttribute("rst-person-name") !== null && "name" || e.getAttribute("rst-person-email") !== null && "email" || "language"))`, &order)
192 if got := strings.Join(order, " "); got != "name email language acct-profile acct-out" {
193 t.Errorf("the open card reads %q, top to bottom; want the name, the email, the language, then the account's links, flat", got)
194 }
195 var items []profileItem
196 if p.account != "" {
197 mustRun(t, ctx, chromedp.Click(p.account, chromedp.ByQuery), chromedp.WaitVisible("#acct-out", chromedp.ByQuery))
198 items = append(items, readProfile(t, ctx, p).Items...)
199 }
200 mustRun(t, ctx, chromedp.Click("#rail-locale > summary", chromedp.ByQuery), chromedp.WaitVisible("#rail-locale [rst-dropdown-menu] a", chromedp.ByQuery))
201 open := readProfile(t, ctx, p)
202 items = append(items, open.Items...)
203 if !open.Open || !open.CardShown || open.CardTop < open.AvBottom {
204 t.Errorf("opened: open %v, card shown %v, card top %d under an avatar ending at %d; want a card dropping from the avatar", open.Open, open.CardShown, open.CardTop, open.AvBottom)
205 }
206 if open.AvW < 44 || open.AvH < 44 {
207 t.Errorf("the open avatar is %dx%dpx; the open state must stay a 44px tap", open.AvW, open.AvH)
208 }
209 requireCardItems(t, "the phone index", items)
210
211 // An outside tap: on a nav row, clear of the card at the
212 // row's inline start. It closes the card and the page
213 // under it never sees it.
214 fx := 0.03
215 if dir == "rtl" {
216 fx = 0.97
217 }
218 clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#nav-team", fx, 0.5, 0, 0), "",
219 fmt.Sprintf(`!document.querySelector(%q).parentElement.open`, p.trigger))
220
221 if scripts {
222 mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery))
223 focus := "#acct-profile"
224 if p.account != "" {
225 mustRun(t, ctx, chromedp.Click(p.account, chromedp.ByQuery), chromedp.WaitVisible(focus, chromedp.ByQuery))
226 }
227 mustRun(t, ctx, chromedp.Focus(focus, chromedp.ByQuery), chromedp.KeyEvent(kb.Escape))
228 if after := readProfile(t, ctx, p); after.Open || !after.Focus {
229 t.Errorf("Escape from a link in the card: open %v, focus on the avatar %v; want it closed and focus handed back", after.Open, after.Focus)
230 }
231 } else {
232 // Scripts off, the avatar's own toggle is all there is.
233 mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery),
234 chromedp.Click(p.trigger, chromedp.ByQuery))
235 settleUntil(t, ctx, fmt.Sprintf(`!document.querySelector(%q).parentElement.open`, p.trigger))
236 }
237 })
238 }
239 }
240 }
241}
242
243// TestTheProfileMenuIsADropUpAtTheRailsFoot is the sidebar on a
244// desktop, where the profile menu is the rail's foot: an avatar at the
245// bottom of the rail at its inline start, opening a card UPWARD that is
246// anchored at the avatar's inline start and grows across the page. It
247// has to stay on screen in a 1280x720 window and a 1024x600 one, with
248// the language menu open inside it; close on an outside tap, scripts on
249// or off, and on Escape; and mirror in RTL, which the inline-start
250// readings cover by being measured from the inline start of each
251// direction.
252func TestTheProfileMenuIsADropUpAtTheRailsFoot(t *testing.T) {
253 p := profileShells["sidebar"]
254 // 1280x420 is a laptop with devtools open: the shortest window the
255 // rail still sits beside the page in. Anchored, the card cannot
256 // flip downward at the rail's foot: the foot is at the bottom of a
257 // rail the window's height, so there is never more room below than
258 // above, and the card's height cap shrinks with the window. What the
259 // short window has to prove is that the card still fits above.
260 for _, vp := range [][2]int64{{1280, 720}, {1024, 600}, {1280, 420}} {
261 for _, dir := range []string{"ltr", "rtl"} {
262 for _, scripts := range []bool{true, false} {
263 for _, anchored := range []bool{true, false} {
264 vp, dir, scripts, anchored := vp, dir, scripts, anchored
265 t.Run(fmt.Sprintf("%dx%d %s scripts %v anchored %v", vp[0], vp[1], dir, scripts, anchored), func(t *testing.T) {
266 pages := map[string]string{"/": profilePage(t, "TestTheProfileMenuIsADropUpAtTheRailsFoot", dir), "/go/": sizingDoc("landed", `<p id="landed">landed</p>`)}
267 rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) })
268 ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
269 defer cancel()
270 acts := []chromedp.Action{chromedp.EmulateViewport(vp[0], vp[1])}
271 if !scripts {
272 acts = append(acts, emulation.SetScriptExecutionDisabled(true))
273 }
274 mustRun(t, ctx, append(acts, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(p.trigger, chromedp.ByQuery))...)
275 settled(t, ctx)
276 // Without anchor positioning, the path every engine
277 // without it takes: the card absolute in the rail,
278 // opened upward by the hand-written insets.
279 if !anchored {
280 mustRun(t, ctx, chromedp.Evaluate(WithoutAnchorPositioning, nil, awaitPromise))
281 }
282 RequireAnchorPositioning(t, ctx, "the drop-up", "[rst-shell-profile] > [rst-dropdown-menu]", anchored)
283 const geom = `(() => {
284 const q = s => document.querySelector(s), rtl = document.documentElement.dir === "rtl";
285 const rail = q("[rst-shell-rail]").getBoundingClientRect(), s = q("[rst-shell-profile] > summary").getBoundingClientRect();
286 const c = q("[rst-shell-profile] > [rst-dropdown-menu]"), cb = c.getBoundingClientRect();
287 const start = b => rtl ? rail.right - b.right : b.left - rail.left;
288 return JSON.stringify({FootGap: Math.round(rail.bottom - s.bottom), AvStart: Math.round(start(s)),
289 Shown: c.checkVisibility(), CardStart: Math.round(start(cb)), CardBottom: Math.round(cb.bottom), AvTop: Math.round(s.top),
290 OnScreen: cb.top >= 0 && cb.bottom <= innerHeight && cb.left >= 0 && cb.right <= document.documentElement.clientWidth});
291 })()`
292 var g struct {
293 FootGap, AvStart, CardStart, CardBottom, AvTop int
294 Shown, OnScreen bool
295 }
296 at(t, ctx, geom, &g)
297 if g.FootGap > 40 || g.AvStart > 24 || g.Shown {
298 t.Errorf("closed: the avatar is %dpx above the rail's foot and %dpx from its inline start, card shown %v; want it at the foot's inline start, closed", g.FootGap, g.AvStart, g.Shown)
299 }
300 if name := axName(ctx, t, p.trigger); name != "Grace Hopper" {
301 t.Errorf("the avatar's accessible name is %q, want the person's name", name)
302 }
303 mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible(p.card+" [rst-shell-who]", chromedp.ByQuery),
304 chromedp.Click("#rail-locale > summary", chromedp.ByQuery), chromedp.WaitVisible("#rail-locale [rst-dropdown-menu] a", chromedp.ByQuery))
305 at(t, ctx, geom, &g)
306 if !g.Shown || g.CardBottom > g.AvTop || !g.OnScreen {
307 t.Errorf("open: card shown %v, its bottom at %d over an avatar whose top is %d, on screen %v; want a drop-up wholly in the window", g.Shown, g.CardBottom, g.AvTop, g.OnScreen)
308 }
309 if d := g.CardStart - g.AvStart; d < -2 || d > 2 {
310 t.Errorf("the card starts %dpx from the rail's inline start and the avatar %dpx; want it anchored at the avatar's inline start", g.CardStart, g.AvStart)
311 }
312 requireCardItems(t, "the drop-up", readProfile(t, ctx, p).Items)
313 clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#main-link", 0.5, 0.5, 0, 0), "",
314 `!document.querySelector("[rst-shell-profile]").open`)
315 if scripts {
316 mustRun(t, ctx, chromedp.Click(p.trigger, chromedp.ByQuery), chromedp.WaitVisible("#acct-profile", chromedp.ByQuery),
317 chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape))
318 if after := readProfile(t, ctx, p); after.Open || !after.Focus {
319 t.Errorf("Escape from a link in the card: open %v, focus on the avatar %v", after.Open, after.Focus)
320 }
321 }
322 })
323 }
324 }
325 }
326 }
327}
328
329// TestThePersonNeverVanishesFromTheRail: an app with a profile and
330// nothing else, no language and no account links, still shows the
331// signed-in person's avatar, on a desktop at the rail's foot and on the
332// phone index at the top; the card then holds the name and email. Only
333// the shell's placeholder, an app with no profile at all and nothing to
334// choose, leaves the foot out — and "nothing to choose" now means the
335// app emptied the scheme block too, since the shipped shell always
336// offers the colour scheme and so always has a card worth opening.
337func TestThePersonNeverVanishesFromTheRail(t *testing.T) {
338 // Three sidebar pages: the person with no language menu (a content
339 // page, then the index), and the placeholder with no language menu
340 // and no scheme menu either.
341 pages := map[string]string{
342 "/person": profilePage(t, "TestThePersonNeverVanishesFromTheRail", "person"),
343 "/person-idx": profilePage(t, "TestThePersonNeverVanishesFromTheRail", "person-idx"),
344 "/placeholder": profilePage(t, "TestThePersonNeverVanishesFromTheRail", "placeholder"),
345 }
346 rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) })
347 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
348 defer cancel()
349 shown := `(() => { const s = document.querySelector("[rst-shell-profile] > summary"); return JSON.stringify({Avatar: !!s && s.checkVisibility()}); })()`
350 for _, c := range []struct {
351 path string
352 w, h int64
353 want bool
354 }{{"/person", 1280, 720, true}, {"/person-idx", 390, 844, true}, {"/placeholder", 1280, 720, false}} {
355 mustRun(t, ctx, chromedp.EmulateViewport(c.w, c.h), chromedp.Navigate(rig.Origin+c.path), chromedp.WaitReady("body"))
356 var got struct{ Avatar bool }
357 at(t, ctx, shown, &got)
358 if got.Avatar != c.want {
359 t.Errorf("%s at %d: avatar shown %v, want %v", c.path, c.w, got.Avatar, c.want)
360 }
361 }
362}
363
364// TestABlankBrandFallsBackToTheTitle: a brand drawn from data that has
365// none renders only whitespace, and the phone index's heading is then
366// the page's title rather than nothing.
367func TestABlankBrandFallsBackToTheTitle(t *testing.T) {
368 // The sidebar index with the title Harbour Ledger and a brand block
369 // that renders only whitespace.
370 pages := map[string]string{"/": profilePage(t, "TestABlankBrandFallsBackToTheTitle", "")}
371 rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) })
372 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
373 defer cancel()
374 mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitReady("body"))
375 var v viewReading
376 at(t, ctx, viewJS, &v)
377 if v.Title != "Harbour Ledger" || v.H1s != 1 {
378 t.Errorf("a whitespace brand: the index heading reads %q (%d h1s); want the title, Harbour Ledger", v.Title, v.H1s)
379 }
380}
381