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 check
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "math"
10 "strconv"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/chromedp/cdproto/emulation"
17 "github.com/chromedp/cdproto/input"
18 "github.com/chromedp/cdproto/tracing"
19 "github.com/chromedp/chromedp"
20
21 "amadan.net/rastrillo/fiesta/internal/harness"
22)
23
24// lightDoc is the depth fixture plus the light.js tag. sizingDoc never
25// links it, so every other drive stays static (a lesson learned: a
26// clock-driven light moved shadows under the static card test).
27func lightDoc(t *testing.T) map[string]string {
28 t.Helper()
29 pages := depthDoc(t)
30 pages["/"] = strings.Replace(pages["/"], `<script defer src="/busy.js"></script>`, `<script defer src="/busy.js"></script><script defer src="/light.js"></script>`, 1)
31 return pages
32}
33
34// lightFollows reads the mode the shipped file is in (the FOLLOW fallback
35// flips it), so the drives assert the behaviour of the file that ships.
36func lightFollows() bool { return strings.Contains(string(lightJS()), "var FOLLOW = true") }
37
38// lightModeDoc is the depth fixture with light.js in the mode asked
39// for: the shipped file when it is in that mode, otherwise the shipped
40// file with its FOLLOW flag flipped, served inline. Both modes are
41// driven whichever one ships, so flipping the flag later cannot leave
42// the other mode's root light untested.
43func lightModeDoc(t *testing.T, follow bool) map[string]string {
44 t.Helper()
45 if follow == lightFollows() {
46 return lightDoc(t)
47 }
48 from, to := "var FOLLOW = false", "var FOLLOW = true"
49 if !follow {
50 from, to = to, from
51 }
52 js := strings.Replace(string(lightJS()), from, to, 1)
53 pages := depthDoc(t)
54 pages["/"] = strings.Replace(pages["/"], `</body>`, `<script>`+js+`</script></body>`, 1)
55 return pages
56}
57
58// hourLight is where sun() puts the light for the page's own clock, so
59// an hour-mode assertion compares against a value that can be wrong.
60func hourLight(t *testing.T, ctx context.Context) (x, y float64) {
61 t.Helper()
62 var hour float64
63 mustRun(t, ctx, chromedp.Evaluate(`new Date().getHours() + new Date().getMinutes() / 60`, &hour))
64 tt := min(1, max(-1, (hour-12)/6))
65 return tt * 0.8, -1 + math.Abs(tt)*0.35
66}
67
68// assertHourLight holds the root light to the hour's, within 0.1.
69func assertHourLight(t *testing.T, ctx context.Context, when string) {
70 t.Helper()
71 wx, wy := hourLight(t, ctx)
72 x, y := lightNum(t, ctx, ":root", "--rst-light-x"), lightNum(t, ctx, ":root", "--rst-light-y")
73 if math.Abs(x-wx) > 0.1 || math.Abs(y-wy) > 0.1 {
74 t.Errorf("%s the light is %v,%v, want the hour's %.3f,%.3f", when, x, y, wx, wy)
75 }
76}
77
78func lightNum(t *testing.T, ctx context.Context, selector, prop string) float64 {
79 t.Helper()
80 var v float64
81 raw := computed(t, ctx, selector, prop)
82 if _, err := fmt.Sscanf(raw, "%f", &v); err != nil {
83 t.Fatalf("%s %s = %q: %v", selector, prop, raw, err)
84 }
85 return v
86}
87
88// move sends a mouse move and waits two frames so the coalesced write
89// has happened.
90func move(t *testing.T, ctx context.Context, x, y float64) {
91 t.Helper()
92 mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, x, y), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise))
93}
94
95func openLight(t *testing.T, ctx context.Context, rig *harness.Rig, reduce bool) {
96 t.Helper()
97 open(t, ctx, rig, "/", reduce)
98 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => addEventListener("load", r, {once: true}) || document.readyState === "complete" && r())`, nil, awaitPromise))
99}
100
101// TestTheLightFollowsThePointerAndSettles (spec §4): in FOLLOW
102// mode a move to the right edge raises --rst-light-x toward 1 and the
103// loop stops once settled; in hour mode the light is the hour's and a
104// move leaves it; either way the button under the pointer takes
105// --rst-mx/--rst-my in its own coordinates and a press sets
106// --rst-px/--rst-py; without the script every value is its default.
107func TestTheLightFollowsThePointerAndSettles(t *testing.T) {
108 for _, withScript := range []bool{true, false} {
109 t.Run(fmt.Sprintf("script %v", withScript), func(t *testing.T) {
110 pages := depthDoc(t)
111 if withScript {
112 pages = lightDoc(t)
113 }
114 rig := sizingRig(t, false, pages, harness.WithHover())
115 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
116 defer cancel()
117 openLight(t, ctx, rig, false)
118 x, y, _, _ := rect(t, ctx, "#b-primary")
119 move(t, ctx, 1270, 400)
120 if !withScript {
121 move(t, ctx, x+10, y+5)
122 if lx, ly := computed(t, ctx, ":root", "--rst-light-x"), computed(t, ctx, ":root", "--rst-light-y"); lx != "0" || ly != "-1" {
123 t.Errorf("no script: light is %s,%s, want 0,-1", lx, ly)
124 }
125 if mx := computed(t, ctx, "#b-primary", "--rst-mx"); mx != "50%" {
126 t.Errorf("no script: --rst-mx is %q, want 50%%", mx)
127 }
128 return
129 }
130 // The oracle for "where the pointer is, in the button's own
131 // pixels" is the event the page saw, not the coordinates the
132 // drive sent: the browser rounds synthetic coordinates and
133 // the box sits at a fraction, so the page records the offset
134 // from the event and the rectangle it had at that moment.
135 mustRun(t, ctx, chromedp.Evaluate(`(() => { const at = e => { const r = e.target.closest("[rst-btn]").getBoundingClientRect(); return (e.clientX - r.left).toFixed(0) + "px/" + (e.clientY - r.top).toFixed(0) + "px"; }; addEventListener("pointermove", e => { if (e.target.closest("[rst-btn]")) window.__move = at(e); }, true); addEventListener("pointerdown", e => { if (e.target.closest("[rst-btn]")) window.__down = at(e); }, true); return true; })()`, nil))
136 move(t, ctx, x+10, y+5)
137 var seen string
138 mustRun(t, ctx, chromedp.Evaluate(`window.__move`, &seen))
139 if got := computed(t, ctx, "#b-primary", "--rst-mx") + "/" + computed(t, ctx, "#b-primary", "--rst-my"); got != seen {
140 t.Errorf("--rst-mx/--rst-my are %s, the page saw the pointer at %s", got, seen)
141 }
142 mustRun(t, ctx, chromedp.MouseEvent(input.MousePressed, x+12, y+8, chromedp.ButtonLeft, chromedp.ClickCount(1)), chromedp.MouseEvent(input.MouseReleased, x+12, y+8, chromedp.ButtonLeft, chromedp.ClickCount(1)),
143 chromedp.Evaluate(`window.__down`, &seen))
144 if got := computed(t, ctx, "#b-primary", "--rst-px") + "/" + computed(t, ctx, "#b-primary", "--rst-py"); got != seen {
145 t.Errorf("press point %s, the page saw the press at %s", got, seen)
146 }
147 move(t, ctx, 1270, 400)
148 time.Sleep(800 * time.Millisecond)
149 lx := lightNum(t, ctx, ":root", "--rst-light-x")
150 if lightFollows() && lx < 0.9 {
151 t.Errorf("--rst-light-x is %v 800ms after a move to the right edge, want it settled near 1", lx)
152 }
153 if !lightFollows() {
154 assertHourLight(t, ctx, "in hour mode, after a pointer move,")
155 }
156 var writes int
157 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => { let n = 0; const o = new MutationObserver(m => n += m.length); o.observe(document.documentElement, {attributes: true, attributeFilter: ["style"]}); setTimeout(() => { o.disconnect(); r(n); }, 400); })`, &writes, awaitPromise))
158 if writes != 0 {
159 t.Errorf("the loop wrote <html> style %d times after settling; it must stop", writes)
160 }
161 })
162 }
163}
164
165// TestReducedMotionIsTotal: under reduce nothing is written; when
166// the setting turns off mid-session the script starts; when it turns
167// on again the loop stops, a pending card timeout is cancelled, and
168// every property is cleared, including on a card that was tapped just
169// before. Forced colours turning on mid-session stops it the same way.
170func TestReducedMotionIsTotal(t *testing.T) {
171 rig := sizingRig(t, false, lightDoc(t), harness.WithHover())
172 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
173 defer cancel()
174 openLight(t, ctx, rig, true)
175 move(t, ctx, 1270, 400)
176 move(t, ctx, 100, 150)
177 time.Sleep(300 * time.Millisecond)
178 if x, lit, mx := computed(t, ctx, ":root", "--rst-light-x"), computed(t, ctx, "#box", "--rst-card-lit"), computed(t, ctx, "#b-primary", "--rst-mx"); x != "0" || lit != "0" || mx != "50%" {
179 t.Errorf("under reduced motion: light-x %s card-lit %s mx %s; want 0, 0, 50%%", x, lit, mx)
180 }
181 setMedia := func(name, value string) {
182 mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: name, Value: value}}))
183 time.Sleep(100 * time.Millisecond)
184 }
185 setMedia("prefers-reduced-motion", "no-preference")
186 move(t, ctx, 1270, 400)
187 move(t, ctx, 100, 150)
188 bx, by, _, _ := rect(t, ctx, "#box")
189 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: bx + 20, Y: by + 10}}), input.DispatchTouchEvent(input.TouchEnd, nil))
190 time.Sleep(300 * time.Millisecond)
191 if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit == 0 {
192 t.Fatal("with reduced motion off a tapped card did not light; the change listener did not start the script")
193 }
194 setMedia("prefers-reduced-motion", "reduce")
195 time.Sleep(1600 * time.Millisecond) // past the tap's 1400ms timeout
196 for sel, prop := range map[string]string{":root": "--rst-light-x", "#box": "--rst-card-lit", "#b-primary": "--rst-mx"} {
197 if got, want := computed(t, ctx, sel, prop), map[string]string{"--rst-light-x": "0", "--rst-card-lit": "0", "--rst-mx": "50%"}[prop]; got != want {
198 t.Errorf("after reduced motion turned on: %s %s = %s, want the default %s", sel, prop, got, want)
199 }
200 }
201 var writes int
202 mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => { let n = 0; const o = new MutationObserver(m => n += m.length); o.observe(document.documentElement, {attributes: true, subtree: true, attributeFilter: ["style"]}); setTimeout(() => { o.disconnect(); r(n); }, 400); })`, &writes, awaitPromise))
203 if writes != 0 {
204 t.Errorf("%d style writes after reduced motion turned on; the loop restarted", writes)
205 }
206 setMedia("prefers-reduced-motion", "no-preference")
207 move(t, ctx, 1270, 400)
208 setMedia("forced-colors", "active")
209 move(t, ctx, 100, 150)
210 time.Sleep(300 * time.Millisecond)
211 if x := computed(t, ctx, ":root", "--rst-light-x"); x != "0" {
212 t.Errorf("under forced colours turned on mid-session the light is %s; want it stopped and cleared", x)
213 }
214}
215
216// TestACardBreathes (spec §4, C10): entering a card raises
217// --rst-card-lit toward 1 over about a second and leaving lowers it; a
218// card removed mid-ease is dropped with no error; the rim's spot walks
219// after the pointer inside the card, in the shipped mode.
220func TestACardBreathes(t *testing.T) {
221 rig := sizingRig(t, false, lightDoc(t), harness.WithHover())
222 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
223 defer cancel()
224 openLight(t, ctx, rig, false)
225 bx, by, _, _ := rect(t, ctx, "#box")
226 move(t, ctx, bx+40, by+20)
227 time.Sleep(300 * time.Millisecond)
228 early := lightNum(t, ctx, "#box", "--rst-card-lit")
229 time.Sleep(1200 * time.Millisecond)
230 late := lightNum(t, ctx, "#box", "--rst-card-lit")
231 if !(early > 0.05 && early < 0.9 && late > 0.9) {
232 t.Errorf("card lit %v after 300ms and %v after 1.5s; want a fade in over about a second", early, late)
233 }
234 // The rim follows the pointer in every mode (the FOLLOW fallback drops
235 // the root light only): 100px further right inside the card, the
236 // spot has walked there a second later.
237 move(t, ctx, bx+140, by+20)
238 time.Sleep(1200 * time.Millisecond)
239 raw := computed(t, ctx, "#box", "--rst-card-x")
240 if cx, err := strconv.ParseFloat(strings.TrimSuffix(raw, "px"), 64); err != nil || math.Abs(cx-140) > 3 {
241 t.Errorf("card x %q 1.2s after the pointer moved to 140px inside it; want the spot to have walked there", raw)
242 }
243 move(t, ctx, 1270, 890)
244 time.Sleep(1500 * time.Millisecond)
245 if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit > 0.05 {
246 t.Errorf("card lit %v 1.5s after the pointer left; want it faded out", lit)
247 }
248 move(t, ctx, bx+40, by+20)
249 mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("box").remove(), true`, nil))
250 time.Sleep(300 * time.Millisecond)
251 move(t, ctx, 1270, 890)
252}
253
254// TestKeysAndTouchLeavePointerMode (C10): in FOLLOW mode, after a
255// pointer move, a key press returns the light to the hour; so does a
256// touch. In hour mode the light is the hour's from the start and a
257// pointer move, a key and a touch all leave it there. The hour is read
258// in the page so no leg depends on the clock, and both modes run
259// whichever one ships (lightModeDoc).
260func TestKeysAndTouchLeavePointerMode(t *testing.T) {
261 for _, leave := range []string{"key", "touch", "hour"} {
262 t.Run(leave, func(t *testing.T) {
263 rig := sizingRig(t, false, lightModeDoc(t, leave != "hour"), harness.WithHover())
264 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
265 defer cancel()
266 openLight(t, ctx, rig, false)
267 if leave == "hour" {
268 assertHourLight(t, ctx, "at load")
269 move(t, ctx, 1270, 400)
270 time.Sleep(600 * time.Millisecond)
271 assertHourLight(t, ctx, "after a move to the right edge")
272 mustRun(t, ctx, chromedp.KeyEvent("\t"))
273 time.Sleep(300 * time.Millisecond)
274 assertHourLight(t, ctx, "after a key")
275 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: 50, Y: 850}}), input.DispatchTouchEvent(input.TouchEnd, nil))
276 time.Sleep(300 * time.Millisecond)
277 assertHourLight(t, ctx, "after a touch")
278 return
279 }
280 move(t, ctx, 1270, 400)
281 time.Sleep(600 * time.Millisecond)
282 if x := lightNum(t, ctx, ":root", "--rst-light-x"); x < 0.5 {
283 t.Fatalf("light-x %v after a move to the right; pointer mode did not take", x)
284 }
285 if leave == "key" {
286 mustRun(t, ctx, chromedp.KeyEvent("\t"))
287 } else {
288 // A synthetic touch also fires the document's
289 // pointerleave, and onLeave leaves pointer mode on its
290 // own, so this leg passed with the touch hook gone (#104
291 // item 7). A capturing listener on the document runs
292 // before light.js's bubble listener on the same target
293 // and stops the event there; what moves the light back
294 // after the touch is then onDown alone.
295 mustRun(t, ctx, chromedp.Evaluate(`window.__leaves = 0; document.addEventListener("pointerleave", e => { window.__leaves++; e.stopImmediatePropagation(); }, true); true`, nil))
296 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: 50, Y: 850}}), input.DispatchTouchEvent(input.TouchEnd, nil))
297 }
298 time.Sleep(800 * time.Millisecond)
299 assertHourLight(t, ctx, "after a "+leave)
300 if leave == "touch" {
301 var leaves int
302 mustRun(t, ctx, chromedp.Evaluate(`window.__leaves`, &leaves))
303 t.Logf("%d pointerleave event(s) held back from light.js during the touch", leaves)
304 }
305 })
306 }
307}
308
309// TestTheHookFreezesTheLight (C14): the hook sets the light at once and
310// holds it against pointer moves.
311func TestTheHookFreezesTheLight(t *testing.T) {
312 rig := sizingRig(t, false, lightDoc(t), harness.WithHover())
313 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
314 defer cancel()
315 openLight(t, ctx, rig, false)
316 mustRun(t, ctx, chromedp.Evaluate(`document.dispatchEvent(new CustomEvent("rst:light", {detail: {x: 0.4, y: -0.8, hold: true}})), true`, nil),
317 chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise))
318 if x, y := computed(t, ctx, ":root", "--rst-light-x"), computed(t, ctx, ":root", "--rst-light-y"); x != "0.400" || y != "-0.800" {
319 t.Errorf("hooked light is %s,%s; want 0.400,-0.800", x, y)
320 }
321 move(t, ctx, 1270, 400)
322 time.Sleep(300 * time.Millisecond)
323 if x := computed(t, ctx, ":root", "--rst-light-x"); x != "0.400" {
324 t.Errorf("held light moved to %s", x)
325 }
326}
327
328// TestATouchPressShowsNoSheen: on a coarse pointer a held button
329// has its press point and no sheen layer.
330func TestATouchPressShowsNoSheen(t *testing.T) {
331 rig := sizingRig(t, true, lightDoc(t))
332 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
333 defer cancel()
334 openLight(t, ctx, rig, false)
335 requirePointer(t, ctx, true)
336 x, y, _, _ := rect(t, ctx, "#b-primary")
337 mustRun(t, ctx, chromedp.Evaluate(`(() => { addEventListener("pointerdown", e => { const b = e.target.closest("[rst-btn]"); if (!b) return; const r = b.getBoundingClientRect(); window.__down = (e.clientX - r.left).toFixed(0) + "px/" + (e.clientY - r.top).toFixed(0) + "px"; }, true); return true; })()`, nil),
338 input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: x + 12, Y: y + 8}}))
339 var seen string
340 mustRun(t, ctx, chromedp.Evaluate(`window.__down`, &seen))
341 if got := computed(t, ctx, "#b-primary", "--rst-px") + "/" + computed(t, ctx, "#b-primary", "--rst-py"); got != seen {
342 t.Errorf("press point %s, the page saw the press at %s", got, seen)
343 }
344 if img := computed(t, ctx, "#b-primary", "background-image"); strings.Contains(img, "radial-gradient(120px") {
345 t.Errorf("a touch press shows the sheen: %q", img)
346 }
347 mustRun(t, ctx, input.DispatchTouchEvent(input.TouchEnd, nil))
348}
349
350// lightFrameBudgetMS is the spec's budget on what light.js adds: style
351// recalculation, layout, pre-paint and paint per frame while the
352// pointer moves over a 500-row list, summed as thread time from the
353// browser's own trace events, on the page with the script minus the
354// same page without it. The spec's 4ms was meant as the light's own
355// cost, and measured as an absolute it was mostly the hover churn the
356// page does with no script at all, which load inflates (#104 item 5);
357// measured in wall time it also counted the moments the renderer
358// waited for a CPU, which a loaded runner adds to both pages and not
359// evenly (#118). Measured as a difference (below) the shipped light
360// adds 0.8 to 0.9ms in wall time on a quiet docs-t1 and 1.1 to 1.7ms
361// in thread time at load 20 to 30, and the pointer-following mode
362// about 2.4 (measured on the merged drive: the shipped mode 1.20 added,
363// PASS; FOLLOW = true 2.46 added, FAIL), so 2ms holds the shipped mode
364// with its headroom halved
365// under load, keeps the fallback's verdict on the other, and sits at
366// half of what the spec allows.
367const lightFrameBudgetMS = 2.0
368
369// traceCategories are the ones that carry the rendering events this
370// drive sums (measured in this Chromium): UpdateLayoutTree is in
371// "blink,devtools.timeline", Layout, PrePaint and Paint in
372// "devtools.timeline". "disabled-by-default-devtools.timeline" alone,
373// which a first draft enabled, carries none of them and would have
374// summed to a passing zero.
375var traceCategories = []string{"devtools.timeline", "blink", "disabled-by-default-devtools.timeline"}
376
377// TestTheLightStaysWithinItsFrameBudget: two seconds of pointer
378// movement over 500 rows, a move every 16ms, on the page with light.js
379// and again on the same page without it. The trace's UpdateLayoutTree
380// (style), Layout, PrePaint and Paint thread time inside the marked
381// window is summed and divided by the frames the page counted; each
382// trace must carry style and paint events or the figure proves
383// nothing. The trace ends two frames and 150ms after the window does,
384// so every slice begun inside it has closed. Also: <html>'s style is
385// written at most twice a frame (two properties, one update), and no
386// task on the page's main thread does 50ms of work (traceTotals says
387// why thread time, not wall time; #118). What is held to the budget
388// is the difference.
389//
390// Measured as a difference in thread time, this page and this drive,
391// on a hover rig, docs-t1 at load 20 to 30 (16 CPUs, other sessions'
392// builds and drives), three runs:
393//
394// FOLLOW = false (shipped) 1.14, 1.31 and 1.67 ms a frame added
395// (2.23 to 3.82 with the script, 1.09 to 2.15 without; wall
396// time read 0.02 to 0.3 above thread time a frame). The load
397// inflates both sides and the difference with them: thread time
398// leaves out the waits, not the colder caches.
399//
400// Measured as a difference in wall time, before #118, docs-t1 at load
401// 1.6 to 2.6:
402//
403// FOLLOW = false (shipped) 0.82 to 0.93 ms a frame added (1.64 to
404// 1.71 with the script, 0.78 to 0.82 without).
405// FOLLOW = true, a root write at most every 200ms: 2.44 ms added
406// (3.32 with, 0.88 without; 20 root writes in the window).
407//
408// Measured as an absolute in thread time on main (#118), docs-t1,
409// every window of two runs each: shipped, 2.19 to 2.50 idle and 1.91
410// to 2.49 with the browser held to two CPUs beside four or six busy
411// loops, where wall time read 4.7 to 12.1 over 32 to 66 frames and the
412// browser's own long-task count reached 20, every one of them 2 to
413// 11ms of work. FOLLOW = true with a root write every 200ms: 3.59 to
414// 4.37 idle and 4.83 to 10.74 loaded; every 100ms, 5.4 to 7.3; every
415// frame, 20 to 34.
416//
417// Measured as an absolute in wall time, before the difference, the
418// same drive read:
419//
420// FOLLOW = false (shipped) 2.07 ms a frame on docs-t1 (the controller's
421// probe at HEAD); 1.74, 1.55, 1.59 before the hover rig.
422// FOLLOW = true, a root write at most every 200ms: 4.11, then 4.15
423// to 4.20 ms a frame on the CI runner, over the 4ms budget, which
424// is why it is off; 3.25 to 4.13 on docs-t1 at load 2.7 to 4.1 (at
425// 125ms 4.27 to 4.49, at 100ms 4.83 to 4.85). Each root write is a
426// restyle and repaint of the whole document, about 12ms over 500
427// rows: about 2ms a frame at five writes a second.
428// FOLLOW = true, every frame, with the button's box-shadow in its
429// transition list (the CSS before fix round 1): 98.27 and 85.96 ms
430// a frame over 23 frames, 22 long tasks: every root write
431// retargeted a shadow transition on all 500 buttons.
432// no script: 0.17 to 1.11 ms a frame (nothing recalculates; the drive
433// refuses that as proof).
434//
435// Two things made the hour-mode figure: the button's box-shadow left
436// its transition list (tokens.css), and the card's three properties are
437// registered as not inheriting, because a write on a 500-row card
438// restyled its 1,500 descendants (21 to 29 ms a frame before). The
439// pointer mode keeps its rate limit and JavaScript easing for the day
440// a cheaper root write makes it affordable.
441func TestTheLightStaysWithinItsFrameBudget(t *testing.T) {
442 var rows strings.Builder
443 rows.WriteString(`<div rst-page><div rst-card id="big" style="--rst-cols: 1fr auto">`)
444 for i := 0; i < 500; i++ {
445 fmt.Fprintf(&rows, `<div rst-lrow><a class="rst-nm" href="#r%d">Row %d</a><button rst-btn="sm">Open</button></div>`, i, i)
446 }
447 rows.WriteString(`</div></div>`)
448 bare := sizingDoc("budget", rows.String())
449 lit := strings.Replace(bare, `<script defer src="/busy.js"></script>`, `<script defer src="/busy.js"></script><script defer src="/light.js"></script>`, 1)
450 rig := sizingRig(t, false, map[string]string{"/": lit, "/bare": bare}, harness.WithHover())
451 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
452 defer cancel()
453 var (
454 mu sync.Mutex
455 events []json.RawMessage
456 done chan struct{}
457 )
458 chromedp.ListenTarget(ctx, func(ev interface{}) {
459 mu.Lock()
460 defer mu.Unlock()
461 switch e := ev.(type) {
462 case *tracing.EventDataCollected:
463 for _, v := range e.Value {
464 events = append(events, json.RawMessage(v))
465 }
466 case *tracing.EventTracingComplete:
467 if done != nil {
468 close(done)
469 done = nil
470 }
471 }
472 })
473 // One window: the page loaded fresh, quiet (no root write for 300ms
474 // and no transition still running from the load, which would
475 // otherwise be restyles inside the window that the pointer did not
476 // cause), then two seconds of pointer movement under a trace.
477 window := func(path string) (perFrame, wallPerFrame float64, frames, writes int, w traceWindow) {
478 mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#big", chromedp.ByQuery),
479 chromedp.Evaluate(`window.__frames = 0; (function f() { window.__frames++; requestAnimationFrame(f); })(); window.__rootWrites = 0; new MutationObserver(m => window.__rootWrites += m.length).observe(document.documentElement, {attributes: true, attributeFilter: ["style"]}); true`, nil),
480 chromedp.Evaluate(`new Promise(r => { let q; const arm = () => { clearTimeout(q); q = setTimeout(() => document.getAnimations().length ? arm() : r(), 300); }; new MutationObserver(arm).observe(document.documentElement, {attributes: true, attributeFilter: ["style"]}); arm(); })`, nil, awaitPromise))
481 finished := make(chan struct{})
482 mu.Lock()
483 events, done = nil, finished
484 mu.Unlock()
485 var frames0, writes0 int
486 mustRun(t, ctx, chromedp.Evaluate(`window.__frames`, &frames0), chromedp.Evaluate(`window.__rootWrites`, &writes0),
487 chromedp.ActionFunc(func(c context.Context) error {
488 return tracing.Start().WithTransferMode(tracing.TransferModeReportEvents).WithTraceConfig(&tracing.TraceConfig{IncludedCategories: traceCategories}).Do(c)
489 }), chromedp.Evaluate(`console.timeStamp("rst-budget-start"), true`, nil))
490 start := time.Now()
491 for i := 0; time.Since(start) < 2*time.Second; i++ {
492 mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, 200+float64(i%60)*15, 100+float64(i%40)*16), chromedp.Sleep(16*time.Millisecond))
493 }
494 var frames1, writes1 int
495 mustRun(t, ctx, chromedp.Evaluate(`console.timeStamp("rst-budget-end"), window.__frames`, &frames1), chromedp.Evaluate(`window.__rootWrites`, &writes1),
496 chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 150))))`, nil, awaitPromise),
497 chromedp.ActionFunc(func(c context.Context) error { return tracing.End().Do(c) }))
498 select {
499 case <-finished:
500 case <-time.After(20 * time.Second):
501 t.Fatal("the trace never completed")
502 }
503 frames, writes = frames1-frames0, writes1-writes0
504 // A page that is not animating (a hidden or throttled tab) counts
505 // a frame or two in 2s. A loaded runner lowers the rate without
506 // stopping it: 45 frames on the fleet, 32 to 66 on docs-t1 with
507 // two CPUs and busy loops, and the thread-time figure held. So
508 // the floor is 20, not the 60 an idle machine clears.
509 if frames < 20 {
510 t.Fatalf("%s: only %d frames in 2s; the page is not animating, the figure would prove nothing", path, frames)
511 }
512 mu.Lock()
513 evs := events
514 mu.Unlock()
515 w, err := traceTotals(evs, "rst-budget-start", "rst-budget-end", "UpdateLayoutTree", "Layout", "PrePaint", "Paint")
516 if err != nil {
517 t.Fatalf("%s: the trace's accounting does not close, so the figure would undercount: %v", path, err)
518 }
519 if w.Tasks == 0 {
520 t.Fatalf("%s: the trace carried no tasks on the page's main thread, so a long task would go unseen", path)
521 }
522 return w.Thread / 1000 / float64(frames), w.Wall / 1000 / float64(frames), frames, writes, w
523 }
524 // The budget is the script's cost, and a busy machine only adds to a
525 // window, never takes away: CI runs this beside thirty other shards
526 // and read 4.11 ms where an idle run reads about 2, and even the
527 // hour-only light read 4.32 on docs-t1 at load 11, because the hover
528 // churn over 500 rows is most of the total (#104 item 5). So the
529 // same pointer path is driven on the same page without light.js,
530 // and what is held to the budget is the difference: the script's
531 // own cost. Both sides are read in thread time (#118): wall time
532 // counts the moments the renderer waited for a CPU, which a loaded
533 // runner adds to each window unevenly, so a subtraction of wall
534 // figures could land anywhere. Thread time leaves out the waits but
535 // not all of the load: a CPU shared with thirty other shards still
536 // runs colder caches, and docs-t1 read 2.2 to 3.4 ms under load
537 // against 2.27 idle. So three windows a side, interleaved so a load
538 // spike lands on both, all of them measured, and the cheapest of
539 // each compared, the way a benchmark reports its best run
540 // (cheapestWindows, and its table test, say why none is skipped).
541 // Only the frame cost takes the cheapest window: the coalescing and
542 // long-task rules are held on every window, here in the closure.
543 // Keeping the cheapest window's long-task count let a first window
544 // with a long task pass behind a clean faster one (a review finding), and a
545 // long task in a slow window is a long task a reader saw; by thread
546 // time, a task that did 50ms of work, not one that waited that long.
547 with, without := cheapestWindows(func(path string) budgetWindow {
548 pf, wpf, f, writes, w := window(path)
549 t.Logf("frame budget %s: %.2f ms of style+layout+paint thread time per frame (%.2f wall) over %d frames (%d root writes, %d of %d tasks over 50ms of thread time, events %v, ms %v)", side(path), pf, wpf, f, writes, w.Long, w.Tasks, w.Counts, w.Sums)
550 if writes > 2*f {
551 t.Errorf("%s: <html> style written %d times in %d frames; moves must be coalesced to one update (two properties) a frame", side(path), writes, f)
552 }
553 if w.Long > 0 {
554 t.Errorf("%s: %d tasks did over 50ms of work during 2s of pointer movement", side(path), w.Long)
555 }
556 return budgetWindow{path, pf, f, w.Counts}
557 }, 3)
558 sides := map[string]budgetWindow{"/": with, "/bare": without}
559 for path, b := range sides {
560 if b.counts["UpdateLayoutTree"] < b.frames/2 || b.counts["Paint"] == 0 {
561 t.Fatalf("%s: the trace carried %d style recalculations and %d paints over %d frames; the figure would prove nothing", side(path), b.counts["UpdateLayoutTree"], b.counts["Paint"], b.frames)
562 }
563 }
564 delta := with.perFrame - without.perFrame
565 t.Logf("frame budget: light.js adds %.2f ms a frame (%.2f with, %.2f without), budget %.1f", delta, with.perFrame, without.perFrame, lightFrameBudgetMS)
566 if delta > lightFrameBudgetMS {
567 t.Errorf("light.js adds %.2f ms a frame of style+layout+paint (%.2f with it, %.2f without), over the %.1f ms budget: set FOLLOW = false in light.js and record why", delta, with.perFrame, without.perFrame, lightFrameBudgetMS)
568 }
569}
570
571// side names a budget page in the log.
572func side(path string) string {
573 if path == "/" {
574 return "with light.js"
575 }
576 return "without light.js"
577}
578
579// TestALeaveAfterAQueuedMoveNeverRelightsACard is the leave-after-move
580// finding: a move over a card and the document's pointerleave in the
581// same task, before the frame that would read the move. onLeave dims
582// every lit card; it must also drop the queued move, or the next frame
583// reads it and lights the card the pointer has just left, and the card
584// settles lit with no pointer on the page. The move is a synthetic
585// PointerEvent on the card, because the harness's mouse cannot leave
586// the document between two frames; the leave is the document event the
587// script listens for. The control is the same move without the leave,
588// which must light the card, so a quiet card above is the leave's
589// doing and not a move that never arrived.
590func TestALeaveAfterAQueuedMoveNeverRelightsACard(t *testing.T) {
591 rig := sizingRig(t, false, lightDoc(t), harness.WithHover())
592 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
593 defer cancel()
594 openLight(t, ctx, rig, false)
595 bx, by, _, _ := rect(t, ctx, "#box")
596 moveJS := fmt.Sprintf(`document.getElementById("box").dispatchEvent(new PointerEvent("pointermove", {pointerType: "mouse", clientX: %.0f, clientY: %.0f, bubbles: true}))`, bx+40, by+20)
597 mustRun(t, ctx, chromedp.Evaluate(moveJS+`, document.dispatchEvent(new PointerEvent("pointerleave", {pointerType: "mouse"})), true`, nil))
598 time.Sleep(1500 * time.Millisecond)
599 if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit > 0.05 {
600 t.Errorf("card lit %v 1.5s after a move queued before the pointer left the document; the leave must drop the queued move", lit)
601 }
602 mustRun(t, ctx, chromedp.Evaluate(moveJS+`, true`, nil))
603 time.Sleep(1500 * time.Millisecond)
604 if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit < 0.9 {
605 t.Fatalf("control: card lit %v 1.5s after the same move with no leave; the synthetic move does not reach the script, so the quiet card proves nothing", lit)
606 }
607}
608
609// TestAPreferenceToggleForgetsSettledCards is the preference-toggle
610// finding: a card lit and settled, reduced motion turned on (stop()
611// clears every property the script wrote), the pointer gone elsewhere
612// while nothing listens, reduced motion off again (start()), and the
613// pointer back on the same spot, to the pixel: the card lights again.
614// Its state used to outlive stop() in the cards WeakMap with l = 1
615// and still = true, so the next light() found nothing left to ease
616// and wrote nothing, and the card stayed dark for good; a return a
617// pixel off would ease the spot and write, which is why the return is
618// exact. Forced colours take the same path through stop().
619func TestAPreferenceToggleForgetsSettledCards(t *testing.T) {
620 rig := sizingRig(t, false, lightDoc(t), harness.WithHover())
621 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
622 defer cancel()
623 openLight(t, ctx, rig, false)
624 reduce := func(value string) chromedp.Action {
625 return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: value}})
626 }
627 bx, by, _, _ := rect(t, ctx, "#box")
628 move(t, ctx, bx+40, by+20)
629 // Three seconds, not the card drive's 1.5: the light eases with a
630 // 400ms constant and is "done" within 0.004 of 1 after about 2.2s,
631 // and only a done card is written once and then left alone, which
632 // is the state that outlived stop().
633 time.Sleep(3000 * time.Millisecond)
634 if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit < 0.99 {
635 t.Fatalf("card lit %v before the toggle; the card never settled lit, so the drive has nothing to forget", lit)
636 }
637 mustRun(t, ctx, reduce("reduce"))
638 time.Sleep(300 * time.Millisecond)
639 if lit := computed(t, ctx, "#box", "--rst-card-lit"); lit != "0" {
640 t.Fatalf("under reduced motion the card is still lit %s; stop() did not clear it", lit)
641 }
642 move(t, ctx, 1270, 890)
643 mustRun(t, ctx, reduce("no-preference"))
644 time.Sleep(300 * time.Millisecond)
645 move(t, ctx, bx+40, by+20)
646 time.Sleep(1500 * time.Millisecond)
647 if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit < 0.9 {
648 t.Errorf("card lit %v 1.5s after reduced motion went on and off and the pointer came back; the card's settled state outlived stop()", lit)
649 }
650}
651