From ee8f03617944c36fe60f3ef0b7b8dafe8b9ad297 Mon Sep 17 00:00:00 2001 From: JT Smith Date: Sun, 1 Mar 2026 01:57:00 -0700 Subject: [PATCH 1/4] feat: use multiple browsers for individual tests --- src/Api/MultiBrowserPendingPage.php | 107 ++++++++++++++++++ src/Api/PendingAwaitablePage.php | 28 +++++ src/Playwright/Client.php | 21 +++- src/Playwright/Playwright.php | 24 ++++ .../Browser/Webpage/MultiBrowserErrorTest.php | 15 +++ .../Webpage/MultiBrowserIntegrationTest.php | 49 ++++++++ tests/Browser/Webpage/MultiBrowserTest.php | 59 ++++++++++ 7 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/Api/MultiBrowserPendingPage.php create mode 100644 tests/Browser/Webpage/MultiBrowserErrorTest.php create mode 100644 tests/Browser/Webpage/MultiBrowserIntegrationTest.php create mode 100644 tests/Browser/Webpage/MultiBrowserTest.php diff --git a/src/Api/MultiBrowserPendingPage.php b/src/Api/MultiBrowserPendingPage.php new file mode 100644 index 00000000..2693557f --- /dev/null +++ b/src/Api/MultiBrowserPendingPage.php @@ -0,0 +1,107 @@ + + */ +final class MultiBrowserPendingPage implements Countable, IteratorAggregate +{ + /** + * @param array $pendingPages + */ + public function __construct( + private array $pendingPages, + ) { + // + } + + /** + * Forward method calls to all pending pages for configuration. + * + * @param array $arguments + */ + public function __call(string $name, array $arguments): self + { + foreach ($this->pendingPages as $pendingPage) { + $pendingPage->{$name}(...$arguments); // @phpstan-ignore-line + } + + return $this; + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->pendingPages); + } + + public function count(): int + { + return count($this->pendingPages); + } + + /** + * Execute a callback on each browser sequentially. + * Each browser is closed before switching to the next. + * If any browser fails, the exception is thrown immediately. + * + * @param callable(PendingAwaitablePage): mixed $callback + */ + public function each(callable $callback): self + { + $previousBrowserType = Playwright::defaultBrowserType(); + + try { + foreach ($this->pendingPages as $pendingPage) { + $browserType = $pendingPage->getBrowserType(); + + Playwright::closeOthers($browserType); + Playwright::setDefaultBrowserType($browserType); + + $callback($pendingPage); + } + } finally { + Playwright::setDefaultBrowserType($previousBrowserType); + } + + return $this; + } + + /** + * Execute a callback and get results from each browser. + * If any browser fails, the exception is thrown immediately. + * + * @param callable(PendingAwaitablePage): mixed $callback + * @return array + */ + public function eachResult(callable $callback): array + { + $results = []; + $previousBrowserType = Playwright::defaultBrowserType(); + + try { + foreach ($this->pendingPages as $pendingPage) { + $browserType = $pendingPage->getBrowserType(); + + Playwright::closeOthers($browserType); + Playwright::setDefaultBrowserType($browserType); + + $results[] = $callback($pendingPage); + } + } finally { + Playwright::setDefaultBrowserType($previousBrowserType); + } + + return $results; + } +} diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 83ad7b77..ce34d726 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -48,6 +48,14 @@ public function __call(string $name, array $arguments): mixed return $this->waitablePage->{$name}(...$arguments); } + /** + * Get the browser type for this pending page. + */ + public function getBrowserType(): BrowserType + { + return $this->browserType; + } + /** * Sets the color scheme to dark mode. */ @@ -154,6 +162,26 @@ public function geolocation(float $latitude, float $longitude): self ]); } + /** + * Sets the browsers to run the test on. + * + * @param array $browserTypes + */ + public function browser(array $browserTypes): MultiBrowserPendingPage + { + $pendingPages = array_map( + fn (BrowserType $browserType): PendingAwaitablePage => new self( + $browserType, + $this->device, + $this->url, + $this->options, + ), + $browserTypes, + ); + + return new MultiBrowserPendingPage($pendingPages); + } + /** * Creates the webpage instance. */ diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..44f041ba 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 Throwable; use function Amp\Websocket\Client\connect; @@ -46,10 +47,10 @@ public static function instance(): self /** * Connects to the Playwright server. */ - public function connectTo(string $url): void + public function connectTo(string $url, ?string $browser = null): void { if (! $this->websocketConnection instanceof WebsocketConnection) { - $browser = Playwright::defaultBrowserType()->toPlaywrightName(); + $browser ??= Playwright::defaultBrowserType()->toPlaywrightName(); $launchOptions = json_encode([ 'headless' => Playwright::isHeadless(), @@ -128,6 +129,22 @@ public function timeout(): int return $this->timeout; } + /** + * Disconnects from the Playwright server. + */ + public function disconnect(): void + { + if ($this->websocketConnection instanceof WebsocketConnection) { + try { + $this->websocketConnection->close(); + } catch (Throwable) { + // Ignore close errors + } + } + + $this->websocketConnection = null; + } + /** * Fetches the response from the Playwright server. */ diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index c0cffae1..bc5b83bf 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -6,6 +6,7 @@ use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; +use Pest\Browser\ServerManager; /** * @internal @@ -79,6 +80,29 @@ public static function close(): void } self::$browserTypes = []; + + Client::instance()->disconnect(); + } + + /** + * Close all browsers except the specified type and reconnect. + */ + public static function closeOthers(BrowserType $exceptBrowserType): void + { + $exceptName = $exceptBrowserType->toPlaywrightName(); + + foreach (self::$browserTypes as $name => $browserType) { + if ($name !== $exceptName) { + $browserType->close(); + } + } + + self::$browserTypes = []; + + Client::instance()->disconnect(); + + $url = ServerManager::instance()->playwright()->url(); + Client::instance()->connectTo($url, $exceptName); } /** diff --git a/tests/Browser/Webpage/MultiBrowserErrorTest.php b/tests/Browser/Webpage/MultiBrowserErrorTest.php new file mode 100644 index 00000000..a5d9a49f --- /dev/null +++ b/tests/Browser/Webpage/MultiBrowserErrorTest.php @@ -0,0 +1,15 @@ + '

Error Test

'); + + visit('/error-test') + ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) + ->each(function ($page) { + $page->assertSee('Non-existent text'); + }); +})->throws(PHPUnit\Framework\ExpectationFailedException::class); diff --git a/tests/Browser/Webpage/MultiBrowserIntegrationTest.php b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php new file mode 100644 index 00000000..55a6a8d0 --- /dev/null +++ b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php @@ -0,0 +1,49 @@ + '

Multi Browser

'); + + visit('/multi')->browser([BrowserType::CHROME, BrowserType::FIREFOX]) + ->each(function ($page) { + $page->assertSee('Multi Browser'); + }); +}); + +it('may run assertions on multiple browsers with visit() and chaining', function (): void { + Route::get('/multi-dark', fn (): string => '

Dark Mode Test

'); + + visit('/multi-dark') + ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) + ->inDarkMode() + ->each(function ($page) { + $page->assertSee('Dark Mode Test'); + }); +}); + +it('may chain configuration options before each()', function (): void { + Route::get('/chain', fn (): string => '

Chain Test

'); + + visit('/chain') + ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) + ->inDarkMode() + ->withLocale('en-US') + ->each(function ($page) { + $page->assertSee('Chain Test'); + }); +}); + +it('may use eachResult() to get results from each browser', function (): void { + Route::get('/each-result', fn (): string => '

Result Test

'); + + $results = visit('/each-result') + ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) + ->eachResult(function ($page) { + return $page->url(); + }); + + expect($results)->toHaveCount(2); +}); diff --git a/tests/Browser/Webpage/MultiBrowserTest.php b/tests/Browser/Webpage/MultiBrowserTest.php new file mode 100644 index 00000000..dcccfedf --- /dev/null +++ b/tests/Browser/Webpage/MultiBrowserTest.php @@ -0,0 +1,59 @@ +browser([BrowserType::CHROME, BrowserType::FIREFOX]); + + expect($pages)->toBeInstanceOf(MultiBrowserPendingPage::class); + expect($pages)->toHaveCount(2); +}); + +it('iterates over multiple browsers', function (): void { + $page = new PendingAwaitablePage( + BrowserType::CHROME, + Device::DESKTOP, + '/test', + [], + ); + + $pages = $page->browser([BrowserType::CHROME, BrowserType::FIREFOX]); + + $count = 0; + foreach ($pages as $p) { + expect($p)->toBeInstanceOf(PendingAwaitablePage::class); + $count++; + } + + expect($count)->toBe(2); +}); + +it('executes callback for each browser', function (): void { + $page = new PendingAwaitablePage( + BrowserType::CHROME, + Device::DESKTOP, + '/test', + [], + ); + + $pages = $page->browser([BrowserType::CHROME, BrowserType::FIREFOX]); + + $browsers = []; + $pages->each(function (PendingAwaitablePage $p) use (&$browsers) { + $browsers[] = $p->getBrowserType(); + }); + + expect($browsers)->toBe([BrowserType::CHROME, BrowserType::FIREFOX]); +}); From 979da494aa1c8b709907895da63135c72a54ca61 Mon Sep 17 00:00:00 2001 From: JT Smith Date: Sun, 1 Mar 2026 02:17:35 -0700 Subject: [PATCH 2/4] fix: rector --- src/Api/MultiBrowserPendingPage.php | 2 +- tests/Browser/Webpage/MultiBrowserErrorTest.php | 2 +- tests/Browser/Webpage/MultiBrowserIntegrationTest.php | 10 ++++------ tests/Browser/Webpage/MultiBrowserTest.php | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Api/MultiBrowserPendingPage.php b/src/Api/MultiBrowserPendingPage.php index 2693557f..df7feedb 100644 --- a/src/Api/MultiBrowserPendingPage.php +++ b/src/Api/MultiBrowserPendingPage.php @@ -15,7 +15,7 @@ * * @implements IteratorAggregate */ -final class MultiBrowserPendingPage implements Countable, IteratorAggregate +final readonly class MultiBrowserPendingPage implements Countable, IteratorAggregate { /** * @param array $pendingPages diff --git a/tests/Browser/Webpage/MultiBrowserErrorTest.php b/tests/Browser/Webpage/MultiBrowserErrorTest.php index a5d9a49f..31b92539 100644 --- a/tests/Browser/Webpage/MultiBrowserErrorTest.php +++ b/tests/Browser/Webpage/MultiBrowserErrorTest.php @@ -9,7 +9,7 @@ visit('/error-test') ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) - ->each(function ($page) { + ->each(function ($page): void { $page->assertSee('Non-existent text'); }); })->throws(PHPUnit\Framework\ExpectationFailedException::class); diff --git a/tests/Browser/Webpage/MultiBrowserIntegrationTest.php b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php index 55a6a8d0..1fcce3e0 100644 --- a/tests/Browser/Webpage/MultiBrowserIntegrationTest.php +++ b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php @@ -8,7 +8,7 @@ Route::get('/multi', fn (): string => '

Multi Browser

'); visit('/multi')->browser([BrowserType::CHROME, BrowserType::FIREFOX]) - ->each(function ($page) { + ->each(function ($page): void { $page->assertSee('Multi Browser'); }); }); @@ -19,7 +19,7 @@ visit('/multi-dark') ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) ->inDarkMode() - ->each(function ($page) { + ->each(function ($page): void { $page->assertSee('Dark Mode Test'); }); }); @@ -31,7 +31,7 @@ ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) ->inDarkMode() ->withLocale('en-US') - ->each(function ($page) { + ->each(function ($page): void { $page->assertSee('Chain Test'); }); }); @@ -41,9 +41,7 @@ $results = visit('/each-result') ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) - ->eachResult(function ($page) { - return $page->url(); - }); + ->eachResult(fn($page): string => $page->url()); expect($results)->toHaveCount(2); }); diff --git a/tests/Browser/Webpage/MultiBrowserTest.php b/tests/Browser/Webpage/MultiBrowserTest.php index dcccfedf..bc9a93b2 100644 --- a/tests/Browser/Webpage/MultiBrowserTest.php +++ b/tests/Browser/Webpage/MultiBrowserTest.php @@ -51,7 +51,7 @@ $pages = $page->browser([BrowserType::CHROME, BrowserType::FIREFOX]); $browsers = []; - $pages->each(function (PendingAwaitablePage $p) use (&$browsers) { + $pages->each(function (PendingAwaitablePage $p) use (&$browsers): void { $browsers[] = $p->getBrowserType(); }); From 2aa3eaa0e4a82c59e376b758a1474139ae12101f Mon Sep 17 00:00:00 2001 From: JT Smith Date: Sun, 1 Mar 2026 02:18:06 -0700 Subject: [PATCH 3/4] fix: pint --- tests/Browser/Webpage/MultiBrowserIntegrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Browser/Webpage/MultiBrowserIntegrationTest.php b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php index 1fcce3e0..0189c66a 100644 --- a/tests/Browser/Webpage/MultiBrowserIntegrationTest.php +++ b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php @@ -41,7 +41,7 @@ $results = visit('/each-result') ->browser([BrowserType::CHROME, BrowserType::FIREFOX]) - ->eachResult(fn($page): string => $page->url()); + ->eachResult(fn ($page): string => $page->url()); expect($results)->toHaveCount(2); }); From 1350161dfbcdc815593714004078af40885ff2c9 Mon Sep 17 00:00:00 2001 From: JT Smith Date: Sun, 1 Mar 2026 02:31:13 -0700 Subject: [PATCH 4/4] fix: reconnect the client if it's not connected --- src/Playwright/Client.php | 8 ++++++++ src/Playwright/Playwright.php | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 44f041ba..4ef6c8c3 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -145,6 +145,14 @@ public function disconnect(): void $this->websocketConnection = null; } + /** + * Check if the client is connected. + */ + public function isConnected(): bool + { + return $this->websocketConnection instanceof WebsocketConnection; + } + /** * Fetches the response from the Playwright server. */ diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index bc5b83bf..9a4767ee 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -7,6 +7,7 @@ use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; use Pest\Browser\ServerManager; +use Throwable; /** * @internal @@ -219,6 +220,15 @@ public static function reset(): void foreach (self::$browserTypes as $browserType) { $browserType->reset(); } + + if (! Client::instance()->isConnected()) { + try { + $url = ServerManager::instance()->playwright()->url(); + Client::instance()->connectTo($url, self::defaultBrowserType()->toPlaywrightName()); + } catch (Throwable) { + // Ignore - ServerManager may not be initialized yet + } + } } /**