| 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. |
| 16 | package galleryrig |
| 17 | |
| 18 | import ( |
| 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. |
| 48 | func 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. |
| 68 | func 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. |
| 111 | const 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. |
| 130 | func 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. |
| 192 | const 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. |
| 209 | func 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 | |
| 220 | func 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. |
| 225 | func 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. |
| 240 | func 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. |
| 264 | func 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. |
| 280 | func 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. |
| 296 | func 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. |
| 312 | func 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. |
| 326 | func 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. |
| 339 | func 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. |
| 355 | func 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. |
| 372 | const 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. |
| 393 | func 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. |
| 415 | func 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 | |