Skip to content

test(webui): read the provider picker's exit animation where it changes - #233

Merged
modacker merged 1 commit into
webuifrom
fix/browser-provider-picker-animation-flake
Oct 23, 2026
Merged

modacker merged 1 commit into
webuifrom
fix/browser-provider-picker-animation-flake

Conversation

@modacker

Copy link
Copy Markdown
Collaborator

What broke

packages/webui/test/browser/provider-form.spec.mjs — "with motion allowed › the provider picker animates in and out" — failed on CI with:

Expected: "webui-dropdown-out"
Received: ""

It is a pre-existing flake on the baseline, not a regression from a change: it hit PR #230's run 39865540489 (job 120126683759) and PR #224's ubuntu verify (run 39868998671, job 120138436238), while other runs of the same baseline passed.

Why it failed

The exit animation is a 120ms window. The popup's class flips to is-leaving — the class the stylesheet hangs webui-dropdown-out on — and 120ms later the skeleton unmounts the popup.

The assertion read that window by polling a live DOM node across the process boundary:

await expect
  .poll(() => listbox.evaluate((node) => getComputedStyle(node).animationName).catch(() => "gone"))
  .toBe("webui-dropdown-out");

expect.poll samples at 0ms, 100ms, 350ms, 850ms… — a grid coarser than the window it is looking for. So a sample could land:

  • before the popup started leaving → it reads the entrance animation (webui-dropdown-in);
  • after the popup was unmounted → the locator resolves the node just before the unmount and the evaluation then reads it detached, and a detached node's computed style is empty for every property → "".

Both outcomes fail the assertion while the popup was never in the wrong state. The empty string is exactly what a detached node reports (Chromium, measured):

attached: 'out'   detached: ''   detachedDuration: ''   detachedDisplay: ''

The var() in the exit duration is not involved: --webui-dropdown-exit-duration resolves to 120ms, and the computed animation on the leaving popup is webui-dropdown-out at 0.12s.

What this changes

The reading moves into the page, to where the state changes. A MutationObserver on the popup's class records the popup's own computed animation-name at the moment that class changes, and the test reads that record. The record outlives the popup, so the sample can neither miss the window nor read a node that is already gone.

The expectation is unchanged: the popup carries the exit animation rather than the entrance one while it fades out. A failure now shows the recorded state instead of an empty string.

How it was checked

  • The flake, reproduced against this branch's baseline (webui @ 63b9739): playwright test packages/webui/test/browser/provider-form.spec.mjs -g "animates in and out" --repeat-each=30 → 7 failed, 23 passed. Five of the failures read Received: "" and one read Received: "webui-dropdown-in" — the two ends of the same race.
  • With the fix: the same command with --repeat-each=50 → 50 passed.
  • playwright test packages/webui/test/browser/provider-form.spec.mjs → 8 passed.
  • playwright test packages/webui/test/browser → 160 passed, 7 skipped, 1 failed. The failure is native-runtime.spec.mjs ("native PTYs survive another page, reload and a full five-slot roster"), which dies with posix_spawnp failed. — it spawns a real PTY and fails the same way with the pre-fix spec, so it is this machine's environment, not this change.
  • The assertion still has teeth: with the exit animation broken in the built stylesheet (animation: none on .webui-dropdown-popup.is-leaving), the case fails with the recorded state — animationName: "none" instead of "webui-dropdown-out".

Not addressed here

The exit animation is cut short by roughly a frame: the stylesheet runs it for --webui-dropdown-exit-duration (120ms) while the skeleton unmounts the popup 120ms after the class flips, and the animation itself starts on the next frame. That shortens the fade cosmetically; it is not a flake, and it is left for a separate change.

The provider picker's exit animation was read by polling a live DOM node
across the process boundary, on a poll grid (100ms and coarser) that is
coarser than the animation it looked for. The popup's exit window is 120ms
and the skeleton unmounts the popup when that window is over, so a sample
that arrives after the unmount reads a detached node — whose computed style
is empty — and a sample that arrives before the popup has started leaving
reads the entrance animation. Both outcomes fail the assertion while the
popup was never in the wrong state.

The reading now happens in the page, where the state changes: a
MutationObserver records the popup's own computed animation-name at the
moment its class changes, and the test reads that record. The record
outlives the popup, so the sample cannot miss the window.

The expectation is unchanged: the popup carries the exit animation rather
than the entrance one while it fades out. A failure now shows the recorded
state instead of an empty string.
@modacker
modacker merged commit a270fa3 into webui Oct 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant