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 history
4
5import (
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.
26const 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.
47func 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.
147func 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.
168func 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.
189func 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