#1908·pest

[pest-plugin-browser] A missing selector hangs forever instead of honouring the action timeout, and enforceTimeLimit then corrupts every later test in the process

Author: ecsolCreated Sep 13, 2026Updated Sep 13, 2026

Filing here because issues are disabled on pestphp/pest-plugin-browser.

Summary

When a locator matches nothing, Locator::check() and Locator::fill() block indefinitely on the Playwright websocket instead of failing at the action timeout. If PHPUnit's own time limit is enabled, its pcntl alarm then fires while the fiber is suspended, which leaves Revolt's shared DriverSuspension pending forever — so every subsequent test in the same process dies with Must call resume() or throw() before calling suspend() again.

So one missing or unclickable element does not produce one failing test. It produces one aborted test plus every remaining test in that process, with the real defect buried at the top of a wall of unrelated errors.

Versions

  • pestphp/pest-plugin-browser v4.3.1 (latest 4.x at the time of writing)
  • revolt/event-loop v1.0.9
  • PHP 8.4

Reproducer

Two tests. No application code and no component library — the only requirement is that the first locator matches nothing on whatever page you visit.

php
<?php

it('a', fn () => visit('/')->fill('no_such_field_at_all', 'x'));
it('b', fn () => visit('/')->assertSee('anything that is really on the page'));

with the time limit enabled in phpunit.xml:

xml
<phpunit enforceTimeLimit="true" defaultTimeLimit="120">

Observed

  • a — RISKY, aborted after 120 s. It does not fail at the browser action timeout.
  • b — errors with Must call resume() or throw() before calling suspend() again, and so does every test after it in that process.

Expected

  • a — fails at the action timeout with a "locator not found" style error, in about a second.
  • b — unaffected.

What the trace shows

The check message is sent to Playwright with timeout:1000. One loadstate event comes back, and then there is 119 seconds of silence — no reply, and nothing reaching the in-process amphp server. The timeout is therefore transmitted, but the failure reply is either never delivered or never processed, and the fiber is left suspended with nothing scheduled to resume it.

The pcntl alarm from enforceTimeLimit is what eventually interrupts it, and it does so at the worst possible moment: mid-suspension, so the shared suspension is never resolved and the loop is unusable for the remainder of the process.

Why the obvious workarounds do not help

  • Lowering defaultTimeLimit does not help — the alarm still fires while the fiber is suspended, just sooner.
  • Disabling enforceTimeLimit removes the cascade and replaces it with a hang: with nothing to interrupt the suspended fiber, the run never finishes.

There is no consumer-side configuration that turns this into one honest red test, which is why it is reported rather than worked around.

Impact

We run ~627 assertNoJavaScriptErrors / assertNoConsoleLogs assertions in a single CI job. One typo'd selector in an unrelated new test wipes all of them, so the job's green stops meaning anything. Measured on one run: the first test went RISKY at 120 s and roughly 20 followed it down, 19 of which were noise.

Happy to test a patch against the reproducer above.