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
3// Package galleryrig is the gallery's shared drive equipment: the
4// rendered tree served at its mount, frames waited on until each
5// shows its own document, and the other conditions the gallery's
6// browser tests and the history package both need. A package rather
7// than test files because two test packages use it; browser-tagged
8// like harness, so chromedp stays out of the ordinary build graph. It
9// imports nothing of the gallery's: the gallery's own tests import it,
10// and an import back would be a cycle, so the rendered files are
11// handed in. It is Rastrillo's galleryrig over Fiesta's harness, the
12// names kept; where the two harnesses differ (Fiesta's Rig flags every
13// response short of a 2xx and every console error itself, and takes
14// Allow and AllowText for the expected ones) the harness's own
15// facilities are used.
16package galleryrig
17
18import (
19 "context"
20 "encoding/json"
21 "net/http"
22 "path"
23 "sort"
24 "strings"
25 "sync"
26 "testing"
27 "time"
28
29 "github.com/chromedp/cdproto/emulation"
30 "github.com/chromedp/cdproto/page"
31 "github.com/chromedp/cdproto/runtime"
32 "github.com/chromedp/chromedp"
33
34 "amadan.net/rastrillo/fiesta/internal/harness"
35)
36
37// Tree serves a rendered tree at the mount its pages expect, and fails
38// the test that uses it if anything asked for a file the tree does not
39// have. Every URL in a page is absolute under the mount, so serving the
40// tree anywhere else would 404 the stylesheet and a drive would measure
41// an unstyled page; and a preview frame naming a missing file loads a
42// 404 page that settles, measures and scans like any other document, so
43// the miss has to be counted here.
44//
45// The failure is reported from t.Cleanup rather than from the request:
46// lazy frames can still be loading after the test function returns,
47// and t.Errorf from a server goroutine after that panics.
48func Tree(t *testing.T, files map[string][]byte, mountPrefix string) http.Handler {
49 t.Helper()
50 var mu sync.Mutex
51 var missing []string
52 t.Cleanup(func() {
53 mu.Lock()
54 defer mu.Unlock()
55 if len(missing) > 0 {
56 t.Errorf("the tree was asked for %d file(s) it does not have: %v", len(missing), missing)
57 }
58 })
59 return TreeRecording(t, files, mountPrefix, func(p string) {
60 mu.Lock()
61 missing = append(missing, p)
62 mu.Unlock()
63 })
64}
65
66// TreeRecording is Tree with each miss handed to the caller, so the
67// recording itself can be tested.
68func TreeRecording(t *testing.T, files map[string][]byte, mountPrefix string, miss func(path string)) http.Handler {
69 t.Helper()
70 types := map[string]string{
71 ".html": "text/html; charset=utf-8",
72 ".css": "text/css; charset=utf-8",
73 ".js": "text/javascript; charset=utf-8",
74 ".svg": "image/svg+xml",
75 }
76 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77 // The browser asks for /favicon.ico on its own; nothing outside
78 // the mount is the tree's to answer.
79 if !strings.HasPrefix(r.URL.Path, mountPrefix) {
80 http.NotFound(w, r)
81 return
82 }
83 name := strings.TrimPrefix(r.URL.Path, mountPrefix)
84 body, ok := files[name]
85 if !ok {
86 miss(r.URL.Path)
87 http.NotFound(w, r)
88 return
89 }
90 if ct, ok := types[path.Ext(name)]; ok {
91 w.Header().Set("Content-Type", ct)
92 }
93 w.Write(body)
94 })
95}
96
97// SettleFrames turns every preview frame eager and reports, for each
98// one, the address its src names (a fresh frame is about:blank for a
99// moment before its file replaces it) and, once that document is
100// complete and populated, its height, or null while it is not there
101// yet. It also reports how many frame documents the page requested,
102// which is the cost a reader pays for reading the whole page.
103//
104// One synchronous read rather than an in-page wait loop: the "scripts
105// off" leg proves the widget needs no script by disabling script
106// execution for the whole page, and Blink still runs a
107// debugger-injected evaluate under that, but stops servicing the
108// timers an async/await loop would need. Eagerly polls by calling this
109// repeatedly from Go instead, which needs nothing running inside the
110// page between calls.
111const SettleFrames = `(() => {
112 const frames = [...document.querySelectorAll(".ds-view__frame")];
113 frames.forEach(f => { f.loading = "eager"; });
114 const heights = frames.map(f => {
115 let d = null;
116 try { d = f.contentDocument; } catch (e) { return null; }
117 if (!d || d.URL !== f.src || d.readyState !== "complete" || !d.body || !d.body.children.length) return null;
118 return d.body.scrollHeight;
119 });
120 const requested = performance.getEntriesByType("resource").filter(e => e.initiatorType === "iframe").length;
121 return JSON.stringify({Titles: frames.map(f => f.title), Heights: heights, Requested: requested});
122})()`
123
124// Eagerly loads every frame on the page and waits until each settles:
125// complete, populated, and the same height across 150ms. Measuring
126// what a reader sees means reaching inside each frame, and a frame
127// that never loaded reports zero height, which reads exactly like a
128// collapsed preview: so a frame that does not settle within 30s fails
129// the leg by name rather than being measured.
130func Eagerly(t *testing.T, ctx context.Context, where string) {
131 t.Helper()
132 type reading struct {
133 Titles []string
134 Heights []*int
135 Requested int
136 }
137 read := func() reading {
138 t.Helper()
139 var raw string
140 if err := chromedp.Run(ctx, chromedp.Evaluate(SettleFrames, &raw)); err != nil {
141 t.Fatalf("%s: loading the framed documents: %v", where, err)
142 }
143 var got reading
144 if err := json.Unmarshal([]byte(raw), &got); err != nil {
145 t.Fatalf("%s: reading the frame settle (%q): %v", where, raw, err)
146 }
147 return got
148 }
149
150 before := read()
151 pending := make([]bool, len(before.Titles))
152 for i := range pending {
153 pending[i] = true
154 }
155 deadline := time.Now().Add(30 * time.Second)
156 for {
157 still := false
158 for _, p := range pending {
159 still = still || p
160 }
161 if !still {
162 t.Logf("%s: %d frames settled; the page requested %d frame documents", where, len(before.Titles), before.Requested)
163 return
164 }
165 if !time.Now().Before(deadline) {
166 var names []string
167 for i, p := range pending {
168 if p {
169 names = append(names, before.Titles[i])
170 }
171 }
172 t.Fatalf("%s: %d of %d frames never settled within 30s: %v", where, len(names), len(before.Titles), names)
173 }
174 time.Sleep(150 * time.Millisecond)
175 after := read()
176 for i, p := range pending {
177 if !p {
178 continue
179 }
180 if before.Heights[i] == nil || after.Heights[i] == nil || *after.Heights[i] != *before.Heights[i] {
181 continue
182 }
183 pending[i] = false
184 }
185 before = after
186 }
187}
188
189// SettleMobile waits after every Mobile radio has been clicked: every
190// frame laid out no wider than a phone, and every frame's width and
191// content height the same across 150ms.
192const SettleMobile = `(async () => {
193 const frames = [...document.querySelectorAll(".ds-view__frame")];
194 const read = () => frames.map(f => {
195 let h = -1;
196 try { h = f.contentDocument.body.scrollHeight; } catch (e) {}
197 return f.offsetWidth + ":" + h;
198 }).join(",");
199 const deadline = Date.now() + 30000;
200 while (Date.now() < deadline) {
201 const a = read();
202 await new Promise(r => setTimeout(r, 150));
203 if (a === read() && frames.every(f => f.offsetWidth <= 390)) return "ok";
204 }
205 return "never settled: " + frames.filter(f => f.offsetWidth > 390).map(f => f.title).slice(0, 5).join("; ");
206})()`
207
208// MobileSettle runs SettleMobile and fails the leg if it times out.
209func MobileSettle(t *testing.T, ctx context.Context, where string) {
210 t.Helper()
211 var state string
212 if err := chromedp.Run(ctx, chromedp.Evaluate(SettleMobile, &state, awaitPromise)); err != nil {
213 t.Fatalf("%s: waiting for the Mobile layout: %v", where, err)
214 }
215 if state != "ok" {
216 t.Fatalf("%s: the frames %s", where, state)
217 }
218}
219
220func awaitPromise(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }
221
222// AddInit runs js in every document the tab loads, before the page's
223// own scripts: the only way to stand in for an API a script reads at
224// DOMContentLoaded, such as the clipboard or a storage that throws.
225func AddInit(js string) chromedp.Action {
226 return chromedp.ActionFunc(func(ctx context.Context) error {
227 _, err := page.AddScriptToEvaluateOnNewDocument(js).Do(ctx)
228 return err
229 })
230}
231
232// Until polls expr until it is true, for ten seconds, and fails the leg
233// naming it otherwise. Polling rather than waiting on an event, because
234// what these legs wait for (a promise settling, a timer, a page restored
235// from the back/forward cache) raises nothing chromedp can wait on. An
236// evaluation that lands while one document is being swapped for the
237// next fails, and the next poll reads the new page; so an error is
238// retried, and one that lasts to the deadline fails the leg by name, as
239// a false would.
240func Until(t *testing.T, ctx context.Context, where, expr string) {
241 t.Helper()
242 deadline := time.Now().Add(10 * time.Second)
243 var last error
244 for {
245 var ok bool
246 err := chromedp.Run(ctx, chromedp.Evaluate(expr, &ok))
247 if err == nil && ok {
248 return
249 }
250 if err != nil {
251 last = err
252 }
253 if time.Now().After(deadline) {
254 t.Fatalf("%s: never true within 10s (last error: %v): %s", where, last, expr)
255 }
256 time.Sleep(100 * time.Millisecond)
257 }
258}
259
260// ClickEvery is the script that clicks every radio of one tab ("d",
261// "m" or "h") and reports how many clicks moved something. It is the
262// instrument's own control: a reading taken after a click that did not
263// land is a reading of the state before it.
264func ClickEvery(mod string) string {
265 return `(() => {
266 let clicked = 0, moved = 0;
267 document.querySelectorAll(".ds-view__tab--` + mod + ` input").forEach(i => {
268 const before = i.checked;
269 i.click();
270 clicked++;
271 if (i.checked && !before) moved++;
272 });
273 return JSON.stringify({clicked: clicked, moved: moved});
274})()`
275}
276
277// NoScripts is a tab with the page's scripts switched off at the
278// engine. Evaluate still runs, through the debugger, so a leg can read
279// a scriptless page without the page having run anything.
280func NoScripts(t *testing.T, parent context.Context) context.Context {
281 t.Helper()
282 ctx, cancel := chromedp.NewContext(parent)
283 t.Cleanup(cancel)
284 if err := chromedp.Run(ctx, emulation.SetScriptExecutionDisabled(true)); err != nil {
285 t.Fatalf("switching scripts off: %v", err)
286 }
287 return ctx
288}
289
290// RequireScriptsOff is NoScripts's own control, run once a page has
291// loaded on its tab: data-gallery-js is the one attribute the gallery's
292// script sets, and only after it runs, so finding it would mean this
293// leg's "scripts off" page ran scripts after all, and every reading
294// taken under it is a reading of the scripted page. Fiesta's mark,
295// where Rastrillo's is data-rst-js.
296func RequireScriptsOff(t *testing.T, ctx context.Context, where string) {
297 t.Helper()
298 var on bool
299 if err := chromedp.Run(ctx, chromedp.Evaluate(`document.documentElement.hasAttribute("data-gallery-js")`, &on)); err != nil {
300 t.Fatalf("%s: reading data-gallery-js: %v", where, err)
301 }
302 if on {
303 t.Fatalf("%s: data-gallery-js is set; the gallery's script ran even though this leg switched scripts off", where)
304 }
305}
306
307// PhoneRig is a browser whose primary pointer is a touch screen,
308// serving what tree builds. It is a launch flag: CDP's touch emulation
309// leaves (pointer: coarse) false in this engine. A leg that checks
310// anything against the page's width passes harness.WithScrollbars too:
311// see RequireDrawnScrollbar.
312func PhoneRig(t *testing.T, tree func() http.Handler, opts ...harness.Option) *harness.Rig {
313 t.Helper()
314 return harness.New(t, func(string) http.Handler { return tree() }, append([]harness.Option{harness.WithCoarsePointer()}, opts...)...)
315}
316
317// RequireDrawnScrollbar is the control for any leg that measures a box
318// against the document's width, and it belongs on a rig built with
319// harness.WithScrollbars. Under chromedp's default --hide-scrollbars,
320// Chromium still reserves the root's stable gutter (15px) in layout but
321// leaves it out of documentElement.clientWidth, and innerWidth includes
322// it either way. A box that really spans the page then reads 15px short
323// of a "document" it can never reach, and a sideways check reads -15,
324// so it would miss an overflow of up to 15px. With the scrollbar drawn,
325// clientWidth and layout agree.
326func RequireDrawnScrollbar(t *testing.T, ctx context.Context, where string) {
327 t.Helper()
328 var gutter float64
329 if err := chromedp.Run(ctx, chromedp.Evaluate(`innerWidth - document.documentElement.clientWidth`, &gutter)); err != nil {
330 t.Fatalf("%s: measuring the scrollbar: %v", where, err)
331 }
332 if gutter <= 0 {
333 t.Fatalf("%s: the root's scrollbar takes %.0fpx, so this browser draws none and documentElement.clientWidth is not the width the page lays out in. Build the rig with harness.WithScrollbars", where, gutter)
334 }
335}
336
337// RequireCoarse is every touch leg's control: a leg running on a fine
338// pointer would measure desktop sizes and pass.
339func RequireCoarse(t *testing.T, ctx context.Context) {
340 t.Helper()
341 var coarse bool
342 if err := chromedp.Run(ctx, chromedp.Evaluate(`matchMedia("(pointer: coarse)").matches`, &coarse)); err != nil {
343 t.Fatalf("reading the pointer: %v", err)
344 }
345 if !coarse {
346 t.Fatal("(pointer: coarse) is false; this rig is not the phone this leg claims to measure")
347 }
348}
349
350// SettleMotion waits out every finite animation and any view
351// transition: the shell slides between the index and a page, and the
352// row the reader came back to flashes. axe reading either mid-flight
353// measures colours at partial opacity that no reader sees once it has
354// landed.
355func SettleMotion(t *testing.T, ctx context.Context, where string) {
356 t.Helper()
357 if err := chromedp.Run(ctx, chromedp.Evaluate(`(async () => {
358 if (document.activeViewTransition) await document.activeViewTransition.finished.catch(() => null);
359 await Promise.all(document.getAnimations()
360 .filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity)
361 .map(a => a.finished.catch(() => null)));
362 return true;
363 })()`, nil, awaitPromise)); err != nil {
364 t.Fatalf("%s: waiting for motion to end: %v", where, err)
365 }
366}
367
368// MeasureFrames reads every frame on the page: section id → [what its
369// document needs, what its box gives it, 1 if it frames a sidebar-shell
370// page]. The tallest state of a section is the one recorded, so one
371// number per section keeps the boxes down a column the same size.
372const MeasureFrames = `(() => {
373 const out = {};
374 for (const f of document.querySelectorAll(".ds-view__frame")) {
375 const section = f.closest("article, section");
376 const id = section ? section.id : "?";
377 const d = f.contentDocument;
378 const need = d ? Math.ceil(Math.max(d.body.getBoundingClientRect().height, d.body.scrollHeight)) : -1;
379 const box = Math.round(parseFloat(getComputedStyle(f).height));
380 const shell = d && d.querySelector("[rst-shell-sidebar]") ? 1 : 0;
381 const was = out[id];
382 if (!was || need > was[0]) out[id] = [need, box, shell];
383 }
384 return JSON.stringify(out);
385})()`
386
387// ReadFrames decodes one MeasureFrames reading: section id → [what its
388// document needs, its box, 1 for a sidebar-shell page]. It judges no
389// height, so a sweep can gather every language's readings before it
390// asserts anything; it fails only on what makes a reading worthless: a
391// reading that does not decode, a page with no rendered example, or a
392// frame with no document in it.
393func ReadFrames(t *testing.T, tab, raw string) map[string][3]int {
394 t.Helper()
395 var got map[string][3]int
396 if err := json.Unmarshal([]byte(raw), &got); err != nil {
397 t.Fatalf("%s: reading the measurements: %v", tab, err)
398 }
399 if len(got) == 0 {
400 t.Fatalf("%s: no section on this page has a rendered example at all; either the page rendered none, or no frame on it loaded", tab)
401 }
402 for id, r := range got {
403 if r[0] < 0 {
404 t.Errorf("%s %s: the frame has no document in it", tab, id)
405 }
406 }
407 return got
408}
409
410// Measured is ReadFrames with each section held to its box. shellOnly
411// decides who gets 48px of grace: the sidebar shell's rail is 100dvh
412// tall, so a page framing one is always the frame's height plus the
413// margin under its content, and no box can fit it. The wide drive
414// grants the grace to every frame, as it always has.
415func Measured(t *testing.T, tab, raw string, shellOnly bool) map[string][3]int {
416 t.Helper()
417 got := ReadFrames(t, tab, raw)
418 names := make([]string, 0, len(got))
419 for name := range got {
420 names = append(names, name)
421 }
422 sort.Strings(names)
423 for _, id := range names {
424 need, box, shell := got[id][0], got[id][1], got[id][2]
425 grace := 48
426 if shellOnly && shell == 0 {
427 grace = 0
428 }
429 if need >= 0 && need > box+grace {
430 t.Errorf("%s %s: its document needs %dpx and its frame is %dpx; raise its height (previewHeights for Desktop, previewMobileHeights or previewMobileLocaleHeights for Mobile) to at least %d", tab, id, need, box, need+20)
431 }
432 if need >= 0 && box > need*4 && box-need > 120 {
433 t.Logf("%s %s: %dpx of frame for %dpx of document; deliberate headroom, or a number to bring down", tab, id, box, need)
434 }
435 }
436 return got
437}
438