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
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.
7package check
8
9import (
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.
33func 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.
47func 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.
100func 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
126type 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).
133func 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).
144func 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.
154func 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.
171func 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.
179func 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.
192func 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.
408func 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.
424const 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).
436func 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.
479func 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.
511const 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.
524func 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