| 1 | //go:build browser |
| 2 | |
| 3 | package history |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/chromedp/cdproto/input" |
| 16 | "github.com/chromedp/cdproto/target" |
| 17 | "github.com/chromedp/chromedp" |
| 18 | |
| 19 | "amadan.net/rastrillo/fiesta/internal/gallery/galleryrig" |
| 20 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 21 | ) |
| 22 | |
| 23 | // markedJS reads every scheme menu on the page: for each, the value of |
| 24 | // the one button carrying aria-current="true", or "" for none and |
| 25 | // "many" for more than one. |
| 26 | const markedJS = `[...document.querySelectorAll("[rst-scheme]")].map(m => { |
| 27 | const on = [...m.querySelectorAll("button")].filter(b => b.getAttribute("aria-current") === "true"); |
| 28 | return on.length === 1 ? on[0].form.elements.scheme.value : on.length ? "many" : ""; |
| 29 | })` |
| 30 | |
| 31 | // TestTheSchemeFollowsAPageBackFromTheCache: the scheme follows a page |
| 32 | // back from the back/forward cache. On a phone shell.js sends Back |
| 33 | // through history, so a page comes back from the cache without |
| 34 | // re-running anything: open an example page from the index in System, |
| 35 | // go Back to the index, choose Dark in its display settings menu, go |
| 36 | // Forward, and the page must be dark, previews and all, with every |
| 37 | // scheme menu on it marking Dark. The control first: the Forward |
| 38 | // really was a cache restore. A leg that silently reloaded the page |
| 39 | // would pass on start-up code alone. The example's frames are all |
| 40 | // loaded and painted before it is left, since the engine does not keep |
| 41 | // a page whose frames are still navigating; when it refuses anyway the |
| 42 | // leg fails naming the engine's notRestoredReasons. |
| 43 | // |
| 44 | // The index's rows open family pages, which frame nothing, so the |
| 45 | // page under test is the first example of the Form family, one more |
| 46 | // hop in: index, Form, the example, then Back twice. |
| 47 | func TestTheSchemeFollowsAPageBackFromTheCache(t *testing.T) { |
| 48 | rig := phoneRig(t) |
| 49 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 50 | defer cancel() |
| 51 | where := "day/en at 390px" |
| 52 | if err := chromedp.Run(ctx, |
| 53 | addInit(`addEventListener("pageshow", e => { window.__restored = e.persisted; });`), |
| 54 | chromedp.EmulateViewport(390, 844), |
| 55 | chromedp.Navigate(rig.Origin+indexHref("day", "en")), |
| 56 | chromedp.WaitReady("body")); err != nil { |
| 57 | t.Fatalf("%s: loading the index: %v", where, err) |
| 58 | } |
| 59 | var hasIndex bool |
| 60 | if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector(".ds-index") && !!document.getElementById("nav-form")`, &hasIndex)); err != nil || !hasIndex { |
| 61 | t.Fatalf("%s: the Overview is not the phone index with a Form row (%v)", where, err) |
| 62 | } |
| 63 | requireCoarse(t, ctx) |
| 64 | for _, c := range []struct{ scheme, attr string }{{"dark", "dark"}, {"system", ""}} { |
| 65 | step := where + ", " + c.scheme |
| 66 | if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById("nav-form").click(); true`, nil)); err != nil { |
| 67 | t.Fatalf("%s: opening Form: %v", step, err) |
| 68 | } |
| 69 | until(t, ctx, step+", Form", `location.pathname.endsWith("/form.html") && document.readyState === "complete"`) |
| 70 | if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector("main .ds-routes a").click(); true`, nil)); err != nil { |
| 71 | t.Fatalf("%s: opening Form's first example: %v", step, err) |
| 72 | } |
| 73 | until(t, ctx, step+", the example", `!location.pathname.endsWith("/form.html") && document.readyState === "complete" && document.querySelectorAll(".ds-view__frame").length > 0`) |
| 74 | // Every frame loaded and painted before the page is left. The |
| 75 | // frames are lazy, and at 390px most are below the fold: a |
| 76 | // frame still navigating when the page goes is a reason the |
| 77 | // engine gives for not keeping the page (notRestoredReasons: |
| 78 | // "navigating"), and the Forward below would be a reload. |
| 79 | galleryrig.Eagerly(t, ctx, step+", the example's frames") |
| 80 | until(t, ctx, step+", the example's frames painted", `[...document.querySelectorAll(".ds-view__frame")].every(f => f.hasAttribute("data-gallery-painted"))`) |
| 81 | var started float64 |
| 82 | if err := chromedp.Run(ctx, chromedp.Evaluate(`performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin`, &started)); err != nil { |
| 83 | t.Fatalf("%s: reading the example's navigation: %v", step, err) |
| 84 | } |
| 85 | if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { |
| 86 | t.Fatalf("%s: going back: %v", step, err) |
| 87 | } |
| 88 | until(t, ctx, step+", Form again", `location.pathname.endsWith("/form.html")`) |
| 89 | if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { |
| 90 | t.Fatalf("%s: going back to the index: %v", step, err) |
| 91 | } |
| 92 | until(t, ctx, step+", the index", `location.pathname.endsWith("/index.html") && !!document.querySelector("#ds-prefs [rst-scheme] form")`) |
| 93 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { |
| 94 | document.getElementById("ds-prefs").open = true; |
| 95 | const m = document.querySelector("#ds-prefs [rst-scheme]"); m.open = true; |
| 96 | [...m.querySelectorAll("form")].find(f => f.elements.scheme.value === "`+c.scheme+`").querySelector("button").click(); |
| 97 | history.forward(); return true; })()`, nil)); err != nil { |
| 98 | t.Fatalf("%s: choosing %s and going forward: %v", step, c.scheme, err) |
| 99 | } |
| 100 | until(t, ctx, step+", Form forward", `location.pathname.endsWith("/form.html")`) |
| 101 | if err := chromedp.Run(ctx, chromedp.Evaluate(`history.forward(); true`, nil)); err != nil { |
| 102 | t.Fatalf("%s: forward to the example: %v", step, err) |
| 103 | } |
| 104 | until(t, ctx, step+", the example again", `!location.pathname.endsWith("/form.html") && !location.pathname.endsWith("/index.html") && window.__restored !== undefined`) |
| 105 | var raw string |
| 106 | if err := chromedp.Run(ctx, chromedp.Evaluate(`JSON.stringify({Restored: window.__restored, |
| 107 | Started: performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin, |
| 108 | Why: JSON.stringify((performance.getEntriesByType("navigation")[0] || {}).notRestoredReasons || null)})`, &raw)); err != nil { |
| 109 | t.Fatalf("%s: reading the restore: %v", step, err) |
| 110 | } |
| 111 | var r struct { |
| 112 | Restored bool |
| 113 | Started float64 |
| 114 | Why string |
| 115 | } |
| 116 | if err := json.Unmarshal([]byte(raw), &r); err != nil { |
| 117 | t.Fatalf("%s: decoding %q: %v", step, raw, err) |
| 118 | } |
| 119 | if !r.Restored || r.Started != started { |
| 120 | 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) |
| 121 | } |
| 122 | // Frames that have not loaded are skipped, but at least one |
| 123 | // must have: every() over none is true. |
| 124 | until(t, ctx, step+", painted", fmt.Sprintf(`(() => { |
| 125 | const loaded = [...document.querySelectorAll(".ds-view__frame")].filter(f => { const d = f.contentDocument; return d && d.URL === f.src; }); |
| 126 | const marked = %s; |
| 127 | return (document.documentElement.getAttribute("data-theme") || "") === %q && loaded.length > 0 && |
| 128 | loaded.every(f => (f.contentDocument.documentElement.getAttribute("data-theme") || "") === %q) && |
| 129 | marked.length === 2 && marked.every(m => m === %q); |
| 130 | })()`, markedJS, c.attr, c.attr, c.scheme)) |
| 131 | if err := chromedp.Run(ctx, chromedp.Evaluate(`history.go(-2); true`, nil)); err != nil { |
| 132 | t.Fatalf("%s: back to the index: %v", step, err) |
| 133 | } |
| 134 | until(t, ctx, step+", index again", `location.pathname.endsWith("/index.html")`) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // The switcher's own address is never left changed: the last legs of |
| 139 | // Rastrillo's TestPositionSurvivesASwitch, here because a Ctrl-click |
| 140 | // has to open a real tab and the last Back has to be a real cache |
| 141 | // restore, neither of which the headless shell gives. |
| 142 | |
| 143 | // newTab reports the next page a click opens in a tab of its own. |
| 144 | // chromedp.WaitNewTarget would wait forever here: it takes only |
| 145 | // targets with an opener, and a tab opened by Ctrl- or middle-clicking |
| 146 | // a link has none. |
| 147 | func newTab(ctx context.Context, url func(string) bool) <-chan target.ID { |
| 148 | ch := make(chan target.ID, 1) |
| 149 | lctx, cancel := context.WithCancel(ctx) |
| 150 | chromedp.ListenTarget(lctx, func(ev any) { |
| 151 | e, ok := ev.(*target.EventTargetCreated) |
| 152 | if !ok || e.TargetInfo.Type != "page" || e.TargetInfo.Attached || !url(e.TargetInfo.URL) { |
| 153 | return |
| 154 | } |
| 155 | select { |
| 156 | case ch <- e.TargetInfo.TargetID: |
| 157 | default: |
| 158 | } |
| 159 | cancel() |
| 160 | }) |
| 161 | return ch |
| 162 | } |
| 163 | |
| 164 | // clickWith is a real mouse click on sel, with a modifier or another |
| 165 | // button, at the match's centre rather than through MouseClickNode, |
| 166 | // which scrolls the page to the node first and so moves the very place |
| 167 | // a click from the pinned bar is meant to carry. |
| 168 | func clickWith(t *testing.T, ctx context.Context, where, sel string, opts ...chromedp.MouseOption) { |
| 169 | t.Helper() |
| 170 | var at []float64 |
| 171 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const el = document.querySelector(`+strconv.Quote(sel)+`), r0 = el.getBoundingClientRect(); |
| 172 | if (r0.top < 0 || r0.bottom > innerHeight) el.scrollIntoView({block: "nearest"}); |
| 173 | const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()`, &at)); err != nil || len(at) != 2 { |
| 174 | t.Fatalf("%s: finding %s: %v", where, sel, err) |
| 175 | } |
| 176 | if err := chromedp.Run(ctx, chromedp.MouseClickXY(at[0], at[1], opts...)); err != nil { |
| 177 | t.Fatalf("%s: clicking %s: %v", where, sel, err) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // TestTheSwitchersAddressIsNeverLeftChanged: a Ctrl-click from deep in |
| 182 | // the page opens the computed place in a new tab and leaves the link |
| 183 | // canonical; a middle-click from the same place opens the link as |
| 184 | // rendered, the other page's top; a plain click from deep in the page |
| 185 | // carries the place; and a page back from the back/forward cache, left |
| 186 | // by a click that carried a place, still has canonical links. The |
| 187 | // last Back is held to be a cache restore, since a fresh load renders |
| 188 | // canonical links whatever the switcher does. |
| 189 | func TestTheSwitchersAddressIsNeverLeftChanged(t *testing.T) { |
| 190 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 191 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 192 | defer cancel() |
| 193 | where := "canonical" |
| 194 | page := rig.Origin + pageHref("day", "en", "tokens.html") |
| 195 | open := func() { |
| 196 | t.Helper() |
| 197 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate("about:blank"), chromedp.Navigate(page), chromedp.WaitReady("body")); err != nil { |
| 198 | t.Fatalf("%s: loading: %v", where, err) |
| 199 | } |
| 200 | var ok bool |
| 201 | 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 { |
| 202 | t.Fatalf("%s: the page has no theme tabs in a bar over anchored groups (%v)", where, err) |
| 203 | } |
| 204 | until(t, ctx, where, `document.readyState === "complete"`) |
| 205 | } |
| 206 | open() |
| 207 | var mid string |
| 208 | if err := chromedp.Run(ctx, chromedp.Evaluate(`[...document.querySelectorAll("[data-ds-anchor]")][2].id`, &mid)); err != nil { |
| 209 | t.Fatalf("%s: reading the groups: %v", where, err) |
| 210 | } |
| 211 | deep := `document.getElementById("` + mid + `").scrollIntoView({block: "start"}); true` |
| 212 | theme := `.ds-top__controls [rst-seg-tabs] a:not([aria-current])` |
| 213 | canonical := `![...document.querySelectorAll(".ds-top__controls a")].some(a => a.getAttribute("href").includes("#"))` |
| 214 | other := func(url string) bool { return strings.Contains(url, "/tokens.html") && !strings.Contains(url, "/day/") } |
| 215 | tabURL := func(how string, opened <-chan target.ID) string { |
| 216 | t.Helper() |
| 217 | select { |
| 218 | case id := <-opened: |
| 219 | tab, closeTab := chromedp.NewContext(ctx, chromedp.WithTargetID(id)) |
| 220 | defer closeTab() |
| 221 | until(t, tab, where+", the "+how+" tab", `location.href.includes("/tokens.html") && !location.href.includes("/day/")`) |
| 222 | var url string |
| 223 | if err := chromedp.Run(tab, chromedp.Location(&url)); err != nil { |
| 224 | t.Fatalf("%s: reading the %s tab: %v", where, how, err) |
| 225 | } |
| 226 | return url |
| 227 | case <-time.After(10 * time.Second): |
| 228 | t.Fatalf("%s: %s a theme link opened no tab", where, how) |
| 229 | } |
| 230 | return "" |
| 231 | } |
| 232 | if err := chromedp.Run(ctx, chromedp.Evaluate(deep, nil)); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | opened := newTab(ctx, other) |
| 236 | clickWith(t, ctx, where, theme, chromedp.ButtonModifiers(input.ModifierCtrl)) |
| 237 | if url := tabURL("Ctrl-clicking", opened); !strings.HasSuffix(url, "#"+mid) { |
| 238 | t.Errorf("%s: the Ctrl-clicked tab opened %s, want the computed place #%s", where, url, mid) |
| 239 | } |
| 240 | until(t, ctx, where+", restored", canonical) |
| 241 | if err := chromedp.Run(ctx, chromedp.Evaluate(deep, nil)); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | middle := newTab(ctx, other) |
| 245 | clickWith(t, ctx, where, theme, chromedp.ButtonMiddle) |
| 246 | if url := tabURL("middle-clicking", middle); strings.Contains(url, "#") { |
| 247 | t.Errorf("%s: a middle-click opened %s; it reads the link as rendered, the top of the page", where, url) |
| 248 | } |
| 249 | const started = `performance.getEntriesByType("navigation")[0].startTime + performance.timeOrigin` |
| 250 | var left float64 |
| 251 | if err := chromedp.Run(ctx, chromedp.Evaluate(started, &left), chromedp.Evaluate(deep, nil), chromedp.Evaluate(`document.querySelector('`+theme+`').click(); true`, nil)); err != nil { |
| 252 | t.Fatal(err) |
| 253 | } |
| 254 | until(t, ctx, where+", deep", `!location.pathname.includes("/day/") && document.readyState === "complete"`) |
| 255 | var hash string |
| 256 | if err := chromedp.Run(ctx, chromedp.Evaluate(`location.hash`, &hash)); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if hash != "#"+mid { |
| 260 | t.Errorf("%s: a plain click from #%s landed at %q", where, mid, hash) |
| 261 | } |
| 262 | if err := chromedp.Run(ctx, chromedp.Evaluate(`history.back(); true`, nil)); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | until(t, ctx, where+", back", `location.pathname.includes("/day/") && `+canonical) |
| 266 | var back float64 |
| 267 | if err := chromedp.Run(ctx, chromedp.Evaluate(started, &back)); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | if back != left { |
| 271 | 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)) |
| 272 | } |
| 273 | } |
| 274 | |