| 1 | //go:build browser |
| 2 | |
| 3 | package gallery |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "math" |
| 10 | "net/http" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/chromedp/cdproto/runtime" |
| 15 | "github.com/chromedp/chromedp" |
| 16 | |
| 17 | "amadan.net/rastrillo/fiesta" |
| 18 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 19 | ) |
| 20 | |
| 21 | // Keeping your place: a theme or language link carries the section |
| 22 | // being read, worked out at the click, and the address bar is never |
| 23 | // rewritten. |
| 24 | |
| 25 | // geometryJS is the rule a switch falls back to, written again here so |
| 26 | // the drive checks the script rather than agreeing with it: among the |
| 27 | // anchors whose top is at or above the reading line, those with the |
| 28 | // greatest top, the first of them in document order. |
| 29 | const geometryJS = `(() => { |
| 30 | const line = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBlockStart) + 1; |
| 31 | let best = null, top = -Infinity; |
| 32 | for (const a of document.querySelectorAll("[data-ds-anchor]")) { |
| 33 | const r = a.getBoundingClientRect(); |
| 34 | if (!r.width && !r.height) continue; |
| 35 | if (r.top <= line && r.top > top) { best = a; top = r.top; } |
| 36 | } |
| 37 | return best ? best.id : ""; |
| 38 | })()` |
| 39 | |
| 40 | const landingJS = `(() => { |
| 41 | const id = decodeURIComponent(location.hash.slice(1)), el = id && document.getElementById(id); |
| 42 | return JSON.stringify({Hash: location.hash, Path: location.pathname, Top: el ? el.getBoundingClientRect().top : -1, |
| 43 | Line: parseFloat(getComputedStyle(document.documentElement).scrollPaddingBlockStart) + 1, |
| 44 | End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2, Y: scrollY}); |
| 45 | })()` |
| 46 | |
| 47 | type landing struct { |
| 48 | Hash, Path string |
| 49 | Top, Line, Y float64 |
| 50 | End bool |
| 51 | } |
| 52 | |
| 53 | // place drives one tab through switches on desktop, where the |
| 54 | // switchers are. |
| 55 | type place struct { |
| 56 | t *testing.T |
| 57 | ctx context.Context |
| 58 | } |
| 59 | |
| 60 | func (p *place) run(where string, actions ...chromedp.Action) { |
| 61 | p.t.Helper() |
| 62 | if err := chromedp.Run(p.ctx, actions...); err != nil { |
| 63 | p.t.Fatalf("%s: %v", where, err) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // frames waits two animation frames: the record is written one frame |
| 68 | // after this document puts a target in place. |
| 69 | func (p *place) frames(where string) { |
| 70 | p.t.Helper() |
| 71 | p.run(where, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, |
| 72 | func(e *runtime.EvaluateParams) *runtime.EvaluateParams { return e.WithAwaitPromise(true) })) |
| 73 | } |
| 74 | |
| 75 | // open loads url as a document of its own, by way of about:blank, since |
| 76 | // navigating to the page already open with only a fragment added |
| 77 | // scrolls within it and loads nothing. |
| 78 | func (p *place) open(where, url string) { |
| 79 | p.t.Helper() |
| 80 | p.run(where, chromedp.EmulateViewport(1280, 900), chromedp.Navigate("about:blank"), chromedp.Navigate(url), chromedp.WaitReady("body")) |
| 81 | present(p.t, p.ctx, where, ".ds-top") |
| 82 | until(p.t, p.ctx, where, `document.readyState === "complete"`) |
| 83 | p.frames(where) |
| 84 | } |
| 85 | |
| 86 | func (p *place) geometry(where string) string { |
| 87 | p.t.Helper() |
| 88 | var id string |
| 89 | p.run(where, chromedp.Evaluate(geometryJS, &id)) |
| 90 | return id |
| 91 | } |
| 92 | |
| 93 | // follow clicks a rail link to an anchor on this page, a real followed |
| 94 | // fragment. |
| 95 | func (p *place) follow(where, id string) { |
| 96 | p.t.Helper() |
| 97 | p.run(where, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil)) |
| 98 | p.frames(where) |
| 99 | } |
| 100 | |
| 101 | // switchTheme clicks the bar's link to the next theme and waits for |
| 102 | // that theme's page; switchLanguage does the same through the language |
| 103 | // menu. Both report where the new page landed. |
| 104 | func (p *place) switchTheme(where string) landing { |
| 105 | p.t.Helper() |
| 106 | var next string |
| 107 | p.run(where, chromedp.Evaluate(`(() => { const a = document.querySelector('.ds-top__controls [rst-seg-tabs] a:not([aria-current])'); a.click(); return a.textContent; })()`, &next)) |
| 108 | until(p.t, p.ctx, where, `!!document.querySelector('link[href*="/themes/`+next+`."][href$=".css"]')`) |
| 109 | return p.landed(where) |
| 110 | } |
| 111 | |
| 112 | func (p *place) switchLanguage(where, code string) landing { |
| 113 | p.t.Helper() |
| 114 | p.run(where, chromedp.Evaluate(`(() => { const d = document.querySelector(".ds-top__controls [rst-locale]"); d.open = true; d.querySelector('a[lang="`+code+`"]').click(); return true; })()`, nil)) |
| 115 | until(p.t, p.ctx, where, `document.documentElement.lang === "`+code+`"`) |
| 116 | return p.landed(where) |
| 117 | } |
| 118 | |
| 119 | func (p *place) landed(where string) landing { |
| 120 | p.t.Helper() |
| 121 | until(p.t, p.ctx, where, `document.readyState === "complete"`) |
| 122 | var raw string |
| 123 | p.run(where, chromedp.Evaluate(landingJS, &raw)) |
| 124 | var l landing |
| 125 | if err := json.Unmarshal([]byte(raw), &l); err != nil { |
| 126 | p.t.Fatalf("%s: decoding %q: %v", where, raw, err) |
| 127 | } |
| 128 | return l |
| 129 | } |
| 130 | |
| 131 | // lands asserts a switch landed on id: the fragment names it, and its |
| 132 | // top is on the reading line, or the page is scrolled to its end with |
| 133 | // the top below the line, because the browser clamps at the end. |
| 134 | func (p *place) lands(where string, l landing, id string) { |
| 135 | p.t.Helper() |
| 136 | if l.Hash != "#"+id { |
| 137 | p.t.Errorf("%s: landed at %q, want #%s", where, l.Hash, id) |
| 138 | return |
| 139 | } |
| 140 | if math.Abs(l.Top-l.Line) > 2 && !(l.End && l.Top > l.Line) { |
| 141 | p.t.Errorf("%s: #%s's top is at %.1fpx, not on the reading line at %.1fpx", where, id, l.Top, l.Line) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // TestPositionSurvivesASwitch: the rules of place, on the Tokens page, |
| 146 | // whose groups the rail links by fragment, and the Icons page, whose |
| 147 | // glyphs share a row. A section on the reading line survives a theme |
| 148 | // switch and a language switch; a followed fragment is the place |
| 149 | // whatever geometry says, the last group included, until a screen's |
| 150 | // scroll lets it go; a fresh load at the last group records it and a |
| 151 | // reload does not; following the same fragment again after a layout |
| 152 | // change renews the record; history is not a destination; a cancelled |
| 153 | // click renews nothing; a fragment that is not an anchor is never |
| 154 | // carried. The legs that need a real new tab or a real back/forward |
| 155 | // cache restore (the switcher's own address staying canonical) are |
| 156 | // the history package's. |
| 157 | func TestPositionSurvivesASwitch(t *testing.T) { |
| 158 | rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) |
| 159 | ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| 160 | defer cancel() |
| 161 | p := &place{t: t, ctx: ctx} |
| 162 | theme := fiesta.ThemeNames()[0] |
| 163 | page := func(theme, locale string) string { return rig.Origin + pageAt(theme, locale, "tokens.html") } |
| 164 | |
| 165 | var ids []string |
| 166 | p.open("finding the groups", page(theme, "en")) |
| 167 | p.run("finding the groups", chromedp.Evaluate(`[...document.querySelectorAll("[data-ds-anchor]")].map(a => a.id)`, &ids)) |
| 168 | if len(ids) < 4 { |
| 169 | t.Fatalf("the Tokens page anchors %d groups; the legs below need four", len(ids)) |
| 170 | } |
| 171 | mid, other, last := ids[2], ids[1], ids[len(ids)-1] |
| 172 | |
| 173 | p.open("scrolled", page(theme, "en")) |
| 174 | p.run("scrolled", chromedp.Evaluate(`document.getElementById("`+mid+`").scrollIntoView({block: "start"}); true`, nil)) |
| 175 | p.lands("scrolled, then theme", p.switchTheme("scrolled, then theme"), mid) |
| 176 | p.lands("scrolled, then language", p.switchLanguage("scrolled, then language", "ja"), mid) |
| 177 | |
| 178 | p.open("followed", page(theme, "en")) |
| 179 | p.follow("followed", mid) |
| 180 | p.lands("followed, then theme", p.switchTheme("followed, then theme"), mid) |
| 181 | p.lands("followed, then language", p.switchLanguage("followed, then language", "ga"), mid) |
| 182 | |
| 183 | // The two cases geometry cannot answer. The last group: the page |
| 184 | // clamps before it reaches the line, so geometry would pick the |
| 185 | // one above it. |
| 186 | p.open("last", page(theme, "en")) |
| 187 | p.follow("last", last) |
| 188 | if g := p.geometry("last"); g == last { |
| 189 | t.Fatalf("last: geometry also picks %s, so this leg cannot tell the record from rule 2; the last group is taller than the viewport", last) |
| 190 | } |
| 191 | p.lands("the last group", p.switchTheme("the last group"), last) |
| 192 | p.open("last, scrolled", page(theme, "en")) |
| 193 | p.follow("last, scrolled", last) |
| 194 | p.run("last, scrolled", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| 195 | want := p.geometry("last, scrolled") |
| 196 | p.lands("last, scrolled, then theme", p.switchTheme("last, scrolled, then theme"), want) |
| 197 | |
| 198 | // Icons: a row of glyphs shares one top. Following the first glyph |
| 199 | // of the second row lands on it, not on the row's last. |
| 200 | icons := rig.Origin + pageAt(theme, "en", "icons.html") |
| 201 | p.open("icons", icons) |
| 202 | var glyph string |
| 203 | p.run("icons", chromedp.Evaluate(`(() => { const all = [...document.querySelectorAll(".ds-icons [data-ds-anchor], .ds-toks [data-ds-anchor]")]; |
| 204 | const firstTop = all[0].getBoundingClientRect().top; return all.find(a => a.getBoundingClientRect().top > firstTop + 1).id; })()`, &glyph)) |
| 205 | p.follow("icons", glyph) |
| 206 | p.lands("icons, a row's first glyph", p.switchTheme("icons, a row's first glyph"), glyph) |
| 207 | |
| 208 | // The record's start, a fresh load at the last group. |
| 209 | p.open("loaded at the last group", page(theme, "en")+"#"+last) |
| 210 | if g := p.geometry("loaded at the last group"); g == last { |
| 211 | t.Fatalf("loaded at the last group: geometry also picks %s, so this leg cannot tell the load record from rule 2", last) |
| 212 | } |
| 213 | p.lands("loaded at the last group, then theme", p.switchTheme("loaded at the last group, then theme"), last) |
| 214 | p.open("reloaded", page(theme, "en")+"#"+last) |
| 215 | p.run("reloaded", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil), chromedp.Reload(), chromedp.WaitReady("body")) |
| 216 | until(t, ctx, "reloaded", `document.readyState === "complete"`) |
| 217 | p.frames("reloaded") |
| 218 | want = p.geometry("reloaded") |
| 219 | p.lands("reloaded, then theme", p.switchTheme("reloaded, then theme"), want) |
| 220 | |
| 221 | // The record's renewal: following the same fragment again after a |
| 222 | // layout change above it (no hashchange fires) puts it back. |
| 223 | p.open("renewed", page(theme, "en")) |
| 224 | p.follow("renewed", last) |
| 225 | p.run("renewed", chromedp.Evaluate(`document.querySelector("main h1").style.marginBlockEnd = "400px"; true`, nil)) |
| 226 | p.follow("renewed", last) |
| 227 | p.lands("renewed, then theme", p.switchTheme("renewed, then theme"), last) |
| 228 | |
| 229 | // History is not a destination: Back restores a scrolled position, |
| 230 | // not the target's. |
| 231 | p.open("history", page(theme, "en")) |
| 232 | p.follow("history", last) |
| 233 | p.run("history", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| 234 | p.follow("history", other) |
| 235 | p.run("history", chromedp.Evaluate(`history.back(); true`, nil)) |
| 236 | until(t, ctx, "history, back", `location.hash === "#`+last+`"`) |
| 237 | p.frames("history, back") |
| 238 | want = p.geometry("history, back") |
| 239 | if want == last { |
| 240 | t.Fatalf("history, back: geometry also picks %s, so this leg cannot tell rule 1 from rule 2; scroll further", last) |
| 241 | } |
| 242 | p.lands("history, back, then theme", p.switchTheme("history, back, then theme"), want) |
| 243 | p.open("history, forward", page(theme, "en")) |
| 244 | p.follow("history, forward", last) |
| 245 | p.run("history, forward", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| 246 | p.follow("history, forward", other) |
| 247 | p.run("history, forward", chromedp.Evaluate(`scrollBy(0, innerHeight); history.back(); true`, nil)) |
| 248 | until(t, ctx, "history, forward, back", `location.hash === "#`+last+`"`) |
| 249 | p.run("history, forward", chromedp.Evaluate(`history.forward(); true`, nil)) |
| 250 | until(t, ctx, "history, forward", `location.hash === "#`+other+`"`) |
| 251 | p.frames("history, forward") |
| 252 | want = p.geometry("history, forward") |
| 253 | p.lands("history, forward, then theme", p.switchTheme("history, forward, then theme"), want) |
| 254 | p.open("history, round trip", page(theme, "en")) |
| 255 | p.follow("history, round trip", last) |
| 256 | p.run("history, round trip", chromedp.Evaluate(`history.back(); true`, nil)) |
| 257 | until(t, ctx, "history, round trip, back", `location.hash === ""`) |
| 258 | p.run("history, round trip", chromedp.Evaluate(`history.forward(); true`, nil)) |
| 259 | until(t, ctx, "history, round trip, forward", `location.hash === "#`+last+`"`) |
| 260 | p.frames("history, round trip, forward") |
| 261 | want = p.geometry("history, round trip, forward") |
| 262 | if want == last { |
| 263 | t.Fatalf("history, round trip: geometry also picks %s, so this leg cannot tell rule 1 from rule 2", last) |
| 264 | } |
| 265 | p.lands("history, round trip, then theme", p.switchTheme("history, round trip, then theme"), want) |
| 266 | |
| 267 | // Only a followed link renews: a click another listener cancels |
| 268 | // moves nothing. |
| 269 | where := "only a followed link, cancelled" |
| 270 | p.open(where, page(theme, "en")) |
| 271 | p.follow(where, last) |
| 272 | p.run(where, chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) |
| 273 | p.run(where, chromedp.Evaluate(`document.addEventListener("click", e => e.preventDefault(), {once: true}); document.querySelector('#ds-nav a[href$="#`+last+`"]').click(); true`, nil)) |
| 274 | p.frames(where) |
| 275 | want = p.geometry(where) |
| 276 | p.lands(where+", then theme", p.switchTheme(where+", then theme"), want) |
| 277 | |
| 278 | // A fragment that is not an anchor (the skip link's #main, a typo) |
| 279 | // is never carried, and never decides the place. |
| 280 | for _, frag := range []string{"main", "nope"} { |
| 281 | where := "loaded at #" + frag |
| 282 | p.open(where, page(theme, "en")+"#"+frag) |
| 283 | want := p.geometry(where) |
| 284 | l := p.switchTheme(where + ", then theme") |
| 285 | if want == "" && l.Hash != "" || want != "" && l.Hash != "#"+want { |
| 286 | t.Errorf("%s: the switch carried %q, want %q", where, l.Hash, map[bool]string{true: "", false: "#" + want}[want == ""]) |
| 287 | } |
| 288 | } |
| 289 | // A plain click from above every anchor lands at the other page's |
| 290 | // top, with the switcher's own address left canonical. |
| 291 | where = "from the top" |
| 292 | p.open(where, page(theme, "en")) |
| 293 | top := p.switchTheme(where) |
| 294 | var canonical bool |
| 295 | p.run(where, chromedp.Evaluate(`![...document.querySelectorAll(".ds-top__controls a")].some(a => a.getAttribute("href").includes("#"))`, &canonical)) |
| 296 | if top.Hash != "" || top.Y != 0 || !canonical { |
| 297 | t.Errorf("%s: a plain click from above every anchor landed at %q, scrolled %.0fpx, links canonical %v; want the other page's top and no fragment left on a switcher", where, top.Hash, top.Y, canonical) |
| 298 | } |
| 299 | } |
| 300 | |