From 19ff6baddd410be01a6a939fbcc552af17f4e347 Mon Sep 17 00:00:00 2001 From: Veljko Vujanovic Date: Mon, 6 Apr 2026 17:11:35 +0200 Subject: [PATCH 1/3] Add feature saving storage states This adds feature to save browser's storage state just like in Playwright --- src/Api/AwaitableWebpage.php | 1 + src/Api/PendingAwaitablePage.php | 14 +++++++ src/Api/Webpage.php | 13 +++++++ src/Playwright/Context.php | 11 ++++++ src/Playwright/Page.php | 14 +++++++ src/Support/StorageState.php | 49 +++++++++++++++++++++++++ tests/Unit/Support/StorageStateTest.php | 45 +++++++++++++++++++++++ 7 files changed, 147 insertions(+) create mode 100644 src/Support/StorageState.php create mode 100644 tests/Unit/Support/StorageStateTest.php diff --git a/src/Api/AwaitableWebpage.php b/src/Api/AwaitableWebpage.php index 71ac96b1..1eb15237 100644 --- a/src/Api/AwaitableWebpage.php +++ b/src/Api/AwaitableWebpage.php @@ -28,6 +28,7 @@ public function __construct( private array $nonAwaitableMethods = [ 'assertScreenshotMatches', 'assertNoAccessibilityIssues', + 'saveStorageState', ], ) { // diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 83ad7b77..86740341 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -10,6 +10,7 @@ use Pest\Browser\Playwright\InitScript; use Pest\Browser\Playwright\Playwright; use Pest\Browser\Support\ComputeUrl; +use Pest\Browser\Support\StorageState; /** * @mixin Webpage|AwaitableWebpage @@ -154,6 +155,19 @@ public function geolocation(float $latitude, float $longitude): self ]); } + /** + * Loads a previously saved storage state (cookies and localStorage) into the browser context. + * + * This allows tests to skip login flows by reusing authenticated state saved with saveStorageState(). + */ + public function withStorageState(string $name): self + { + return new self($this->browserType, $this->device, $this->url, [ + 'storageState' => StorageState::path($name), + ...$this->options, + ]); + } + /** * Creates the webpage instance. */ diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..5f0eff59 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -95,6 +95,19 @@ public function value(string $selector): string return $this->guessLocator($selector)->inputValue(); } + /** + * Saves the current browser context's storage state (cookies and localStorage) to a file. + * + * The file is saved to tests/Browser/StorageState/.json and can be loaded in + * subsequent tests via withStorageState() to avoid repetitive login flows. + */ + public function saveStorageState(?string $name = null): self + { + $this->page->saveStorageState($name); + + return $this; + } + /** * Gets the locator for the given selector. */ diff --git a/src/Playwright/Context.php b/src/Playwright/Context.php index 447d8582..24aedc72 100644 --- a/src/Playwright/Context.php +++ b/src/Playwright/Context.php @@ -102,4 +102,15 @@ public function addInitScript(string $script): self return $this; } + + /** + * Returns the current storage state (cookies and localStorage) as a JSON string. + */ + public function storageState(): string + { + $response = $this->sendMessage('storageState'); + $result = $this->processResultResponse($response); + + return (string) json_encode($result); + } } diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index d97f3db3..9aa0d860 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -11,6 +11,7 @@ use Pest\Browser\Support\Screenshot; use Pest\Browser\Support\Selector; use Pest\Browser\Support\Shell; +use Pest\Browser\Support\StorageState; use Pest\TestSuite; use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; @@ -438,6 +439,19 @@ public function screenshot(bool $fullPage = true, ?string $filename = null): ?st return Screenshot::save($binary, $filename); } + /** + * Saves the current browser context's storage state (cookies and localStorage) to a file. + * + * The file is stored under tests/Browser/StorageState/ and can be loaded in subsequent + * tests via withStorageState() to skip repetitive login flows. + */ + public function saveStorageState(?string $name = null): string + { + $json = $this->context->storageState(); + + return StorageState::save($json, $name); + } + /** * Make screenshot of a specific element. */ diff --git a/src/Support/StorageState.php b/src/Support/StorageState.php new file mode 100644 index 00000000..80d1a6d3 --- /dev/null +++ b/src/Support/StorageState.php @@ -0,0 +1,49 @@ +rootPath + .'/tests/Browser/StorageState'; + } + + /** + * Return the full path for a storage state file. + */ + public static function path(string $name): string + { + return self::dir().DIRECTORY_SEPARATOR.mb_ltrim($name, '/').'.json'; + } + + /** + * Save a storage state JSON string to the filesystem. + */ + public static function save(string $json, ?string $name = null): string + { + if ($name === null) { + // @phpstan-ignore-next-line + $name = str_replace('__pest_evaluable_', '', test()->name()); + } + + if (is_dir(self::dir()) === false) { + mkdir(self::dir(), 0755, true); + } + + file_put_contents(self::path($name), $json); + + return $name; + } +} diff --git a/tests/Unit/Support/StorageStateTest.php b/tests/Unit/Support/StorageStateTest.php new file mode 100644 index 00000000..2123fbbf --- /dev/null +++ b/tests/Unit/Support/StorageStateTest.php @@ -0,0 +1,45 @@ +toBeTrue(); + + @unlink(StorageState::path('auth')); +}); + +it('returns the name used to save the state', function (): void { + $name = StorageState::save('{"cookies":[],"origins":[]}', 'my-session'); + + expect($name)->toBe('my-session'); + + @unlink(StorageState::path('my-session')); +}); + +it('builds the correct file path from a name', function (): void { + $path = StorageState::path('auth'); + + expect($path)->toEndWith('tests/Browser/StorageState/auth.json'); +}); + +it('strips a leading slash from the name', function (): void { + $path = StorageState::path('/auth'); + + expect($path)->not->toContain('//'); + expect($path)->toEndWith('tests/Browser/StorageState/auth.json'); +}); + +it('overwrites an existing state file on re-save', function (): void { + StorageState::save('{"cookies":[],"origins":[]}', 'overwrite-test'); + StorageState::save('{"cookies":[{"name":"session"}],"origins":[]}', 'overwrite-test'); + + $contents = file_get_contents(StorageState::path('overwrite-test')); + + expect($contents)->toContain('session'); + + @unlink(StorageState::path('overwrite-test')); +}); From 1fe2bf81a1adb8795e361b79fea602776545a3b3 Mon Sep 17 00:00:00 2001 From: "Veljko V." Date: Sat, 11 Apr 2026 21:34:02 +0200 Subject: [PATCH 2/3] fix: saving storage state file --- .gitignore | 1 + src/Api/PendingAwaitablePage.php | 13 +++++++++++-- src/Playwright/Context.php | 9 +++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4733d042..e2665181 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage.xml # Playwright node_modules/ /tests/Browser/Screenshots +/tests/Browser/StorageState # MacOS .DS_Store diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 86740341..8a734eb6 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -176,7 +176,7 @@ private function createAwaitablePage(): AwaitableWebpage $options = $this->options; $host = $this->extractHost($options); - return $this->withTemporaryHost($host, fn (): AwaitableWebpage => $this->buildAwaitablePage($options)); + return $this->withTemporaryHost($host, fn(): AwaitableWebpage => $this->buildAwaitablePage($options)); } /** @@ -184,6 +184,13 @@ private function createAwaitablePage(): AwaitableWebpage */ private function buildAwaitablePage(array $options): AwaitableWebpage { + if (isset($options['storageState']) && is_string($options['storageState'])) { + $options['storageState'] = json_decode( + (string) file_get_contents($options['storageState']), + true, + ); + } + $browser = Playwright::browser($this->browserType)->launch(); $context = $browser->newContext([ @@ -198,8 +205,10 @@ private function buildAwaitablePage(array $options): AwaitableWebpage $url = ComputeUrl::from($this->url); + $gotoOptions = array_diff_key($options, array_flip(['storageState', 'host'])); + return new AwaitableWebpage( - $context->newPage()->goto($url, $options), + $context->newPage()->goto($url, $gotoOptions), $url, ); } diff --git a/src/Playwright/Context.php b/src/Playwright/Context.php index 24aedc72..abd5655d 100644 --- a/src/Playwright/Context.php +++ b/src/Playwright/Context.php @@ -109,8 +109,13 @@ public function addInitScript(string $script): self public function storageState(): string { $response = $this->sendMessage('storageState'); - $result = $this->processResultResponse($response); - return (string) json_encode($result); + foreach ($response as $message) { + if (isset($message['result'])) { + return (string) json_encode($message['result']); + } + } + + return '{"cookies":[],"origins":[]}'; } } From 92b5ad7c973acfe85dffb788096eb7ebe5f524fb Mon Sep 17 00:00:00 2001 From: "Veljko V." Date: Sat, 11 Apr 2026 21:36:06 +0200 Subject: [PATCH 3/3] fix: spacing --- src/Api/PendingAwaitablePage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 8a734eb6..15aef179 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -176,7 +176,7 @@ private function createAwaitablePage(): AwaitableWebpage $options = $this->options; $host = $this->extractHost($options); - return $this->withTemporaryHost($host, fn(): AwaitableWebpage => $this->buildAwaitablePage($options)); + return $this->withTemporaryHost($host, fn (): AwaitableWebpage => $this->buildAwaitablePage($options)); } /**