From 78c8477f84c710e408320f84799c2e2f318cbff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Moreno=20Pe=C3=B1a?= Date: Mon, 12 Jan 2026 22:44:35 +0100 Subject: [PATCH 1/3] fix: Prevent silent crashes on WebSocket failure and add cookie persistence Problem: - Navigation operations (visit, goto, navigate) would cause PHP to crash silently - WebSocket connection failures resulted in infinite loops with no error output - Cookies were lost between visit() calls due to context isolation Root Cause: - Client::fetch() returned empty string when WebSocket died - json_decode('') returns null, causing null access in loop - No timeout mechanism to detect hung operations - Each visit() created a new browser context, losing all cookies Fixes: 1. Client.php: - Add timeout mechanism using hrtime() - Detect null/empty WebSocket responses - Validate JSON decode results - Throw clear RuntimeException instead of hanging 2. Context.php: - Add storageState() to export cookies/localStorage - Add addCookies() to import cookies - Add cookies() to read current cookies - Add clearCookies() to remove cookies 3. PendingAwaitablePage.php: - Add withStorageState() for cookie persistence - Add withStorageStateFromFile() for file-based state These changes ensure: - Tests fail with clear error messages instead of crashing silently - Cookies can be persisted across visit() calls - Proper timeout handling prevents infinite loops Fixes pestphp/pest#1605 Co-Authored-By: Claude Opus 4.5 --- composer.json | 1 + src/Api/PendingAwaitablePage.php | 44 ++++++++++++++++++++++ src/Playwright/Client.php | 41 +++++++++++++++++++-- src/Playwright/Context.php | 63 ++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 5f6bcce3..de89efd8 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "pestphp/pest-plugin-browser", + "version": "4.2.99", "description": "Pest plugin to test browser interactions", "keywords": [ "php", diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 83ad7b77..0fce5866 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -4,6 +4,7 @@ namespace Pest\Browser\Api; +use InvalidArgumentException; use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; use Pest\Browser\Enums\Device; @@ -154,6 +155,49 @@ public function geolocation(float $latitude, float $longitude): self ]); } + /** + * Sets the storage state (cookies, localStorage) for the page context. + * + * This allows you to reuse authentication state from a previous session. + * + * @param array{cookies?: array, origins?: array}>} $storageState + */ + public function withStorageState(array $storageState): self + { + return new self($this->browserType, $this->device, $this->url, [ + 'storageState' => $storageState, + ...$this->options, + ]); + } + + /** + * Loads storage state from a file path. + * + * The file should contain JSON with cookies and localStorage data + * from a previous Context::storageState() call. + */ + public function withStorageStateFromFile(string $path): self + { + if (! file_exists($path)) { + throw new InvalidArgumentException("Storage state file not found: $path"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new InvalidArgumentException("Could not read storage state file: $path"); + } + + /** @var array{cookies?: array, origins?: array}|null $storageState */ + $storageState = json_decode($contents, true); + + if ($storageState === null) { + throw new InvalidArgumentException("Invalid storage state JSON in: $path"); + } + + return $this->withStorageState($storageState); + } + /** * Creates the webpage instance. */ diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..1677e2e6 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -8,6 +8,7 @@ use Generator; use Pest\Browser\Exceptions\PlaywrightOutdatedException; use PHPUnit\Framework\ExpectationFailedException; +use RuntimeException; use function Amp\Websocket\Client\connect; @@ -75,6 +76,9 @@ public function execute(string $guid, string $method, array $params = [], array assert($this->websocketConnection instanceof WebsocketConnection, 'WebSocket client is not connected.'); $requestId = uniqid(); + $startTime = hrtime(true); + $operationTimeout = $params['timeout'] ?? $this->timeout; + $maxWaitTimeNs = $operationTimeout * 1_000_000; // Convert ms to ns $requestJson = (string) json_encode([ 'id' => $requestId, @@ -87,10 +91,33 @@ public function execute(string $guid, string $method, array $params = [], array $this->websocketConnection->sendText($requestJson); while (true) { + // Check for timeout to prevent infinite loops + $elapsed = hrtime(true) - $startTime; + if ($elapsed > $maxWaitTimeNs) { + throw new RuntimeException( + "Playwright operation '$method' timed out after ".round($elapsed / 1_000_000_000, 2).' seconds' + ); + } + $responseJson = $this->fetch($this->websocketConnection); - /** @var array{id: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ + + // Handle null/empty responses (WebSocket connection lost) + if ($responseJson === null || $responseJson === '') { + throw new RuntimeException( + "WebSocket connection lost while executing '$method' on '$guid'" + ); + } + + /** @var array{id: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}}|null $response */ $response = json_decode($responseJson, true); + // Handle JSON decode failure + if ($response === null) { + throw new RuntimeException( + 'Invalid JSON response from Playwright server: '.substr($responseJson, 0, 100) + ); + } + if (isset($response['error']['error']['message'])) { $message = $response['error']['error']['message']; @@ -130,9 +157,17 @@ public function timeout(): int /** * Fetches the response from the Playwright server. + * + * Returns null if the WebSocket connection is closed. */ - private function fetch(WebsocketConnection $client): string + private function fetch(WebsocketConnection $client): ?string { - return (string) $client->receive()?->read(); + $message = $client->receive(); + + if ($message === null) { + return null; // Connection closed + } + + return $message->read(); } } diff --git a/src/Playwright/Context.php b/src/Playwright/Context.php index 447d8582..4e41f30e 100644 --- a/src/Playwright/Context.php +++ b/src/Playwright/Context.php @@ -102,4 +102,67 @@ public function addInitScript(string $script): self return $this; } + + /** + * Gets the storage state (cookies, localStorage, sessionStorage). + * + * @return array{cookies: array, origins: array}>} + */ + public function storageState(): array + { + $response = $this->sendMessage('storageState'); + + /** @var array{result: array{cookies: array, origins: array}} $message */ + foreach ($response as $message) { + if (isset($message['result'])) { + return $message['result']; + } + } + + return ['cookies' => [], 'origins' => []]; + } + + /** + * Adds cookies into this browser context. + * + * @param array $cookies + */ + public function addCookies(array $cookies): self + { + $response = $this->sendMessage('addCookies', ['cookies' => $cookies]); + $this->processVoidResponse($response); + + return $this; + } + + /** + * Gets all cookies in this browser context. + * + * @param array $urls Optional URLs to filter cookies + * @return array + */ + public function cookies(array $urls = []): array + { + $response = $this->sendMessage('cookies', $urls !== [] ? ['urls' => $urls] : []); + + /** @var array{result: array{cookies: array}} $message */ + foreach ($response as $message) { + if (isset($message['result']['cookies'])) { + return $message['result']['cookies']; + } + } + + return []; + } + + /** + * Clears all cookies from this browser context. + */ + public function clearCookies(): self + { + $response = $this->sendMessage('clearCookies'); + $this->processVoidResponse($response); + + return $this; + } } From b5efeba7a34a19b14b969c1ac86a04ac47a1e5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Moreno=20Pe=C3=B1a?= Date: Tue, 13 Jan 2026 16:51:01 +0100 Subject: [PATCH 2/3] Update src/Playwright/Client.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Playwright/Client.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 1677e2e6..7a8b94a4 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -101,8 +101,8 @@ public function execute(string $guid, string $method, array $params = [], array $responseJson = $this->fetch($this->websocketConnection); - // Handle null/empty responses (WebSocket connection lost) - if ($responseJson === null || $responseJson === '') { + // Handle null responses (WebSocket connection lost) + if ($responseJson === null) { throw new RuntimeException( "WebSocket connection lost while executing '$method' on '$guid'" ); From e9848e61a4936bcee6431f92bc2388d3fd4ef4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Moreno=20Pe=C3=B1a?= Date: Tue, 13 Jan 2026 16:51:21 +0100 Subject: [PATCH 3/3] Update composer.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index de89efd8..5f6bcce3 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,5 @@ { "name": "pestphp/pest-plugin-browser", - "version": "4.2.99", "description": "Pest plugin to test browser interactions", "keywords": [ "php",