#1910·pest

[Bug]: Browser plugin — evaluate()/script() can return another call's result (processResultResponse ignores the request id)

Author: ecsolCreated Sep 14, 2026Updated Sep 14, 2026

What Happened

Webpage::script() / Page::evaluate() can return the result of an earlier, unrelated call, because the value is picked by shape instead of by request id.

Client::execute() yields every websocket frame it reads until it sees one whose id matches its own $requestId:

php
// src/Playwright/Client.php
$requestId = uniqid();
// ...
while (true) {
    $response = json_decode($this->fetch($this->websocketConnection), true);
    // ...
    yield $response;                                  // <- every frame is yielded
    if (isset($response['id']) && $response['id'] === $requestId) {
        break;
    }
}

processResultResponse() then consumes that generator and returns the first frame carrying result.value, without ever comparing id:

php
// src/Playwright/Concerns/InteractsWithPlaywright.php
private function processResultResponse(Generator $response): mixed
{
    foreach ($response as $message) {
        if (isset($message['result']['value'])) {
            return JavaScriptSerializer::parseValue($message['result']['value']);
        }
    }

    return null;
}

So if a frame belonging to a previous request is still in the stream when the next evaluate() runs, that frame's value is returned as this call's result. The call succeeds and returns plausible data — it is simply the wrong data, which makes it very hard to spot.

Observed in CI. A helper that evaluates window.__pestBrowser.jsErrors returned {text: "…", count: 2} — the return value of the previous script() call in the same test, which read a card's text. Nothing was wrong with the page.

The same root cause surfaces as a hard crash through assertNoJavaScriptErrors():

php
// src/Api/Concerns/MakesConsoleAssertions.php
expect($javaScriptErrors)->toBeEmpty(sprintf(
    '…: %s',
    // …
    implode(', ', array_map(fn (array $log) => $log['message'], $javaScriptErrors)),
));

Two problems compound here: javaScriptErrors() may receive some other call's value (not a list of array{message: string}), and the failure message is built by sprintf(...) before the expectation runs, so array_map(fn (array $log) => …) throws

TypeError: {closure:…assertNoJavaScriptErrors():86}(): Argument #1 ($log) must be of type array, string given

even when the assertion would have passed. The real value is swallowed and the test reports a TypeError in plugin internals.

How to Reproduce

Any browser test that makes two script() calls in a row can hit it; it is a race, so it reproduces intermittently and much more often on a loaded machine (a shared CI runner). The shape is:

php
$page = visit('/some-page');

$first  = $page->script('(() => ({ text: "hello", count: 2 }))()');
$second = $page->script('(() => ["a", "b"])()');   // may return {text: "hello", count: 2}

We hit it on a self-hosted runner in roughly 1 of 3 runs of a 164-test browser suite; on a developer laptop it is rare.

Suggested fixes:

  1. processResultResponse() should only accept the frame whose id matches the request that produced the generator (the id is known in Client::execute(), so either filter there or pass it down).
  2. Independently, build the failure message lazily (or defensively) in assertNoJavaScriptErrors(), so a malformed entry cannot turn a passing assertion into a TypeError that hides the value.

Happy to send a PR if the direction above looks right.

Pest Version

pestphp/pest v5.1.3, pestphp/pest-plugin-browser v5.0.1

PHP Version

8.4.23

Operating System

macOS (developer machines) and Linux (CI runner, container image)

Notes

Laravel 13 app, LaravelHttpServer driver, playwright 1.62.1.