//go:build browser package check import ( "context" "encoding/json" "fmt" "math" "strconv" "strings" "sync" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/input" "github.com/chromedp/cdproto/tracing" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/harness" ) // lightDoc is the depth fixture plus the light.js tag. sizingDoc never // links it, so every other drive stays static (a lesson learned: a // clock-driven light moved shadows under the static card test). func lightDoc(t *testing.T) map[string]string { t.Helper() pages := depthDoc(t) pages["/"] = strings.Replace(pages["/"], ``, ``, 1) return pages } // lightFollows reads the mode the shipped file is in (the FOLLOW fallback // flips it), so the drives assert the behaviour of the file that ships. func lightFollows() bool { return strings.Contains(string(lightJS()), "var FOLLOW = true") } // lightModeDoc is the depth fixture with light.js in the mode asked // for: the shipped file when it is in that mode, otherwise the shipped // file with its FOLLOW flag flipped, served inline. Both modes are // driven whichever one ships, so flipping the flag later cannot leave // the other mode's root light untested. func lightModeDoc(t *testing.T, follow bool) map[string]string { t.Helper() if follow == lightFollows() { return lightDoc(t) } from, to := "var FOLLOW = false", "var FOLLOW = true" if !follow { from, to = to, from } js := strings.Replace(string(lightJS()), from, to, 1) pages := depthDoc(t) pages["/"] = strings.Replace(pages["/"], ``, ``, 1) return pages } // hourLight is where sun() puts the light for the page's own clock, so // an hour-mode assertion compares against a value that can be wrong. func hourLight(t *testing.T, ctx context.Context) (x, y float64) { t.Helper() var hour float64 mustRun(t, ctx, chromedp.Evaluate(`new Date().getHours() + new Date().getMinutes() / 60`, &hour)) tt := min(1, max(-1, (hour-12)/6)) return tt * 0.8, -1 + math.Abs(tt)*0.35 } // assertHourLight holds the root light to the hour's, within 0.1. func assertHourLight(t *testing.T, ctx context.Context, when string) { t.Helper() wx, wy := hourLight(t, ctx) x, y := lightNum(t, ctx, ":root", "--rst-light-x"), lightNum(t, ctx, ":root", "--rst-light-y") if math.Abs(x-wx) > 0.1 || math.Abs(y-wy) > 0.1 { t.Errorf("%s the light is %v,%v, want the hour's %.3f,%.3f", when, x, y, wx, wy) } } func lightNum(t *testing.T, ctx context.Context, selector, prop string) float64 { t.Helper() var v float64 raw := computed(t, ctx, selector, prop) if _, err := fmt.Sscanf(raw, "%f", &v); err != nil { t.Fatalf("%s %s = %q: %v", selector, prop, raw, err) } return v } // move sends a mouse move and waits two frames so the coalesced write // has happened. func move(t *testing.T, ctx context.Context, x, y float64) { t.Helper() mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, x, y), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) } func openLight(t *testing.T, ctx context.Context, rig *harness.Rig, reduce bool) { t.Helper() open(t, ctx, rig, "/", reduce) mustRun(t, ctx, chromedp.Evaluate(`new Promise(r => addEventListener("load", r, {once: true}) || document.readyState === "complete" && r())`, nil, awaitPromise)) } // TestTheLightFollowsThePointerAndSettles (spec §4): in FOLLOW // mode a move to the right edge raises --rst-light-x toward 1 and the // loop stops once settled; in hour mode the light is the hour's and a // move leaves it; either way the button under the pointer takes // --rst-mx/--rst-my in its own coordinates and a press sets // --rst-px/--rst-py; without the script every value is its default. func TestTheLightFollowsThePointerAndSettles(t *testing.T) { for _, withScript := range []bool{true, false} { t.Run(fmt.Sprintf("script %v", withScript), func(t *testing.T) { pages := depthDoc(t) if withScript { pages = lightDoc(t) } rig := sizingRig(t, false, pages, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) x, y, _, _ := rect(t, ctx, "#b-primary") move(t, ctx, 1270, 400) if !withScript { move(t, ctx, x+10, y+5) if lx, ly := computed(t, ctx, ":root", "--rst-light-x"), computed(t, ctx, ":root", "--rst-light-y"); lx != "0" || ly != "-1" { t.Errorf("no script: light is %s,%s, want 0,-1", lx, ly) } if mx := computed(t, ctx, "#b-primary", "--rst-mx"); mx != "50%" { t.Errorf("no script: --rst-mx is %q, want 50%%", mx) } return } // The oracle for "where the pointer is, in the button's own // pixels" is the event the page saw, not the coordinates the // drive sent: the browser rounds synthetic coordinates and // the box sits at a fraction, so the page records the offset // from the event and the rectangle it had at that moment. 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)) move(t, ctx, x+10, y+5) var seen string mustRun(t, ctx, chromedp.Evaluate(`window.__move`, &seen)) if got := computed(t, ctx, "#b-primary", "--rst-mx") + "/" + computed(t, ctx, "#b-primary", "--rst-my"); got != seen { t.Errorf("--rst-mx/--rst-my are %s, the page saw the pointer at %s", got, seen) } 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)), chromedp.Evaluate(`window.__down`, &seen)) if got := computed(t, ctx, "#b-primary", "--rst-px") + "/" + computed(t, ctx, "#b-primary", "--rst-py"); got != seen { t.Errorf("press point %s, the page saw the press at %s", got, seen) } move(t, ctx, 1270, 400) time.Sleep(800 * time.Millisecond) lx := lightNum(t, ctx, ":root", "--rst-light-x") if lightFollows() && lx < 0.9 { t.Errorf("--rst-light-x is %v 800ms after a move to the right edge, want it settled near 1", lx) } if !lightFollows() { assertHourLight(t, ctx, "in hour mode, after a pointer move,") } var writes int 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)) if writes != 0 { t.Errorf("the loop wrote style %d times after settling; it must stop", writes) } }) } } // TestReducedMotionIsTotal: under reduce nothing is written; when // the setting turns off mid-session the script starts; when it turns // on again the loop stops, a pending card timeout is cancelled, and // every property is cleared, including on a card that was tapped just // before. Forced colours turning on mid-session stops it the same way. func TestReducedMotionIsTotal(t *testing.T) { rig := sizingRig(t, false, lightDoc(t), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, true) move(t, ctx, 1270, 400) move(t, ctx, 100, 150) time.Sleep(300 * time.Millisecond) 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%" { t.Errorf("under reduced motion: light-x %s card-lit %s mx %s; want 0, 0, 50%%", x, lit, mx) } setMedia := func(name, value string) { mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: name, Value: value}})) time.Sleep(100 * time.Millisecond) } setMedia("prefers-reduced-motion", "no-preference") move(t, ctx, 1270, 400) move(t, ctx, 100, 150) bx, by, _, _ := rect(t, ctx, "#box") mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: bx + 20, Y: by + 10}}), input.DispatchTouchEvent(input.TouchEnd, nil)) time.Sleep(300 * time.Millisecond) if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit == 0 { t.Fatal("with reduced motion off a tapped card did not light; the change listener did not start the script") } setMedia("prefers-reduced-motion", "reduce") time.Sleep(1600 * time.Millisecond) // past the tap's 1400ms timeout for sel, prop := range map[string]string{":root": "--rst-light-x", "#box": "--rst-card-lit", "#b-primary": "--rst-mx"} { 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 { t.Errorf("after reduced motion turned on: %s %s = %s, want the default %s", sel, prop, got, want) } } var writes int 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)) if writes != 0 { t.Errorf("%d style writes after reduced motion turned on; the loop restarted", writes) } setMedia("prefers-reduced-motion", "no-preference") move(t, ctx, 1270, 400) setMedia("forced-colors", "active") move(t, ctx, 100, 150) time.Sleep(300 * time.Millisecond) if x := computed(t, ctx, ":root", "--rst-light-x"); x != "0" { t.Errorf("under forced colours turned on mid-session the light is %s; want it stopped and cleared", x) } } // TestACardBreathes (spec §4, C10): entering a card raises // --rst-card-lit toward 1 over about a second and leaving lowers it; a // card removed mid-ease is dropped with no error; the rim's spot walks // after the pointer inside the card, in the shipped mode. func TestACardBreathes(t *testing.T) { rig := sizingRig(t, false, lightDoc(t), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) bx, by, _, _ := rect(t, ctx, "#box") move(t, ctx, bx+40, by+20) time.Sleep(300 * time.Millisecond) early := lightNum(t, ctx, "#box", "--rst-card-lit") time.Sleep(1200 * time.Millisecond) late := lightNum(t, ctx, "#box", "--rst-card-lit") if !(early > 0.05 && early < 0.9 && late > 0.9) { t.Errorf("card lit %v after 300ms and %v after 1.5s; want a fade in over about a second", early, late) } // The rim follows the pointer in every mode (the FOLLOW fallback drops // the root light only): 100px further right inside the card, the // spot has walked there a second later. move(t, ctx, bx+140, by+20) time.Sleep(1200 * time.Millisecond) raw := computed(t, ctx, "#box", "--rst-card-x") if cx, err := strconv.ParseFloat(strings.TrimSuffix(raw, "px"), 64); err != nil || math.Abs(cx-140) > 3 { t.Errorf("card x %q 1.2s after the pointer moved to 140px inside it; want the spot to have walked there", raw) } move(t, ctx, 1270, 890) time.Sleep(1500 * time.Millisecond) if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit > 0.05 { t.Errorf("card lit %v 1.5s after the pointer left; want it faded out", lit) } move(t, ctx, bx+40, by+20) mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("box").remove(), true`, nil)) time.Sleep(300 * time.Millisecond) move(t, ctx, 1270, 890) } // TestKeysAndTouchLeavePointerMode (C10): in FOLLOW mode, after a // pointer move, a key press returns the light to the hour; so does a // touch. In hour mode the light is the hour's from the start and a // pointer move, a key and a touch all leave it there. The hour is read // in the page so no leg depends on the clock, and both modes run // whichever one ships (lightModeDoc). func TestKeysAndTouchLeavePointerMode(t *testing.T) { for _, leave := range []string{"key", "touch", "hour"} { t.Run(leave, func(t *testing.T) { rig := sizingRig(t, false, lightModeDoc(t, leave != "hour"), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) if leave == "hour" { assertHourLight(t, ctx, "at load") move(t, ctx, 1270, 400) time.Sleep(600 * time.Millisecond) assertHourLight(t, ctx, "after a move to the right edge") mustRun(t, ctx, chromedp.KeyEvent("\t")) time.Sleep(300 * time.Millisecond) assertHourLight(t, ctx, "after a key") mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: 50, Y: 850}}), input.DispatchTouchEvent(input.TouchEnd, nil)) time.Sleep(300 * time.Millisecond) assertHourLight(t, ctx, "after a touch") return } move(t, ctx, 1270, 400) time.Sleep(600 * time.Millisecond) if x := lightNum(t, ctx, ":root", "--rst-light-x"); x < 0.5 { t.Fatalf("light-x %v after a move to the right; pointer mode did not take", x) } if leave == "key" { mustRun(t, ctx, chromedp.KeyEvent("\t")) } else { // A synthetic touch also fires the document's // pointerleave, and onLeave leaves pointer mode on its // own, so this leg passed with the touch hook gone (#104 // item 7). A capturing listener on the document runs // before light.js's bubble listener on the same target // and stops the event there; what moves the light back // after the touch is then onDown alone. mustRun(t, ctx, chromedp.Evaluate(`window.__leaves = 0; document.addEventListener("pointerleave", e => { window.__leaves++; e.stopImmediatePropagation(); }, true); true`, nil)) mustRun(t, ctx, input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: 50, Y: 850}}), input.DispatchTouchEvent(input.TouchEnd, nil)) } time.Sleep(800 * time.Millisecond) assertHourLight(t, ctx, "after a "+leave) if leave == "touch" { var leaves int mustRun(t, ctx, chromedp.Evaluate(`window.__leaves`, &leaves)) t.Logf("%d pointerleave event(s) held back from light.js during the touch", leaves) } }) } } // TestTheHookFreezesTheLight (C14): the hook sets the light at once and // holds it against pointer moves. func TestTheHookFreezesTheLight(t *testing.T) { rig := sizingRig(t, false, lightDoc(t), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) mustRun(t, ctx, chromedp.Evaluate(`document.dispatchEvent(new CustomEvent("rst:light", {detail: {x: 0.4, y: -0.8, hold: true}})), true`, nil), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) if x, y := computed(t, ctx, ":root", "--rst-light-x"), computed(t, ctx, ":root", "--rst-light-y"); x != "0.400" || y != "-0.800" { t.Errorf("hooked light is %s,%s; want 0.400,-0.800", x, y) } move(t, ctx, 1270, 400) time.Sleep(300 * time.Millisecond) if x := computed(t, ctx, ":root", "--rst-light-x"); x != "0.400" { t.Errorf("held light moved to %s", x) } } // TestATouchPressShowsNoSheen: on a coarse pointer a held button // has its press point and no sheen layer. func TestATouchPressShowsNoSheen(t *testing.T) { rig := sizingRig(t, true, lightDoc(t)) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) requirePointer(t, ctx, true) x, y, _, _ := rect(t, ctx, "#b-primary") 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), input.DispatchTouchEvent(input.TouchStart, []*input.TouchPoint{{X: x + 12, Y: y + 8}})) var seen string mustRun(t, ctx, chromedp.Evaluate(`window.__down`, &seen)) if got := computed(t, ctx, "#b-primary", "--rst-px") + "/" + computed(t, ctx, "#b-primary", "--rst-py"); got != seen { t.Errorf("press point %s, the page saw the press at %s", got, seen) } if img := computed(t, ctx, "#b-primary", "background-image"); strings.Contains(img, "radial-gradient(120px") { t.Errorf("a touch press shows the sheen: %q", img) } mustRun(t, ctx, input.DispatchTouchEvent(input.TouchEnd, nil)) } // lightFrameBudgetMS is the spec's budget on what light.js adds: style // recalculation, layout, pre-paint and paint per frame while the // pointer moves over a 500-row list, summed as thread time from the // browser's own trace events, on the page with the script minus the // same page without it. The spec's 4ms was meant as the light's own // cost, and measured as an absolute it was mostly the hover churn the // page does with no script at all, which load inflates (#104 item 5); // measured in wall time it also counted the moments the renderer // waited for a CPU, which a loaded runner adds to both pages and not // evenly (#118). Measured as a difference (below) the shipped light // adds 0.8 to 0.9ms in wall time on a quiet docs-t1 and 1.1 to 1.7ms // in thread time at load 20 to 30, and the pointer-following mode // about 2.4 (measured on the merged drive: the shipped mode 1.20 added, // PASS; FOLLOW = true 2.46 added, FAIL), so 2ms holds the shipped mode // with its headroom halved // under load, keeps the fallback's verdict on the other, and sits at // half of what the spec allows. const lightFrameBudgetMS = 2.0 // traceCategories are the ones that carry the rendering events this // drive sums (measured in this Chromium): UpdateLayoutTree is in // "blink,devtools.timeline", Layout, PrePaint and Paint in // "devtools.timeline". "disabled-by-default-devtools.timeline" alone, // which a first draft enabled, carries none of them and would have // summed to a passing zero. var traceCategories = []string{"devtools.timeline", "blink", "disabled-by-default-devtools.timeline"} // TestTheLightStaysWithinItsFrameBudget: two seconds of pointer // movement over 500 rows, a move every 16ms, on the page with light.js // and again on the same page without it. The trace's UpdateLayoutTree // (style), Layout, PrePaint and Paint thread time inside the marked // window is summed and divided by the frames the page counted; each // trace must carry style and paint events or the figure proves // nothing. The trace ends two frames and 150ms after the window does, // so every slice begun inside it has closed. Also: 's style is // written at most twice a frame (two properties, one update), and no // task on the page's main thread does 50ms of work (traceTotals says // why thread time, not wall time; #118). What is held to the budget // is the difference. // // Measured as a difference in thread time, this page and this drive, // on a hover rig, docs-t1 at load 20 to 30 (16 CPUs, other sessions' // builds and drives), three runs: // // FOLLOW = false (shipped) 1.14, 1.31 and 1.67 ms a frame added // (2.23 to 3.82 with the script, 1.09 to 2.15 without; wall // time read 0.02 to 0.3 above thread time a frame). The load // inflates both sides and the difference with them: thread time // leaves out the waits, not the colder caches. // // Measured as a difference in wall time, before #118, docs-t1 at load // 1.6 to 2.6: // // FOLLOW = false (shipped) 0.82 to 0.93 ms a frame added (1.64 to // 1.71 with the script, 0.78 to 0.82 without). // FOLLOW = true, a root write at most every 200ms: 2.44 ms added // (3.32 with, 0.88 without; 20 root writes in the window). // // Measured as an absolute in thread time on main (#118), docs-t1, // every window of two runs each: shipped, 2.19 to 2.50 idle and 1.91 // to 2.49 with the browser held to two CPUs beside four or six busy // loops, where wall time read 4.7 to 12.1 over 32 to 66 frames and the // browser's own long-task count reached 20, every one of them 2 to // 11ms of work. FOLLOW = true with a root write every 200ms: 3.59 to // 4.37 idle and 4.83 to 10.74 loaded; every 100ms, 5.4 to 7.3; every // frame, 20 to 34. // // Measured as an absolute in wall time, before the difference, the // same drive read: // // FOLLOW = false (shipped) 2.07 ms a frame on docs-t1 (the controller's // probe at HEAD); 1.74, 1.55, 1.59 before the hover rig. // FOLLOW = true, a root write at most every 200ms: 4.11, then 4.15 // to 4.20 ms a frame on the CI runner, over the 4ms budget, which // is why it is off; 3.25 to 4.13 on docs-t1 at load 2.7 to 4.1 (at // 125ms 4.27 to 4.49, at 100ms 4.83 to 4.85). Each root write is a // restyle and repaint of the whole document, about 12ms over 500 // rows: about 2ms a frame at five writes a second. // FOLLOW = true, every frame, with the button's box-shadow in its // transition list (the CSS before fix round 1): 98.27 and 85.96 ms // a frame over 23 frames, 22 long tasks: every root write // retargeted a shadow transition on all 500 buttons. // no script: 0.17 to 1.11 ms a frame (nothing recalculates; the drive // refuses that as proof). // // Two things made the hour-mode figure: the button's box-shadow left // its transition list (tokens.css), and the card's three properties are // registered as not inheriting, because a write on a 500-row card // restyled its 1,500 descendants (21 to 29 ms a frame before). The // pointer mode keeps its rate limit and JavaScript easing for the day // a cheaper root write makes it affordable. func TestTheLightStaysWithinItsFrameBudget(t *testing.T) { var rows strings.Builder rows.WriteString(`
`) for i := 0; i < 500; i++ { fmt.Fprintf(&rows, `
Row %d
`, i, i) } rows.WriteString(`
`) bare := sizingDoc("budget", rows.String()) lit := strings.Replace(bare, ``, ``, 1) rig := sizingRig(t, false, map[string]string{"/": lit, "/bare": bare}, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() var ( mu sync.Mutex events []json.RawMessage done chan struct{} ) chromedp.ListenTarget(ctx, func(ev interface{}) { mu.Lock() defer mu.Unlock() switch e := ev.(type) { case *tracing.EventDataCollected: for _, v := range e.Value { events = append(events, json.RawMessage(v)) } case *tracing.EventTracingComplete: if done != nil { close(done) done = nil } } }) // One window: the page loaded fresh, quiet (no root write for 300ms // and no transition still running from the load, which would // otherwise be restyles inside the window that the pointer did not // cause), then two seconds of pointer movement under a trace. window := func(path string) (perFrame, wallPerFrame float64, frames, writes int, w traceWindow) { mustRun(t, ctx, chromedp.EmulateViewport(1280, 800), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("#big", chromedp.ByQuery), 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), 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)) finished := make(chan struct{}) mu.Lock() events, done = nil, finished mu.Unlock() var frames0, writes0 int mustRun(t, ctx, chromedp.Evaluate(`window.__frames`, &frames0), chromedp.Evaluate(`window.__rootWrites`, &writes0), chromedp.ActionFunc(func(c context.Context) error { return tracing.Start().WithTransferMode(tracing.TransferModeReportEvents).WithTraceConfig(&tracing.TraceConfig{IncludedCategories: traceCategories}).Do(c) }), chromedp.Evaluate(`console.timeStamp("rst-budget-start"), true`, nil)) start := time.Now() for i := 0; time.Since(start) < 2*time.Second; i++ { mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, 200+float64(i%60)*15, 100+float64(i%40)*16), chromedp.Sleep(16*time.Millisecond)) } var frames1, writes1 int mustRun(t, ctx, chromedp.Evaluate(`console.timeStamp("rst-budget-end"), window.__frames`, &frames1), chromedp.Evaluate(`window.__rootWrites`, &writes1), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 150))))`, nil, awaitPromise), chromedp.ActionFunc(func(c context.Context) error { return tracing.End().Do(c) })) select { case <-finished: case <-time.After(20 * time.Second): t.Fatal("the trace never completed") } frames, writes = frames1-frames0, writes1-writes0 // A page that is not animating (a hidden or throttled tab) counts // a frame or two in 2s. A loaded runner lowers the rate without // stopping it: 45 frames on the fleet, 32 to 66 on docs-t1 with // two CPUs and busy loops, and the thread-time figure held. So // the floor is 20, not the 60 an idle machine clears. if frames < 20 { t.Fatalf("%s: only %d frames in 2s; the page is not animating, the figure would prove nothing", path, frames) } mu.Lock() evs := events mu.Unlock() w, err := traceTotals(evs, "rst-budget-start", "rst-budget-end", "UpdateLayoutTree", "Layout", "PrePaint", "Paint") if err != nil { t.Fatalf("%s: the trace's accounting does not close, so the figure would undercount: %v", path, err) } if w.Tasks == 0 { t.Fatalf("%s: the trace carried no tasks on the page's main thread, so a long task would go unseen", path) } return w.Thread / 1000 / float64(frames), w.Wall / 1000 / float64(frames), frames, writes, w } // The budget is the script's cost, and a busy machine only adds to a // window, never takes away: CI runs this beside thirty other shards // and read 4.11 ms where an idle run reads about 2, and even the // hour-only light read 4.32 on docs-t1 at load 11, because the hover // churn over 500 rows is most of the total (#104 item 5). So the // same pointer path is driven on the same page without light.js, // and what is held to the budget is the difference: the script's // own cost. Both sides are read in thread time (#118): wall time // counts the moments the renderer waited for a CPU, which a loaded // runner adds to each window unevenly, so a subtraction of wall // figures could land anywhere. Thread time leaves out the waits but // not all of the load: a CPU shared with thirty other shards still // runs colder caches, and docs-t1 read 2.2 to 3.4 ms under load // against 2.27 idle. So three windows a side, interleaved so a load // spike lands on both, all of them measured, and the cheapest of // each compared, the way a benchmark reports its best run // (cheapestWindows, and its table test, say why none is skipped). // Only the frame cost takes the cheapest window: the coalescing and // long-task rules are held on every window, here in the closure. // Keeping the cheapest window's long-task count let a first window // with a long task pass behind a clean faster one (a review finding), and a // long task in a slow window is a long task a reader saw; by thread // time, a task that did 50ms of work, not one that waited that long. with, without := cheapestWindows(func(path string) budgetWindow { pf, wpf, f, writes, w := window(path) 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) if writes > 2*f { t.Errorf("%s: style written %d times in %d frames; moves must be coalesced to one update (two properties) a frame", side(path), writes, f) } if w.Long > 0 { t.Errorf("%s: %d tasks did over 50ms of work during 2s of pointer movement", side(path), w.Long) } return budgetWindow{path, pf, f, w.Counts} }, 3) sides := map[string]budgetWindow{"/": with, "/bare": without} for path, b := range sides { if b.counts["UpdateLayoutTree"] < b.frames/2 || b.counts["Paint"] == 0 { 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) } } delta := with.perFrame - without.perFrame t.Logf("frame budget: light.js adds %.2f ms a frame (%.2f with, %.2f without), budget %.1f", delta, with.perFrame, without.perFrame, lightFrameBudgetMS) if delta > lightFrameBudgetMS { 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) } } // side names a budget page in the log. func side(path string) string { if path == "/" { return "with light.js" } return "without light.js" } // TestALeaveAfterAQueuedMoveNeverRelightsACard is the leave-after-move // finding: a move over a card and the document's pointerleave in the // same task, before the frame that would read the move. onLeave dims // every lit card; it must also drop the queued move, or the next frame // reads it and lights the card the pointer has just left, and the card // settles lit with no pointer on the page. The move is a synthetic // PointerEvent on the card, because the harness's mouse cannot leave // the document between two frames; the leave is the document event the // script listens for. The control is the same move without the leave, // which must light the card, so a quiet card above is the leave's // doing and not a move that never arrived. func TestALeaveAfterAQueuedMoveNeverRelightsACard(t *testing.T) { rig := sizingRig(t, false, lightDoc(t), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) bx, by, _, _ := rect(t, ctx, "#box") moveJS := fmt.Sprintf(`document.getElementById("box").dispatchEvent(new PointerEvent("pointermove", {pointerType: "mouse", clientX: %.0f, clientY: %.0f, bubbles: true}))`, bx+40, by+20) mustRun(t, ctx, chromedp.Evaluate(moveJS+`, document.dispatchEvent(new PointerEvent("pointerleave", {pointerType: "mouse"})), true`, nil)) time.Sleep(1500 * time.Millisecond) if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit > 0.05 { 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) } mustRun(t, ctx, chromedp.Evaluate(moveJS+`, true`, nil)) time.Sleep(1500 * time.Millisecond) if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit < 0.9 { 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) } } // TestAPreferenceToggleForgetsSettledCards is the preference-toggle // finding: a card lit and settled, reduced motion turned on (stop() // clears every property the script wrote), the pointer gone elsewhere // while nothing listens, reduced motion off again (start()), and the // pointer back on the same spot, to the pixel: the card lights again. // Its state used to outlive stop() in the cards WeakMap with l = 1 // and still = true, so the next light() found nothing left to ease // and wrote nothing, and the card stayed dark for good; a return a // pixel off would ease the spot and write, which is why the return is // exact. Forced colours take the same path through stop(). func TestAPreferenceToggleForgetsSettledCards(t *testing.T) { rig := sizingRig(t, false, lightDoc(t), harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() openLight(t, ctx, rig, false) reduce := func(value string) chromedp.Action { return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-reduced-motion", Value: value}}) } bx, by, _, _ := rect(t, ctx, "#box") move(t, ctx, bx+40, by+20) // Three seconds, not the card drive's 1.5: the light eases with a // 400ms constant and is "done" within 0.004 of 1 after about 2.2s, // and only a done card is written once and then left alone, which // is the state that outlived stop(). time.Sleep(3000 * time.Millisecond) if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit < 0.99 { t.Fatalf("card lit %v before the toggle; the card never settled lit, so the drive has nothing to forget", lit) } mustRun(t, ctx, reduce("reduce")) time.Sleep(300 * time.Millisecond) if lit := computed(t, ctx, "#box", "--rst-card-lit"); lit != "0" { t.Fatalf("under reduced motion the card is still lit %s; stop() did not clear it", lit) } move(t, ctx, 1270, 890) mustRun(t, ctx, reduce("no-preference")) time.Sleep(300 * time.Millisecond) move(t, ctx, bx+40, by+20) time.Sleep(1500 * time.Millisecond) if lit := lightNum(t, ctx, "#box", "--rst-card-lit"); lit < 0.9 { 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) } }