From a9af12d756d92abb3ef365ce3e75a2ed5871e55f Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Fri, 3 Oct 2025 05:53:38 +0100 Subject: [PATCH 1/9] register Page with Client to enable events to be handled --- src/Playwright/Client.php | 32 +++++++++++++++++++++++++++++++- src/Playwright/Page.php | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..85cd10be 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -26,6 +26,13 @@ final class Client */ private ?WebsocketConnection $websocketConnection = null; + /** + * Registry of Page instances for handling events. + * + * @var Page[] + */ + private array $pages = []; + /** * Default timeout for requests in milliseconds. */ @@ -87,8 +94,10 @@ public function execute(string $guid, string $method, array $params = [], array $this->websocketConnection->sendText($requestJson); while (true) { + // @phpstan-ignore-next-line $responseJson = $this->fetch($this->websocketConnection); - /** @var array{id: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ + + /** @var array{id: string|null, guid: string|null, method: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ $response = json_decode($responseJson, true); if (isset($response['error']['error']['message'])) { @@ -101,6 +110,11 @@ public function execute(string $guid, string $method, array $params = [], array throw new ExpectationFailedException($message); } + if (isset($response['method']) && $response['method'] === '__dispose__' + && isset($response['guid'], $this->pages[$response['guid']])) { + $this->unregisterPage($response['guid']); + } + yield $response; if ( @@ -128,6 +142,22 @@ public function timeout(): int return $this->timeout; } + /** + * Registers the current page for event handling. + */ + public function registerPage(string $guid, Page $page): void + { + $this->pages[$guid] = $page; + } + + /** + * Removes page from event handling. + */ + public function unregisterPage(string $guid): void + { + unset($this->pages[$guid]); + } + /** * Fetches the response from the Playwright server. */ diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index d97f3db3..fae11cab 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -40,7 +40,7 @@ public function __construct( private readonly string $guid, private readonly string $frameGuid, ) { - // + Client::instance()->registerPage($guid, $this); } /** From 6919eca915dbed4f5f6043c690d8e748886ecd7e Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Fri, 3 Oct 2025 06:27:12 +0100 Subject: [PATCH 2/9] Add popup window support Add Page->pendingPopup() to get an object that will resolve to a window opened by page. --- src/Api/Concerns/InteractsWithPopups.php | 36 ++++++ src/Api/PendingAwaitablePopup.php | 63 ++++++++++ src/Api/Webpage.php | 1 + src/Playwright/Client.php | 20 +++- src/Playwright/Page.php | 43 +++++++ tests/Browser/Webpage/PopupTest.php | 141 +++++++++++++++++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/Api/Concerns/InteractsWithPopups.php create mode 100644 src/Api/PendingAwaitablePopup.php create mode 100644 tests/Browser/Webpage/PopupTest.php diff --git a/src/Api/Concerns/InteractsWithPopups.php b/src/Api/Concerns/InteractsWithPopups.php new file mode 100644 index 00000000..5725d993 --- /dev/null +++ b/src/Api/Concerns/InteractsWithPopups.php @@ -0,0 +1,36 @@ +page->pendingPopup(); + } + + /** + * Remove any previously set popup handler. + */ + public function removePendingPopup(): self + { + $this->page->removePendingPopup(); + + return $this; + } + + /** + * Check if a popup handler is currently set. + */ + public function hasPendingPopup(): bool + { + return $this->page->hasPendingPopup(); + } +} diff --git a/src/Api/PendingAwaitablePopup.php b/src/Api/PendingAwaitablePopup.php new file mode 100644 index 00000000..261bbfd2 --- /dev/null +++ b/src/Api/PendingAwaitablePopup.php @@ -0,0 +1,63 @@ + $arguments + */ + public function __call(string $name, array $arguments): mixed + { + if ($this->waitablePage instanceof AwaitableWebpage) { + // @phpstan-ignore-next-line + return $this->waitablePage->{$name}(...$arguments); + } + + $result = Execution::instance()->waitForExpectation(function () use ($name, $arguments): mixed { + if (is_null($this->waitablePage)) { + $e = new ExpectationFailedException('No popup opened'); + throw BrowserExpectationFailedException::from($this->opener, $e); + } + + // @phpstan-ignore-next-line + return $this->waitablePage->{$name}(...$arguments); + }); + + return $result === $this->waitablePage + ? $this + : $result; + } + + public function handlePopupCreation(string $popupGuid, string $frameGuid): void + { + $page = new Page($this->opener->context(), $popupGuid, $frameGuid); + $this->waitablePage = new AwaitableWebpage($page, '(popup)'); + } +} diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..7a54bf8d 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -14,6 +14,7 @@ use Concerns\HasWaitCapabilities, Concerns\InteractsWithElements, Concerns\InteractsWithFrames, + Concerns\InteractsWithPopups, Concerns\InteractsWithScreen, Concerns\InteractsWithTab, Concerns\InteractsWithToolbar, diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 85cd10be..b7c5db78 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -97,7 +97,7 @@ public function execute(string $guid, string $method, array $params = [], array // @phpstan-ignore-next-line $responseJson = $this->fetch($this->websocketConnection); - /** @var array{id: string|null, guid: string|null, method: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ + /** @var array{id: string|null, guid: string|null, method: string|null, params: array{add: string|null, type: string|null, guid: string|null, initializer: array{mainFrame: array{guid: string}, opener: array{guid: string}}|null }, error: array{error: array{message: string|null}}} $response */ $response = json_decode($responseJson, true); if (isset($response['error']['error']['message'])) { @@ -110,6 +110,12 @@ public function execute(string $guid, string $method, array $params = [], array throw new ExpectationFailedException($message); } + if (isset($response['method']) && $response['method'] === '__create__' + && isset($response['params']['type']) && $response['params']['type'] === 'Page' + && isset($response['guid'], $response['params']['guid'], $response['params']['initializer']['opener']['guid'])) { + $this->handlePopupCreation($response['params']['initializer']['opener']['guid'], $response['params']['guid'], $response['params']['initializer']); + } + if (isset($response['method']) && $response['method'] === '__dispose__' && isset($response['guid'], $this->pages[$response['guid']])) { $this->unregisterPage($response['guid']); @@ -158,6 +164,18 @@ public function unregisterPage(string $guid): void unset($this->pages[$guid]); } + /** + * Handles popup creation events. + * + * @param array{mainFrame: array{guid: string}, opener: array{guid: string}} $initializer + */ + private function handlePopupCreation(string $openerGuid, string $popupGuid, array $initializer): void + { + if (isset($this->pages[$openerGuid]) && $this->pages[$openerGuid]->hasPendingPopup()) { + $this->pages[$openerGuid]->handlePopupCreation($popupGuid, $initializer['mainFrame']['guid']); + } + } + /** * Fetches the response from the Playwright server. */ diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index fae11cab..841bb036 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -5,6 +5,7 @@ namespace Pest\Browser\Playwright; use Generator; +use Pest\Browser\Api\PendingAwaitablePopup; use Pest\Browser\Execution; use Pest\Browser\Support\ImageDiffView; use Pest\Browser\Support\JavaScriptSerializer; @@ -32,6 +33,11 @@ final class Page */ private bool $strictLocators = true; + /** + * Pending AwaitablePage for a Popup. + */ + private ?PendingAwaitablePopup $pendingPopup = null; + /** * Creates a new page instance. */ @@ -566,6 +572,43 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void } } + /** + * Sets up a popup handler for this page. + */ + public function pendingPopup(): PendingAwaitablePopup + { + $this->pendingPopup = new PendingAwaitablePopup($this); + + return $this->pendingPopup; + } + + /** + * Removes any previously set popup handler from this page. + */ + public function removePendingPopup(): void + { + $this->pendingPopup = null; + } + + /** + * Checks if a popup handler is currently set. + */ + public function hasPendingPopup(): bool + { + return $this->pendingPopup instanceof PendingAwaitablePopup; + } + + /** + * Handles a popup creation event from the Playwright server. + */ + public function handlePopupCreation(string $popupGuid, string $frameGuid): void + { + if ($this->pendingPopup instanceof PendingAwaitablePopup) { + $this->pendingPopup->handlePopupCreation($popupGuid, $frameGuid); + $this->removePendingPopup(); + } + } + /** * Closes the page. */ diff --git a/tests/Browser/Webpage/PopupTest.php b/tests/Browser/Webpage/PopupTest.php new file mode 100644 index 00000000..d38c0ded --- /dev/null +++ b/tests/Browser/Webpage/PopupTest.php @@ -0,0 +1,141 @@ + ' + +
+ '); + + Route::get('/popup', fn (): string => ' + + '); + + $page = visit('/'); + + $popup = $page->pendingPopup(); + $page->click('#popup-btn'); + + $popup->assertSeeIn('#popup-content', 'Another page'); + + expect($page->text('#result'))->toBe('Window opened'); +}); + +it('can interact with popup', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + Route::get('/popup', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $popup = $page->pendingPopup(); + $page->click('#popup-btn'); + + $popup->click('#change-btn'); + + expect($page->text('#result'))->toBe('Window opened'); + + expect($popup->text('#result'))->toBe('altered'); +}); + +it('removes pending popup from page when opened', function (): void { + Route::get('/', fn (): string => ' + + '); + + Route::get('/popup', fn (): string => ' + + '); + + $page = visit('/'); + + $popup = $page->pendingPopup(); + $page->click('#popup-btn'); + + $popup->assertSeeIn('#popup-content', 'Another page'); + + expect($page->hasPendingPopup())->toBeFalse(); +}); + +it('can remove pending popup', function (): void { + $page = visit('/'); + + expect($page->hasPendingPopup())->toBeFalse(); + + $popup = $page->pendingPopup(); + expect($popup)->toBeInstanceOf(PendingAwaitablePopup::class); + expect($page->hasPendingPopup())->toBeTrue(); + + $page->removePendingPopup(); + expect($page->hasPendingPopup())->toBeFalse(); +}); + +it('can open popups for multiple pages', function (): void { + Route::get('/a', fn (): string => ' + + '); + Route::get('/b', fn (): string => ' + + '); + + Route::get('/popup-a', fn (): string => ' + + '); + Route::get('/popup-b', fn (): string => ' + + '); + + $pageA = visit('/a'); + $pageB = visit('/b'); + + $popupA = $pageA->pendingPopup(); + $popupB = $pageB->pendingPopup(); + + $pageB->click('#popup-btn'); + $pageA->click('#popup-btn'); + + $popupA->assertSeeIn('#popup-content', 'Popup Window A'); + $popupB->assertSeeIn('#popup-content', 'Popup Window B'); +}); + +it('can open a nested popup', function (): void { + Route::get('/', fn (): string => ' + + '); + + Route::get('/popup', fn (): string => ' + + '); + Route::get('/nested-popup', fn (): string => ' + + '); + + $page = visit('/'); + + $popup = $page->pendingPopup(); + $page->click('#popup-btn'); + + $nested = $popup->pendingPopup(); + $popup->click('#nested-popup-btn'); + + $nested->assertSeeIn('#popup-content', 'Nested Window'); +}); + +it('fails interaction if popup does not open', function (): void { + $page = visit('/'); + + $popup = $page->pendingPopup(); + + $popup->click('#no-btn'); +})->throws(ExpectationFailedException::class, 'No popup opened'); From 6a567f47114c89f41794e2c7dcc0986714890d6b Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Fri, 3 Oct 2025 22:45:35 +0100 Subject: [PATCH 3/9] test popup: window.open in new window Test popup as new window rather than just a new tab --- tests/Browser/Webpage/PopupTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Browser/Webpage/PopupTest.php b/tests/Browser/Webpage/PopupTest.php index d38c0ded..c1d1535e 100644 --- a/tests/Browser/Webpage/PopupTest.php +++ b/tests/Browser/Webpage/PopupTest.php @@ -8,7 +8,10 @@ it('can handle window.open', function (): void { Route::get('/', fn (): string => ' - +
'); From 4add780cbb03964ace785f01d734da72418c8154 Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Fri, 3 Oct 2025 22:47:01 +0100 Subject: [PATCH 4/9] test popup: anchor with target Test opening a new tab with a link --- tests/Browser/Webpage/PopupTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Browser/Webpage/PopupTest.php b/tests/Browser/Webpage/PopupTest.php index c1d1535e..be77fb21 100644 --- a/tests/Browser/Webpage/PopupTest.php +++ b/tests/Browser/Webpage/PopupTest.php @@ -29,6 +29,23 @@ expect($page->text('#result'))->toBe('Window opened'); }); +it('can handle link with target', function (): void { + Route::get('/', fn (): string => ' + Open Link in new tab + '); + + Route::get('/popup', fn(): string => ' + + '); + + $page = visit('/'); + + $popup = $page->pendingPopup(); + $page->click('#popup-link'); + + $popup->assertSeeIn('#popup-content', 'Another tab'); +}); + it('can interact with popup', function (): void { Route::get('/', fn (): string => ' From 87449e3e32a8b537ae24ea50a58542411081289c Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Sat, 4 Oct 2025 04:52:52 +0100 Subject: [PATCH 5/9] popup fix lint --- tests/Browser/Webpage/PopupTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Browser/Webpage/PopupTest.php b/tests/Browser/Webpage/PopupTest.php index be77fb21..eac85a0f 100644 --- a/tests/Browser/Webpage/PopupTest.php +++ b/tests/Browser/Webpage/PopupTest.php @@ -34,7 +34,7 @@ Open Link in new tab '); - Route::get('/popup', fn(): string => ' + Route::get('/popup', fn (): string => ' '); From 3a2e8542429d2529eff6f3b3c33c24889fcc4558 Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Mon, 20 Oct 2025 04:05:33 +0100 Subject: [PATCH 6/9] test popup: can make smoke assertions Check initScript gets run on the popup window so that we can make assertions for console logs / js errors. --- tests/Browser/Webpage/PopupTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Browser/Webpage/PopupTest.php b/tests/Browser/Webpage/PopupTest.php index eac85a0f..404c970e 100644 --- a/tests/Browser/Webpage/PopupTest.php +++ b/tests/Browser/Webpage/PopupTest.php @@ -159,3 +159,23 @@ $popup->click('#no-btn'); })->throws(ExpectationFailedException::class, 'No popup opened'); + +it('can check for smoke in popup window', function (): void { + Route::get('/', fn (): string => ' + + '); + + Route::get('/popup', fn (): string => ' + +
Some Content
+ '); + + $page = visit('/'); + + $popup = $page->pendingPopup(); + $page->click('#popup-btn'); + + $popup->click('#log-btn'); + + $popup->assertNoConsoleLogs(); +})->throws(ExpectationFailedException::class, 'but found 1: popped up and logged'); From 7e03669025d9aa3ada4e967e9a7e2a358ce003c5 Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Mon, 20 Oct 2025 04:07:10 +0100 Subject: [PATCH 7/9] fix PendingAwaitablePopup mixin tag phpstan does not parse multiple classes separated by pipe character '|' (multiple classes can be specified in multiple @mixin tag lines but in this case that is not needed as AwaitableWebpage already has a @mixin for Webpage) --- src/Api/PendingAwaitablePopup.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/PendingAwaitablePopup.php b/src/Api/PendingAwaitablePopup.php index 261bbfd2..0aff663e 100644 --- a/src/Api/PendingAwaitablePopup.php +++ b/src/Api/PendingAwaitablePopup.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\ExpectationFailedException; /** - * @mixin Webpage|AwaitableWebpage + * @mixin AwaitableWebpage */ final class PendingAwaitablePopup { From 334ae1ff037409aa1e41baa6fd34acf0e6d95d95 Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Sat, 25 Oct 2025 17:29:50 +0100 Subject: [PATCH 8/9] Use WeakReference for register of Pages Allow Page to be garbage collected if all other references to it have gone out of scope. --- src/Playwright/Client.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index b7c5db78..77536c2a 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 WeakReference; use function Amp\Websocket\Client\connect; @@ -29,7 +30,7 @@ final class Client /** * Registry of Page instances for handling events. * - * @var Page[] + * @var array> */ private array $pages = []; @@ -117,7 +118,7 @@ public function execute(string $guid, string $method, array $params = [], array } if (isset($response['method']) && $response['method'] === '__dispose__' - && isset($response['guid'], $this->pages[$response['guid']])) { + && isset($response['guid']) && $this->getPage($response['guid']) instanceof Page) { $this->unregisterPage($response['guid']); } @@ -153,7 +154,7 @@ public function timeout(): int */ public function registerPage(string $guid, Page $page): void { - $this->pages[$guid] = $page; + $this->pages[$guid] = WeakReference::create($page); } /** @@ -164,6 +165,15 @@ public function unregisterPage(string $guid): void unset($this->pages[$guid]); } + private function getPage(string $guid): ?Page + { + if (! array_key_exists($guid, $this->pages)) { + return null; + } + + return $this->pages[$guid]->get(); + } + /** * Handles popup creation events. * @@ -171,8 +181,9 @@ public function unregisterPage(string $guid): void */ private function handlePopupCreation(string $openerGuid, string $popupGuid, array $initializer): void { - if (isset($this->pages[$openerGuid]) && $this->pages[$openerGuid]->hasPendingPopup()) { - $this->pages[$openerGuid]->handlePopupCreation($popupGuid, $initializer['mainFrame']['guid']); + $opener = $this->getPage($openerGuid); + if ($opener instanceof Page && $opener->hasPendingPopup()) { + $opener->handlePopupCreation($popupGuid, $initializer['mainFrame']['guid']); } } From c9bab1ff462fddea47eace31829d14385b7547b3 Mon Sep 17 00:00:00 2001 From: Bill Ruddock Date: Thu, 6 Nov 2025 06:10:52 +0000 Subject: [PATCH 9/9] lint fix: first class callable syntax --- src/Support/JavaScriptSerializer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/JavaScriptSerializer.php b/src/Support/JavaScriptSerializer.php index 149c5a38..adb9b652 100644 --- a/src/Support/JavaScriptSerializer.php +++ b/src/Support/JavaScriptSerializer.php @@ -148,7 +148,7 @@ public static function parseValue(mixed $value): mixed // Handle arrays if (isset($value['a'])) { - return array_map(fn (mixed $item): mixed => self::parseValue($item), $value['a']); + return array_map(self::parseValue(...), $value['a']); } // Handle objects