diff --git a/src/Api/MultiBrowserPendingPage.php b/src/Api/MultiBrowserPendingPage.php new file mode 100644 index 00000000..df7feedb --- /dev/null +++ b/src/Api/MultiBrowserPendingPage.php @@ -0,0 +1,107 @@ + + */ +final readonly 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..4ef6c8c3 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,30 @@ 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; + } + + /** + * 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 c0cffae1..9a4767ee 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -6,6 +6,8 @@ use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; +use Pest\Browser\ServerManager; +use Throwable; /** * @internal @@ -79,6 +81,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); } /** @@ -195,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 + } + } } /** diff --git a/tests/Browser/Webpage/MultiBrowserErrorTest.php b/tests/Browser/Webpage/MultiBrowserErrorTest.php new file mode 100644 index 00000000..31b92539 --- /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): 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 new file mode 100644 index 00000000..0189c66a --- /dev/null +++ b/tests/Browser/Webpage/MultiBrowserIntegrationTest.php @@ -0,0 +1,47 @@ + '

Multi Browser

'); + + visit('/multi')->browser([BrowserType::CHROME, BrowserType::FIREFOX]) + ->each(function ($page): void { + $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): void { + $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): void { + $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(fn ($page): string => $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..bc9a93b2 --- /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): void { + $browsers[] = $p->getBrowserType(); + }); + + expect($browsers)->toBe([BrowserType::CHROME, BrowserType::FIREFOX]); +});