Skip to content

[Bug]: Browser actions run through the assertion retry loop with a hardcoded 1000ms per-attempt timeout #1755

Description

@likemusic

What Happened

AwaitableWebpage::__call() routes every Webpage method through Execution::waitForExpectation() — not only assertions, but state-changing actions as well (navigate, click, press, fill, type, typeSlowly, …). Only assertScreenshotMatches and assertNoAccessibilityIssues are excluded.

Inside that loop, every attempt is given a hardcoded 1000ms Playwright timeout, while the value from Configuration::timeout() only sizes the retry window:

// src/Execution.php:138
public function waitForExpectation(callable $callback): mixed
{
    $timeout = Playwright::timeout();      // <- from Configuration::timeout()

    $start = microtime(true);
    $end = $start + ($timeout / 1_000);    // <- only sizes the retry WINDOW

    while (microtime(true) < $end) {
        try {
            return Playwright::usingTimeout(1_000, $callback);   // <- hardcoded 1000ms per attempt
        } catch (ExpectationFailedException) {
            //
        }

        $this->resetAssertions($originalCount);
        self::instance()->tick();
    }

    return $callback();   // <- ONLY this call gets the configured timeout
}

This causes three distinct problems.

1. Operations that legitimately need >1s can never succeed inside the loop

Every attempt is doomed, so the work is only ever done by the final $callback() after the loop has burned the entire configured timeout doing nothing useful. The net effect: wall-clock time grows linearly with Configuration::timeout(), even though nothing about the page changed.

navigate() is the clearest case, because Page::goto() always waits for load:

// src/Playwright/Page.php:94
$response = $this->sendMessage('goto', [
    ...['url' => $url, 'waitUntil' => 'load'],
    ...$options,
]);

With a page that takes a constant ~2s to respond (reproducer below), a single navigate() measures:

Configuration::timeout() wall-clock for one navigate() doomed 1000ms attempts before it
3000 ms 5.32s / 4.81s / 5.38s 3
6000 ms 8.91s / 8.55s / 8.74s 6
12000 ms 14.82s / 14.89s / 14.74s 12

(three runs each; server delay constant at 2s)

It fits T ≈ configured_timeout + actual_work. Raising the timeout to give slow pages more room makes the suite slower by exactly that amount, on every such call. On a real-world suite of ours, a test with 6 navigations took 117s at timeout(15000) and 45.6s at timeout(3000) — up to ~77% of the runtime was the loop spinning on attempts that could not succeed.

This is also why Timeout 1000ms exceeded shows up while something entirely different is configured (as reported in #1511): the loop itself installs the 1000ms budget.

2. Actions are retried, so side effects are repeated

Because actions go through the same loop, a retried action is performed again. typeSlowly() shows this deterministically — 12 chars × 100ms delay ≈ 1.2s per attempt, which always exceeds the 1000ms attempt budget:

Playwright::setTimeout(3000);

$page->typeSlowly('q', 'hello-world!');

// actual value of the input afterwards:
'hello-worlhello-worlhello-worlhello-worlhello-worlhello-worlhello-worlhello-worlhello-worlhello-world!'

Each doomed attempt types ~10 characters before being cut off, and the text accumulates. The number of repeats is not stable across runs (we observed 3–13). For a form this means repeated submissions; #1511 and pestphp/pest-plugin-browser#227 report the same effect with duplicated clicks.

A click() that triggers a slow navigation fails outright, spending ~2× the configured timeout:

Configuration::timeout() wall-clock result
3000 ms 6.33s Timeout 3000ms exceeded
6000 ms 12.35s Timeout 6000ms exceeded

3. Configuration::timeout() does not mean what it documents

It is documented as "Sets the assertion's timeout in milliseconds", but it is really the retry-window budget. A single assertion or action never gets that timeout, except on the final post-loop call.

Expected vs actual

Expected: a single action/assertion may take up to the configured timeout; raising Configuration::timeout() gives slow pages more room without making tests slower; actions are performed exactly once.

Actual: each attempt is capped at 1000ms regardless of configuration; raising the timeout adds exactly that much dead wall-clock to every slow call; actions are re-executed and their side effects repeat.

How to Reproduce

Fresh Laravel app with pestphp/pest-plugin-browser, no application code involved. The delay uses Amp\delay() rather than sleep() on purpose: the plugin's HTTP server runs in-process on the same event loop as the WebSocket connection to Playwright, so a blocking sleep() would stall that loop and mask the effect.

<?php

use Illuminate\Support\Facades\Route;
use Pest\Browser\Playwright\Playwright;

use function Amp\delay;

it('navigate wall-time grows with the configured timeout', function (int $timeout): void {
    Route::get('/', fn (): string => '<h1>Home</h1>');
    Route::get('/slow', function (): string {
        delay(2); // constant, non-blocking 2s server delay

        return '<h1>Slow page</h1>';
    });

    Playwright::setTimeout($timeout); // same as Configuration::timeout()

    $page = visit('/');

    $start = microtime(true);
    $page->navigate('/slow');
    $elapsed = microtime(true) - $start;

    $page->assertSee('Slow page');

    dump(sprintf('timeout=%5dms -> navigate took %.2Fs', $timeout, $elapsed));
})->with([3000, 6000, 12000]);

Output — the 2s page costs timeout + ~2.8s every time:

"timeout= 3000ms -> navigate took 5.32s"
"timeout= 6000ms -> navigate took 8.91s"
"timeout=12000ms -> navigate took 14.82s"

And for the repeated side effects:

it('typeSlowly is re-executed, so the text is typed more than once', function (): void {
    Route::get('/', fn (): string => '<input type="text" name="q">');

    Playwright::setTimeout(5000);

    $page = visit('/');

    // 12 chars x 100ms = ~1.2s per attempt, always over the 1000ms attempt budget
    $page->typeSlowly('q', 'hello-world!');

    dump($page->value('q'));
});

Output:

'hello-worlhello-worlhello-worlhello-worlhello-worlhello-worlhello-world!'

Pest Version

pestphp/pest 4.7.5, pestphp/pest-plugin-browser v4.3.1 (the code is identical on 4.x HEAD 734236a and on 5.x HEAD e040c9a), Playwright 1.59.1

PHP Version

8.4.23

Operation System

Linux

Notes

This looks like a regression rather than a deliberate design choice. git log -L 138,160:src/Execution.php suggests the 1000ms was never intended as a retry step. When the loop was introduced (1dbf414 feat: livewire::test), the window was a 1 second default, so usingTimeout(1000) matched the whole window exactly:

public function waitForExpectation(callable $callback, int|float $timeout = 1): mixed
{
    // ...
    $end = $start + $timeout;             // 1 second
    while (microtime(true) < $end) {
        try {
            return Playwright::usingTimeout(1000, $callback);   // == the whole window

Then 9519b23 chore: improves flakiness made the window configurable, but left the per-attempt budget pinned:

-    public function waitForExpectation(callable $callback, int|float $timeout = 1): mixed
+    public function waitForExpectation(callable $callback): mixed
     {
+        $timeout = Playwright::timeout();
-        $end = $start + $timeout;
+        $end = $start + ($timeout / 1_000);
         while (microtime(true) < $end) {
             try {
-                return Playwright::usingTimeout(1000, $callback);
+                return Playwright::usingTimeout(1_000, $callback);

That is the point where Configuration::timeout() got decoupled from the per-attempt budget.

Related:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions