#1911·pest

[Bug]: Browser element assertion hangs forever instead of timing out when the element never appears

Author: mauritskorseCreated Sep 14, 2026Updated Sep 14, 2026
Labelsbug

What Happened

An element assertion whose selector never matches does not fail at the configured timeout. The test process waits indefinitely. In CI this turned a missing frontend asset into a 20 minute job timeout with no failure message.

Expected

The assertion fails after about 30 seconds (the configured timeout) with an expectation message.

Actual

Nothing is printed and the test never finishes. The outer timeout kills it with exit code 124. In CI the same situation ran until the step's 20 minute bound.

While it hangs, the PHP process, the Playwright server and the Chromium renderer all sit at 0% CPU. PHP is blocked in poll with two sockets open: the Playwright websocket and the database.

Results

Tested on the same project and Playwright version in the range (npm install --save-dev playwright@<version>):

Playwright MissingElementTest
1.59.1 fails after 61s: "Timeout 30000ms exceeded."
1.60.0 fails after 63s: "Timeout 30000ms exceeded."
1.61.1 fails after 62s: "Timeout 30000ms exceeded."
1.62.0 hangs, killed after 180s (exit 124)
1.62.1 hangs, killed after 180s (exit 124)

The behaviour changed in Playwright 1.62.0. With 1.61.1 and earlier, the getAttribute timeout reaches the plugin as an error. (The 61s is 30s of waitForExpectation() retries plus one final 30s attempt.)

Where it blocks

Logging in Pest\Browser\Playwright\Client::execute() shows the stuck request. Two temporary lines were added:

php
$this->websocketConnection->sendText($requestJson);
error_log('SEND '.$requestJson);               // added

// inside the while loop:
$response = json_decode($responseJson, true);
error_log('RECV '.substr($responseJson, 0, 300)); // added

Output (trimmed):

SEND {"id":"6aa7bf5ef169a","guid":"frame@...","method":"goto","params":{"timeout":30000,"url":"http://127.0.0.1:36249/","waitUntil":"load"}}
RECV {"guid":"frame@...","method":"loadstate","params":{"add":"load"}}
SEND {"id":"6aa7bf604db76","guid":"frame@...","method":"getAttribute","params":{"timeout":1000,"selector":"ui-never-rendered > [contenteditable]","strict":true,"name":"contenteditable"}}
RECV {"id":"6aa7bf5ef169a","result":{"response":{"guid":"response@..."}}}

The last RECV is the late reply to goto, which execute() returned from early on the load event. After that, no message of any kind arrives, not even an error for getAttribute. We watched for 78 seconds in one run and 4 minutes in another.

The call path is assertAttribute()Execution::waitForExpectation()Playwright::usingTimeout(1_000, ...)Locator::getAttribute()Client::execute(). The request carries timeout: 1000, so Playwright should answer with a TimeoutError after a second and the retry loop would give up at 30 seconds. With no reply, execute() blocks in WebsocketConnection::receive(), which has no timeout of its own, so the loop's deadline check in waitForExpectation() never runs again.

How to Reproduce

  1. In a Laravel app with Pest 4 and the browser plugin installed (npm install [email protected], npx playwright install chromium), set a browser timeout in tests/Pest.php:

    php
    pest()->browser()->timeout(30_000);
  2. Add a test that asserts an attribute on an element that is never rendered:

    php
    // tests/Browser/MissingElementTest.php
    it('fails instead of hanging', function () {
        visit('/')->assertAttribute('ui-never-rendered > [contenteditable]', 'contenteditable', 'true');
    });
  3. Run it with an outer bound so it cannot run forever:

    bash
    timeout 4m php artisan test tests/Browser/MissingElementTest.php; echo "exit $?"

Sample Repository

No response

Pest Version

Pest 4.7.5, pest-plugin-browser 4.3.1, pest-plugin-laravel 4.1.0

PHP Version

PHP 8.4.12

Operation System

Linux