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: 6 additions & 0 deletions internal/interaction-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ to show a new position on at least four of the control's frames. The cast step (
is read from the value's changes in the judged part, so a position the page
commits off the grid once the block ends does not shrink it.
A main-thread animation still fails: the control moves and it does not.
Each such run counts in the control read's `belowStep`. A proof that claims a
new position on every frame the control moved passes `judgeEveryFrame`. A frame
the cast's step explains is then too little to judge: `busyWindowProof` throws
`BusyFrameUnjudgedError` with each such frame, the component's move per frame
there and the cast's step. A verdict that fails anyway comes back as it is, so
a frozen or main-thread component stays a red on the component.

`controlClock(control)` reads the compositor's own time in each frame off the
control's position, for a proof that judges motion against time, not against
Expand Down
112 changes: 102 additions & 10 deletions internal/interaction-harness/src/busy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
* the control when it has one. `castStep`, the screencast's position step
* read from its frames. `pooledBusyProof`, which judges several short
* blocks as one. `BusyWindowTooLateError`, for a block that began too late
* for the motion it was meant to catch.
* for the motion it was meant to catch. `BusyFrameUnjudgedError`, for a
* frame the cast could not judge in a proof that judges every frame
* (`judgeEveryFrame`).
* `BUSY_PROOF_LOOKAROUND_MS` is how far around its judged part a proof
* reads frames.
* `trackControl` and `controlClock` read the control's box and its own
Expand Down Expand Up @@ -475,6 +477,45 @@ export class BusyWindowTooLateError extends Error {
}
}

/** A frame a busy proof could not judge (see `BusyFrameUnjudgedError`). */
export interface UnjudgedFrame {
/** When the frame was presented, ms after the window began. */
readonly atMs: number;
/**
* The component's move per frame there, px: the change that ends the run of
* equal positions that holds the frame, over the run's frames.
*/
readonly movePx: number;
/** The cast's position step for the judged value, px. */
readonly stepPx: number;
}

/**
* A frame the cast could not show the component's step on, in a busy proof
* that judges every frame (`judgeEveryFrame`). The control moved on that
* frame. The component moved under two of the cast's steps a frame, and the
* frame shows no change. So the run is too little to judge: it fails by name,
* not as a red on the component. A frozen or main-thread component still
* fails the verdict, since the cast's step does not explain its hold.
*/
export class BusyFrameUnjudgedError extends Error {
constructor(
/** Each frame the proof could not judge. */
readonly frames: readonly UnjudgedFrame[],
summary: string,
) {
super(
`busyWindowProof: too little to judge: ${frames
.map(
frame =>
`at +${frame.atMs.toFixed(1)} ms the component moved about ${frame.movePx.toFixed(2)} px a frame, under two of the cast's ${frame.stepPx} px steps, and the frame shows no step while the control moved`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (Miss): movePx is the run's catch-up over its frames (lines 1742-1744), but underStep can excuse the frame on the pace before the run (line 1678), which allows a catch-up of two frames of that pace plus a step. Replaying the recorded cast with the top edge held at +1106.8 ms and caught up 4 px on the next frame throws "at +1106.8 ms the component moved about 2.07 px a frame, under two of the cast's 1 px steps". Report the pace the rule used here, or drop the "under two" clause.

)
.join('; ')}. ${summary}`,
);
this.name = 'BusyFrameUnjudgedError';
}
}

export async function blockMainThread(
page: Page,
ms: number,
Expand Down Expand Up @@ -1029,14 +1070,24 @@ function motionStepPx(values: readonly number[]): number {
* `FRAME_STARVATION_MS` in the block. Without a control, a block with no
* frame is a frozen animation's, and it fails the verdict. `pooledBusyProof`
* reads each run without the throw and judges the runs together.
*
* With `judgeEveryFrame`, a frame the cast could not show the component's
* step on is too little to judge (the frames `belowStep` counts). When the
* verdict otherwise passes, the proof throws `BusyFrameUnjudgedError` with
* each such frame, the component's move there and the cast's step. A failed
* verdict is returned as it is: it is the component's red.
*/
export function busyWindowProof(
...args: Parameters<typeof readBusyWindow>
): BusyProof {
const {proof, starvation} = readBusyWindow(...args);
const {proof, starvation, unjudged} = readBusyWindow(...args);
if (starvation != null) {
throw new FrameStarvationError(starvation);
}
// A failed verdict is the component's red, whatever else the cast hid.
if (unjudged.length > 0 && proof.verdict.failures.length === 0) {
throw new BusyFrameUnjudgedError(unjudged, proof.summary);
}
return proof;
}

Expand Down Expand Up @@ -1064,6 +1115,7 @@ function readBusyWindow(
minJudgedFrames = 4,
presented,
control,
judgeEveryFrame = false,
}: {
axis?: 'x' | 'y' | 'width' | 'height';
marginMs?: number;
Expand Down Expand Up @@ -1131,8 +1183,20 @@ function readBusyWindow(
* screencast with `CONTROL_RGB`): judge the component against them.
*/
control?: readonly TrackedFrame[];
/**
* With a control: judge every frame the control moved on by the
* component's own step. A frame the cast could not show that step on
* (`belowStep`) is then too little to judge, and `busyWindowProof` throws
* `BusyFrameUnjudgedError`. Such a frame is never a pass, and never a red
* on the component.
*/
judgeEveryFrame?: boolean;
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: unjudged is only filled inside if (control != null) (line 1586), so judgeEveryFrame: true without a control is silently ignored: busyWindowProof(photo, block, {axis: 'y', settledWithin: 24, judgeEveryFrame: true}) on the recorded replay passes with no failures. The doc scopes the option to "With a control", and the zoom proof passes one, but a proof that forgets it gets a pass where it asked for every frame to be judged. Consider throwing when judgeEveryFrame is set without control.

} = {},
): {proof: BusyProof; starvation: FrameShortfall | null} {
): {
proof: BusyProof;
starvation: FrameShortfall | null;
unjudged: readonly UnjudgedFrame[];
} {
let end = window.to - marginMs;
if (settledWithin != null && settledWithinSteps != null) {
throw new Error(
Expand Down Expand Up @@ -1518,6 +1582,7 @@ function readBusyWindow(
return changes;
};
let controlRead: BusyControlRead | null = null;
let unjudged: UnjudgedFrame[] = [];
if (control != null) {
const controlChanges = controlChangesUpTo(control, judged.to);
const controlEdges = [judged.from, ...controlChanges, judged.to];
Expand All @@ -1540,6 +1605,15 @@ function readBusyWindow(
}
return s;
};
// The first frame after that run: the change that ends it, or the
// series' length when no change does.
const runEnd = (i: number): number => {
let k = i + 1;
while (k < series.length && same(k, i)) {
k += 1;
}
return k;
};
// The component's motion per frame over up to four frames before frame
// `start`, or null with fewer than two to read.
const pxPerFrameBefore = (start: number): number | null => {
Expand Down Expand Up @@ -1570,10 +1644,7 @@ function readBusyWindow(
return false;
}
const start = runStart(i);
let k = i + 1;
while (k < series.length && same(k, i)) {
k += 1;
}
const k = runEnd(i);
if (k >= series.length) {
return false;
}
Expand Down Expand Up @@ -1654,11 +1725,32 @@ function readBusyWindow(
...delivered.filter(t => changed.has(t) || underStep(t)),
judged.to,
];
const excused = controlChanges.filter(underStep);
if (judgeEveryFrame) {
unjudged = excused.map(t => {
const i = seriesIndex.get(t);
if (i == null) {
throw new Error(
'busyWindowProof: an excused frame is missing from the series',
);
}
const start = runStart(i);
const k = runEnd(i);
const runFrames = (series[k].t - series[start].t) / cadence;
return {
atMs: t - window.from,
movePx:
Math.abs(series[k].value - series[i].value) /
Math.max(1, runFrames),
stepPx,
};
});
}
controlRead = {
moved: controlChanges.length,
movedWith: controlChanges.filter(t => changed.has(t) || underStep(t))
.length,
belowStep: controlChanges.filter(underStep).length,
belowStep: excused.length,
longestStallMs: Math.max(
...controlEdges.slice(1).map((t, i) => t - controlEdges[i]),
),
Expand Down Expand Up @@ -1800,7 +1892,7 @@ function readBusyWindow(
verdict: {pass: failures.length === 0, failures},
summary,
};
return {proof, starvation};
return {proof, starvation, unjudged};
}

/** One run of a pooled busy proof (see `pooledBusyProof`). */
Expand Down Expand Up @@ -1869,7 +1961,7 @@ export function pooledBusyProof(
runs: readonly BusyRun[],
options: Omit<
NonNullable<Parameters<typeof busyWindowProof>[2]>,
'control' | 'minMovedFraction'
'control' | 'minMovedFraction' | 'judgeEveryFrame'
> & {
/** The fewest frames a run's control must move on for the run to count. */
minRunFrames?: number;
Expand Down
Loading
Loading