diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..792523f6 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -4,10 +4,15 @@ namespace Pest\Browser\Playwright; +use Amp\Cancellation; +use Amp\CancelledException; +use Amp\TimeoutCancellation; use Amp\Websocket\Client\WebsocketConnection; +use Amp\Websocket\WebsocketMessage; use Generator; use Pest\Browser\Exceptions\PlaywrightOutdatedException; use PHPUnit\Framework\ExpectationFailedException; +use RuntimeException; use function Amp\Websocket\Client\connect; @@ -31,6 +36,11 @@ final class Client */ private int $timeout = 5_000; + /** + * Seconds allowed on top of the timeout before a request is given up on. + */ + private float $requestGraceSeconds = 30.0; + /** * Returns the current client instance. */ @@ -81,13 +91,31 @@ public function execute(string $guid, string $method, array $params = [], array 'guid' => $guid, 'method' => $method, 'params' => ['timeout' => $this->timeout, ...$params], - 'metadata' => $meta, + // Playwright reads the timeout from the metadata since 1.62.0, where an + // absent value means no timeout at all. Older servers read it from params. + 'metadata' => ['timeout' => $this->timeout, ...$meta], ]); $this->websocketConnection->sendText($requestJson); + $allowance = ($this->timeout / 1000) + $this->requestGraceSeconds; + $deadline = microtime(true) + $allowance; + + // The cancellation bounds a request the server never answers, and the deadline + // bounds one that only ever receives unrelated messages. Neither covers both. + $cancellation = new TimeoutCancellation($allowance); + while (true) { - $responseJson = $this->fetch($this->websocketConnection); + if (microtime(true) > $deadline) { + throw $this->unanswered($method, $allowance); + } + + try { + $responseJson = $this->fetch($this->websocketConnection, $cancellation); + } catch (CancelledException) { + throw $this->unanswered($method, $allowance); + } + /** @var array{id: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ $response = json_decode($responseJson, true); @@ -128,11 +156,31 @@ public function timeout(): int return $this->timeout; } + /** + * Builds the failure raised when a request is never answered. + */ + private function unanswered(string $method, float $allowance): RuntimeException + { + return new RuntimeException(sprintf( + 'The Playwright server did not answer [%s] within %.1f seconds.', + $method, + $allowance, + )); + } + /** * Fetches the response from the Playwright server. */ - private function fetch(WebsocketConnection $client): string + private function fetch(WebsocketConnection $client, Cancellation $cancellation): string { - return (string) $client->receive()?->read(); + $message = $client->receive($cancellation); + + // Without this, the null returned once the connection closes casts to an empty + // string and leaves the caller waiting on a response that can never arrive. + if (! $message instanceof WebsocketMessage) { + throw new RuntimeException('The Playwright server closed the connection unexpectedly.'); + } + + return (string) $message->read($cancellation); } } diff --git a/tests/Unit/Playwright/ClientTest.php b/tests/Unit/Playwright/ClientTest.php new file mode 100644 index 00000000..fce5b60b --- /dev/null +++ b/tests/Unit/Playwright/ClientTest.php @@ -0,0 +1,85 @@ +createStub(WebsocketConnection::class); + $connection->method('receive')->willReturn(null); + + $client = new Client(); + $client->setTimeout(100); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + new ReflectionProperty(Client::class, 'requestGraceSeconds')->setValue($client, 0.2); + + expect(fn (): array => iterator_to_array($client->execute('page@1', 'goto'))) + ->toThrow(RuntimeException::class, 'The Playwright server closed the connection unexpectedly.'); +}); + +it('gives up on a request that only ever receives unrelated messages', function (): void { + $connection = $this->createStub(WebsocketConnection::class); + $connection->method('receive')->willReturnCallback( + fn (): WebsocketMessage => WebsocketMessage::fromText('{"guid":"page@1","method":"console"}') + ); + + $client = new Client(); + $client->setTimeout(100); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + new ReflectionProperty(Client::class, 'requestGraceSeconds')->setValue($client, 0.2); + + expect(fn (): array => iterator_to_array($client->execute('page@1', 'goto'))) + ->toThrow('The Playwright server did not answer [goto]'); +}); + +it('returns the response matching the request 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); + }); + + $connection->method('receive')->willReturnCallback(function () use (&$sent): WebsocketMessage { + return WebsocketMessage::fromText((string) json_encode([ + 'id' => $sent['id'], + 'result' => ['value' => 'pong'], + ])); + }); + + $client = new Client(); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + + $messages = iterator_to_array($client->execute('page@1', 'goto')); + + expect($messages)->toHaveCount(1) + ->and($messages[0]['result']['value'])->toBe('pong'); +}); + +it('sends the timeout as metadata so the server enforces it', 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']])); + }); + + $client = new Client(); + $client->setTimeout(100); + + new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection); + + iterator_to_array($client->execute('page@1', 'click')); + + expect($sent['metadata']['timeout'])->toBe(100) + ->and($sent['params']['timeout'])->toBe(100); +});