diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 0166202d9ec44..4cce8060a77da 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -278,7 +278,11 @@ a just-started compositor animation late (a sheet's release held on screen for late on a loaded runner, too late to wait on before blocking. Only a hold that starts before the block, on two frames at least, at the position the page last set before the block is excused. Pass that position as `setBeforeBlock`, such -as the drag's last position at the release. An animation frozen at the block's +as the drag's last position at the release. Without it, a block with `during` +takes the position on the cast's last frame before the caught animation's +start. Chromium on a loaded runner presents a just-started compositor +animation late too: a slider's coast first showed 25–111 ms after its start. +An animation frozen at the block's start holds a position of its own, and is judged as a stall. Every frame after its first change is judged, so a freeze later in the block still fails. An element off screen before the block, such as a card that enters from an diff --git a/internal/interaction-harness/src/busy.control.test.ts b/internal/interaction-harness/src/busy.control.test.ts index a17129f332db2..467a408cfb676 100644 --- a/internal/interaction-harness/src/busy.control.test.ts +++ b/internal/interaction-harness/src/busy.control.test.ts @@ -1048,4 +1048,142 @@ describe('a hold that starts before the block', () => { expect(proof.verdict.pass, proof.summary).toBe(false); expect(proof.onScreenFromMs).toBeNull(); }); + + // A window that caught the animation (`blockMainThread`'s `during`) knows + // its start. The position on the cast's last frame before that start then + // stands for the position the page set. + const caught = { + ...BLOCK, + animation: {from: holdFrom, to: BLOCK.to + 500, holds: true}, + }; + + it('takes the position from the last frame before the caught animation starts when no position is given', () => { + const proof = busyWindowProof(sheet(), caught, {axis: 'y', control}); + const named = busyWindowProof(sheet(), caught, { + axis: 'y', + control, + setBeforeBlock: released, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.onScreenFromMs).toBeGreaterThanOrEqual(100); + expect(proof.summary).toBe(named.summary); + }); + + it("keeps the position the page set over the caught animation's start", () => { + const proof = busyWindowProof(sheet(), caught, { + axis: 'y', + control, + setBeforeBlock: released + 6, + }); + expect(proof.verdict.pass, proof.summary).toBe(false); + expect(proof.onScreenFromMs).toBeNull(); + }); + + it('still fails a main-thread animation held at its start until after the block', () => { + let y = 0; + const frames = moving().map(f => { + if (f.t < holdFrom || f.t >= BLOCK.to + 50) { + y += 3; + } + return {...f, box: {...f.box!, y}} as TrackedFrame; + }); + const proof = busyWindowProof(frames, caught, {axis: 'y', control}); + expect(proof.verdict.pass, proof.summary).toBe(false); + expect(proof.onScreenFromMs).toBeNull(); + }); + + it('does not excuse a hold at a position the animation moved to after its start', () => { + // Moving for 60 ms after the caught animation's start, then held there + // until 100 ms into the block, then moving on every frame. + let y = 0; + const frames = moving().map(f => { + if (f.t < holdFrom + 60 || f.t >= holdTo) { + y += 3; + } + return {...f, box: {...f.box!, y}} as TrackedFrame; + }); + const proof = busyWindowProof(frames, caught, {axis: 'y', control}); + expect(proof.verdict.pass, proof.summary).toBe(false); + expect(proof.onScreenFromMs).toBeNull(); + }); + + it("judges a recorded slider coast the same with the caught animation's start as with the position the page set", () => { + // Recorded in Chromium on a loaded runner: SliderTouch's coast through a + // 400 ms block, with the busy control. Each row is [ms from the block's + // start, thumb x, control x] on one cast frame. The thumb holds at its + // release position, 163 px, from before the coast's start. Here the hold + // is extended through the second judged frame, as a late hand-off can be. + const recorded: ReadonlyArray = [ + [-222.1, 156, 130], + [-188.6, 156, 150], + [-179, 156, 160], + [-153.7, 163, 180], + [-144.6, 163, 190], + [-122.4, 163, 200], + [-86.2, 163, 210], + [-85.6, 163, 220], + [-45.2, 163, 240], + [-29.8, 163, 230], + [-24.1, 163, 220], + [15.2, 206, 190], + [42.1, 214, 180], + [49.2, 220, 170], + [66.8, 225, 160], + [93.7, 229, 150], + [135.3, 232, 140], + [139, 236, 130], + [171.4, 241, 100], + [186.5, 243, 90], + [210.6, 244, 80], + [231.8, 245, 60], + [259.7, 246, 50], + [279.9, 246, 40], + [287.7, 247, 30], + [318.4, 247, 20], + ]; + const from = 10_000; + const coastStart = from - 74.7; + const holdUntil = from + 49.2; + const thumb = recorded.map( + ([t, x]) => + ({ + t: from + t, + box: { + x: from + t > coastStart && from + t <= holdUntil ? 163 : x, + y: 0, + width: 28, + height: 28, + }, + pixels: 400, + step: {x: 1, y: 1}, + }) as TrackedFrame, + ); + const slider = recorded.map( + ([t, , x]) => + ({ + t: from + t, + box: {x, y: 0, width: 12, height: 12}, + pixels: 100, + }) as TrackedFrame, + ); + const window = { + from, + to: from + 400, + animation: {from: coastStart, to: coastStart + 564.3, holds: true}, + }; + const options = { + axis: 'x', + settledWithinSteps: 2, + control: slider, + } as const; + const byStart = busyWindowProof(thumb, window, options); + const named = busyWindowProof(thumb, window, { + ...options, + setBeforeBlock: 163, + }); + const plain = busyWindowProof(thumb, {from, to: from + 400}, options); + expect(byStart.verdict.failures, byStart.summary).toEqual([]); + expect(byStart.summary).toBe(named.summary); + expect(plain.verdict.pass, plain.summary).toBe(false); + }); }); diff --git a/internal/interaction-harness/src/busy.ts b/internal/interaction-harness/src/busy.ts index ff21f0e52fac7..614813d146bed 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -1797,10 +1797,13 @@ function motionStepPx(values: readonly number[]): number { * at the first presented change inside it: WebKit presents a just-started * compositor animation late (a sheet's release held on screen for 130 to * 300 ms while its timeline ran, and the screencast reaches Node about 0.7 s - * late on a loaded runner, too late to wait on before blocking). Only a hold + * late on a loaded runner, too late to wait on before blocking). Chromium on + * a loaded runner does too. A slider's coast first showed 25–111 ms after + * its start (median 57 ms), with a catch-up on that first frame. Only a hold * at `setBeforeBlock`, the value the page itself last set before the block, - * is excused. Every frame after that first change is judged, so a freeze - * later in the block fails. + * is excused. Without it, a window with a caught animation takes the value on + * the cast's last frame before that animation's start. Every frame after that + * first change is judged, so a freeze later in the block fails. * An element off screen before the judged part is judged from its first * position inside the part, as a card that enters from an edge. The cast's * frames before the part must show the screen without it. A main-thread @@ -2154,9 +2157,11 @@ function judgeBusyWindow( * coordinates: where the box sits at rest, plus the offset the page wrote. * A hold that starts before the block and ends at its first presented * change can be a late hand-off. It is one only when it holds this value, - * where the animation starts. Without it, or when the hold shows another - * value, the hold is judged as a stall. An animation can freeze at the - * block's start. + * where the animation starts. Without it, a window that caught an + * animation (`blockMainThread`'s `during`) takes the value on the cast's + * last frame before that animation's start. When the hold shows another + * value, or no value is known, the hold is judged as a stall. An + * animation can freeze at the block's start. */ setBeforeBlock?: number; /** @@ -2383,13 +2388,23 @@ function judgeBusyWindow( // up to a step off. A cast with no step is read within a pixel, and a // sub-pixel read within half a pixel. const matchPx = subpixelStepPx != null ? 0.5 : (readStepPx ?? 1); + // Where the animation starts: the value the page set (`setBeforeBlock`). + // Without it, the caught animation's start dates it: the value on the + // cast's last frame before that start. Unlike the progress replay's + // anchor, a frame that shows no element is not skipped. An earlier value + // can be stale, so the hold then gets no excuse. + const preStart = + setBeforeBlock ?? + (heldCurve == null + ? undefined + : [...tracked].reverse().find(f => f.t < heldCurve.from)?.box?.[axis]); const heldFromBeforeBlock = - setBeforeBlock != null && + preStart != null && lead.length > 0 && holdStart > 0 && prior[holdStart].t < window.from && heldFrames >= 2 && - Math.abs(lead[lead.length - 1] - setBeforeBlock) <= matchPx; + Math.abs(lead[lead.length - 1] - preStart) <= matchPx; // Off screen before the judged part, as a card that enters from an edge: // the cast's frames there show the screen without it. Its first position // inside the part is then its on-screen start, and judging starts there.