| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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). |
| 27 | func 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. |
| 36 | func 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. |
| 43 | func 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. |
| 60 | func 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. |
| 69 | func 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 | |
| 78 | func 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. |
| 90 | func 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 | |
| 95 | func 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. |
| 107 | func 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. |
| 170 | func 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. |
| 220 | func 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). |
| 260 | func 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. |
| 311 | func 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. |
| 330 | func 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. |
| 367 | const 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. |
| 375 | var 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. |
| 441 | func 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. |
| 572 | func 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. |
| 590 | func 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(). |
| 619 | func 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 | |