Skip to content

test(interaction-harness): a busy proof names a frame it cannot judge - #10765

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:test/busy-proof-unjudged-frame
Nov 9, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:test/busy-proof-unjudged-frame

Conversation

@vjeux

@vjeux vjeux commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

LightboxZoom.interaction.browser.spec.ts:1039, the busy double-tap zoom proof, failed in Chromium CI at expect(proof.control?.belowStep).toBe(0). It received 1.

The control moved its full 10 px on that frame. The photo's top edge moved about 0.84 px, the zoom's slow start at tempo 5, under the cast's 1 px step. So the frame read no change. The proof's own rule counted it as moved (belowStep), and the spec's stricter claim failed as an unnamed red.

Intended invariant

spec:AST-094 FR5: motion keeps advancing at display rate through a main-thread block. The busy proof judges that claim frame by frame. A frame the cast cannot judge fails by name: it is never a red on the component, and never a pass.

Change

  • busyWindowProof gains judgeEveryFrame (default off). With it, a frame the control moved on where the cast could not show the component's step (one belowStep counts) throws BusyFrameUnjudgedError. The error carries each such frame, the component's move per frame there and the cast's step.
  • A failed verdict comes back as it is. A frozen or main-thread component stays a red on the component.
  • The zoom proof opts in. Every assertion stays, belowStep === 0 included. A run that throws still writes its frames and storyboard.
  • Replay: busy.unjudged.test.ts replays the failing CI run's 175 frames from busy.unjudged.fixture.ts.
  • The README's busy-main-thread section states the option.
  • Builds on test(interaction-harness): a busy proof stops where its curve falls back #10766 (merged as 3e27971): one commit on main.

Evidence

  • Failure before the change: the replay passes the proof with belowStep 1, which the spec rejects without a name.
  • Success after the change: with judgeEveryFrame, the replay throws BusyFrameUnjudgedError naming +38.9 ms, 0.84 px a frame under the 1 px step.
  • Product/runtime behavior verified unchanged: test code only.
  • CI impact: the zoom proof's sub-step frame now fails by name.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

The CI profile is the Playwright container with --cpus=4, CI=1, LP_NUM_THREADS=4, one worker and eight busy loops. It was measured at 53f7843. Since then the head added a shared runEnd helper and comments, with no behaviour change. The zoom spec now writes its evidence on the throw path too.

  • busy.unjudged.test.ts, 5 cases:
    • the replay without the option passes with belowStep 1;
    • with it, the error names the frame;
    • the same cast, that frame showing its step, passes;
    • a frozen photo stays a red on the photo;
    • a main-thread zoom stays a red on the photo.
  • Harness unit suite: 224 passed (13 files), also at this head.
  • Zoom proof, Chromium, CI profile: 39 of 40 passed. The one failure was a different red, "moved on 33 of the 34 frames the control moved". That cast stamped frames in bursts near the zoom's tail. A 74–78 ms gap carried one frame of control motion, then a 9 ms pair a full frame. That frame is not a sub-step frame by the proof's rule, so it stays a red. Judging frames on the control's own clock would address it, and is planned separately.
  • Zoom proof, WebKit container, CI profile: 7 of 20 passed. 13 threw the named FrameStarvationError, with the control moving on 2 or 3 frames in the 2.5 s block. That is this container's load on WebKit, unchanged by this PR. CI's WebKit leg judged the same proof on 6 frames.
  • Mutants (ca8ee85939, not for landing):
    • the zoom's keyframes hold on the compositor from 50 to 1500 ms. Chromium 3 of 3 fail the verdict: "moved on 4 of the 90 frames the control moved";
    • the spec's own main-thread and frozen zoom mutants with judgeEveryFrame. Chromium 6 of 6 and WebKit 6 of 6 still fail as reds on the photo.
  • Whole zoom proof and its mutant tests, Chromium, at this head: 6 passed.
  • The throw path, forced at tempo 15 in Chromium: 2 of 2 throw BusyFrameUnjudgedError. Each still attaches the frame JSON (with unjudged) and the storyboard.
  • tsc (harness and core): 0 errors. ESLint: 0. Prettier clean. pnpm review:pre --base 3e27971f3a --head 36af927ac0: 527 authored lines, merges cleanly, 0 findings.

@vercel

vercel Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 9, 2026 10:38am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 9, 2026, 10:49 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 36af927

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
Lightbox · Storybook 122.6 kB (0) · 10.6 kB CSS (0) 3,377 (0) ✓ · ✓ · ✓

Package size: no published package changed; no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 29 rows, core 36 rows, theme-butter 1 row, theme-y2k 1 row; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

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.

Requesting changes: one fix in the zoom proof's failure path.

  • The zoom proof now throws BusyFrameUnjudgedError at LightboxZoom.interaction.browser.spec.ts:1087, before writeEvidence (line 1093) and castStoryboard (line 1116), so a run that hits the sub-step frame uploads no frame JSON or storyboard (the evidence busy.unjudged.fixture.ts was built from); write the evidence first, or catch the error, write it with error.frames, and rethrow.

Two smaller points. pooledBusyProof accepts judgeEveryFrame through its options type but ignores it, so a pooled proof with the option still passes an excused frame; omit the option there or honour it. The description's "Intended invariant" credits spec:AST-094 FR5 with the named-failure rule, but FR5 only requires motion to keep advancing through a main-thread block; also, the replay's error reads 0.84 px, not 0.85 px.

[Automated review]

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 9, 2026
@vjeux
vjeux force-pushed the test/busy-proof-unjudged-frame branch from 24a5dbd to 32c29cc Compare November 9, 2026 09:22
@cixzhang

cixzhang commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 32c29cc

@cixzhang

cixzhang commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 38e68a3

@astracat-bot astracat-bot Bot left a comment

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.

Approved pending CI: CI has not run on this head yet because the pull request is a draft, and it runs once the pull request is marked ready for review (the ci:run label is already on it).

Both points from the earlier review are fixed: the zoom proof keeps its frame JSON and storyboard when it throws BusyFrameUnjudgedError, and pooledBusyProof no longer accepts judgeEveryFrame. In a WebKit build that casts about every 22 ms, the zoom proof now fails by name on three or four sub-step frames near +1.2 to +1.4 s, where the base failed the same runs at belowStep === 0, so that flake remains for the planned control-clock follow-up.

  • Non-blocking: judgeEveryFrame only acts when a control is passed; without one, busyWindowProof ignores it and the recorded replay passes, so consider throwing when the option is set without a control.

[Automated review]

* `BusyFrameUnjudgedError`. Such a frame is never a pass, and never a red
* on the component.
*/
judgeEveryFrame?: boolean;

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.

A busy proof against a control counts a frame as moved when the cast's
step explains a run of equal positions there (belowStep). Lightbox's
double-tap zoom proof claims a new position on every frame the control
moved, so it asserted belowStep 0. In CI the zoom's slow start moved
the photo's top edge about 0.85 px a frame, under the cast's 1 px step.
One frame read no change, and the assertion failed as an unnamed red.

With the new judgeEveryFrame option, such a frame is too little to
judge. busyWindowProof throws BusyFrameUnjudgedError with the 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. Lightbox's zoom proof passes
the option, and a run that throws still writes its frames and
storyboard. No bound changes.

busy.unjudged.test.ts replays the CI run's frames. Without the option
the proof passes with belowStep 1. With it, the error names the frame
at +38.9 ms.
@vjeux
vjeux force-pushed the test/busy-proof-unjudged-frame branch from 38e68a3 to 36af927 Compare November 9, 2026 10:31
@cixzhang

cixzhang commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 36af927

@astracat-bot astracat-bot Bot left a comment

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.

Approved pending CI: CI has not run on this head yet because the pull request is a draft, and it runs once the pull request is marked ready for review (the ci:run label is already on it).

In Chromium the zoom proof passes with judgeEveryFrame, its main-thread and frozen mutants still fail, and the replay shows the sub-step frame passing on main and failing by name here. Two non-blocking points:

  • Miss: BusyFrameUnjudgedError's message says the component moved "under two of the cast's steps" even when movePx is 2 or more (a replay with a 4 px catch-up after a 1.7 px/frame pace reports "about 2.07 px a frame, under two of the cast's 1 px steps"), so report the pace the rule used or drop that clause.
  • judgeEveryFrame without a control is still ignored silently; consider throwing when the option is set without one.

[Automated review]

`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.

@vjeux
vjeux marked this pull request as ready for review November 9, 2026 10:55
@cixzhang
cixzhang merged commit 5c336a5 into meta:main Nov 9, 2026
128 checks passed
@vjeux
vjeux deleted the test/busy-proof-unjudged-frame branch November 9, 2026 11:19
cixzhang pushed a commit that referenced this pull request Nov 9, 2026
…ols (#10772)

* fix(interaction-harness): a WebKit busy proof dates frames on controls

WebKit stamps the frame after a delivery gap about 60 ms late, carrying
one frame of motion. A busy proof on the stamps then reads a stall the
control does not show.

A WebKit busy proof with a control now dates each frame from the
controls' positions. 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, each at its own time, and the proof
reports its judged part on the cast's stamps. A proof that judges every
frame near the control's turns opts in to the control ahead. Chromium
casts and casts with no control keep their stamps. Each cast frame
records its engine for this.

* fix(interaction-harness): WebKit busy proofs keep one frame per interval

WebKit's compositor can draw two frames 6 to 9 ms apart. A curve then
shows no new step between them, and a busy proof read a held frame.

A WebKit busy proof now keeps one dated frame per display interval, the
earlier one, read to one px of the controls' travel. A frame dated from
a neighbour always stays, so no held frame is left out. A WebKit cast
rarely shows two frames one interval apart, so the proof judges no
30 Hz cadence there. Its summary says so in a note, with the count of
such pairs.

* Merge main into harness/webkit-busy-dating

Resolves the one conflict with #10765 in pooledBusyProof's options:
the Omit list keeps both additions.

* test(core): the Switch colour proof dates frames through the harness

The Switch track-colour busy proof dated WebKit frames itself, with its
own reading, display-rate and cadence helpers. With the harness now
dating every WebKit busy proof with a control, two rules ran on it.

It now passes both controls and their starts to pooledBusyProof, which
dates and keeps its frames. The harness exports pageTimesOnControls,
the readings its dating starts from, for checks that need a frame's
own time: the colour's landing and the thumb's curve. The replay of
two late-stamped WebKit casts tags its frames as WebKit's: dated by
the harness it passes, while their stamps and their readings alone
fail.

* test(core): the animator proofs read frame times from the harness

The animator's spring and retarget proofs read each frame's page time
from the two controls inline: the reading pageTimesOnControls returns.
They now call it, so a fix to the harness's reading reaches them too.

A unit case pins each of the order rule's two passes, not their removal
together. In a late burst, a held frame with no reading moves to just
before the next frame with one, and still reads as a stall.

This branch was successfully deployed

1 active deployment
Preview — 36af927a Deployed Nov 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants