| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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 | // topbarPage is the real topbar layout as rastrillo's drive rendered it, in |
| 21 | // one direction: a nav whose current item is Posts (#nav-posts, |
| 22 | // #nav-drafts, #nav-settings), an account menu (#acct-profile), a |
| 23 | // language menu (#bar-locale), and a link in the page (#main-link) far |
| 24 | // enough down that the card never covers it. |
| 25 | func topbarPage(t *testing.T, dir string) string { |
| 26 | t.Helper() |
| 27 | return fixture(t, "card_browser_test.go", "topbarPage", dir) |
| 28 | } |
| 29 | |
| 30 | // cardPage is one of the other pages this file's drives read, as rastrillo |
| 31 | // rendered it, by the drive's name and the page's case. |
| 32 | func cardPage(t *testing.T, test, kase string) string { |
| 33 | t.Helper() |
| 34 | return fixture(t, "card_browser_test.go", test, kase) |
| 35 | } |
| 36 | |
| 37 | // cardRig serves pages with the assets the layouts link, on a touch |
| 38 | // screen. scripts=false disables script execution in the engine, which |
| 39 | // is the no-JavaScript half of every claim below. |
| 40 | func cardRig(t *testing.T, pages map[string]string, scripts bool) (*harness.Rig, context.Context, context.CancelFunc) { |
| 41 | t.Helper() |
| 42 | pages["/go/"] = sizingDoc("landed", `<p id="landed">landed</p>`) |
| 43 | rig := harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }, harness.WithCoarsePointer()) |
| 44 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 45 | if !scripts { |
| 46 | if err := chromedp.Run(ctx, emulation.SetScriptExecutionDisabled(true)); err != nil { |
| 47 | cancel() |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | } |
| 51 | return rig, ctx, cancel |
| 52 | } |
| 53 | |
| 54 | // cardJS is one reading of the bar: the card's box, main's box, what is |
| 55 | // open, where focus is, and the current item's marks. |
| 56 | const cardJS = `(() => { |
| 57 | const q = s => document.querySelector(s); |
| 58 | const menu = q("[rst-shell-menu], .rst-shell__menu"), tail = q("[rst-shell-tail], .rst-shell__tail"); |
| 59 | const acct = q("[rst-shell-account], .rst-shell__account"), main = q("main"); |
| 60 | const cur = q("#nav-posts"), cs = getComputedStyle(cur), t = tail.getBoundingClientRect(), m = main.getBoundingClientRect(); |
| 61 | const a = document.activeElement; |
| 62 | return JSON.stringify({MenuOpen: menu.open, AcctOpen: acct.open, TailDisplay: getComputedStyle(tail).display, |
| 63 | CardLeft: t.left, CardRight: t.right, CardTop: t.top, |
| 64 | RootLeft: document.documentElement.getBoundingClientRect().left, RootRight: document.documentElement.getBoundingClientRect().right, |
| 65 | MainBox: [m.left, m.top, m.width, m.height].map(Math.round).join(","), |
| 66 | Focus: a ? (a.id || (a.closest("[rst-shell-menu], .rst-shell__menu") ? "menu-summary" : a.closest("[rst-shell-account], .rst-shell__account") ? "account-summary" : a.tagName)) : "none", |
| 67 | FocusShown: !!a && a.getClientRects().length > 0, |
| 68 | CurBg: cs.backgroundColor, CurShadow: cs.boxShadow, CurStart: cs.borderInlineStartWidth, CurEnd: cs.borderBlockEndWidth, |
| 69 | AccentSoft: getComputedStyle(document.documentElement).getPropertyValue("--rst-accent-soft").trim(), |
| 70 | AcctPanel: getComputedStyle(acct.querySelector("[rst-dropdown-menu], .rst-dropdown__menu")).position, |
| 71 | Path: location.pathname}); |
| 72 | })()` |
| 73 | |
| 74 | type cardReading struct { |
| 75 | MenuOpen, AcctOpen, FocusShown bool |
| 76 | TailDisplay, MainBox, Focus, Path string |
| 77 | CardLeft, CardRight, CardTop float64 |
| 78 | RootLeft, RootRight float64 |
| 79 | CurBg, CurShadow, CurStart, CurEnd string |
| 80 | AccentSoft, AcctPanel string |
| 81 | } |
| 82 | |
| 83 | func readCard(t *testing.T, ctx context.Context) cardReading { |
| 84 | t.Helper() |
| 85 | var c cardReading |
| 86 | at(t, ctx, cardJS, &c) |
| 87 | return c |
| 88 | } |
| 89 | |
| 90 | // resolvedColor turns a colour custom property into the colour a |
| 91 | // computed style reports, so the current item's fill can be compared. |
| 92 | func resolvedColor(t *testing.T, ctx context.Context, prop string) string { |
| 93 | t.Helper() |
| 94 | var s string |
| 95 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const e = document.createElement("span"); e.style.backgroundColor = "var(`+prop+`)"; document.body.appendChild(e); const c = getComputedStyle(e).backgroundColor; e.remove(); return c; })()`, &s)) |
| 96 | if s == "" || s == "rgba(0, 0, 0, 0)" { |
| 97 | t.Fatalf("%s resolved to %q; a colour compared with it would be compared with nothing", prop, s) |
| 98 | } |
| 99 | return s |
| 100 | } |
| 101 | |
| 102 | // TestTheTopbarMenuIsACardOverThePage checks the topbar's card with |
| 103 | // scripts off and on, LTR and RTL, at 390 with a coarse pointer. |
| 104 | func TestTheTopbarMenuIsACardOverThePage(t *testing.T) { |
| 105 | for _, dir := range []string{"ltr", "rtl"} { |
| 106 | for _, scripts := range []bool{false, true} { |
| 107 | name := dir + map[bool]string{false: ", no script", true: ", scripts on"}[scripts] |
| 108 | t.Run(name, func(t *testing.T) { |
| 109 | rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, dir)}, scripts) |
| 110 | defer cancel() |
| 111 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 112 | chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 113 | requirePointer(t, ctx, true) |
| 114 | closed := readCard(t, ctx) |
| 115 | mustRun(t, ctx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 116 | open := readCard(t, ctx) |
| 117 | if !open.MenuOpen { |
| 118 | t.Fatal("the Menu did not open") |
| 119 | } |
| 120 | if open.MainBox != closed.MainBox { |
| 121 | t.Errorf("opening the card moved the page: main %s -> %s", closed.MainBox, open.MainBox) |
| 122 | } |
| 123 | // Against the root box, not clientWidth: this rig's touch |
| 124 | // Chromium reports clientWidth as the whole 390 while |
| 125 | // tokens.css's scrollbar-gutter: stable still reserves a |
| 126 | // 15px classic gutter, so the page's own inline end is |
| 127 | // 375 and clientWidth would count the gutter as gap. |
| 128 | gap := open.RootRight - open.CardRight |
| 129 | if dir == "rtl" { |
| 130 | gap = open.CardLeft - open.RootLeft |
| 131 | } |
| 132 | if gap < 0 || gap > 12+1 { |
| 133 | t.Errorf("the card's inline-end edge is %.1fpx from the viewport's, want within 0.75rem + 1px", gap) |
| 134 | } |
| 135 | // The current item wears the nav thumb: the theme's current |
| 136 | // fill and the thumb's shadow layers (transparent in a flat |
| 137 | // theme), never the rail's border marks. |
| 138 | if fill := resolvedColor(t, ctx, "--rst-nav-current-bg, var(--rst-accent-soft)"); open.CurBg != fill || open.CurStart != "0px" || open.CurEnd != "0px" { |
| 139 | t.Errorf("the current item is bg %s border-start %s border-end %s; want %s, 0, 0", open.CurBg, open.CurStart, open.CurEnd, fill) |
| 140 | } |
| 141 | // The premise of the no-script leg: fiesta.js would close |
| 142 | // the card on Escape, so a card that survives one proves no |
| 143 | // script is running, and the close below is the CSS layer's. |
| 144 | if !scripts { |
| 145 | mustRun(t, ctx, chromedp.KeyEvent(kb.Escape)) |
| 146 | if !readCard(t, ctx).MenuOpen { |
| 147 | t.Fatal("Escape closed the card with scripts disabled; something is still running script, and this leg would not be the no-JavaScript case") |
| 148 | } |
| 149 | } |
| 150 | // An outside tap over a link in the page closes the card |
| 151 | // and does not follow the link. A page with no Menu also |
| 152 | // ends the wait: that is the link followed, which |
| 153 | // clickAndStay then reports as the navigation it was. |
| 154 | clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#main-link", 0.5, 0.5, 0, 0), "", `(() => { const m = document.querySelector("[rst-shell-menu]"); return !m || !m.open; })()`) |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // TestEscapeClosesTheWholeCard: from a plain nav link in the card, and |
| 161 | // from inside the account menu in it, one Escape closes everything and |
| 162 | // focus lands on the Menu summary; opening the account menu keeps the |
| 163 | // card open. Both spellings of the whole topbar. |
| 164 | func TestEscapeClosesTheWholeCard(t *testing.T) { |
| 165 | for _, spelling := range []string{"attribute", "class"} { |
| 166 | t.Run(spelling, func(t *testing.T) { |
| 167 | page := topbarPage(t, "ltr") |
| 168 | menu, acct := "[rst-shell-menu] > summary", "[rst-shell-account] > summary" |
| 169 | if spelling == "class" { |
| 170 | page, menu, acct = classSpelling(t, page), ".rst-shell__menu > summary", ".rst-shell__account > summary" |
| 171 | if !strings.Contains(page, "rst-shell__tail") { |
| 172 | t.Fatal("the class spelling of the topbar has no rst-shell__tail; the leg would test the attribute spelling twice") |
| 173 | } |
| 174 | } |
| 175 | rig, ctx, cancel := cardRig(t, map[string]string{"/": page}, true) |
| 176 | defer cancel() |
| 177 | open := func() { |
| 178 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 179 | chromedp.WaitVisible(menu, chromedp.ByQuery), chromedp.Click(menu, chromedp.ByQuery)) |
| 180 | } |
| 181 | open() |
| 182 | mustRun(t, ctx, chromedp.Focus("#nav-drafts", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| 183 | if c := readCard(t, ctx); c.MenuOpen || c.Focus != "menu-summary" { |
| 184 | t.Errorf("Escape from a nav link: open=%v focus=%s, want closed and the Menu summary", c.MenuOpen, c.Focus) |
| 185 | } |
| 186 | open() |
| 187 | mustRun(t, ctx, chromedp.Click(acct, chromedp.ByQuery)) |
| 188 | if c := readCard(t, ctx); !c.MenuOpen || !c.AcctOpen { |
| 189 | t.Errorf("opening the account menu inside the card: card open=%v, account open=%v; want both", c.MenuOpen, c.AcctOpen) |
| 190 | } |
| 191 | mustRun(t, ctx, chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| 192 | if c := readCard(t, ctx); c.MenuOpen || c.AcctOpen || c.Focus != "menu-summary" { |
| 193 | t.Errorf("Escape from inside the account menu: card %v account %v focus %s; want both closed by one Escape and focus on the Menu summary", c.MenuOpen, c.AcctOpen, c.Focus) |
| 194 | } |
| 195 | }) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // TestTheCardSurvivesAResize: open the card at 390 and widen to 1280, |
| 200 | // and the bar is today's (the tail inline, the current item underlined) |
| 201 | // and a click on a link in the page follows it while the <details> is |
| 202 | // still open (no leftover layer); open the account menu in the card, |
| 203 | // widen, and it is today's positioned panel, and Escape focuses its |
| 204 | // visible summary; narrow again and the card is closed, because that |
| 205 | // Escape closed every menu, the hidden one included. |
| 206 | func TestTheCardSurvivesAResize(t *testing.T) { |
| 207 | rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, "ltr")}, true) |
| 208 | defer cancel() |
| 209 | load := func() { |
| 210 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 211 | chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), |
| 212 | chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 213 | } |
| 214 | load() |
| 215 | mustRun(t, ctx, chromedp.EmulateViewport(1280, 900)) |
| 216 | wide := readCard(t, ctx) |
| 217 | if !wide.MenuOpen { |
| 218 | t.Fatal("the <details> closed on the resize; the leftover-layer case this leg is for has not arisen") |
| 219 | } |
| 220 | if wide.TailDisplay != "contents" { |
| 221 | t.Errorf("widened with the card open: the tail is display %s, want today's contents", wide.TailDisplay) |
| 222 | } |
| 223 | if wide.CurEnd != "2px" { |
| 224 | t.Errorf("widened: the current item's underline is %s, want today's 2px", wide.CurEnd) |
| 225 | } |
| 226 | // A leftover layer would swallow this click; landing is the proof |
| 227 | // there is none. |
| 228 | clickAndLand(t, ctx, probe(t, ctx, "#main-link", 0.5, 0.5, 0, 0), "/go/main") |
| 229 | |
| 230 | load() |
| 231 | mustRun(t, ctx, chromedp.Click("[rst-shell-account] > summary", chromedp.ByQuery), chromedp.EmulateViewport(1280, 900)) |
| 232 | if c := readCard(t, ctx); !c.AcctOpen || c.AcctPanel == "static" { |
| 233 | t.Errorf("widened with the account menu open: open %v, panel position %s; want today's positioned panel", c.AcctOpen, c.AcctPanel) |
| 234 | } |
| 235 | mustRun(t, ctx, chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| 236 | if c := readCard(t, ctx); c.Focus != "account-summary" || !c.FocusShown { |
| 237 | t.Errorf("Escape after widening focused %q (shown %v); want the account summary, which is visible", c.Focus, c.FocusShown) |
| 238 | } |
| 239 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844)) |
| 240 | if c := readCard(t, ctx); c.MenuOpen { |
| 241 | t.Error("narrowed again after Escape: the card is open, but Escape closes every menu, the hidden one included") |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // TestTheCardsControlsAreTaps: the Menu summary, the nav rows and the |
| 246 | // account, language and colour-scheme summaries in the open card, at |
| 247 | // 44px. |
| 248 | func TestTheCardsControlsAreTaps(t *testing.T) { |
| 249 | rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, "ltr")}, true) |
| 250 | defer cancel() |
| 251 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 252 | chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 253 | requirePointer(t, ctx, true) |
| 254 | // The Menu and the card, not the whole bar: while the card is open |
| 255 | // the brand is under the outside-tap layer on purpose, so a tap there |
| 256 | // closes the card instead of following it. |
| 257 | got := readTargets(t, ctx, `(() => { `+measureFn+`; return JSON.stringify([...measure(document.querySelector("[rst-shell-menu]")), ...measure(document.querySelector("[rst-shell-tail]"))]); })()`) |
| 258 | if len(got) != 7 { |
| 259 | t.Fatalf("measured %d controls in the open card, want exactly 7: the Menu summary, three nav rows, the account, language and colour-scheme summaries", len(got)) |
| 260 | } |
| 261 | assertTargets(t, "390 touch, the open card", got) |
| 262 | } |
| 263 | |
| 264 | // TestAModalCoversTheTopbarOnAPhone: the bar is lifted above in-page |
| 265 | // menu panels only while its card is open. Lifted for good, it painted |
| 266 | // over a modal's scrim (fixed, z-index 10, inside main) on a phone: the |
| 267 | // bar stayed undimmed and live, and its Menu could open a card over the |
| 268 | // modal. Read at the brand and at the Menu summary, the two things a tap |
| 269 | // on a phone's bar reaches. The sidebar's and console's content pages |
| 270 | // have the same exposure through their sticky back strip: above the |
| 271 | // scrim, Back stayed live under an open modal. |
| 272 | // |
| 273 | // The pages are the shell examples (layout-topbar, layout-sidebar-page |
| 274 | // and layout-console-page) with the modal example in the slot: the |
| 275 | // drive reads the brand, the Menu summary and the back control, which |
| 276 | // every one of them carries, and the modal's overlay. |
| 277 | func TestAModalCoversTheTopbarOnAPhone(t *testing.T) { |
| 278 | modal := defaultMarkup(t, "modal") |
| 279 | hits := func(t *testing.T, ctx context.Context, sels ...string) { |
| 280 | t.Helper() |
| 281 | for _, sel := range sels { |
| 282 | var hit string |
| 283 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const r = document.querySelector(`+"`"+sel+"`"+`).getBoundingClientRect(); |
| 284 | const h = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); |
| 285 | return !h ? "nothing" : h.closest("[rst-modal-overlay]") ? "overlay" : h.tagName + (h.getAttributeNames().filter(n => n.startsWith("rst-")).map(n => "[" + n + "]").join("")); })()`, &hit)) |
| 286 | if hit != "overlay" { |
| 287 | t.Errorf("a tap on %s with a modal open lands on %s, want the modal's overlay: it paints above the scrim", sel, hit) |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | t.Run("topbar", func(t *testing.T) { |
| 292 | rig, ctx, cancel := cardRig(t, map[string]string{"/": shellPage(t, "topbar", modal)}, true) |
| 293 | defer cancel() |
| 294 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 295 | chromedp.WaitVisible("[rst-modal-overlay]", chromedp.ByQuery)) |
| 296 | requirePointer(t, ctx, true) |
| 297 | hits(t, ctx, "[rst-shell-brand]", "[rst-shell-menu] > summary") |
| 298 | }) |
| 299 | for _, name := range []string{"sidebar", "console"} { |
| 300 | t.Run(name+" content page", func(t *testing.T) { |
| 301 | pages := map[string]string{"/": shellPage(t, name+"-page", modal)} |
| 302 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 303 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 304 | defer cancel() |
| 305 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 306 | chromedp.WaitVisible("[rst-modal-overlay]", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-back] a", chromedp.ByQuery)) |
| 307 | requirePointer(t, ctx, true) |
| 308 | hits(t, ctx, "[rst-shell-back] a") |
| 309 | }) |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // TestAClickOnTheDocumentItselfDoesNotThrow: a click whose target is the |
| 314 | // document (a script's synthetic dispatch) has no closest(), and light |
| 315 | // dismiss runs in a capture listener on every click, so a throw there |
| 316 | // is an error on every such dispatch and leaves any open menu open. |
| 317 | func TestAClickOnTheDocumentItselfDoesNotThrow(t *testing.T) { |
| 318 | rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, "ltr")}, true) |
| 319 | defer cancel() |
| 320 | mustRun(t, ctx, chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+"/"), |
| 321 | chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 322 | // The shim is deferred; the drive starts once it has bound, which |
| 323 | // the account menu closing on an outside click shows. |
| 324 | settleUntil(t, ctx, `(() => { const a = document.querySelector("[rst-shell-account]"); a.open = true; document.body.click(); return !a.open; })()`) |
| 325 | var got string |
| 326 | mustRun(t, ctx, chromedp.Evaluate(`(() => { let err = "none"; addEventListener("error", e => { err = e.message; }); |
| 327 | const a = document.querySelector("[rst-shell-account]"); a.open = true; |
| 328 | document.dispatchEvent(new MouseEvent("click", {bubbles: true})); |
| 329 | return err + (a.open ? " (menu left open)" : ""); })()`, &got)) |
| 330 | if got != "none" { |
| 331 | t.Errorf("a click dispatched on the document: %s; want no error and the open menu closed", got) |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // TestTheCardFitsTheSmallestPhone: at 320, with this rig's classic |
| 336 | // scrollbar gutter reserved, the card stays inside the page at its |
| 337 | // inline start too. Sized from the viewport (100vw) it counted the |
| 338 | // gutter as room and overhung the start edge by 3px. |
| 339 | func TestTheCardFitsTheSmallestPhone(t *testing.T) { |
| 340 | for _, dir := range []string{"ltr", "rtl"} { |
| 341 | t.Run(dir, func(t *testing.T) { |
| 342 | rig, ctx, cancel := cardRig(t, map[string]string{"/": topbarPage(t, dir)}, true) |
| 343 | defer cancel() |
| 344 | mustRun(t, ctx, chromedp.EmulateViewport(320, 640), chromedp.Navigate(rig.Origin+"/"), |
| 345 | chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), |
| 346 | chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 347 | c := readCard(t, ctx) |
| 348 | if !c.MenuOpen { |
| 349 | t.Fatal("the Menu did not open") |
| 350 | } |
| 351 | if c.CardLeft < c.RootLeft-0.5 || c.CardRight > c.RootRight+0.5 { |
| 352 | t.Errorf("at 320 the card spans %.1f to %.1f and the page %.1f to %.1f; it overhangs", c.CardLeft, c.CardRight, c.RootLeft, c.RootRight) |
| 353 | } |
| 354 | }) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | // TestTheConsoleBarIsTheSameCard: the console's tail folds into the same |
| 359 | // card as the topbar's: the page does not move, the card sits at the |
| 360 | // inline end, an outside tap closes it with and without scripts, and |
| 361 | // one Escape from inside its account menu closes both. |
| 362 | func TestTheConsoleBarIsTheSameCard(t *testing.T) { |
| 363 | for _, c := range []struct { |
| 364 | dir string |
| 365 | scripts bool |
| 366 | }{{"ltr", false}, {"ltr", true}, {"rtl", false}, {"rtl", true}} { |
| 367 | scripts := c.scripts |
| 368 | t.Run(fmt.Sprintf("%s scripts %v", c.dir, scripts), func(t *testing.T) { |
| 369 | // The console with one account link (#acct-profile) and a |
| 370 | // link in the page (#main-link) 450px down. |
| 371 | pages := map[string]string{"/": cardPage(t, "TestTheConsoleBarIsTheSameCard", c.dir)} |
| 372 | rig := harness.New(t, func(string) http.Handler { return shellAssets(t, pages) }, harness.WithCoarsePointer()) |
| 373 | ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) |
| 374 | defer cancel() |
| 375 | acts := []chromedp.Action{chromedp.EmulateViewport(390, 844)} |
| 376 | if !scripts { |
| 377 | acts = append(acts, emulation.SetScriptExecutionDisabled(true)) |
| 378 | } |
| 379 | acts = append(acts, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery)) |
| 380 | mustRun(t, ctx, acts...) |
| 381 | var before, open struct { |
| 382 | Main, Basis, Inner string |
| 383 | Gap float64 |
| 384 | } |
| 385 | // Gap is the card's distance from the page's inline end, which is |
| 386 | // the left edge in RTL, measured against the root element's box: |
| 387 | // tokens.css reserves a stable scrollbar gutter, so the viewport's |
| 388 | // edge is not the page's. Basis and Inner are the card's own box |
| 389 | // rules: the console's base rule for its open tail (flex-basis 100% |
| 390 | // and a gap) comes earlier in the file than the card's, at the same |
| 391 | // weight, so these prove the card's rules won. |
| 392 | read := `(() => { const m = document.querySelector("main").getBoundingClientRect(), tail = document.querySelector("[rst-shell-tail]"), t = tail.getBoundingClientRect(), cs = getComputedStyle(tail); |
| 393 | const rtl = document.documentElement.dir === "rtl", h = document.documentElement.getBoundingClientRect(); |
| 394 | return JSON.stringify({Main: [m.left, m.top, m.width].map(Math.round).join(","), Gap: rtl ? t.left - h.left : h.right - t.right, Basis: cs.flexBasis, Inner: cs.rowGap}); })()` |
| 395 | at(t, ctx, read, &before) |
| 396 | mustRun(t, ctx, chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-tail] [rst-shell-account] > summary", chromedp.ByQuery)) |
| 397 | at(t, ctx, read, &open) |
| 398 | if open.Main != before.Main || open.Gap < 0 || open.Gap > 13 { |
| 399 | t.Errorf("main %s -> %s, card %.1fpx from the edge; want a card over a page that did not move", before.Main, open.Main, open.Gap) |
| 400 | } |
| 401 | if open.Basis != "auto" || open.Inner != "0px" { |
| 402 | t.Errorf("the console's open tail has flex-basis %s and gap %s; the card's auto and 0 lost to the console's own rule", open.Basis, open.Inner) |
| 403 | } |
| 404 | clickAndStay(t, ctx, probeScripted(t, ctx, scripts, "#main-link", 0.5, 0.5, 0, 0), "", `!document.querySelector("[rst-shell-menu]").open`) |
| 405 | if scripts { |
| 406 | mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-shell-menu] > summary", chromedp.ByQuery), |
| 407 | chromedp.Click("[rst-shell-menu] > summary", chromedp.ByQuery), chromedp.WaitVisible("[rst-shell-tail] [rst-shell-account] > summary", chromedp.ByQuery), |
| 408 | chromedp.Click("[rst-shell-account] > summary", chromedp.ByQuery), chromedp.WaitVisible("#acct-profile", chromedp.ByQuery), |
| 409 | chromedp.Focus("#acct-profile", chromedp.ByQuery), chromedp.KeyEvent(kb.Escape)) |
| 410 | var s struct { |
| 411 | Menu, Acct bool |
| 412 | Focus string |
| 413 | } |
| 414 | at(t, ctx, `JSON.stringify({Menu: document.querySelector("[rst-shell-menu]").open, Acct: document.querySelector("[rst-shell-account]").open, Focus: document.activeElement.closest("[rst-shell-menu]") ? "menu" : document.activeElement.tagName})`, &s) |
| 415 | if s.Menu || s.Acct || s.Focus != "menu" { |
| 416 | t.Errorf("Escape from the account menu in the console's card: %+v; want both closed and focus on the Menu summary", s) |
| 417 | } |
| 418 | } |
| 419 | }) |
| 420 | } |
| 421 | } |
| 422 | |