Skip to content

fix: exclude mutating actions from the expectation retry loop - #249

Open
ibrasho wants to merge 1 commit into
pestphp:5.xfrom
ibrasho:fix/actions-not-awaitable
Open

fix: exclude mutating actions from the expectation retry loop#249
ibrasho wants to merge 1 commit into
pestphp:5.xfrom
ibrasho:fix/actions-not-awaitable

Conversation

@ibrasho

@ibrasho ibrasho commented Aug 14, 2026

Copy link
Copy Markdown

What this fixes

AwaitableWebpage::__call() routes every page method through Execution::waitForExpectation(), which re-runs the whole call under a hard-coded 1s cap per attempt until the configured timeout runs out, then makes one final uncapped attempt. Every Playwright error surfaces as ExpectationFailedException, so a timeout is retried exactly like a failed assertion.

That is correct for an assertion and wrong for an action. A click changes the page it clicks. When the first attempt needs more than 1s to complete — routine on a 2-core CI runner with a heavy SPA page — the click has already fired and a dialog is open, but the attempt is judged failed. The retry then aims at a button the dialog's overlay now covers, which can never become actionable. The test spends the whole timeout plus one uncapped attempt and fails with Timeout Nms exceeded on a click that succeeded.

In practice this shows up as browser suites that are green locally going red on CI on a shifting set of tests (whichever click crossed 1s that run), and raising the timeout makes each failure take longer instead of fixing it. Related report: pestphp/pest#1511.

The change

typeSlowly is already excluded from the retry loop, with the comment "Retrying this action would append the value to what was already typed." This PR extends the same treatment to the other methods that repeat their effect when re-fired:

  • click / rightClick — the retry aims at a page the first attempt already changed
  • drag — the retry starts from an element the first attempt already moved
  • keys / press / pressAndWaitFor / withKeyDown — a re-sent Enter submits the form again; a multi-key sequence is replayed from the start
  • append — the identical hazard typeSlowly was excluded for

Deliberately NOT excluded: the state-setting inputs (fill, type, clear, check, uncheck, radio, select, attach, hover), where a retry is harmless, and every assertion, where retry-until-timeout is exactly right.

The excluded branch calls the method directly, so the action gets the full configured timeout in one attempt. That is also the stronger wait: Playwright's actionability check (attached → visible → stable → receives events → enabled) runs inside the one call, so it waits without ever firing twice.

Verification

The included regression test simulates a slow main thread: the button's first pointerdown busy-loops 1.2s, and the click shows an overlay covering the button.

  • Before this change: the test fails — the capped first attempt is judged a timeout after the click already fired, every retry aims at the covered button, and the run ends with a timeout. The page state at failure proves the double-fire hazard: window.downs === 1, overlay open.
  • After this change: the test passes in ~1s.
  • Full suite: 358 passed. pint and phpstan clean.

No signatures change. Configurations with a timeout ≤ 1000ms already bypass the loop entirely, so they see no behavior change.

If you would prefer a smaller first step, click + drag alone fixes the loudest failure mode — the keyboard methods and append are the same hazard class, just quieter. Happy to adjust.

waitForExpectation() re-runs the whole call under a 1s cap per attempt.
For an assertion that is the right tool. For an action it re-fires a
side effect: a click whose first attempt needs more than 1s has already
opened its dialog, and the retry then aims at a button the overlay now
covers. The test burns the whole timeout and fails on a click that
worked. A retried Enter double-submits a form; a retried drag starts
from an element the first attempt already moved.

typeSlowly is already excluded for exactly this reason. Extend the same
treatment to the other methods that repeat their effect when re-fired.
The excluded branch calls the method directly, so the action gets the
full timeout in one attempt — and Playwright's actionability check
(attached, visible, stable, receives events, enabled) waits inside that
one call, so nothing fires twice.
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