| 1 | //go:build browser |
| 2 | |
| 3 | // shell.js in a browser: the back control that reuses history only |
| 4 | // when it can prove what is behind it, the slide that knows which way |
| 5 | // it is going, and a link beside the back control that is not the |
| 6 | // back control. |
| 7 | package check |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "fmt" |
| 12 | "net/http" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/chromedp/cdproto/emulation" |
| 18 | cdppage "github.com/chromedp/cdproto/page" |
| 19 | "github.com/chromedp/cdproto/runtime" |
| 20 | "github.com/chromedp/chromedp" |
| 21 | |
| 22 | "amadan.net/rastrillo/fiesta" |
| 23 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 24 | ) |
| 25 | |
| 26 | // shellDoc is a page in the vocabulary the sidebar and console layouts |
| 27 | // write for shell.js: a root marked with its view, a back link, a rail |
| 28 | // of nav links, and a main with a same-page link. The markup is by hand |
| 29 | // because this drive is about the script. It links shell.css but not |
| 30 | // tokens.css, so what is measured is shell.js and not the stylesheet's |
| 31 | // narrow layout: a link the stylesheet hides cannot take focus, and the |
| 32 | // drive would fail for a reason that has nothing to do with the script. |
| 33 | func shellDoc(view, up, nav, main string) string { |
| 34 | return `<!doctype html><html lang="en" dir="ltr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>shell</title>` + |
| 35 | `<link rel="stylesheet" href="/shell.css">` + |
| 36 | `<script defer blocking="render" src="/shell.js"></script></head><body>` + |
| 37 | `<div rst-shell-sidebar="` + view + `"><div rst-shell-back><a id="back" href="` + up + `" rel="up">Sections</a></div>` + |
| 38 | `<aside rst-shell-rail><nav rst-shell-nav>` + nav + `</nav></aside>` + |
| 39 | `<main rst-shell-main id="main">` + main + `</main></div></body></html>` |
| 40 | } |
| 41 | |
| 42 | // shellSite is three small apps on one origin. / has ids on its nav and |
| 43 | // fragments on its up links; /b/ has neither (focus return must come |
| 44 | // from the record alone); /q/ has an index with a query, nav hrefs |
| 45 | // written absolute, and an id that needs decoding. /old redirects to /, |
| 46 | // the "up that redirects" case, and /self is its own up. |
| 47 | func shellSite(t *testing.T) func(origin string) http.Handler { |
| 48 | return func(origin string) http.Handler { |
| 49 | nav := `<a id="nav-invoices" href="/invoices">Invoices</a><a id="nav-orders" href="/orders">Orders</a><a id="nav-r" href="/r/page">Redirecting</a>` |
| 50 | content := func(name string) string { |
| 51 | return `<h1>` + name + `</h1><p><a id="section-link" href="#part">To a part of this page</a></p><div style="block-size: 150vh"></div><h2 id="part">Part</h2>` |
| 52 | } |
| 53 | navB := `<a href="/b/invoices">Invoices</a><a href="/b/orders">Orders</a>` |
| 54 | navQ := `<a id="nav-q-orders" href="` + origin + `/q/orders?status=open">Orders</a><a id="nav-café" href="/q/cafe">Café</a>` |
| 55 | pages := map[string]string{ |
| 56 | "/": shellDoc("index", "/", nav, `<h1>Home</h1>`), |
| 57 | "/invoices": shellDoc("page", "/#nav-invoices", nav, content("Invoices")), |
| 58 | "/orders": shellDoc("page", "/#nav-orders", nav, content("Orders")), |
| 59 | "/r/page": shellDoc("page", "/old#nav-r", nav, content("Redirecting")), |
| 60 | "/self": shellDoc("page", "/self", nav, content("Self")), |
| 61 | "/b/": shellDoc("index", "/b/", navB, `<h1>B</h1>`), |
| 62 | "/b/invoices": shellDoc("page", "/b/", navB, content("B invoices")), |
| 63 | "/q/": shellDoc("index", "/q/?tab=all", navQ, `<h1>Q</h1>`), |
| 64 | "/q/orders": shellDoc("page", "/q/?tab=all#nav-q-orders", navQ, content("Q orders")), |
| 65 | "/q/cafe": shellDoc("page", "/q/?tab=all#nav-caf%C3%A9", navQ, content("Café")), |
| 66 | // A content page whose back strip also holds a menu, the way |
| 67 | // the gallery puts its display settings there, with a link to |
| 68 | // the very page the back control points at. |
| 69 | "/strip": strings.Replace(shellDoc("page", "/#nav-invoices", nav, content("Strip")), |
| 70 | `rel="up">Sections</a></div>`, |
| 71 | `rel="up">Sections</a><details open><summary>Settings</summary><a id="strip-menu-link" href="/?theme=other">Other theme</a></details></div>`, 1), |
| 72 | } |
| 73 | mux := http.NewServeMux() |
| 74 | stylesheets(t, mux) |
| 75 | for name, body := range map[string][]byte{"shell.js": shippedScript(t, "shell.js"), "shell.css": fiesta.ShellCSS()} { |
| 76 | body, ct := body, map[bool]string{true: "text/javascript", false: "text/css"}[strings.HasSuffix(name, ".js")] |
| 77 | mux.HandleFunc("GET /"+name, func(w http.ResponseWriter, _ *http.Request) { |
| 78 | w.Header().Set("Content-Type", ct) |
| 79 | w.Write(body) |
| 80 | }) |
| 81 | } |
| 82 | mux.HandleFunc("GET /old", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) }) |
| 83 | for path, html := range pages { |
| 84 | html, pattern := html, "GET "+path |
| 85 | if strings.HasSuffix(path, "/") { |
| 86 | pattern += "{$}" |
| 87 | } |
| 88 | mux.HandleFunc(pattern, func(w http.ResponseWriter, _ *http.Request) { |
| 89 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 90 | fmt.Fprint(w, html) |
| 91 | }) |
| 92 | } |
| 93 | return mux |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // tab opens a fresh tab (a fresh session history, so "what is behind |
| 98 | // this entry" is only what the leg put there) at 390 wide, with an |
| 99 | // optional script run in every document before the page's own. |
| 100 | func tab(t *testing.T, rig *harness.Rig, init string, media ...*emulation.MediaFeature) (context.Context, func(), *[]string) { |
| 101 | t.Helper() |
| 102 | ctx, cancel := chromedp.NewContext(rig.Context()) |
| 103 | ctx, stop := context.WithTimeout(ctx, 90*time.Second) |
| 104 | thrown := &[]string{} |
| 105 | chromedp.ListenTarget(ctx, func(ev any) { |
| 106 | if e, ok := ev.(*runtime.EventExceptionThrown); ok { |
| 107 | *thrown = append(*thrown, e.ExceptionDetails.Error()) |
| 108 | } |
| 109 | }) |
| 110 | acts := []chromedp.Action{chromedp.EmulateViewport(390, 844)} |
| 111 | if len(media) > 0 { |
| 112 | acts = append(acts, emulation.SetEmulatedMedia().WithFeatures(media)) |
| 113 | } |
| 114 | if init != "" { |
| 115 | acts = append(acts, chromedp.ActionFunc(func(c context.Context) error { |
| 116 | _, err := cdppage.AddScriptToEvaluateOnNewDocument(init).Do(c) |
| 117 | return err |
| 118 | })) |
| 119 | } |
| 120 | if err := chromedp.Run(ctx, acts...); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | return ctx, func() { stop(); cancel() }, thrown |
| 124 | } |
| 125 | |
| 126 | type shellState struct { |
| 127 | Path, Focus string |
| 128 | Len int |
| 129 | } |
| 130 | |
| 131 | // state is where the tab is, how long its history is, and what has |
| 132 | // focus (an id, else the href, else the tag). |
| 133 | func state(t *testing.T, ctx context.Context) shellState { |
| 134 | t.Helper() |
| 135 | var s shellState |
| 136 | at(t, ctx, `JSON.stringify({Path: location.pathname + location.search + location.hash, Len: history.length, |
| 137 | Focus: (a => a.id || a.getAttribute("href") || a.tagName)(document.activeElement)})`, &s) |
| 138 | return s |
| 139 | } |
| 140 | |
| 141 | // visit navigates with a real load; follow clicks through the page, |
| 142 | // which is what works after a back/forward-cache restore (no load event |
| 143 | // fires there, so chromedp's own waits would hang). |
| 144 | func visit(t *testing.T, ctx context.Context, url string) { |
| 145 | t.Helper() |
| 146 | if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // follow clicks sel from a timer, so the evaluation returns before the |
| 152 | // document it runs in is replaced, then waits until the destination's |
| 153 | // own marker holds and the destination has loaded. |
| 154 | func follow(t *testing.T, ctx context.Context, sel, until string) { |
| 155 | t.Helper() |
| 156 | if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`setTimeout(() => document.querySelector(%q).click(), 0), true`, sel), nil)); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | settleUntil(t, ctx, loaded(until)) |
| 160 | } |
| 161 | |
| 162 | // loaded adds "and the document has loaded" to a leg's marker. A path |
| 163 | // holds the moment the new document commits, before its deferred |
| 164 | // shell.js has run; blocking="render" holds back paint, not a click |
| 165 | // from the drive. A click in that gap reaches a page with no listener |
| 166 | // and takes the scriptless path, which is correct for a reader and |
| 167 | // wrong for a leg that means to drive the script: on a loaded fleet |
| 168 | // runner the back control then followed its link and history grew by |
| 169 | // one (#127). Deferred scripts run before readyState is "complete", |
| 170 | // and a page restored from the back/forward cache is already complete. |
| 171 | func loaded(until string) string { |
| 172 | return "(" + until + `) && document.readyState === "complete"` |
| 173 | } |
| 174 | |
| 175 | // revealed waits until the page has loaded and painted twice more, by |
| 176 | // which time pagereveal has fired and shell.js has done whatever it was |
| 177 | // going to do with focus. A leg that asserts focus did NOT move needs |
| 178 | // this, or it would pass by reading too early. |
| 179 | func revealed(t *testing.T, ctx context.Context) { |
| 180 | t.Helper() |
| 181 | settleUntil(t, ctx, `document.readyState === "complete"`) |
| 182 | if err := chromedp.Run(ctx, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, |
| 183 | func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // TestTheBackControlReusesHistoryWhenItCanProveWhatIsBehind drives the |
| 189 | // scripted journeys through the index and its pages: when the back |
| 190 | // control may reuse history, when it must follow its link instead, and |
| 191 | // where focus lands on the index afterwards. |
| 192 | func TestTheBackControlReusesHistoryWhenItCanProveWhatIsBehind(t *testing.T) { |
| 193 | rig := harness.New(t, shellSite(t)) |
| 194 | |
| 195 | t.Run("index, page, back: history reused, focus on the link followed", func(t *testing.T) { |
| 196 | ctx, done, thrown := tab(t, rig, "") |
| 197 | defer done() |
| 198 | visit(t, ctx, rig.Origin+"/") |
| 199 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| 200 | before := state(t, ctx) |
| 201 | follow(t, ctx, "#back", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| 202 | if after := state(t, ctx); after.Len != before.Len || after.Path != "/" || after.Focus != "nav-invoices" { |
| 203 | t.Errorf("after the back control: %+v (before it %+v); want history unchanged, at /, focus on nav-invoices", after, before) |
| 204 | } |
| 205 | if len(*thrown) > 0 { |
| 206 | t.Errorf("uncaught: %v", *thrown) |
| 207 | } |
| 208 | }) |
| 209 | |
| 210 | t.Run("index, page, #fragment, back: the link is followed", func(t *testing.T) { |
| 211 | ctx, done, _ := tab(t, rig, "") |
| 212 | defer done() |
| 213 | visit(t, ctx, rig.Origin+"/") |
| 214 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| 215 | follow(t, ctx, "#section-link", `location.hash === "#part"`) |
| 216 | before := state(t, ctx) |
| 217 | follow(t, ctx, "#back", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| 218 | if after := state(t, ctx); after.Len != before.Len+1 || after.Focus != "nav-invoices" { |
| 219 | t.Errorf("back from a fragment on the same page: %+v (before %+v); want the link followed (history +1) and focus on nav-invoices", after, before) |
| 220 | } |
| 221 | }) |
| 222 | |
| 223 | t.Run("a deep link, back: the link is followed and the record focuses", func(t *testing.T) { |
| 224 | ctx, done, _ := tab(t, rig, "") |
| 225 | defer done() |
| 226 | visit(t, ctx, rig.Origin+"/orders") |
| 227 | follow(t, ctx, "#back", `location.pathname === "/"`) |
| 228 | settleUntil(t, ctx, `document.activeElement.id === "nav-orders"`) |
| 229 | }) |
| 230 | |
| 231 | t.Run("no ids, no fragment: focus from the record alone", func(t *testing.T) { |
| 232 | ctx, done, _ := tab(t, rig, "") |
| 233 | defer done() |
| 234 | visit(t, ctx, rig.Origin+"/b/invoices") |
| 235 | follow(t, ctx, "#back", `location.pathname === "/b/"`) |
| 236 | settleUntil(t, ctx, `document.activeElement.getAttribute("href") === "/b/invoices"`) |
| 237 | }) |
| 238 | |
| 239 | const brokenStorage = `Object.defineProperty(window, "sessionStorage", {configurable: true, get() { throw new DOMException("denied", "SecurityError"); }});` |
| 240 | // Chromium focuses a fragment's target itself when it loads a URL |
| 241 | // with one, so landing on /#nav-invoices proves nothing about the |
| 242 | // script. A history return to that URL does not refocus: the drive |
| 243 | // blurs, leaves, and comes back, and only shell.js's fragment rule |
| 244 | // can put focus on the link then. |
| 245 | t.Run("storage throws, with a fragment: focus from the fragment", func(t *testing.T) { |
| 246 | ctx, done, thrown := tab(t, rig, brokenStorage) |
| 247 | defer done() |
| 248 | visit(t, ctx, rig.Origin+"/invoices") |
| 249 | follow(t, ctx, "#back", `location.pathname === "/" && document.activeElement.id === "nav-invoices"`) |
| 250 | follow(t, ctx, "#nav-orders", `location.pathname === "/orders"`) |
| 251 | back(t, ctx, `location.pathname === "/"`) |
| 252 | settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`) |
| 253 | if len(*thrown) > 0 { |
| 254 | t.Errorf("storage that throws broke the script: %v", *thrown) |
| 255 | } |
| 256 | }) |
| 257 | |
| 258 | t.Run("storage throws, no fragment: browser default, nothing thrown", func(t *testing.T) { |
| 259 | ctx, done, thrown := tab(t, rig, brokenStorage) |
| 260 | defer done() |
| 261 | visit(t, ctx, rig.Origin+"/b/invoices") |
| 262 | follow(t, ctx, "#back", `location.pathname === "/b/"`) |
| 263 | revealed(t, ctx) |
| 264 | if s := state(t, ctx); s.Focus != "BODY" { |
| 265 | t.Errorf("with no record and no fragment, focus is on %q, want the browser's default", s.Focus) |
| 266 | } |
| 267 | if len(*thrown) > 0 { |
| 268 | t.Errorf("uncaught: %v", *thrown) |
| 269 | } |
| 270 | }) |
| 271 | |
| 272 | t.Run("modified and middle clicks are not intercepted", func(t *testing.T) { |
| 273 | ctx, done, _ := tab(t, rig, "") |
| 274 | defer done() |
| 275 | visit(t, ctx, rig.Origin+"/") |
| 276 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| 277 | for _, init := range []string{"{ctrlKey: true}", "{metaKey: true}", "{shiftKey: true}", "{altKey: true}", "{button: 1}"} { |
| 278 | var prevented bool |
| 279 | // A listener on window runs after shell.js's on document, |
| 280 | // records whether it was intercepted, then stops the real |
| 281 | // navigation so the next click starts from the same page. |
| 282 | js := `(() => { let seen = null; addEventListener("click", e => { seen = e.defaultPrevented; e.preventDefault(); }, {once: true}); |
| 283 | document.getElementById("back").dispatchEvent(new MouseEvent("click", Object.assign({bubbles: true, cancelable: true, button: 0}, ` + init + `))); |
| 284 | return seen; })()` |
| 285 | if err := chromedp.Run(ctx, chromedp.Evaluate(js, &prevented)); err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | if prevented { |
| 289 | t.Errorf("a click with %s on the back control was intercepted; it must do what the browser does", init) |
| 290 | } |
| 291 | } |
| 292 | // The control: a plain click IS intercepted here. |
| 293 | var plain bool |
| 294 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { let seen = null; addEventListener("click", e => { seen = e.defaultPrevented; }, {once: true}); |
| 295 | document.getElementById("back").dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, button: 0})); return seen; })()`, &plain)); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | if !plain { |
| 299 | t.Error("CONTROL: a plain click on the back control was not intercepted either, so the modifier legs prove nothing") |
| 300 | } |
| 301 | }) |
| 302 | |
| 303 | t.Run("an up that redirects: the link is followed", func(t *testing.T) { |
| 304 | ctx, done, _ := tab(t, rig, "") |
| 305 | defer done() |
| 306 | visit(t, ctx, rig.Origin+"/") |
| 307 | follow(t, ctx, "#nav-r", `location.pathname === "/r/page"`) |
| 308 | before := state(t, ctx) |
| 309 | follow(t, ctx, "#back", `location.pathname === "/"`) |
| 310 | if after := state(t, ctx); after.Len != before.Len+1 { |
| 311 | t.Errorf("an up behind a redirect: history %d -> %d; the link must be followed, not history reused", before.Len, after.Len) |
| 312 | } |
| 313 | }) |
| 314 | |
| 315 | t.Run("two sections in a row: the record beats a stale fragment", func(t *testing.T) { |
| 316 | ctx, done, _ := tab(t, rig, "") |
| 317 | defer done() |
| 318 | visit(t, ctx, rig.Origin+"/invoices") |
| 319 | follow(t, ctx, "#back", `location.pathname === "/" && location.hash === "#nav-invoices"`) |
| 320 | follow(t, ctx, "#nav-orders", `location.pathname === "/orders"`) |
| 321 | back(t, ctx, `location.pathname === "/" && document.activeElement.id === "nav-orders"`) |
| 322 | }) |
| 323 | |
| 324 | t.Run("query, absolute hrefs and an encoded id", func(t *testing.T) { |
| 325 | ctx, done, _ := tab(t, rig, "") |
| 326 | defer done() |
| 327 | visit(t, ctx, rig.Origin+"/q/?tab=all") |
| 328 | follow(t, ctx, "#nav-q-orders", `location.pathname === "/q/orders"`) |
| 329 | before := state(t, ctx) |
| 330 | follow(t, ctx, "#back", `location.pathname === "/q/" && document.activeElement.id === "nav-q-orders"`) |
| 331 | if after := state(t, ctx); after.Len != before.Len { |
| 332 | t.Errorf("an up with a query and an absolute nav href: history %d -> %d; path+query were not compared resolved", before.Len, after.Len) |
| 333 | } |
| 334 | // The encoded id, through the history return the fragment leg |
| 335 | // above explains: location.hash keeps the percent-encoding, and |
| 336 | // only a decoded id finds the link. |
| 337 | ctx2, done2, _ := tab(t, rig, brokenStorage) |
| 338 | defer done2() |
| 339 | visit(t, ctx2, rig.Origin+"/q/cafe") |
| 340 | follow(t, ctx2, "#back", `location.pathname === "/q/" && document.activeElement.id === "nav-café"`) |
| 341 | follow(t, ctx2, "#nav-q-orders", `location.pathname === "/q/orders"`) |
| 342 | back(t, ctx2, `location.pathname === "/q/"`) |
| 343 | settleUntil(t, ctx2, `document.activeElement.id === "nav-café"`) |
| 344 | }) |
| 345 | |
| 346 | // An engine with cross-document view transitions but no types: the |
| 347 | // stub, installed before shell.js's listener, hands every reveal a |
| 348 | // transition without a types set. The slide's direction is cosmetic; |
| 349 | // the focus return must still happen and nothing may throw. |
| 350 | t.Run("a transition with no types: focus still returns", func(t *testing.T) { |
| 351 | const untyped = `addEventListener("pagereveal", e => Object.defineProperty(e, "viewTransition", {value: {}}));` |
| 352 | ctx, done, thrown := tab(t, rig, untyped) |
| 353 | defer done() |
| 354 | visit(t, ctx, rig.Origin+"/") |
| 355 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices"`) |
| 356 | follow(t, ctx, "#back", `location.pathname === "/"`) |
| 357 | settleUntil(t, ctx, `document.activeElement.id === "nav-invoices"`) |
| 358 | if len(*thrown) > 0 { |
| 359 | t.Errorf("a transition without types broke the script: %v", *thrown) |
| 360 | } |
| 361 | }) |
| 362 | |
| 363 | // A record left by a content page is taken by the next index revealed, |
| 364 | // whatever its width. Left behind by a wide reveal, it would focus a |
| 365 | // stale link on the next phone-width index reached without leaving a |
| 366 | // page (here, a reload of the index). |
| 367 | t.Run("a wide reveal consumes the record", func(t *testing.T) { |
| 368 | ctx, done, _ := tab(t, rig, "") |
| 369 | defer done() |
| 370 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 800)); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | visit(t, ctx, rig.Origin+"/b/invoices") |
| 374 | follow(t, ctx, "#back", `location.pathname === "/b/"`) |
| 375 | revealed(t, ctx) |
| 376 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(390, 844)); err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | visit(t, ctx, rig.Origin+"/b/") |
| 380 | revealed(t, ctx) |
| 381 | if s := state(t, ctx); s.Focus != "BODY" { |
| 382 | t.Errorf("a phone-width index reached by reload focused %q: a record from a wide reveal was left behind", s.Focus) |
| 383 | } |
| 384 | }) |
| 385 | |
| 386 | // An index left in the default page view carries a back control whose |
| 387 | // up is the page itself. After a same-page #fragment, the entry behind |
| 388 | // is that same URL but the same document, and going back to it would |
| 389 | // be a scroll with no reveal: no slide, no focus return. The link is |
| 390 | // followed instead. |
| 391 | t.Run("an up that is this document: the link is followed", func(t *testing.T) { |
| 392 | ctx, done, _ := tab(t, rig, "") |
| 393 | defer done() |
| 394 | visit(t, ctx, rig.Origin+"/self") |
| 395 | follow(t, ctx, "#section-link", `location.hash === "#part"`) |
| 396 | before := state(t, ctx) |
| 397 | follow(t, ctx, "#back", `location.pathname === "/self" && location.hash === ""`) |
| 398 | if after := state(t, ctx); after.Len != before.Len+1 { |
| 399 | t.Errorf("an up behind a same-document entry: history %d -> %d; the link must be followed, not history reused", before.Len, after.Len) |
| 400 | } |
| 401 | }) |
| 402 | } |
| 403 | |
| 404 | // back is the browser's own Back button: a traversal the back control |
| 405 | // did not start, so shell.js has no one-shot flag to read. Before it, |
| 406 | // focus is dropped, so what has focus afterwards is what the returning |
| 407 | // page put there and not what the page kept from before it was left. |
| 408 | func back(t *testing.T, ctx context.Context, until string) { |
| 409 | t.Helper() |
| 410 | if err := chromedp.Run(ctx, chromedp.Evaluate(`document.activeElement && document.activeElement.blur(), setTimeout(() => history.back(), 0), true`, nil)); err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | settleUntil(t, ctx, loaded(until)) |
| 414 | } |
| 415 | |
| 416 | // recordReveal logs every pagereveal's transition types into |
| 417 | // sessionStorage. This listener is installed before the page's own |
| 418 | // scripts, so it runs BEFORE shell.js's, when no type has been added |
| 419 | // yet: the types are read in log, which runs once the reveal is over |
| 420 | // (after the transition's finished promise settles, or on a timer when |
| 421 | // there is no transition), by which point every listener has run. The |
| 422 | // drive waits for each entry before it starts the next navigation, so |
| 423 | // no step begins while the previous slide is still running. |
| 424 | const recordReveal = `addEventListener("pagereveal", e => { const vt = e.viewTransition; |
| 425 | const log = () => { const types = vt ? [...vt.types].join("+") || "untyped" : "none"; |
| 426 | const l = JSON.parse(sessionStorage.getItem("reveal-log") || "[]"); |
| 427 | l.push(location.pathname + ":" + types); sessionStorage.setItem("reveal-log", JSON.stringify(l)); }; |
| 428 | if (vt) vt.finished.then(log, log); else setTimeout(log, 0); });` |
| 429 | |
| 430 | // TestTheSlideKnowsWhichWayItIsGoing: forward going in; back coming out |
| 431 | // through the back control, through the browser's own Back (which sets |
| 432 | // no flag, so only the Navigation API's traverse can say which way it |
| 433 | // went), and through a back control that follows its link (a new entry, |
| 434 | // not a traverse, so only the flag can); and no transition at all under |
| 435 | // reduced motion (shell.css never opts in). |
| 436 | func TestTheSlideKnowsWhichWayItIsGoing(t *testing.T) { |
| 437 | rig := harness.New(t, shellSite(t)) |
| 438 | for _, reduced := range []bool{false, true} { |
| 439 | t.Run(fmt.Sprintf("reduced motion %v", reduced), func(t *testing.T) { |
| 440 | var media []*emulation.MediaFeature |
| 441 | want := `["/:none","/invoices:forward","/:back","/orders:forward","/:back","/invoices:forward","/:back"]` |
| 442 | if reduced { |
| 443 | media = []*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: "reduce"}} |
| 444 | want = `["/:none","/invoices:none","/:none","/orders:none","/:none","/invoices:none","/:none"]` |
| 445 | } |
| 446 | ctx, done, _ := tab(t, rig, recordReveal, media...) |
| 447 | defer done() |
| 448 | logged := func(n int) string { |
| 449 | return fmt.Sprintf(`JSON.parse(sessionStorage.getItem("reveal-log") || "[]").length === %d`, n) |
| 450 | } |
| 451 | visit(t, ctx, rig.Origin+"/") |
| 452 | settleUntil(t, ctx, logged(1)) |
| 453 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices" && `+logged(2)) |
| 454 | follow(t, ctx, "#back", `location.pathname === "/" && `+logged(3)) |
| 455 | follow(t, ctx, "#nav-orders", `location.pathname === "/orders" && `+logged(4)) |
| 456 | back(t, ctx, `location.pathname === "/" && `+logged(5)) |
| 457 | // A same-page fragment first, so the back control cannot |
| 458 | // reuse history and follows its link. |
| 459 | follow(t, ctx, "#nav-invoices", `location.pathname === "/invoices" && `+logged(6)) |
| 460 | follow(t, ctx, "#section-link", `location.hash === "#part"`) |
| 461 | follow(t, ctx, "#back", `location.pathname === "/" && `+logged(7)) |
| 462 | var log string |
| 463 | if err := chromedp.Run(ctx, chromedp.Evaluate(`sessionStorage.getItem("reveal-log")`, &log)); err != nil { |
| 464 | t.Fatal(err) |
| 465 | } |
| 466 | if log != want { |
| 467 | t.Errorf("pagereveal saw %s, want %s", log, want) |
| 468 | } |
| 469 | }) |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | // TestALinkBesideTheBackControlIsNotTheBackControl: a page may put more |
| 474 | // in the back strip than the back link (the gallery's display settings |
| 475 | // menu sits at its inline end), and those links are ordinary links. A |
| 476 | // click on one must not record a "back" for the slide, nor call |
| 477 | // history.back() when the entry behind happens to be the same page: it |
| 478 | // is followed, and history grows by one. |
| 479 | func TestALinkBesideTheBackControlIsNotTheBackControl(t *testing.T) { |
| 480 | rig := harness.New(t, shellSite(t)) |
| 481 | init := `(() => { const set = Storage.prototype.setItem, back = History.prototype.back; |
| 482 | Storage.prototype.setItem = function (k, v) { if (k === "rst-shell-back") set.call(this, "__went_back", v); return set.call(this, k, v); }; |
| 483 | History.prototype.back = function () { sessionStorage.setItem("__history_back", "1"); return back.call(this); }; })()` |
| 484 | ctx, done, thrown := tab(t, rig, init) |
| 485 | defer done() |
| 486 | // The entry behind /strip is the very page its menu link names, so |
| 487 | // the back control's own rule would reuse history for it. |
| 488 | visit(t, ctx, rig.Origin+"/?theme=other") |
| 489 | revealed(t, ctx) |
| 490 | visit(t, ctx, rig.Origin+"/strip") |
| 491 | revealed(t, ctx) |
| 492 | before := state(t, ctx) |
| 493 | follow(t, ctx, "#strip-menu-link", `location.pathname === "/" && location.search === "?theme=other"`) |
| 494 | revealed(t, ctx) |
| 495 | after := state(t, ctx) |
| 496 | var rec struct{ WentBack, HistoryBack string } |
| 497 | at(t, ctx, `JSON.stringify({WentBack: sessionStorage.getItem("__went_back") || "", HistoryBack: sessionStorage.getItem("__history_back") || ""})`, &rec) |
| 498 | if rec.WentBack != "" || rec.HistoryBack != "" { |
| 499 | t.Errorf("a menu link in the back strip was taken for the back control: recorded back %q, history.back called %q", rec.WentBack, rec.HistoryBack) |
| 500 | } |
| 501 | if after.Len != before.Len+1 { |
| 502 | t.Errorf("history %d -> %d after the menu link; want it followed (+1)", before.Len, after.Len) |
| 503 | } |
| 504 | if errs, _ := scriptErrors(t, *thrown); len(errs) > 0 { |
| 505 | t.Errorf("uncaught: %v", errs) |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | // skippedTransition is the rejection headless Chromium reports when it |
| 510 | // skips a cross-document view transition before the new page reveals. |
| 511 | const skippedTransition = `AbortError: Transition was skipped` |
| 512 | |
| 513 | // scriptErrors is every uncaught exception except a skipped transition, |
| 514 | // and how many skipped transitions it set aside. |
| 515 | // Measured on this machine's Chromium with the real layouts: on roughly |
| 516 | // one navigation in five the incoming transition is skipped before |
| 517 | // pagereveal (the event carries no viewTransition, so no script ever saw |
| 518 | // it) and the browser reports its own unhandled promise. The skip |
| 519 | // happens with every deferred script the layouts load and not with |
| 520 | // shell.js alone, and navigation, history and focus are unaffected: the |
| 521 | // page simply arrives without the slide. Anything else thrown is still a |
| 522 | // failure, and so is every navigation in a drive being skipped: that is |
| 523 | // no longer a race, it is a slide that never runs. |
| 524 | func scriptErrors(t *testing.T, thrown []string) (errs []string, skipped int) { |
| 525 | t.Helper() |
| 526 | for _, e := range thrown { |
| 527 | if strings.Contains(e, skippedTransition) && strings.Contains(e, "Uncaught (in promise)") { |
| 528 | t.Logf("the browser skipped a transition (no slide this time): %s", e) |
| 529 | skipped++ |
| 530 | continue |
| 531 | } |
| 532 | errs = append(errs, e) |
| 533 | } |
| 534 | return errs, skipped |
| 535 | } |
| 536 | |