//go:build browser package gallery import ( "context" "encoding/json" "fmt" "math" "net/http" "testing" "time" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" ) // Keeping your place: a theme or language link carries the section // being read, worked out at the click, and the address bar is never // rewritten. // geometryJS is the rule a switch falls back to, written again here so // the drive checks the script rather than agreeing with it: among the // anchors whose top is at or above the reading line, those with the // greatest top, the first of them in document order. const geometryJS = `(() => { const line = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBlockStart) + 1; let best = null, top = -Infinity; for (const a of document.querySelectorAll("[data-ds-anchor]")) { const r = a.getBoundingClientRect(); if (!r.width && !r.height) continue; if (r.top <= line && r.top > top) { best = a; top = r.top; } } return best ? best.id : ""; })()` const landingJS = `(() => { const id = decodeURIComponent(location.hash.slice(1)), el = id && document.getElementById(id); return JSON.stringify({Hash: location.hash, Path: location.pathname, Top: el ? el.getBoundingClientRect().top : -1, Line: parseFloat(getComputedStyle(document.documentElement).scrollPaddingBlockStart) + 1, End: Math.abs(scrollY + innerHeight - document.documentElement.scrollHeight) < 2, Y: scrollY}); })()` type landing struct { Hash, Path string Top, Line, Y float64 End bool } // place drives one tab through switches on desktop, where the // switchers are. type place struct { t *testing.T ctx context.Context } func (p *place) run(where string, actions ...chromedp.Action) { p.t.Helper() if err := chromedp.Run(p.ctx, actions...); err != nil { p.t.Fatalf("%s: %v", where, err) } } // frames waits two animation frames: the record is written one frame // after this document puts a target in place. func (p *place) frames(where string) { p.t.Helper() p.run(where, chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`, nil, func(e *runtime.EvaluateParams) *runtime.EvaluateParams { return e.WithAwaitPromise(true) })) } // open loads url as a document of its own, by way of about:blank, since // navigating to the page already open with only a fragment added // scrolls within it and loads nothing. func (p *place) open(where, url string) { p.t.Helper() p.run(where, chromedp.EmulateViewport(1280, 900), chromedp.Navigate("about:blank"), chromedp.Navigate(url), chromedp.WaitReady("body")) present(p.t, p.ctx, where, ".ds-top") until(p.t, p.ctx, where, `document.readyState === "complete"`) p.frames(where) } func (p *place) geometry(where string) string { p.t.Helper() var id string p.run(where, chromedp.Evaluate(geometryJS, &id)) return id } // follow clicks a rail link to an anchor on this page, a real followed // fragment. func (p *place) follow(where, id string) { p.t.Helper() p.run(where, chromedp.Evaluate(fmt.Sprintf(`document.querySelector('#ds-nav a[href$="#%s"]').click(); true`, id), nil)) p.frames(where) } // switchTheme clicks the bar's link to the next theme and waits for // that theme's page; switchLanguage does the same through the language // menu. Both report where the new page landed. func (p *place) switchTheme(where string) landing { p.t.Helper() var next string 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)) until(p.t, p.ctx, where, `!!document.querySelector('link[href*="/themes/`+next+`."][href$=".css"]')`) return p.landed(where) } func (p *place) switchLanguage(where, code string) landing { p.t.Helper() 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)) until(p.t, p.ctx, where, `document.documentElement.lang === "`+code+`"`) return p.landed(where) } func (p *place) landed(where string) landing { p.t.Helper() until(p.t, p.ctx, where, `document.readyState === "complete"`) var raw string p.run(where, chromedp.Evaluate(landingJS, &raw)) var l landing if err := json.Unmarshal([]byte(raw), &l); err != nil { p.t.Fatalf("%s: decoding %q: %v", where, raw, err) } return l } // lands asserts a switch landed on id: the fragment names it, and its // top is on the reading line, or the page is scrolled to its end with // the top below the line, because the browser clamps at the end. func (p *place) lands(where string, l landing, id string) { p.t.Helper() if l.Hash != "#"+id { p.t.Errorf("%s: landed at %q, want #%s", where, l.Hash, id) return } if math.Abs(l.Top-l.Line) > 2 && !(l.End && l.Top > l.Line) { p.t.Errorf("%s: #%s's top is at %.1fpx, not on the reading line at %.1fpx", where, id, l.Top, l.Line) } } // TestPositionSurvivesASwitch: the rules of place, on the Tokens page, // whose groups the rail links by fragment, and the Icons page, whose // glyphs share a row. A section on the reading line survives a theme // switch and a language switch; a followed fragment is the place // whatever geometry says, the last group included, until a screen's // scroll lets it go; a fresh load at the last group records it and a // reload does not; following the same fragment again after a layout // change renews the record; history is not a destination; a cancelled // click renews nothing; a fragment that is not an anchor is never // carried. The legs that need a real new tab or a real back/forward // cache restore (the switcher's own address staying canonical) are // the history package's. func TestPositionSurvivesASwitch(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() p := &place{t: t, ctx: ctx} theme := fiesta.ThemeNames()[0] page := func(theme, locale string) string { return rig.Origin + pageAt(theme, locale, "tokens.html") } var ids []string p.open("finding the groups", page(theme, "en")) p.run("finding the groups", chromedp.Evaluate(`[...document.querySelectorAll("[data-ds-anchor]")].map(a => a.id)`, &ids)) if len(ids) < 4 { t.Fatalf("the Tokens page anchors %d groups; the legs below need four", len(ids)) } mid, other, last := ids[2], ids[1], ids[len(ids)-1] p.open("scrolled", page(theme, "en")) p.run("scrolled", chromedp.Evaluate(`document.getElementById("`+mid+`").scrollIntoView({block: "start"}); true`, nil)) p.lands("scrolled, then theme", p.switchTheme("scrolled, then theme"), mid) p.lands("scrolled, then language", p.switchLanguage("scrolled, then language", "ja"), mid) p.open("followed", page(theme, "en")) p.follow("followed", mid) p.lands("followed, then theme", p.switchTheme("followed, then theme"), mid) p.lands("followed, then language", p.switchLanguage("followed, then language", "ga"), mid) // The two cases geometry cannot answer. The last group: the page // clamps before it reaches the line, so geometry would pick the // one above it. p.open("last", page(theme, "en")) p.follow("last", last) if g := p.geometry("last"); g == last { 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) } p.lands("the last group", p.switchTheme("the last group"), last) p.open("last, scrolled", page(theme, "en")) p.follow("last, scrolled", last) p.run("last, scrolled", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) want := p.geometry("last, scrolled") p.lands("last, scrolled, then theme", p.switchTheme("last, scrolled, then theme"), want) // Icons: a row of glyphs shares one top. Following the first glyph // of the second row lands on it, not on the row's last. icons := rig.Origin + pageAt(theme, "en", "icons.html") p.open("icons", icons) var glyph string p.run("icons", chromedp.Evaluate(`(() => { const all = [...document.querySelectorAll(".ds-icons [data-ds-anchor], .ds-toks [data-ds-anchor]")]; const firstTop = all[0].getBoundingClientRect().top; return all.find(a => a.getBoundingClientRect().top > firstTop + 1).id; })()`, &glyph)) p.follow("icons", glyph) p.lands("icons, a row's first glyph", p.switchTheme("icons, a row's first glyph"), glyph) // The record's start, a fresh load at the last group. p.open("loaded at the last group", page(theme, "en")+"#"+last) if g := p.geometry("loaded at the last group"); g == last { t.Fatalf("loaded at the last group: geometry also picks %s, so this leg cannot tell the load record from rule 2", last) } p.lands("loaded at the last group, then theme", p.switchTheme("loaded at the last group, then theme"), last) p.open("reloaded", page(theme, "en")+"#"+last) p.run("reloaded", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil), chromedp.Reload(), chromedp.WaitReady("body")) until(t, ctx, "reloaded", `document.readyState === "complete"`) p.frames("reloaded") want = p.geometry("reloaded") p.lands("reloaded, then theme", p.switchTheme("reloaded, then theme"), want) // The record's renewal: following the same fragment again after a // layout change above it (no hashchange fires) puts it back. p.open("renewed", page(theme, "en")) p.follow("renewed", last) p.run("renewed", chromedp.Evaluate(`document.querySelector("main h1").style.marginBlockEnd = "400px"; true`, nil)) p.follow("renewed", last) p.lands("renewed, then theme", p.switchTheme("renewed, then theme"), last) // History is not a destination: Back restores a scrolled position, // not the target's. p.open("history", page(theme, "en")) p.follow("history", last) p.run("history", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) p.follow("history", other) p.run("history", chromedp.Evaluate(`history.back(); true`, nil)) until(t, ctx, "history, back", `location.hash === "#`+last+`"`) p.frames("history, back") want = p.geometry("history, back") if want == last { t.Fatalf("history, back: geometry also picks %s, so this leg cannot tell rule 1 from rule 2; scroll further", last) } p.lands("history, back, then theme", p.switchTheme("history, back, then theme"), want) p.open("history, forward", page(theme, "en")) p.follow("history, forward", last) p.run("history, forward", chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) p.follow("history, forward", other) p.run("history, forward", chromedp.Evaluate(`scrollBy(0, innerHeight); history.back(); true`, nil)) until(t, ctx, "history, forward, back", `location.hash === "#`+last+`"`) p.run("history, forward", chromedp.Evaluate(`history.forward(); true`, nil)) until(t, ctx, "history, forward", `location.hash === "#`+other+`"`) p.frames("history, forward") want = p.geometry("history, forward") p.lands("history, forward, then theme", p.switchTheme("history, forward, then theme"), want) p.open("history, round trip", page(theme, "en")) p.follow("history, round trip", last) p.run("history, round trip", chromedp.Evaluate(`history.back(); true`, nil)) until(t, ctx, "history, round trip, back", `location.hash === ""`) p.run("history, round trip", chromedp.Evaluate(`history.forward(); true`, nil)) until(t, ctx, "history, round trip, forward", `location.hash === "#`+last+`"`) p.frames("history, round trip, forward") want = p.geometry("history, round trip, forward") if want == last { t.Fatalf("history, round trip: geometry also picks %s, so this leg cannot tell rule 1 from rule 2", last) } p.lands("history, round trip, then theme", p.switchTheme("history, round trip, then theme"), want) // Only a followed link renews: a click another listener cancels // moves nothing. where := "only a followed link, cancelled" p.open(where, page(theme, "en")) p.follow(where, last) p.run(where, chromedp.Evaluate(`scrollBy(0, -innerHeight); true`, nil)) p.run(where, chromedp.Evaluate(`document.addEventListener("click", e => e.preventDefault(), {once: true}); document.querySelector('#ds-nav a[href$="#`+last+`"]').click(); true`, nil)) p.frames(where) want = p.geometry(where) p.lands(where+", then theme", p.switchTheme(where+", then theme"), want) // A fragment that is not an anchor (the skip link's #main, a typo) // is never carried, and never decides the place. for _, frag := range []string{"main", "nope"} { where := "loaded at #" + frag p.open(where, page(theme, "en")+"#"+frag) want := p.geometry(where) l := p.switchTheme(where + ", then theme") if want == "" && l.Hash != "" || want != "" && l.Hash != "#"+want { t.Errorf("%s: the switch carried %q, want %q", where, l.Hash, map[bool]string{true: "", false: "#" + want}[want == ""]) } } // A plain click from above every anchor lands at the other page's // top, with the switcher's own address left canonical. where = "from the top" p.open(where, page(theme, "en")) top := p.switchTheme(where) var canonical bool p.run(where, chromedp.Evaluate(`![...document.querySelectorAll(".ds-top__controls a")].some(a => a.getAttribute("href").includes("#"))`, &canonical)) if top.Hash != "" || top.Y != 0 || !canonical { 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) } }