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..7a8b94a4 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 responses (WebSocket connection lost) + if ($responseJson === null) { + 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; + } }