| 1 | //go:build browser |
| 2 | |
| 3 | package history |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "net/http" |
| 9 | "strconv" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/chromedp/chromedp" |
| 14 | |
| 15 | "amadan.net/rastrillo/fiesta" |
| 16 | "amadan.net/rastrillo/fiesta/example" |
| 17 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 18 | ) |
| 19 | |
| 20 | // A shell demo opened in a tab of its own, left and come back to from |
| 21 | // the back/forward cache, in the scheme stored while it was away. |
| 22 | |
| 23 | // frameDoc is the path of one state's frame document within the tree. |
| 24 | func frameDoc(theme, locale, name, state string) string { |
| 25 | return theme + "/" + locale + "/frames/" + name + "/" + state + ".html" |
| 26 | } |
| 27 | |
| 28 | // defaultState is the manifest's default state for name. |
| 29 | func defaultState(t *testing.T, name string) string { |
| 30 | t.Helper() |
| 31 | m, err := example.Load(fiesta.FS()) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | state, ok := m.Default(name) |
| 36 | if !ok || state == "" { |
| 37 | t.Fatalf("the manifest has no default state for %s", name) |
| 38 | } |
| 39 | return state |
| 40 | } |
| 41 | |
| 42 | func run(t *testing.T, ctx context.Context, actions ...chromedp.Action) { |
| 43 | t.Helper() |
| 44 | if err := chromedp.Run(ctx, actions...); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // readJSON evaluates a JS expression returning a JSON string into v. |
| 50 | func readJSON(t *testing.T, ctx context.Context, js string, v any) { |
| 51 | t.Helper() |
| 52 | var raw string |
| 53 | if err := chromedp.Run(ctx, chromedp.Evaluate(js, &raw)); err != nil { |
| 54 | t.Fatalf("evaluating: %v\n%s", err, js) |
| 55 | } |
| 56 | if err := json.Unmarshal([]byte(raw), v); err != nil { |
| 57 | t.Fatalf("decoding %q: %v", raw, err) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // schemeMarkJS reports the scheme menu's buttons and which is marked. |
| 62 | const schemeMarkJS = `JSON.stringify([...document.querySelectorAll("[rst-scheme] button")].map(b => ({ |
| 63 | V: b.form && b.form.elements.scheme ? b.form.elements.scheme.value : "", |
| 64 | Cur: b.getAttribute("aria-current") || "" |
| 65 | })))` |
| 66 | |
| 67 | // schemeOf reads the root's scheme attribute and computed color-scheme. |
| 68 | const schemeOf = `JSON.stringify({Theme: document.documentElement.getAttribute("data-theme") || "", Scheme: getComputedStyle(document.documentElement).colorScheme})` |
| 69 | |
| 70 | // TestADemoComesBackFromTheCacheInTheStoredScheme: a demo opened in a |
| 71 | // tab of its own, left for the gallery, whose stored scheme changes |
| 72 | // while it is away, comes back from the back/forward cache in the new |
| 73 | // scheme with its menu marked: Dark to Light, then Light to System, on |
| 74 | // the scheme menu's document; and Dark to Light on the sidebar demo, |
| 75 | // left by a click into its page. The control first, every time: the |
| 76 | // return really was a cache restore (pageshow.persisted, and the same |
| 77 | // navigation start as before), since a document reloaded instead would |
| 78 | // pass on start-up code alone and prove nothing about the pageshow |
| 79 | // handler. Back is history.back() from the page itself, polled for: |
| 80 | // chromedp's NavigateBack waits for a load a restored page never fires, |
| 81 | // and its node waits never resolve after a history navigation |
| 82 | // (check/menus_browser_test.go). |
| 83 | // |
| 84 | // Full Chromium only, which is why it is in this package: the headless |
| 85 | // shell never restores a page from the cache (hack/gotest.sh's |
| 86 | // FULL_CHROMIUM note), and on it this fails naming the engine's reason |
| 87 | // rather than passing on a reload. |
| 88 | func TestADemoComesBackFromTheCacheInTheStoredScheme(t *testing.T) { |
| 89 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 90 | ctx, cancel := context.WithTimeout(rig.Context(), 3*time.Minute) |
| 91 | defer cancel() |
| 92 | theme := fiesta.ThemeNames()[0] |
| 93 | index := indexHref(theme, "en") |
| 94 | menu := frameDoc(theme, "en", "scheme-menu", defaultState(t, "scheme-menu")) |
| 95 | sidebar := frameDoc(theme, "en", "layout-sidebar", defaultState(t, "layout-sidebar")) |
| 96 | page := frameDoc(theme, "en", "layout-sidebar-page", defaultState(t, "layout-sidebar-page")) |
| 97 | |
| 98 | run(t, ctx, |
| 99 | addInit(`addEventListener("pageshow", function (e) { window.__restored = e.persisted; });`), |
| 100 | chromedp.EmulateViewport(1280, 900), |
| 101 | chromedp.Navigate(rig.Origin+index), |
| 102 | chromedp.WaitReady("body"), |
| 103 | chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); "ok"`, new(string)), |
| 104 | ) |
| 105 | |
| 106 | readRoot := func(where, wantTheme, wantScheme string) { |
| 107 | t.Helper() |
| 108 | until(t, ctx, where, `document.readyState === "complete"`) |
| 109 | var got struct{ Theme, Scheme string } |
| 110 | readJSON(t, ctx, schemeOf, &got) |
| 111 | if got.Theme != wantTheme { |
| 112 | t.Errorf("%s: <html> carries data-theme=%q, want %q", where, got.Theme, wantTheme) |
| 113 | } |
| 114 | if got.Scheme != wantScheme { |
| 115 | t.Errorf("%s: color-scheme computes %q, want %q", where, got.Scheme, wantScheme) |
| 116 | } |
| 117 | } |
| 118 | readMark := func(where, scheme string) { |
| 119 | t.Helper() |
| 120 | var buttons []struct{ V, Cur string } |
| 121 | readJSON(t, ctx, schemeMarkJS, &buttons) |
| 122 | if len(buttons) == 0 { |
| 123 | t.Fatalf("%s: no scheme menu button; the mark would be read off nothing", where) |
| 124 | } |
| 125 | for _, b := range buttons { |
| 126 | if (b.V == scheme) != (b.Cur == "true") { |
| 127 | t.Errorf("%s: the %s button is aria-current=%q; %s alone is marked", where, b.V, b.Cur, scheme) |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | // away leaves the document (leave is the statement that does), waits |
| 132 | // to be at awayAt, changes the stored scheme there (store), goes |
| 133 | // back, and requires the return to be a cache restore of the |
| 134 | // document whose path ends in back. |
| 135 | away := func(where, leave, awayAt, store, back string) { |
| 136 | t.Helper() |
| 137 | var started float64 |
| 138 | run(t, ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin`, &started)) |
| 139 | run(t, ctx, chromedp.Evaluate(`window.__restored = undefined; `+leave+`; true`, nil)) |
| 140 | until(t, ctx, where+", away", `location.pathname.endsWith("`+awayAt+`") && document.readyState === "complete"`) |
| 141 | run(t, ctx, chromedp.Evaluate(store+`; history.back(); true`, nil)) |
| 142 | until(t, ctx, where+", back", `location.pathname.endsWith("`+back+`") && window.__restored !== undefined`) |
| 143 | var raw string |
| 144 | run(t, ctx, chromedp.Evaluate(`JSON.stringify({Restored: window.__restored === true, |
| 145 | Started: performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin, |
| 146 | Why: JSON.stringify((performance.getEntriesByType("navigation")[0] || {}).notRestoredReasons || null)})`, &raw)) |
| 147 | var r struct { |
| 148 | Restored bool |
| 149 | Started float64 |
| 150 | Why string |
| 151 | } |
| 152 | if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| 153 | t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 154 | } |
| 155 | if !r.Restored || r.Started != started { |
| 156 | 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", |
| 157 | where, r.Restored, strconv.FormatFloat(started, 'f', 0, 64), strconv.FormatFloat(r.Started, 'f', 0, 64), r.Why) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // The scheme menu's document: Dark, then Light on the way back, then |
| 162 | // System on the way back. |
| 163 | run(t, ctx, chromedp.Navigate(rig.Origin+mountPrefix+menu), chromedp.WaitReady("body")) |
| 164 | readRoot("the menu document", "dark", "dark") |
| 165 | readMark("the menu document", "dark") |
| 166 | toIndex := `location.assign("` + index + `")` |
| 167 | menuPath := "/" + menu |
| 168 | away("Dark to Light", toIndex, "/index.html", `localStorage.setItem("fiesta-scheme", "light")`, menuPath) |
| 169 | readRoot("the menu document back from the cache with Light stored", "light", "light") |
| 170 | readMark("the menu document back from the cache with Light stored", "light") |
| 171 | away("Light to System", toIndex, "/index.html", `localStorage.removeItem("fiesta-scheme")`, menuPath) |
| 172 | readRoot("the menu document back from the cache with nothing stored", "", "light dark") |
| 173 | readMark("the menu document back from the cache with nothing stored", "system") |
| 174 | |
| 175 | // The shell demo: Dark, into its page by the nav, Light on the way back. |
| 176 | run(t, ctx, |
| 177 | chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); "ok"`, new(string)), |
| 178 | chromedp.Navigate(rig.Origin+mountPrefix+sidebar), |
| 179 | chromedp.WaitReady("body"), |
| 180 | ) |
| 181 | readRoot("the sidebar demo", "dark", "dark") |
| 182 | away("the sidebar demo, Dark to Light", `document.querySelector("[rst-shell-nav] a").click()`, "/"+page, `localStorage.setItem("fiesta-scheme", "light")`, "/"+sidebar) |
| 183 | readRoot("the sidebar demo back from the cache with Light stored", "light", "light") |
| 184 | |
| 185 | // A fresh navigation before the screen: after a history |
| 186 | // navigation chromedp's node waits never resolve, so the flush of |
| 187 | // the page's problems is taken on a document loaded anew. |
| 188 | run(t, ctx, chromedp.Navigate(rig.Origin+mountPrefix+menu), chromedp.WaitReady("body")) |
| 189 | rig.Screen("body", "the scheme menu's document in Light") |
| 190 | } |
| 191 | |