Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions src/Playwright/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
}
85 changes: 85 additions & 0 deletions tests/Unit/Playwright/ClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

use Amp\Websocket\Client\WebsocketConnection;
use Amp\Websocket\WebsocketMessage;
use Pest\Browser\Playwright\Client;

it('reports a closed connection instead of looping on it', function (): void {
$connection = $this->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);
});