rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package gallery
4
5import (
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.
29const 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
40const 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
47type 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.
55type place struct {
56 t *testing.T
57 ctx context.Context
58}
59
60func (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.
69func (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.
78func (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
86func (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.
95func (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.
104func (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
112func (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
119func (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.
134func (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.
157func 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