diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 22085d5ca27eb..32e0da2e18c26 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -363,6 +363,36 @@ turn, the one ahead is mid-leg. Track it with `trackControl(page, frames, resolves with each control's `start` on the page clock, where its own time is zero. Keep the component's marker out of the top 28 px then. +In WebKit, a busy proof with a control judges every frame on the controls' +clock, not on the screencast's stamps. WebKit stamps the frame after a delivery +gap about 60 ms late. So `busyWindowProof` and `pooledBusyProof` date each +WebKit frame from the control's positions, the component's and the control's +alike. A frame with no reading keeps its stamp's distance from the nearest frame +whose stamp agrees with its reading. The dated frames keep the cast's order, +since the cast delivers them in the order they were drawn. The proof's `judged` +part stays on the cast's stamps, so a spec selects the judged part from its own +cast; frames the display-rate keep left out fall inside it too. A spec that +needs a frame's own time for another check reads it with `pageTimesOnControls`, +the readings the proof dates from. Without the control ahead, a late frame near +the first control's turn keeps its lateness. A proof that judges every frame +near a turn starts the control ahead too +(`startBusyControl(page, {ahead: true})`). It passes that control's frames as +`ahead`, per run in a pool; `controlStarts` is optional. Chromium casts and +casts with no control are judged on their stamps. Under load, Chromium's cast +also stamps frames in bursts, which a proof on its stamps can misread. On +WebKit's stamps, a late frame inside the judged part changes no verdict, since +the proof reads a slow curve's held frames as motion. At the part's start, it +shows a stall the control does not have: the false reds came from there. + +A WebKit proof keeps one dated frame per display interval. A frame under one +interval after the last one kept is left out; the earlier one stays. WebKit's +compositor can draw two frames 6 to 9 ms apart, and a curve then shows no new +step between them. A frame with no reading always stays, since its time is a +neighbour's. A WebKit cast rarely shows two frames one interval apart. So +the proof judges motion on every kept frame the control moved, but no 30 Hz +cadence. Its summary says so in a note, with the count of pairs one interval +apart. Chromium's cast judges that cadence. + An animation that lasts a fraction of a second, such as a 0.2 s fade, leaves a block only a few frames to judge: in WebKit, or on a loaded machine, the control can present fewer than four, and one run cannot decide. Play it several times diff --git a/internal/interaction-harness/src/busy.ts b/internal/interaction-harness/src/busy.ts index 576684e5123be..0fd05536bb5ec 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -118,6 +118,11 @@ export async function trackControl( * the first control is there, the one ahead is mid-leg, so the two read * every frame. Track it with `trackControl(page, frames, {ahead: true})`, and * keep the marker out of its strip too (the top `2 * CONTROL_SIDE + 4` px). + * + * A WebKit busy proof dates its frames on the controls' clock. Without the + * control ahead, a frame stamped late near the first control's turn keeps its + * lateness. A proof that judges every frame near a turn starts the one ahead + * too, and passes its frames to `busyWindowProof` as `ahead`. */ export async function startBusyControl( page: Page, @@ -265,6 +270,15 @@ const CONTROL_AGREE_MS = 20; * gives are under 120 ms apart, and a stamp 60 ms late could pick either. */ const CONTROL_EDGE = 36; +/** + * The least time between two dated frames, ms, far under a frame interval. + * The proof matches frames by time, so no two may share one. + */ +const DATED_GAP_MS = 0.01; +/** One display interval, ms: the compositor presents at most once in one. */ +const DISPLAY_FRAME_MS = 1000 / 60; +/** How close the controls' clock reads a time, ms: one px of travel. */ +const CONTROL_READ_MS = CONTROL_LEG_MS / CONTROL_TRAVEL; /** * The compositor's clock in each frame of `control` (`startBusyControl`'s box, @@ -288,6 +302,17 @@ const CONTROL_EDGE = 36; export function controlClock( control: readonly TrackedFrame[], ): Map { + return readControlClock(control).readings; +} + +/** + * `controlClock`'s readings, with the origin they count from (epoch ms, the + * stamps' clock), or a null origin when no frame has a reading. + */ +function readControlClock(control: readonly TrackedFrame[]): { + readonly readings: Map; + readonly origin: number | null; +} { const cycle = 2 * CONTROL_LEG_MS; const usable = control.flatMap((frame, i) => { const x = frame.box?.x; @@ -305,7 +330,7 @@ export function controlClock( }); const clock = new Map(); if (usable.length === 0) { - return clock; + return {readings: clock, origin: null}; } const onCycle = (a: number, b: number) => { const d = Math.abs(a - b) % cycle; @@ -344,7 +369,264 @@ export function controlClock( Math.abs(out - expected) <= Math.abs(back - expected) ? out : back, ); } - return clock; + return {readings: clock, origin: anchored}; +} + +/** + * Each control frame's time on the page clock, epoch ms, by its cast stamp. It + * is read from the busy controls' positions: the first control's clock, else + * the one `ahead`. The stamp only picks the control's cycle, so a stamp that + * runs late moves no reading. Each control counts from its start + * (`controlStarts`), or else from the origin that most of its frames' stamps + * agree with (`controlClock`). A frame with no reading has no time here. + * Frames tracked from the same screencast share its stamps, so a spec looks + * their times up here. The busy proofs date their frames from these times. + */ +export function pageTimesOnControls( + control: readonly TrackedFrame[], + { + ahead, + controlStarts, + }: { + /** The control started `ahead`, from the same screencast. */ + readonly ahead?: readonly TrackedFrame[]; + /** When the first control started, then the one ahead (`startBusyControl`). */ + readonly controlStarts?: readonly [number, number?]; + } = {}, +): Map { + const cycle = 2 * CONTROL_LEG_MS; + const readers = [ + {...readControlClock(control), start: controlStarts?.[0]}, + ...(ahead == null + ? [] + : [{...readControlClock(ahead), start: controlStarts?.[1]}]), + ].flatMap(({readings, origin, start}) => { + const from = start ?? origin; + return from == null ? [] : [{readings, from}]; + }); + const times = new Map(); + for (const {t: stamp} of [...control, ...(ahead ?? [])]) { + if (times.has(stamp)) { + continue; + } + for (const {readings, from} of readers) { + const ms = readings.get(stamp); + if (ms != null) { + times.set( + stamp, + from + ms + Math.round((stamp - from - ms) / cycle) * cycle, + ); + break; + } + } + } + return times; +} + +/** + * Each stamp's time on the page clock: its own reading where it has one + * (`pageTimesOnControls`). + * + * A stamp with no reading keeps its stamp's distance from the nearest stamp + * whose reading agrees with it, within `CONTROL_AGREE_MS`. Such a stamp is + * near a turn, or repeats a position. A late stamp's distance would carry its + * lateness. Without such a stamp, the nearest stamp with a reading serves. + * Dropping the frame would thin both series every half cycle. + * + * The times keep the stamps' order: a cast delivers frames in the order they + * were drawn. Two agreeing stamps can differ in lateness by twice + * `CONTROL_AGREE_MS`, so the stamps without a reading between them could + * swap. So such a stamp never passes the next stamp with a reading. Each time + * comes at least `DATED_GAP_MS` after the time of the stamp before it, since + * the proof matches frames by time. `read` holds the stamps whose time is + * their own reading. Null when no stamp has a reading. + */ +function controlPageTimes( + stamps: readonly number[], + control: readonly TrackedFrame[], + ahead: readonly TrackedFrame[] | undefined, + controlStarts: readonly [number, number?] | undefined, +): {times: Map; read: ReadonlySet} | null { + const own = pageTimesOnControls(control, {ahead, controlStarts}); + const read = new Map(); + for (const stamp of stamps) { + const ms = own.get(stamp); + if (ms != null) { + read.set(stamp, ms); + } + } + if (read.size === 0) { + return null; + } + const dated = [...read.keys()]; + const agreeing = dated.filter( + stamp => Math.abs(stamp - (read.get(stamp) ?? stamp)) <= CONTROL_AGREE_MS, + ); + const anchors = agreeing.length > 0 ? agreeing : dated; + const times = new Map(); + for (const stamp of stamps) { + const own = read.get(stamp); + if (own != null) { + times.set(stamp, own); + continue; + } + let nearest = anchors[0]; + for (const other of anchors) { + if (Math.abs(other - stamp) < Math.abs(nearest - stamp)) { + nearest = other; + } + } + times.set(stamp, (read.get(nearest) ?? nearest) + stamp - nearest); + } + const order = [...stamps].sort((a, b) => a - b); + let ceiling = Number.POSITIVE_INFINITY; + for (const stamp of [...order].reverse()) { + const own = read.get(stamp); + if (own == null) { + times.set(stamp, Math.min(times.get(stamp) ?? stamp, ceiling)); + } else { + ceiling = own; + } + } + let floor = Number.NEGATIVE_INFINITY; + for (const stamp of order) { + floor = Math.max(times.get(stamp) ?? stamp, floor + DATED_GAP_MS); + times.set(stamp, floor); + } + return {times, read: new Set(read.keys())}; +} + +/** + * The component's and the control's frames dated on the controls' clock + * (`controlPageTimes`), in the cast's order, which those times keep. Both + * series come from one screencast, so each frame takes the same time in both. + * `stampOf` gives each dated time's stamp, and `readTimes` holds the times + * that are a frame's own reading. Null when the controls give no reading: + * the proof then judges the cast's stamps. + */ +function datedOnControlClock( + tracked: readonly TrackedFrame[], + control: readonly TrackedFrame[], + ahead: readonly TrackedFrame[] | undefined, + controlStarts: readonly [number, number?] | undefined, +): { + tracked: TrackedFrame[]; + control: TrackedFrame[]; + stampOf: ReadonlyMap; + readTimes: ReadonlySet; +} | null { + if (control.length === 0) { + return null; + } + const stamps = [...new Set([...tracked, ...control].map(frame => frame.t))]; + const dating = controlPageTimes(stamps, control, ahead, controlStarts); + if (dating == null) { + return null; + } + const {times, read} = dating; + const date = (frames: readonly TrackedFrame[]) => + [...frames] + .sort((a, b) => a.t - b.t) + .map(frame => ({...frame, t: times.get(frame.t) ?? frame.t})); + return { + tracked: date(tracked), + control: date(control), + stampOf: new Map([...times].map(([stamp, time]) => [time, stamp])), + readTimes: new Set([...read].map(stamp => times.get(stamp) ?? Number.NaN)), + }; +} + +/** + * The dated frames at display rate: at most one each display interval, read + * to the px (`CONTROL_READ_MS`). A frame under one interval after the last one + * kept is left out, and the earlier one stays. WebKit's compositor can draw + * two frames 6 to 9 ms apart, and a curve then shows no new step between them. + * Only frames whose time is their own reading count. A frame dated from a + * neighbour always stays. Its time is too rough to call it early, and leaving + * it out could hide a held frame. Near a turn without the control ahead, such + * a frame can then read as held, a false red that `ahead` removes. Both + * series keep the same frames. + */ +function atDisplayRate< + T extends { + readonly tracked: readonly TrackedFrame[]; + readonly control: readonly TrackedFrame[]; + readonly readTimes: ReadonlySet; + }, +>(dated: T): T { + const times = [ + ...new Set([...dated.tracked, ...dated.control].map(frame => frame.t)), + ].sort((a, b) => a - b); + const kept = new Set(); + let last = Number.NEGATIVE_INFINITY; + for (const t of times) { + if (!dated.readTimes.has(t)) { + kept.add(t); + continue; + } + // To a nanosecond, so float error never drops a frame one read early. + if (t - last + 1e-6 >= DISPLAY_FRAME_MS - CONTROL_READ_MS) { + kept.add(t); + last = t; + } + } + return { + ...dated, + tracked: dated.tracked.filter(frame => kept.has(frame.t)), + control: dated.control.filter(frame => kept.has(frame.t)), + }; +} + +/** + * Pairs of judged frames one display interval apart, with the control moved + * between them. On such a pair, a fade that holds every other frame shows no + * step. Each frame finds its control frame by time. A pair without both counts + * as unmatched. + */ +function displayIntervalPairs( + tracked: readonly TrackedFrame[], + control: readonly TrackedFrame[], + judged: {readonly from: number; readonly to: number}, +): {pairs: number; unmatched: number} { + const controlAt = new Map(control.map(frame => [frame.t, frame])); + const inside = tracked.filter( + frame => frame.t >= judged.from && frame.t <= judged.to, + ); + let pairs = 0; + let unmatched = 0; + for (let i = 1; i < inside.length; i += 1) { + const [a, b] = [inside[i - 1], inside[i]]; + if (b.t - a.t >= 2 * DISPLAY_FRAME_MS - CONTROL_READ_MS) { + continue; + } + const [from, to] = [controlAt.get(a.t), controlAt.get(b.t)]; + if (from == null || to == null) { + unmatched += 1; + } else if (from.box?.x !== to.box?.x) { + pairs += 1; + } + } + return {pairs, unmatched}; +} + +/** + * The judged part on the cast's own stamps, from the first frame judged to + * the last. A caller selects the judged frames from its own cast by them. + */ +function judgedOnStamps( + judged: {readonly from: number; readonly to: number}, + dated: { + readonly tracked: readonly TrackedFrame[]; + readonly stampOf: ReadonlyMap; + }, +): {readonly from: number; readonly to: number} { + const inside = dated.tracked.filter( + frame => + frame.box != null && frame.t >= judged.from && frame.t <= judged.to, + ); + const from = dated.stampOf.get(inside.at(0)?.t ?? Number.NaN); + const to = dated.stampOf.get(inside.at(-1)?.t ?? Number.NaN); + return from == null || to == null ? judged : {from, to}; } /** What the control presented in the judged part (see `busyWindowProof`). */ @@ -798,7 +1080,12 @@ export interface BusyProof extends FrameStats { readonly window: BusyWindow; /** * The part of the window judged: inside the margins, up to `settledWithin`, - * and from `onScreenFromMs` when that is set. + * and from `onScreenFromMs` when that is set. On a WebKit cast dated on the + * controls' clock, it runs from the cast's stamp of the first frame judged + * to that of the last. So a caller selects the judged part by its stamps. + * Frames the display-rate keep left out fall inside it too. Only this part + * moves back to the stamps. The summary's times, its stall and its control + * read stay on the controls' clock. */ readonly judged: {readonly from: number; readonly to: number}; /** @@ -1063,6 +1350,22 @@ function motionStepPx(values: readonly number[]): number { * `maxMedianFrameMs`, when given, still applies; `minMovedFraction` and the * absolute stall, frame and motion limits do not. * + * A WebKit cast with a control is judged on the controls' clock, not on its + * stamps. WebKit stamps the frame after a delivery gap about 60 ms late, + * carrying one frame of motion. So each frame takes its time from the + * controls' positions (`controlClock`), the component's and the control's + * alike. A frame with no reading keeps its stamp's distance from the nearest + * frame whose stamp agrees with its reading. The dated frames keep the cast's + * order. Without the control `ahead`, a late frame near the first control's + * turn keeps its lateness. Chromium casts and casts with no control are + * judged on their stamps. + * + * A WebKit proof keeps one dated frame per display interval, the earlier + * one. A frame with no reading always stays. The proof judges motion on every + * kept frame the control moved. Its cast rarely shows two frames one interval + * apart, so it judges no 30 Hz cadence. A note in the summary says so, with + * the count of such pairs. + * * With a control, a run the machine starved of frames proves nothing. So it * throws `FrameStarvationError` with its numbers and returns no verdict. A * starved run has two marks. Its control moved on fewer than @@ -1098,8 +1401,73 @@ export function busyWindowProof( */ export const BUSY_PROOF_LOOKAROUND_MS = 250; -/** The proof `busyWindowProof` returns, and the starvation it throws. */ +/** + * The proof `busyWindowProof` returns, and the starvation it throws. A WebKit + * cast with a control is judged on the controls' clock + * (`datedOnControlClock`); any other cast on its stamps. WebKit stamps the + * frame after a delivery gap about 60 ms late, carrying one frame of motion. + */ function readBusyWindow( + tracked: readonly TrackedFrame[], + window: BusyWindow, + { + ahead, + controlStarts, + ...options + }: NonNullable[2]> & { + /** + * The control started `ahead` (`startBusyControl`), tracked from the same + * screencast (`trackControl` with `ahead`). It dates the WebKit frames + * that fall near the first control's turns, where that control gives no + * time. + */ + ahead?: readonly TrackedFrame[]; + /** + * When the first control started, then the one ahead (each + * `startBusyControl`'s `start`). Without them, each control counts from + * the origin that most of its frames' stamps agree with. + */ + controlStarts?: readonly [number, number?]; + } = {}, +): ReturnType { + const {control} = options; + const dated = + control != null && control.every(frame => frame.engine === 'webkit') + ? datedOnControlClock(tracked, control, ahead, controlStarts) + : null; + if (dated == null) { + return judgeBusyWindow(tracked, window, options); + } + const shown = atDisplayRate(dated); + const read = judgeBusyWindow(shown.tracked, window, { + ...options, + control: shown.control, + }); + // A WebKit cast rarely shows two frames one interval apart, so the proof + // cannot judge a 30 Hz cadence there. It says so, with the count. + const {pairs, unmatched} = displayIntervalPairs( + shown.tracked, + shown.control, + read.proof.judged, + ); + const note = + `cadence not judged: ${pairs} display-interval pairs` + + (unmatched > 0 + ? `, ${unmatched} without a control frame at their time` + : ''); + return { + ...read, + proof: { + ...read.proof, + judged: judgedOnStamps(read.proof.judged, shown), + notes: [...read.proof.notes, note], + summary: `${read.proof.summary}; note: ${note}`, + }, + }; +} + +/** `readBusyWindow` on the frames it judges, dated. */ +function judgeBusyWindow( tracked: readonly TrackedFrame[], window: BusyWindow, { @@ -1903,6 +2271,10 @@ export interface BusyRun { readonly control: readonly TrackedFrame[]; /** The run's block (`blockMainThread`). */ readonly window: BusyWindow; + /** The control started `ahead`, from the same screencast (see `busyWindowProof`). */ + readonly ahead?: readonly TrackedFrame[]; + /** When the run's controls started (see `busyWindowProof`). */ + readonly controlStarts?: readonly [number, number?]; } export interface PooledBusyProof { @@ -1961,7 +2333,11 @@ export function pooledBusyProof( runs: readonly BusyRun[], options: Omit< NonNullable[2]>, - 'control' | 'minMovedFraction' | 'judgeEveryFrame' + | 'control' + | 'ahead' + | 'controlStarts' + | 'minMovedFraction' + | 'judgeEveryFrame' > & { /** The fewest frames a run's control must move on for the run to count. */ minRunFrames?: number; @@ -1989,6 +2365,8 @@ export function pooledBusyProof( const {proof} = readBusyWindow(run.tracked, run.window, { ...windowOptions, control: run.control, + ahead: run.ahead, + controlStarts: run.controlStarts, }); late.push(null); return proof; diff --git a/internal/interaction-harness/src/busy.webkitclock.test.ts b/internal/interaction-harness/src/busy.webkitclock.test.ts new file mode 100644 index 0000000000000..bb01bd1340c96 --- /dev/null +++ b/internal/interaction-harness/src/busy.webkitclock.test.ts @@ -0,0 +1,475 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file busy.webkitclock.test.ts + * @input Synthetic WebKit casts (no browser) + * @output A WebKit busy proof with a control judges each frame at its time on + * the controls' clock. A frame stamped late after a delivery gap passes. A + * half-rate fade, on a full cast or a sparse one, and a frozen fade still + * fail. Near a turn, the control ahead dates a late frame; without it, the + * frame keeps its lateness. A frame with no reading takes its time from a + * neighbour whose stamp agrees with its reading. A measured cast keeps its + * order, no two frames share a time, and its judged part stays on its + * stamps. It keeps one frame per display interval and notes that it judges + * no 30 Hz cadence. Chromium casts and casts with no control get their + * stamps' proof. + * @position Unit test of ./busy's busyWindowProof on WebKit casts + */ + +import {describe, expect, it} from 'vitest'; +import {busyWindowProof, pageTimesOnControls, pooledBusyProof} from './busy'; +import type {TrackedFrame} from './frames'; + +const FRAME = 1000 / 60; +const BLOCK = {from: 1000, to: 1700}; + +/** The control's x at page time `ms`: 240 px one way every 400 ms, from 0. */ +function xAt(ms: number): number { + const phase = ((ms % 800) + 800) % 800; + return phase < 400 ? (phase / 400) * 240 : 240 - ((phase - 400) / 400) * 240; +} + +/** A frame the compositor drew at `at`, which the cast stamped at `stamp`. */ +interface Shot { + readonly at: number; + readonly stamp: number; +} + +/** Frames one display interval apart, each stamped when it was drawn. */ +function onTime(from = 800, to = 1900): Shot[] { + const shots: Shot[] = []; + for (let i = 0; from + i * FRAME <= to; i += 1) { + shots.push({at: from + i * FRAME, stamp: from + i * FRAME}); + } + return shots; +} + +/** + * A delivery gap after frame `k + 1`, as WebKit's cast shows one. The frame + * after frame `k` carries one frame of motion, and the cast stamps it 60 ms + * late. Nothing is drawn for 77 ms. The frames after that are one interval + * apart, each stamped when it was drawn. + */ +function lateAfterGap(k: number): Shot[] { + const shots = onTime().slice(0, k + 1); + const drawn = shots[k].at + FRAME; + shots.push({at: drawn, stamp: drawn + 60}); + return [...shots, ...onTime(drawn + 77)]; +} + +/** + * The frames as the busy proof gets them. The component's value is on x, as + * is each control's position: the first one's, and the one half a leg ahead. + */ +function cast( + shots: readonly Shot[], + value: (at: number) => number, + engine?: 'chromium' | 'webkit', +): {tracked: TrackedFrame[]; control: TrackedFrame[]; ahead: TrackedFrame[]} { + const frame = (stamp: number, x: number, side: number): TrackedFrame => ({ + t: stamp, + box: {x: Math.round(x), y: 0, width: side, height: side}, + pixels: side * side, + ...(engine == null ? {} : {engine}), + }); + return { + tracked: shots.map(({at, stamp}) => frame(stamp, value(at), 1)), + control: shots.map(({at, stamp}) => frame(stamp, xAt(at), 12)), + ahead: shots.map(({at, stamp}) => frame(stamp, xAt(at + 200), 12)), + }; +} + +/** + * A fade from 212 by `perFrame` units each display interval. At 0.6 no frame + * lands half-way between two whole units, so no rounding tie decides a step. + */ +const fade = + (perFrame: number) => + (at: number): number => + 212 - (perFrame * (at - 800)) / FRAME; + +/** The busy proof's failures for these frames, on the x axis. */ +function failures(frames: ReturnType): readonly string[] { + return busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: frames.control, + }).verdict.failures; +} + +describe('busyWindowProof on a WebKit cast with a control', () => { + it("judges a frame stamped late after a delivery gap at its time on the control's clock", () => { + // The late frame was drawn at the block's start, before the judged part. + // On its stamps it falls 60 ms into the judged part, and the fade's step + // there makes a stall that the control does not show. + const shots = lateAfterGap(11); + expect(failures(cast(shots, fade(0.6))).join('\n')).toMatch( + /longest stall 57\.0 ms, longer than the control's 40\.0 ms/, + ); + const webkit = cast(shots, fade(0.6), 'webkit'); + expect(failures(webkit)).toEqual([]); + expect( + busyWindowProof(webkit.tracked, BLOCK, { + axis: 'x', + control: webkit.control, + ahead: webkit.ahead, + }).verdict.failures, + ).toEqual([]); + }); + + it('still fails a fade that changes every other frame', () => { + // An ease-out over 1 s: its late steps are small, so a frame it holds is + // not one cast step of motion. + const eased = (at: number) => { + const progress = Math.min(Math.max((at - 800) / 1000, 0), 1); + return 212 - 120 * (1 - (1 - progress) ** 2); + }; + const halfRate = (at: number) => + eased(800 + 2 * FRAME * Math.floor((at - 800) / (2 * FRAME) + 1e-6)); + expect(failures(cast(onTime(), eased, 'webkit'))).toEqual([]); + expect(failures(cast(onTime(), halfRate, 'webkit')).join('\n')).toMatch( + /moved on \d+ of the 39 frames the control moved.*\n.*longest stall 33\.3 ms, longer than the control's 16\.7 ms/, + ); + }); + + it('still fails a fade that changes every other frame on a sparse cast: pairs of frames 17 ms apart, 75 ms between pairs', () => { + const sparse: Shot[] = []; + for (let at = 800, i = 0; at <= 1900; i += 1) { + sparse.push({at, stamp: at}); + at += i % 2 === 0 ? 17 : 75; + } + const halfRate = (at: number) => + 212 - 6 * Math.floor((at - 800) / (2 * FRAME) + 1e-6); + expect(failures(cast(sparse, fade(3), 'webkit'))).toEqual([]); + for (const engine of [undefined, 'webkit'] as const) { + expect(failures(cast(sparse, halfRate, engine)).join('\n')).toMatch( + /moved on 10 of the 14 frames the control moved/, + ); + } + }); + + it('still fails a fade that freezes', () => { + const frozen = (at: number) => fade(2)(Math.min(at, 1300)); + expect(failures(cast(onTime(), frozen, 'webkit')).join('\n')).toMatch( + /moved on 17 of the 39 frames the control moved.*\n.*longest stall 380\.0 ms, longer than the control's 16\.7 ms/, + ); + }); + + it("dates a late frame near the first control's turn by the control ahead; without it, the frame keeps its lateness", () => { + // After frame 21 the first control is at 220 px, within 36 px of its turn, + // and gives the late frame no time. The control ahead is mid-leg there. + const shots = lateAfterGap(21); + const frames = cast(shots, (at: number) => fade(0.6)(at - 10), 'webkit'); + const summary = (control: TrackedFrame[], ahead?: TrackedFrame[]) => + busyWindowProof(frames.tracked, BLOCK, {axis: 'x', control, ahead}) + .summary; + // Without it, the late frame keeps its stamp's distance from the frame + // after the gap, its nearest dated neighbour: still 60 ms late. + expect(summary(frames.control)).toMatch(/longest stall 93\.0 ms/); + expect(summary(frames.control, frames.ahead)).toMatch( + /longest stall 76\.7 ms/, + ); + }); + + it("dates a frame with no reading from a neighbour whose stamp agrees with its reading, so a late frame's stamp does not hide the gap after it", () => { + // The frame after frame 41 is read at its time but stamped 60 ms late. The + // frames after the 77 ms gap fall near the first control's turn, with no + // reading. Dated from the late frame's stamp, the first of them would come + // 17 ms after it, and the gap would not show. + const frames = cast(lateAfterGap(41), fade(0.6), 'webkit'); + const proof = (ahead?: TrackedFrame[]) => + busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: frames.control, + ahead, + }); + // The control ahead reads those frames, so their times are known. + expect(proof().summary).toBe(proof(frames.ahead).summary); + expect(proof().verdict.failures.join('\n')).toMatch( + /longest stall 93\.3 ms, longer than the control's 76\.7 ms/, + ); + }); + + it("keeps a measured cast's order: its dated frames never swap, so a linear motion stays monotonic", () => { + // One WebKit cast of a 700 ms block over a linear transition, under load. + // Each row is [stamp in ms from the block's start, control x, box y]. + // Near the control's turns, frames with no reading take their times from + // agreeing stamps whose lateness differs. + const measured: readonly (readonly [number, number, number])[] = [ + [-294.1, 5, 40], + [-276.5, 28, 40], + [-230.0, 38, 40], + [-196.5, 66, 40], + [-179.3, 86, 40], + [-129.1, 96, 40], + [-95.2, 126, 58], + [-77.9, 146, 70], + [-2.3, 157, 76], + [14.7, 203, 104], + [100.6, 213, 110], + [118.2, 218, 140], + [170.3, 206, 146], + [204.5, 176, 164], + [222.1, 155, 176], + [270.8, 145, 182], + [304.4, 114, 198], + [363.4, 94, 210], + [400.7, 60, 232], + [418.4, 38, 244], + [499.7, 27, 250], + [516.8, 24, 278], + [568.9, 34, 284], + [604.2, 65, 304], + [621.7, 87, 316], + [669.9, 97, 322], + [704.8, 126, 338], + [765.3, 146, 350], + [817.6, 204, 384], + [870.1, 215, 390], + [904.0, 236, 408], + [921.6, 216, 420], + [970.1, 205, 426], + [1002.0, 176, 444], + [1018.9, 157, 454], + [1066.8, 146, 460], + [1098.7, 118, 478], + [1116.4, 98, 488], + [1166.9, 88, 494], + [1198.5, 58, 512], + [1216.0, 39, 524], + [1263.5, 28, 530], + [1298.4, 2, 546], + [1315.6, 23, 558], + [1365.0, 33, 564], + [1403.0, 62, 582], + [1419.8, 86, 596], + [1469.1, 96, 598], + [1502.2, 125, 598], + [1519.5, 145, 598], + [1567.9, 156, 600], + [1600.8, 185, 600], + ]; + const measuredCast = (engine?: 'webkit') => ({ + tracked: measured.map(([t, , y]): TrackedFrame => ({ + t, + box: {x: 20, y, width: 80, height: 80}, + pixels: 6400, + step: {x: 2, y: 2}, + ...(engine == null ? {} : {engine}), + })), + control: measured.map(([t, x]): TrackedFrame => ({ + t, + box: {x, y: 0, width: 12, height: 12}, + pixels: 144, + ...(engine == null ? {} : {engine}), + })), + }); + const window = {from: 0, to: 700}; + const stamps = measuredCast(); + const onStamps = busyWindowProof(stamps.tracked, window, { + axis: 'y', + control: stamps.control, + }); + expect(onStamps.monotonic, onStamps.summary).toBe(true); + const webkit = measuredCast('webkit'); + const dated = busyWindowProof(webkit.tracked, window, { + axis: 'y', + control: webkit.control, + }); + expect(dated.monotonic, dated.summary).toBe(true); + expect(dated.verdict.failures, dated.summary).toEqual([]); + }); + + it('gives each dated frame its own time: a held frame that the order rule moves up to a moved one still reads as a stall', () => { + // From frame 28 on, the cast stamps every frame 19 ms later. Frames 21 to + // 27 are near the control's turn, with no reading. Frame 25 takes its time + // from frame 28's lateness and would come before frame 24, so the order + // rule moves it up. The component holds on frame 25 while the control moves. + const shots = onTime().map((shot, i) => + i >= 28 ? {...shot, stamp: shot.stamp + 19} : shot, + ); + const linear = (at: number) => 20 + (6 * (at - 800)) / FRAME; + const heldAt = 800 + 25 * FRAME; + const held = (at: number) => + linear(Math.abs(at - heldAt) < 1 ? heldAt - FRAME : at); + expect(failures(cast(shots, linear, 'webkit'))).toEqual([]); + expect(failures(cast(shots, held))).not.toEqual([]); + expect(failures(cast(shots, held, 'webkit')).join('\n')).toMatch( + /moved on 3\d of the 3\d frames the control moved/, + ); + }); + + it('gives two frames of a late burst different times, so a held frame clamped to the next reading still reads as a stall', () => { + // Frames 27 to 31 come in a burst, stamped 50, 55, 40, 25 and 10 ms late. + // Frame 27 is the last near the control's turn, with no reading. It takes + // its time from frame 30, whose stamp agrees with its reading, and would + // come after frame 28. So the order rule moves it to just before frame 28. + // The component holds on frame 27 while the control moves. + const late = [50, 55, 40, 25, 10]; + const shots = onTime().map((shot, i) => + i >= 27 && i <= 31 ? {...shot, stamp: shot.stamp + late[i - 27]} : shot, + ); + const linear = (at: number) => 20 + (6 * (at - 800)) / FRAME; + const heldAt = 800 + 27 * FRAME; + const held = (at: number) => + linear(Math.abs(at - heldAt) < 1 ? heldAt - FRAME : at); + expect(failures(cast(shots, linear, 'webkit'))).toEqual([]); + expect(failures(cast(shots, held, 'webkit'))[0]).toMatch( + /^moved on 39 of the 40 frames the control moved/, + ); + }); + + it("reports the judged part on the cast's stamps, so a caller's own frames select exactly the frames judged", () => { + // The late frame was drawn before the judged part, and its stamp falls + // inside it. The proof leaves it out on the controls' clock, so a + // selection by the judged part on the stamps must leave it out too. + const webkit = cast(lateAfterGap(11), fade(0.6), 'webkit'); + const proof = busyWindowProof(webkit.tracked, BLOCK, { + axis: 'x', + control: webkit.control, + }); + const selected = webkit.tracked.filter( + frame => + frame.box != null && + frame.t >= proof.judged.from && + frame.t <= proof.judged.to, + ); + expect(proof.summary).toContain(`: ${selected.length} frames presented`); + }); + + it('keeps one frame per display interval: a frame drawn 6.6 to 8.4 ms after another shows no new step and is left out', () => { + // A curve of 4 px a frame in 2 px steps: two steps, so no early-frame + // leniency applies. Every fourth frame in the block has a second frame + // drawn 6.6 or 8.4 ms after it, still at its step. + const shots = onTime().flatMap((shot, i) => { + if (shot.at <= 1000 || shot.at >= 1650 || i % 4 !== 0) { + return [shot]; + } + const after = i % 8 === 0 ? 6.6 : 8.4; + return [shot, {at: shot.at + after, stamp: shot.stamp + after}]; + }); + const stepped = (at: number) => + 20 + 4 * Math.floor((at - 800) / FRAME + 1e-6); + const frames = cast(shots, stepped, 'webkit'); + const proof = (ahead?: TrackedFrame[]) => + busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: frames.control, + ahead, + }).verdict.failures; + // The control ahead reads the frames near the first control's turns. + expect(proof(frames.ahead)).toEqual([]); + // Without it, the second frame at a turn has no reading. It stays, and + // still reads as held. + expect(proof().join('\n')).toMatch(/moved on \d+ of the \d+ frames/); + }); + + it('judges no 30 Hz cadence on a sparse cast and says so: a fade that changes every other interval passes, with the count of pairs in a note', () => { + // Frames two display intervals apart. A fade that changes every other + // interval moves on every frame shown, so only the note can tell. + const sparse = onTime().filter((_, i) => i % 2 === 0); + const halfRate = (at: number) => + 212 - 1.2 * Math.floor((at - 800) / (2 * FRAME) + 1e-6); + const proof = (value: (at: number) => number) => { + const frames = cast(sparse, value, 'webkit'); + return busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: frames.control, + }); + }; + expect(proof(halfRate).verdict.failures).toEqual([]); + expect(proof(halfRate).summary).toMatch( + /; note: cadence not judged: 0 display-interval pairs$/, + ); + const frozen = proof(at => halfRate(Math.min(at, 1300))); + expect(frozen.verdict.failures).not.toEqual([]); + expect(frozen.summary).toMatch(/cadence not judged: 0 display-interval/); + }); + + it('on a dense cast, a fade that changes every other interval still fails in both engines; only WebKit carries the cadence note', () => { + const eased = (at: number) => { + const progress = Math.min(Math.max((at - 800) / 1000, 0), 1); + return 212 - 120 * (1 - (1 - progress) ** 2); + }; + const halfRate = (at: number) => + eased(800 + 2 * FRAME * Math.floor((at - 800) / (2 * FRAME) + 1e-6)); + const proof = (engine?: 'webkit') => { + const frames = cast(onTime(), halfRate, engine); + return busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: frames.control, + }); + }; + expect(proof('webkit').verdict.failures).not.toEqual([]); + expect(proof('webkit').summary).toMatch( + /cadence not judged: [1-9]\d* display-interval pairs/, + ); + expect(proof().verdict.failures).not.toEqual([]); + expect(proof().summary).not.toContain('cadence not judged'); + }); + + it('matches each frame to its control frame by time, and names the pairs that have none', () => { + const frames = cast(onTime(), fade(0.6), 'webkit'); + const control = frames.control.filter((_, i) => i % 7 !== 3); + const proof = busyWindowProof(frames.tracked, BLOCK, {axis: 'x', control}); + expect(proof.summary).toMatch( + /cadence not judged: \d+ display-interval pairs, [1-9]\d* without a control frame at their time$/, + ); + }); + + it("carries the cadence note in each run's own summary in a pooled proof", () => { + const runs = [0.6, 0.9].map(perFrame => { + const frames = cast(onTime(), fade(perFrame), 'webkit'); + return {tracked: frames.tracked, control: frames.control, window: BLOCK}; + }); + const pooled = pooledBusyProof(runs, {axis: 'x'}); + expect(pooled.summary).toMatch( + /; run 1: [^;]*(;[^;]*)*?; note: cadence not judged: \d+ display-interval pairs; run 2: .*; note: cadence not judged: \d+ display-interval pairs$/, + ); + }); + + it('gives each control frame with a reading its own time on the page clock, and none to a frame near a turn', () => { + // The frame after frame 11 is drawn one interval later, stamped 60 ms + // late: it reads at its drawn time. + const gap = cast(lateAfterGap(11), fade(0.6), 'webkit'); + const late = lateAfterGap(11)[12]; + expect(pageTimesOnControls(gap.control).get(late.stamp)).toBeCloseTo( + late.at, + 0, + ); + // At 1200 ms the first control turns, and only the one ahead reads it. + const shots = onTime(); + const frames = cast(shots, fade(0.6), 'webkit'); + const turn = shots[24].stamp; + expect(pageTimesOnControls(frames.control).has(turn)).toBe(false); + expect( + pageTimesOnControls(frames.control, {ahead: frames.ahead}).get(turn), + ).toBeCloseTo(turn, 0); + }); + + it("dates the frames from the controls' starts as from the origin their stamps agree with", () => { + const frames = cast(lateAfterGap(11), fade(0.6), 'webkit'); + const proof = (controlStarts?: readonly [number, number?]) => + busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: frames.control, + ahead: frames.ahead, + controlStarts, + }); + expect(proof([0, -200]).summary).toBe(proof().summary); + expect(proof([0, -200]).verdict.failures).toEqual([]); + }); + + it('judges a Chromium cast and a WebKit cast with no control on their stamps, as before', () => { + const shots = lateAfterGap(11); + const stamped = cast(shots, fade(0.6)); + const chromium = cast(shots, fade(0.6), 'chromium'); + const webkit = cast(shots, fade(0.6), 'webkit'); + const proof = (frames: ReturnType, control: boolean) => + busyWindowProof(frames.tracked, BLOCK, { + axis: 'x', + control: control ? frames.control : undefined, + }); + expect(proof(chromium, true)).toEqual(proof(stamped, true)); + expect(proof(webkit, false)).toEqual(proof(stamped, false)); + }); +}); diff --git a/internal/interaction-harness/src/frames.ts b/internal/interaction-harness/src/frames.ts index a03e28a2ed9fd..7c158b399799e 100644 --- a/internal/interaction-harness/src/frames.ts +++ b/internal/interaction-harness/src/frames.ts @@ -39,6 +39,8 @@ export interface CastFrame { /** CSS px of the viewport the frame shows. */ readonly viewportWidth: number; readonly viewportHeight: number; + /** The engine whose screencast made the frame (`startScreencast`). */ + readonly engine?: 'chromium' | 'webkit'; } export interface Screencast { @@ -115,6 +117,7 @@ async function chromiumScreencast( format, viewportWidth: frame.metadata.deviceWidth || viewport.width, viewportHeight: frame.metadata.deviceHeight || viewport.height, + engine: 'chromium', }); cdp .send('Page.screencastFrameAck', {sessionId: frame.sessionId}) @@ -167,6 +170,7 @@ async function webkitScreencast( format: 'jpeg', viewportWidth: viewport.width, viewportHeight: viewport.height, + engine: 'webkit', }); session.send('Screencast.screencastFrameAck', {generation}).catch(() => {}); }; @@ -386,6 +390,8 @@ export interface TrackedFrame { * `SUBPIXEL_STEP_PX`, whatever the color. */ readonly step?: {readonly x: number; readonly y: number}; + /** The engine of the cast frame it was read from (`CastFrame.engine`). */ + readonly engine?: 'chromium' | 'webkit'; } /** @@ -699,9 +705,10 @@ export async function trackMarkers( const block = isDefaultMarker.some(Boolean) ? colorBlockPx(batch[k].data, batch[k].format) : null; + const {engine} = batch[k]; colors.forEach((_, c) => { - out[c].push( - subpixel + out[c].push({ + ...(subpixel ? { t: batch[k].t, box: result.boxes[c], @@ -722,8 +729,9 @@ export async function trackMarkers( t: batch[k].t, box: result.boxes[c], pixels: result.pixels[c], - }, - ); + }), + ...(engine == null ? {} : {engine}), + }); }); }); } diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index c94d17cdd7ea6..168b830c668f2 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -1891,6 +1891,70 @@ test.describe('frame evidence', () => { await first.remove(); }); + test('a busy proof given the control ahead judges a compositor animation through a blocked main thread, and a rAF one still freezes', async ({ + page, + }, testInfo) => { + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + // Opted in: the control ahead dates the frames near the first one's turns. + const first = await startBusyControl(page); + const ahead = await startBusyControl(page, {ahead: true}); + const run = async (driver: 'compositor' | 'raf') => { + const cast = await startScreencast(page); + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1100, easing: 'linear'}, + ); + return; + } + const start = performance.now(); + const step = (now: number) => { + const progress = Math.min(1, (now - start) / 1100); + el.style.transform = `translateY(${560 * progress}px)`; + if (progress < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + }, driver); + // A rendering update first, so the compositor drops the last run's + // animation before the block. + await page.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const window = await blockMainThread( + page, + 700, + driver === 'compositor' ? {during: box} : {}, + ); + await page.waitForTimeout(200); + const castFrames = await cast.stop(); + return busyWindowProof(await trackMarker(page, castFrames), window, { + axis: 'y', + control: await trackControl(page, castFrames), + ahead: await trackControl(page, castFrames, {ahead: true}), + controlStarts: [first.start, ahead.start], + }); + }; + const compositor = await run('compositor'); + testInfo.annotations.push({ + type: 'busy proof with the control ahead', + description: compositor.summary, + }); + expect(compositor.verdict.failures, compositor.summary).toEqual([]); + const raf = await run('raf'); + expect(raf.verdict.failures, raf.summary).not.toEqual([]); + await first.remove(); + }); + test('a hold that starts before the block is judged from its first presented change inside it; a single change and then a freeze still fails', async ({ page, }) => { diff --git a/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts b/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts index 3535248573e14..3a596bbf5d70c 100644 --- a/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts +++ b/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts @@ -29,7 +29,6 @@ import { blockMainThread, compareCurve, cubicBezier, - controlClock, countBetween, advanceFrozenPage, drag, @@ -57,6 +56,7 @@ import { waitForRest, withStorybook, writeEvidence, + pageTimesOnControls, pooledBusyProof, FrameStarvationError, trackBrightness, @@ -2232,20 +2232,6 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', */ const WHOLE_MARKER_PX = Math.ceil((MARKER_PX * THUMB_OFF) / THUMB_ON) - 2; - /** How long each busy-window control takes to cross and come back, ms. */ - const CONTROL_CYCLE_MS = 800; - /** - * How far each control travels in a leg, px: the harness's control, as - * `CONTROL_CYCLE_MS` is its cycle. - */ - const CONTROL_TRAVEL_PX = 240; - /** - * How long a control takes to travel one px, ms. The controls' clock reads - * a frame's time from the control's position, to the px. So two frames one - * display interval apart can read this much closer. - */ - const CONTROL_READ_MS = CONTROL_CYCLE_MS / 2 / CONTROL_TRAVEL_PX; - /** One 60 Hz frame, ms. */ const FRAME_60_MS = 1000 / 60; @@ -2356,93 +2342,6 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', return [first.start, ahead.start]; } - /** - * Each cast frame's time on the page clock, read from the two controls' - * positions (`startControls`, tracked with `trackControl`). The compositor - * samples every animation at one time in a frame, so the controls give the - * frame's own time. The cast's stamp can run late: WebKit stamps a frame - * that follows a delivery gap tens of ms after the frame it shows. A frame - * that shows neither control mid-leg, or repeats the frame before it, gets - * null. - */ - function controlPageTime( - control: ReadonlyArray, - ahead: ReadonlyArray, - starts: [number, number], - ): (stamp: number) => number | null { - const readers = [ - {clock: controlClock(control), origin: starts[0]}, - {clock: controlClock(ahead), origin: starts[1]}, - ]; - return stamp => { - for (const {clock, origin} of readers) { - const ms = clock.get(stamp); - if (ms != null) { - // The stamp only picks the control's cycle. - const cycles = Math.round((stamp - origin - ms) / CONTROL_CYCLE_MS); - return origin + ms + cycles * CONTROL_CYCLE_MS; - } - } - return null; - }; - } - - /** `controlPageTime` for a cast, with the first control's own frames. */ - async function castPageTime( - page: Page, - castFrames: Parameters[1], - starts: [number, number], - ): Promise<{ - pageTime: (stamp: number) => number | null; - control: TrackedFrame[]; - }> { - const control = await trackControl(page, castFrames); - const ahead = await trackControl(page, castFrames, {ahead: true}); - return {pageTime: controlPageTime(control, ahead, starts), control}; - } - - /** - * The frames shown at display rate: at most one each 60 Hz interval on the - * page clock (`castPageTime`), read to the px (`CONTROL_READ_MS`). A frame - * under one interval after the last one kept is left out. WebKit's - * compositor can draw two frames closer than that, and a slow curve then - * shows no step between them. Each pair of kept frames one interval apart - * stays a pair. - */ - function atDisplayRate(frames: ReadonlyArray): T[] { - const kept: T[] = []; - for (const frame of frames) { - const last = kept.at(-1); - if (last == null || frame.t - last.t >= FRAME_60_MS - CONTROL_READ_MS) { - kept.push(frame); - } - } - return kept; - } - - /** - * How many pairs of a run's frames in its judged part are one display - * interval apart (`atDisplayRate`), with the control moved between them. A - * fade that holds every other frame shows no step on such a pair. - */ - function displayIntervalPairs( - run: BusyRun, - judged: {from: number; to: number}, - ): number { - let pairs = 0; - for (let i = 1; i < run.tracked.length; i++) { - const [a, b] = [run.tracked[i - 1], run.tracked[i]]; - const inJudged = a.t >= judged.from && b.t <= judged.to; - const oneInterval = b.t - a.t < 2 * FRAME_60_MS - CONTROL_READ_MS; - const controlMoved = - run.control[i]?.box?.x !== run.control[i - 1]?.box?.x; - if (inJudged && oneInterval && controlMoved) { - pairs++; - } - } - return pairs; - } - /** * The thumb's center in each presented frame, on the page clock, `[ms, * px]`. The two controls time each frame by the compositor's own @@ -2460,7 +2359,10 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', castFrames: Parameters[1], starts: [number, number], ): Promise { - const {pageTime} = await castPageTime(page, castFrames, starts); + const pageTime = pageTimesOnControls(await trackControl(page, castFrames), { + ahead: await trackControl(page, castFrames, {ahead: true}), + controlStarts: starts, + }); const tracked = await trackMarker(page, castFrames); const shown = tracked.filter( (f): f is typeof f & {box: NonNullable} => @@ -2476,7 +2378,7 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', dropsBefore += 1; continue; } - const at = pageTime(f.t); + const at = pageTime.get(f.t); if (at != null) { clocked.push([at, f.box.x + f.box.width / 2, dropsBefore]); dropsBefore = 0; @@ -3331,7 +3233,6 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', test('P10 · the track colour keeps fading in while the main thread is blocked', async ({ page, - browserName, }, testInfo) => { const { driver, @@ -3401,30 +3302,31 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', f.t >= block.from - BUSY_PROOF_LOOKAROUND_MS && f.t <= block.to + BUSY_PROOF_LOOKAROUND_MS, ); - // The red channel's mean in an 8 px square: a scalar on x. Each frame - // is judged at its time on the controls' clock, not its cast stamp, and - // at display rate. - const {pageTime, control} = await castPageTime(page, read, starts); - const dated = (frames: ReadonlyArray): T[] => - atDisplayRate( - frames - .flatMap(frame => { - const at = pageTime(frame.t); - return at == null ? [] : [{...frame, t: at}]; - }) - .sort((a, b) => a.t - b.t), - ); - const tracked = dated( - await trackBrightness(page, read, { - ...probe, - size: 8, - }), - ); + // The red channel's mean in an 8 px square: a scalar on x. With both + // controls, a WebKit proof judges each frame at its time on the + // controls' clock, at display rate. + const control = await trackControl(page, read); + const ahead = await trackControl(page, read, {ahead: true}); + const colour = await trackBrightness(page, read, {...probe, size: 8}); runs.push({ - tracked, - control: dated(control), + tracked: colour, + control, + ahead, + controlStarts: starts, window: {from: block.from, to: Math.min(block.to, fade.to)}, }); + // The landing reads each frame at its own time on the controls' clock. + // A frame with no reading there is left out of it. + const pageTime = pageTimesOnControls(control, { + ahead, + controlStarts: starts, + }); + const tracked = colour + .flatMap(frame => { + const at = pageTime.get(frame.t); + return at == null ? [] : [{...frame, t: at}]; + }) + .sort((a, b) => a.t - b.t); evidence.push({ on, block, @@ -3475,21 +3377,6 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', type: 'busy window', description: proof.summary, }); - // WebKit's cast gives few pairs of frames one display interval apart. - // So a fade that holds every other frame can still show a step on each - // frame judged there. This run does not judge that cadence, and says so. - if (browserName === 'webkit') { - const pairs = runs.reduce((count, run, i) => { - const judged = proof.runs[i]?.judged; - return judged == null - ? count - : count + displayIntervalPairs(run, judged); - }, 0); - test.info().annotations.push({ - type: 'cadence', - description: `cadence not judged: ${pairs} display-interval pairs`, - }); - } // The colour changes on every frame the control moves on, and in each run // it stalls no longer than the control. expect(proof.verdict.failures, proof.summary).toEqual([]); @@ -3523,76 +3410,59 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', test("P10 · on two WebKit casts that stamped frames late, the track colour proof judges each frame at its time on the control's clock, at display rate", () => { WEBKIT_LATE_STAMPS.forEach((recorded, cast) => { - const stamped: BusyRun[] = recorded.map(run => ({ - tracked: run.colour.map(([t, red]) => ({ - t, - box: {x: red, y: 0, width: 1, height: 1}, - pixels: 1, - })), - control: run.control.map(([t, x]) => ({ - t, - box: - x == null - ? null - : {x, y: 0, width: CONTROL_SIDE, height: CONTROL_SIDE}, - pixels: x == null ? 0 : CONTROL_SIDE * CONTROL_SIDE, - })), - window: run.window, - })); - const onClock = (run: BusyRun, displayRate: boolean): BusyRun => { - // The cast recorded one control and not its start: the start is the - // one that most frames' stamps agree with. - const offsets = [...controlClock(run.control).entries()].map( - ([stamp, ms]) => stamp - ms, - ); - const unwrapped = offsets - .map( - offset => - offset - - Math.round((offset - offsets[0]) / CONTROL_CYCLE_MS) * - CONTROL_CYCLE_MS, - ) - .sort((a, b) => a - b); - const start = unwrapped[Math.floor(unwrapped.length / 2)]; - const pageTime = controlPageTime(run.control, [], [start, start]); - const dated = ( - frames: ReadonlyArray, - ): T[] => { - const timed = frames + // Tagged as WebKit's frames, the harness dates them. The cast recorded + // one control and not its start, so the harness counts from the origin + // its stamps agree with. + const runs = (engine?: 'webkit'): BusyRun[] => + recorded.map(run => ({ + tracked: run.colour.map(([t, red]) => ({ + t, + box: {x: red, y: 0, width: 1, height: 1}, + pixels: 1, + ...(engine == null ? {} : {engine}), + })), + control: run.control.map(([t, x]) => ({ + t, + box: + x == null + ? null + : {x, y: 0, width: CONTROL_SIDE, height: CONTROL_SIDE}, + pixels: x == null ? 0 : CONTROL_SIDE * CONTROL_SIDE, + ...(engine == null ? {} : {engine}), + })), + window: run.window, + })); + // By the cast's stamps, as each run was judged live: it fails. + expect( + pooledBusyProof(runs(), FADE_PROOF).verdict.failures, + `cast ${cast + 1}, by its stamps`, + ).not.toEqual([]); + // At each frame's own time on the control's clock, every frame kept, it + // still fails. A frame drawn 6 to 9 ms after the one before shows no + // step of the fade's slow tail. + const onReadings = runs().map(run => { + const pageTime = pageTimesOnControls(run.control); + const timed = (frames: ReadonlyArray): TrackedFrame[] => + frames .flatMap(frame => { - const at = pageTime(frame.t); + const at = pageTime.get(frame.t); return at == null ? [] : [{...frame, t: at}]; }) .sort((a, b) => a.t - b.t); - return displayRate ? atDisplayRate(timed) : timed; - }; return { ...run, - tracked: dated(run.tracked), - control: dated(run.control), + tracked: timed(run.tracked), + control: timed(run.control), }; - }; - // By the cast's stamps, as each run was judged live: it fails. - expect( - pooledBusyProof(stamped, FADE_PROOF).verdict.failures, - `cast ${cast + 1}, by its stamps`, - ).not.toEqual([]); - // On the control's clock alone it still fails. A frame drawn 6 to 9 ms - // after the one before shows no step of the fade's slow tail. + }); expect( - pooledBusyProof( - stamped.map(run => onClock(run, false)), - FADE_PROOF, - ).verdict.failures, + pooledBusyProof(onReadings, FADE_PROOF).verdict.failures, `cast ${cast + 1}, on the control's clock`, ).not.toEqual([]); - const proof = pooledBusyProof( - stamped.map(run => onClock(run, true)), - FADE_PROOF, - ); + const proof = pooledBusyProof(runs('webkit'), FADE_PROOF); expect( proof.verdict.failures, - `cast ${cast + 1}, at display rate: ${proof.summary}`, + `cast ${cast + 1}, dated by the harness: ${proof.summary}`, ).toEqual([]); }); }); diff --git a/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts b/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts index 2648b13fbeb15..bbedfbd9c759f 100644 --- a/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts +++ b/packages/core/src/motion/__tests__/animator.interaction.browser.spec.ts @@ -30,6 +30,7 @@ import { controlClock, countBetween, markElement, + pageTimesOnControls, startBusyControl, startScreencast, traceRendering, @@ -186,31 +187,29 @@ test('a spring keeps advancing every frame through a 1 s main-thread block, on i // main-thread animation never does; see the next test). expect(proof.verdict.failures, proof.summary).toEqual([]); expect(proof.monotonic).toBe(true); - // And it is the spring. Each control moves 240 px out and back every 800 - // ms, linearly, on the compositor. Its start plus its own time places each - // frame on the page clock (controlClock). The frame's stamp only picks the - // whole cycle, so a late stamp moves no frame. controlClock reads no frame - // within 36 px of a turn, 120 ms of every 400. The second control is - // mid-leg there, half a leg ahead, so it reads the frames the first cannot. + // And it is the spring. Each frame's time on the page clock comes from the + // two controls' positions (`pageTimesOnControls`), so a late stamp moves no + // frame. The first control reads no frame within 36 px of a turn, 120 ms + // of every 400. The second control is mid-leg there, half a leg ahead, so + // it reads the frames the first cannot. const inside = tracked.filter( f => f.box != null && f.t > busy.from + 20 && f.t < busy.to - 20, ); - const clocks = [ - {clock: controlClock(control), origin: controlStart}, - {clock: controlClock(second), origin: secondStart}, - ]; + const pageTime = pageTimesOnControls(control, { + ahead: second, + controlStarts: [controlStart, secondStart], + }); + const byFirst = controlClock(control); const timed = inside.flatMap(f => { - const reader = clocks.find(({clock}) => clock.has(f.t)); - if (reader == null) { + const at = pageTime.get(f.t); + if (at == null) { return []; } - const ms = reader.clock.get(f.t) ?? NaN; - const cycles = Math.round((f.t - reader.origin - ms) / 800); return [ { x: (f.box?.x ?? NaN) - MARKER_INSET, - at: reader.origin + ms + cycles * 800 - start, - bySecond: reader !== clocks[0], + at: at - start, + bySecond: !byFirst.has(f.t), }, ]; }); @@ -904,18 +903,15 @@ for (const how of ['in a task', 'in a frame callback of the page'] as const) { const second = await trackControl(page, frames, {ahead: true}); // Each frame on the page clock, read from the two controls, as in the 1 s // block above. - const clocks = [ - {clock: controlClock(control), origin: controlStart}, - {clock: controlClock(second), origin: secondStart}, - ]; + const pageTime = pageTimesOnControls(control, { + ahead: second, + controlStarts: [controlStart, secondStart], + }); const timed = tracked.flatMap(f => { - const reader = clocks.find(({clock}) => clock.has(f.t)); - if (f.box == null || reader == null) { + const at = pageTime.get(f.t); + if (f.box == null || at == null) { return []; } - const ms = reader.clock.get(f.t) ?? NaN; - const cycles = Math.round((f.t - reader.origin - ms) / 800); - const at = reader.origin + ms + cycles * 800; return at >= run.call - 50 && at <= run.end + 150 ? [{x: f.box.x - MARKER_INSET, at}] : [];