//go:build browser package history import ( "context" "encoding/json" "net/http" "strconv" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/harness" ) // A shell demo opened in a tab of its own, left and come back to from // the back/forward cache, in the scheme stored while it was away. // frameDoc is the path of one state's frame document within the tree. func frameDoc(theme, locale, name, state string) string { return theme + "/" + locale + "/frames/" + name + "/" + state + ".html" } // defaultState is the manifest's default state for name. func defaultState(t *testing.T, name string) string { t.Helper() m, err := example.Load(fiesta.FS()) if err != nil { t.Fatal(err) } state, ok := m.Default(name) if !ok || state == "" { t.Fatalf("the manifest has no default state for %s", name) } return state } func run(t *testing.T, ctx context.Context, actions ...chromedp.Action) { t.Helper() if err := chromedp.Run(ctx, actions...); err != nil { t.Fatal(err) } } // readJSON evaluates a JS expression returning a JSON string into v. func readJSON(t *testing.T, ctx context.Context, js string, v any) { t.Helper() var raw string if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw)); err != nil { t.Fatalf("evaluating: %v\n%s", err, js) } if err := json.Unmarshal([]byte(raw), v); err != nil { t.Fatalf("decoding %q: %v", raw, err) } } // schemeMarkJS reports the scheme menu's buttons and which is marked. const schemeMarkJS = `JSON.stringify([...document.querySelectorAll("[rst-scheme] button")].map(b => ({ V: b.form && b.form.elements.scheme ? b.form.elements.scheme.value : "", Cur: b.getAttribute("aria-current") || "" })))` // schemeOf reads the root's scheme attribute and computed color-scheme. const schemeOf = `JSON.stringify({Theme: document.documentElement.getAttribute("data-theme") || "", Scheme: getComputedStyle(document.documentElement).colorScheme})` // TestADemoComesBackFromTheCacheInTheStoredScheme: a demo opened in a // tab of its own, left for the gallery, whose stored scheme changes // while it is away, comes back from the back/forward cache in the new // scheme with its menu marked: Dark to Light, then Light to System, on // the scheme menu's document; and Dark to Light on the sidebar demo, // left by a click into its page. The control first, every time: the // return really was a cache restore (pageshow.persisted, and the same // navigation start as before), since a document reloaded instead would // pass on start-up code alone and prove nothing about the pageshow // handler. Back is history.back() from the page itself, polled for: // chromedp's NavigateBack waits for a load a restored page never fires, // and its node waits never resolve after a history navigation // (check/menus_browser_test.go). // // Full Chromium only, which is why it is in this package: the headless // shell never restores a page from the cache (hack/gotest.sh's // FULL_CHROMIUM note), and on it this fails naming the engine's reason // rather than passing on a reload. func TestADemoComesBackFromTheCacheInTheStoredScheme(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) defer cancel() theme := fiesta.ThemeNames()[0] index := indexHref(theme, "en") menu := frameDoc(theme, "en", "scheme-menu", defaultState(t, "scheme-menu")) sidebar := frameDoc(theme, "en", "layout-sidebar", defaultState(t, "layout-sidebar")) page := frameDoc(theme, "en", "layout-sidebar-page", defaultState(t, "layout-sidebar-page")) run(t, ctx, addInit(`addEventListener("pageshow", function (e) { window.__restored = e.persisted; });`), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+index), chromedp.WaitReady("body"), chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); "ok"`, new(string)), ) readRoot := func(where, wantTheme, wantScheme string) { t.Helper() until(t, ctx, where, `document.readyState === "complete"`) var got struct{ Theme, Scheme string } readJSON(t, ctx, schemeOf, &got) if got.Theme != wantTheme { t.Errorf("%s: carries data-theme=%q, want %q", where, got.Theme, wantTheme) } if got.Scheme != wantScheme { t.Errorf("%s: color-scheme computes %q, want %q", where, got.Scheme, wantScheme) } } readMark := func(where, scheme string) { t.Helper() var buttons []struct{ V, Cur string } readJSON(t, ctx, schemeMarkJS, &buttons) if len(buttons) == 0 { t.Fatalf("%s: no scheme menu button; the mark would be read off nothing", where) } for _, b := range buttons { if (b.V == scheme) != (b.Cur == "true") { t.Errorf("%s: the %s button is aria-current=%q; %s alone is marked", where, b.V, b.Cur, scheme) } } } // away leaves the document (leave is the statement that does), waits // to be at awayAt, changes the stored scheme there (store), goes // back, and requires the return to be a cache restore of the // document whose path ends in back. away := func(where, leave, awayAt, store, back string) { t.Helper() var started float64 run(t, ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin`, &started)) run(t, ctx, chromedp.Evaluate(`window.__restored = undefined; `+leave+`; true`, nil)) until(t, ctx, where+", away", `location.pathname.endsWith("`+awayAt+`") && document.readyState === "complete"`) run(t, ctx, chromedp.Evaluate(store+`; history.back(); true`, nil)) until(t, ctx, where+", back", `location.pathname.endsWith("`+back+`") && window.__restored !== undefined`) var raw string run(t, ctx, chromedp.Evaluate(`JSON.stringify({Restored: window.__restored === true, Started: performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin, Why: JSON.stringify((performance.getEntriesByType("navigation")[0] || {}).notRestoredReasons || null)})`, &raw)) var r struct { Restored bool Started float64 Why string } if err := json.Unmarshal([]byte(raw), &r); err != nil { t.Fatalf("%s: decoding %q: %v", where, raw, err) } if !r.Restored || r.Started != started { t.Fatalf("%s: the return was not a back/forward cache restore (persisted %v, navigation %s then %s; the engine says %s); the engine must be full Chromium and the tree handler must not send Cache-Control: no-store", where, r.Restored, strconv.FormatFloat(started, 'f', 0, 64), strconv.FormatFloat(r.Started, 'f', 0, 64), r.Why) } } // The scheme menu's document: Dark, then Light on the way back, then // System on the way back. run(t, ctx, chromedp.Navigate(rig.Origin+mountPrefix+menu), chromedp.WaitReady("body")) readRoot("the menu document", "dark", "dark") readMark("the menu document", "dark") toIndex := `location.assign("` + index + `")` menuPath := "/" + menu away("Dark to Light", toIndex, "/index.html", `localStorage.setItem("fiesta-scheme", "light")`, menuPath) readRoot("the menu document back from the cache with Light stored", "light", "light") readMark("the menu document back from the cache with Light stored", "light") away("Light to System", toIndex, "/index.html", `localStorage.removeItem("fiesta-scheme")`, menuPath) readRoot("the menu document back from the cache with nothing stored", "", "light dark") readMark("the menu document back from the cache with nothing stored", "system") // The shell demo: Dark, into its page by the nav, Light on the way back. run(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); "ok"`, new(string)), chromedp.Navigate(rig.Origin+mountPrefix+sidebar), chromedp.WaitReady("body"), ) readRoot("the sidebar demo", "dark", "dark") away("the sidebar demo, Dark to Light", `document.querySelector("[rst-shell-nav] a").click()`, "/"+page, `localStorage.setItem("fiesta-scheme", "light")`, "/"+sidebar) readRoot("the sidebar demo back from the cache with Light stored", "light", "light") // A fresh navigation before the screen: after a history // navigation chromedp's node waits never resolve, so the flush of // the page's problems is taken on a document loaded anew. run(t, ctx, chromedp.Navigate(rig.Origin+mountPrefix+menu), chromedp.WaitReady("body")) rig.Screen("body", "the scheme menu's document in Light") }