//go:build browser package history import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "testing" "time" "github.com/chromedp/cdproto/input" "github.com/chromedp/cdproto/target" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" "amadan.net/rastrillo/fiesta/internal/harness" ) // markedJS reads every scheme menu on the page: for each, the value of // the one button carrying aria-current="true", or "" for none and // "many" for more than one. const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => { const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true"); return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : ""; })` // TestTheSchemeFollowsAPageBackFromTheCache: the scheme follows a page // back from the back/forward cache. On a phone shell.js sends Back // through history, so a page comes back from the cache without // re-running anything: open an example page from the index in System, // go Back to the index, choose Dark in its display settings menu, go // Forward, and the page must be dark, previews and all, with every // scheme menu on it marking Dark. The control first: the Forward // really was a cache restore. A leg that silently reloaded the page // would pass on start-up code alone. The example's frames are all // loaded and painted before it is left, since the engine does not keep // a page whose frames are still navigating; when it refuses anyway the // leg fails naming the engine's notRestoredReasons. // // The index's rows open family pages, which frame nothing, so the // page under test is the first example of the Form family, one more // hop in: index, Form, the example, then Back twice. func TestTheSchemeFollowsAPageBackFromTheCache(t *testing.T) { rig := phoneRig(t) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() where := "day/en at 390px" if err := chromedp.Run(ctx, addInit(`addEventListener("pageshow", e => { window.__restored = e.persisted; });`), chromedp.EmulateViewport(390, 844), chromedp.Navigate(rig.Origin+indexHref("day", "en")), chromedp.WaitReady("body")); err != nil { t.Fatalf("%s: loading the index: %v", where, err) } var hasIndex bool if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector(".ds-index") && !!document.getElementById("nav-form")`, &hasIndex)); err != nil || !hasIndex { t.Fatalf("%s: the Overview is not the phone index with a Form row (%v)", where, err) } requireCoarse(t, ctx) for _, c := range []struct{ scheme, attr string }{{"dark", "dark"}, {"system", ""}} { step := where + ", " + c.scheme if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("nav-form").click(); true`, nil)); err != nil { t.Fatalf("%s: opening Form: %v", step, err) } until(t, ctx, step+", Form", `location.pathname.endsWith("/form.html") && document.readyState === "complete"`) if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector("main .ds-routes a").click(); true`, nil)); err != nil { t.Fatalf("%s: opening Form's first example: %v", step, err) } until(t, ctx, step+", the example", `!location.pathname.endsWith("/form.html") && document.readyState === "complete" && document.querySelectorAll(".ds-view__frame").length > 0`) // Every frame loaded and painted before the page is left. The // frames are lazy, and at 390px most are below the fold: a // frame still navigating when the page goes is a reason the // engine gives for not keeping the page (notRestoredReasons: // "navigating"), and the Forward below would be a reload. galleryrig.Eagerly(t, ctx, step+", the example's frames") until(t, ctx, step+", the example's frames painted", `[...document.querySelectorAll(".ds-view__frame")].every(f => f.hasAttribute("data-gallery-painted"))`) var started float64 if err := chromedp.Run(ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin`, &started)); err != nil { t.Fatalf("%s: reading the example's navigation: %v", step, err) } if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { t.Fatalf("%s: going back: %v", step, err) } until(t, ctx, step+", Form again", `location.pathname.endsWith("/form.html")`) if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { t.Fatalf("%s: going back to the index: %v", step, err) } until(t, ctx, step+", the index", `location.pathname.endsWith("/index.html") && !!document.querySelector("#ds-prefs [rst-scheme] form")`) if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { document.getElementById("ds-prefs").open = true; const m = document.querySelector("#ds-prefs [rst-scheme]"); m.open = true; [...m.querySelectorAll("form")].find(f => f.elements.scheme.value === "`+c.scheme+`").querySelector("button").click(); history.forward(); return true; })()`, nil)); err != nil { t.Fatalf("%s: choosing %s and going forward: %v", step, c.scheme, err) } until(t, ctx, step+", Form forward", `location.pathname.endsWith("/form.html")`) if err := chromedp.Run(ctx, chromedp.Evaluate(`history.forward(); true`, nil)); err != nil { t.Fatalf("%s: forward to the example: %v", step, err) } until(t, ctx, step+", the example again", `!location.pathname.endsWith("/form.html") && !location.pathname.endsWith("/index.html") && window.__restored !== undefined`) var raw string if err := chromedp.Run(ctx, chromedp.Evaluate(`JSON.stringify({Restored: window.__restored, Started: performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin, Why: JSON.stringify((performance.getEntriesByType("navigation")[0] || {}).notRestoredReasons || null)})`, &raw)); err != nil { t.Fatalf("%s: reading the restore: %v", step, err) } var r struct { Restored bool Started float64 Why string } if err := json.Unmarshal([]byte(raw), &r); err != nil { t.Fatalf("%s: decoding %q: %v", step, raw, err) } if !r.Restored || r.Started != started { t.Fatalf("%s: Forward was not a back/forward cache restore (persisted %v, navigation %s then %s; the engine says %s). The tree handler must not send Cache-Control: no-store", step, r.Restored, strconv.FormatFloat(started, 'f', 0, 64), strconv.FormatFloat(r.Started, 'f', 0, 64), r.Why) } // Frames that have not loaded are skipped, but at least one // must have: every() over none is true. until(t, ctx, step+", painted", fmt.Sprintf(`(() => { const loaded = [...document.querySelectorAll(".ds-view__frame")].filter(f => { const d = f.contentDocument; return d && d.URL === f.src; }); const marked = %s; return (document.documentElement.getAttribute("data-theme") || "") === %q && loaded.length > 0 && loaded.every(f => (f.contentDocument.documentElement.getAttribute("data-theme") || "") === %q) && marked.length === 2 && marked.every(m => m === %q); })()`, markedJS, c.attr, c.attr, c.scheme)) if err := chromedp.Run(ctx, chromedp.Evaluate(`history.go(-2); true`, nil)); err != nil { t.Fatalf("%s: back to the index: %v", step, err) } until(t, ctx, step+", index again", `location.pathname.endsWith("/index.html")`) } } // The switcher's own address is never left changed: the last legs of // Rastrillo's TestPositionSurvivesASwitch, here because a Ctrl-click // has to open a real tab and the last Back has to be a real cache // restore, neither of which the headless shell gives. // newTab reports the next page a click opens in a tab of its own. // chromedp.WaitNewTarget would wait forever here: it takes only // targets with an opener, and a tab opened by Ctrl- or middle-clicking // a link has none. func newTab(ctx context.Context, url func(string) bool) <-chan target.ID { ch := make(chan target.ID, 1) lctx, cancel := context.WithCancel(ctx) chromedp.ListenTarget(lctx, func(ev any) { e, ok := ev.(*target.EventTargetCreated) if !ok || e.TargetInfo.Type != "page" || e.TargetInfo.Attached || !url(e.TargetInfo.URL) { return } select { case ch <- e.TargetInfo.TargetID: default: } cancel() }) return ch } // clickWith is a real mouse click on sel, with a modifier or another // button, at the match's centre rather than through MouseClickNode, // which scrolls the page to the node first and so moves the very place // a click from the pinned bar is meant to carry. func clickWith(t *testing.T, ctx context.Context, where, sel string, opts ...chromedp.MouseOption) { t.Helper() var at []float64 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const el = document.querySelector(`+strconv.Quote(sel)+`), r0 = el.getBoundingClientRect(); if (r0.top < 0 || r0.bottom > innerHeight) el.scrollIntoView({block: "nearest"}); const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()`, &at)); err != nil || len(at) != 2 { t.Fatalf("%s: finding %s: %v", where, sel, err) } if err := chromedp.Run(ctx, chromedp.MouseClickXY(at[0], at[1], opts...)); err != nil { t.Fatalf("%s: clicking %s: %v", where, sel, err) } } // TestTheSwitchersAddressIsNeverLeftChanged: a Ctrl-click from deep in // the page opens the computed place in a new tab and leaves the link // canonical; a middle-click from the same place opens the link as // rendered, the other page's top; a plain click from deep in the page // carries the place; and a page back from the back/forward cache, left // by a click that carried a place, still has canonical links. The // last Back is held to be a cache restore, since a fresh load renders // canonical links whatever the switcher does. func TestTheSwitchersAddressIsNeverLeftChanged(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() where := "canonical" page := rig.Origin + pageHref("day", "en", "tokens.html") open := func() { t.Helper() if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate("about:blank"), chromedp.Navigate(page), chromedp.WaitReady("body")); err != nil { t.Fatalf("%s: loading: %v", where, err) } var ok bool if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector(".ds-top__controls [rst-seg-tabs]") && document.querySelectorAll("[data-ds-anchor]").length > 2`, &ok)); err != nil || !ok { t.Fatalf("%s: the page has no theme tabs in a bar over anchored groups (%v)", where, err) } until(t, ctx, where, `document.readyState === "complete"`) } open() var mid string if err := chromedp.Run(ctx, chromedp.Evaluate(`[...document.querySelectorAll("[data-ds-anchor]")][2].id`, &mid)); err != nil { t.Fatalf("%s: reading the groups: %v", where, err) } deep := `document.getElementById("` + mid + `").scrollIntoView({block: "start"}); true` theme := `.ds-top__controls [rst-seg-tabs] a:not([aria-current])` canonical := `![...document.querySelectorAll(".ds-top__controls a")].some(a => a.getAttribute("href").includes("#"))` other := func(url string) bool { return strings.Contains(url, "/tokens.html") && !strings.Contains(url, "/day/") } tabURL := func(how string, opened <-chan target.ID) string { t.Helper() select { case id := <-opened: tab, closeTab := chromedp.NewContext(ctx, chromedp.WithTargetID(id)) defer closeTab() until(t, tab, where+", the "+how+" tab", `location.href.includes("/tokens.html") && !location.href.includes("/day/")`) var url string if err := chromedp.Run(tab, chromedp.Location(&url)); err != nil { t.Fatalf("%s: reading the %s tab: %v", where, how, err) } return url case <-time.After(10 * time.Second): t.Fatalf("%s: %s a theme link opened no tab", where, how) } return "" } if err := chromedp.Run(ctx, chromedp.Evaluate(deep, nil)); err != nil { t.Fatal(err) } opened := newTab(ctx, other) clickWith(t, ctx, where, theme, chromedp.ButtonModifiers(input.ModifierCtrl)) if url := tabURL("Ctrl-clicking", opened); !strings.HasSuffix(url, "#"+mid) { t.Errorf("%s: the Ctrl-clicked tab opened %s, want the computed place #%s", where, url, mid) } until(t, ctx, where+", restored", canonical) if err := chromedp.Run(ctx, chromedp.Evaluate(deep, nil)); err != nil { t.Fatal(err) } middle := newTab(ctx, other) clickWith(t, ctx, where, theme, chromedp.ButtonMiddle) if url := tabURL("middle-clicking", middle); strings.Contains(url, "#") { t.Errorf("%s: a middle-click opened %s; it reads the link as rendered, the top of the page", where, url) } const started = `performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin` var left float64 if err := chromedp.Run(ctx, chromedp.Evaluate(started, &left), chromedp.Evaluate(deep, nil), chromedp.Evaluate(`document.querySelector('`+theme+`').click(); true`, nil)); err != nil { t.Fatal(err) } until(t, ctx, where+", deep", `!location.pathname.includes("/day/") && document.readyState === "complete"`) var hash string if err := chromedp.Run(ctx, chromedp.Evaluate(`location.hash`, &hash)); err != nil { t.Fatal(err) } if hash != "#"+mid { t.Errorf("%s: a plain click from #%s landed at %q", where, mid, hash) } if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { t.Fatal(err) } until(t, ctx, where+", back", `location.pathname.includes("/day/") && `+canonical) var back float64 if err := chromedp.Run(ctx, chromedp.Evaluate(started, &back)); err != nil { t.Fatal(err) } if back != left { t.Fatalf("%s: the last Back was not a back/forward cache restore (navigation %s then %s), so its canonical links prove nothing. The tree handler must not send Cache-Control: no-store", where, strconv.FormatFloat(left, 'f', 0, 64), strconv.FormatFloat(back, 'f', 0, 64)) } }