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..0aff663e --- /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 876e7af3..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; @@ -26,6 +27,13 @@ final class Client */ private ?WebsocketConnection $websocketConnection = null; + /** + * Registry of Page instances for handling events. + * + * @var array> + */ + private array $pages = []; + /** * Default timeout for requests in milliseconds. */ @@ -87,8 +95,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, 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'])) { @@ -101,6 +111,17 @@ 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->getPage($response['guid']) instanceof Page) { + $this->unregisterPage($response['guid']); + } + yield $response; if ( @@ -128,6 +149,44 @@ 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] = WeakReference::create($page); + } + + /** + * Removes page from event handling. + */ + 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. + * + * @param array{mainFrame: array{guid: string}, opener: array{guid: string}} $initializer + */ + private function handlePopupCreation(string $openerGuid, string $popupGuid, array $initializer): void + { + $opener = $this->getPage($openerGuid); + if ($opener instanceof Page && $opener->hasPendingPopup()) { + $opener->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 d97f3db3..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. */ @@ -40,7 +46,7 @@ public function __construct( private readonly string $guid, private readonly string $frameGuid, ) { - // + Client::instance()->registerPage($guid, $this); } /** @@ -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/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 diff --git a/tests/Browser/Webpage/PopupTest.php b/tests/Browser/Webpage/PopupTest.php new file mode 100644 index 00000000..404c970e --- /dev/null +++ b/tests/Browser/Webpage/PopupTest.php @@ -0,0 +1,181 @@ + ' + +
+ '); + + 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 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 => ' + +
+ '); + + 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'); + +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');