Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion internal/interaction-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions internal/interaction-harness/src/busy.control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly [number, number, number]> = [
[-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);
});
});
31 changes: 23 additions & 8 deletions internal/interaction-harness/src/busy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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.
Expand Down
Loading