From 75121c89d36018f9679720406ad822d02eb8a488 Mon Sep 17 00:00:00 2001 From: bepsvpt <8221099+bepsvpt@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:17:36 +0800 Subject: [PATCH] fix: drop response frames addressed to another request --- src/Playwright/Client.php | 9 +++ tests/Unit/Playwright/ClientTest.php | 115 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/Unit/Playwright/ClientTest.php diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..ea381f29 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -91,6 +91,15 @@ public function execute(string $guid, string $method, array $params = [], array /** @var array{id: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ $response = json_decode($responseJson, true); + // A response addressed to another request was stranded by a generator + // abandoned before its final frame was read — goto() breaks on its + // waitUntil event, leaving its response unconsumed. Processing it here + // would desynchronize the stream, and the TargetClosedError a context + // close sends to a still-settling goto would fail an unrelated command. + if (isset($response['id']) && $response['id'] !== $requestId) { + continue; + } + if (isset($response['error']['error']['message'])) { $message = $response['error']['error']['message']; diff --git a/tests/Unit/Playwright/ClientTest.php b/tests/Unit/Playwright/ClientTest.php new file mode 100644 index 00000000..3c8efec3 --- /dev/null +++ b/tests/Unit/Playwright/ClientTest.php @@ -0,0 +1,115 @@ +createStub(WebsocketConnection::class); + + $sent = null; + $connection->method('sendText')->willReturnCallback(function (string $payload) use (&$sent): void { + $sent = json_decode($payload, true); + }); + + $frames = [ + ['id' => 'stranded-request', 'result' => ['value' => 'stale']], + ]; + + $connection->method('receive')->willReturnCallback(function () use (&$frames, &$sent): WebsocketMessage { + $frame = array_shift($frames) ?? ['id' => $sent['id']]; + + return WebsocketMessage::fromText((string) json_encode($frame)); + }); + + $client = new Client(); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + + $messages = iterator_to_array($client->execute('page@1', 'evaluateExpression')); + + expect($messages)->toHaveCount(1) + ->and($messages[0]['id'])->not->toBe('stranded-request'); +}); + +it('does not fail the current request on an error frame addressed to another one', function (): void { + $connection = $this->createStub(WebsocketConnection::class); + + $sent = null; + $connection->method('sendText')->willReturnCallback(function (string $payload) use (&$sent): void { + $sent = json_decode($payload, true); + }); + + $frames = [ + ['id' => 'stranded-goto', 'error' => ['error' => ['message' => 'Target page, context or browser has been closed']]], + ]; + + $connection->method('receive')->willReturnCallback(function () use (&$frames, &$sent): WebsocketMessage { + $frame = array_shift($frames) ?? ['id' => $sent['id']]; + + return WebsocketMessage::fromText((string) json_encode($frame)); + }); + + $client = new Client(); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + + $messages = iterator_to_array($client->execute('browser@1', 'newContext')); + + expect($messages)->toHaveCount(1) + ->and($messages[0]['id'])->not->toBe('stranded-goto'); +}); + +it('still fails the current request on its own error frame', function (): void { + $connection = $this->createStub(WebsocketConnection::class); + + $sent = null; + $connection->method('sendText')->willReturnCallback(function (string $payload) use (&$sent): void { + $sent = json_decode($payload, true); + }); + + $connection->method('receive')->willReturnCallback(function () use (&$sent): WebsocketMessage { + return WebsocketMessage::fromText((string) json_encode([ + 'id' => $sent['id'], + 'error' => ['error' => ['message' => 'Target page, context or browser has been closed']], + ])); + }); + + $client = new Client(); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + + expect(fn (): array => iterator_to_array($client->execute('page@1', 'click'))) + ->toThrow(ExpectationFailedException::class, 'Target page, context or browser has been closed'); +}); + +it('still yields event frames, which carry no id', function (): void { + $connection = $this->createStub(WebsocketConnection::class); + + $sent = null; + $connection->method('sendText')->willReturnCallback(function (string $payload) use (&$sent): void { + $sent = json_decode($payload, true); + }); + + $frames = [ + ['guid' => 'frame@1', 'method' => 'navigated', 'params' => ['url' => 'http://127.0.0.1/']], + ]; + + $connection->method('receive')->willReturnCallback(function () use (&$frames, &$sent): WebsocketMessage { + $frame = array_shift($frames) ?? ['id' => $sent['id']]; + + return WebsocketMessage::fromText((string) json_encode($frame)); + }); + + $client = new Client(); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + + $messages = iterator_to_array($client->execute('frame@1', 'goto', ['url' => 'http://127.0.0.1/'])); + + expect($messages)->toHaveCount(2) + ->and($messages[0]['method'])->toBe('navigated'); +});