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:
What Happened
AwaitableWebpage::__call()routes everyWebpagemethod throughExecution::waitForExpectation()— not only assertions, but state-changing actions as well (navigate,click,press,fill,type,typeSlowly, …). OnlyassertScreenshotMatchesandassertNoAccessibilityIssuesare excluded.Inside that loop, every attempt is given a hardcoded 1000ms Playwright timeout, while the value from
Configuration::timeout()only sizes the retry window: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 withConfiguration::timeout(), even though nothing about the page changed.navigate()is the clearest case, becausePage::goto()always waits forload:With a page that takes a constant ~2s to respond (reproducer below), a single
navigate()measures:Configuration::timeout()navigate()(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 attimeout(15000)and 45.6s attimeout(3000)— up to ~77% of the runtime was the loop spinning on attempts that could not succeed.This is also why
Timeout 1000ms exceededshows 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: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()Timeout 3000ms exceededTimeout 6000ms exceeded3.
Configuration::timeout()does not mean what it documentsIt 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 usesAmp\delay()rather thansleep()on purpose: the plugin's HTTP server runs in-process on the same event loop as the WebSocket connection to Playwright, so a blockingsleep()would stall that loop and mask the effect.Output — the 2s page costs
timeout + ~2.8severy time:And for the repeated side effects:
Output:
Pest Version
pestphp/pest 4.7.5, pestphp/pest-plugin-browser v4.3.1 (the code is identical on
4.xHEAD734236aand on5.xHEADe040c9a), Playwright 1.59.1PHP 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.phpsuggests 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, sousingTimeout(1000)matched the whole window exactly:Then
9519b23 chore: improves flakinessmade the window configurable, but left the per-attempt budget pinned:That is the point where
Configuration::timeout()got decoupled from the per-attempt budget.Related:
NON_RETRYABLE_ACTION_METHODSlist does not include the navigation methods (navigate,refresh,back,forward), which are exactly the ones behind problem 1 above.