diff --git a/src/Api/Concerns/InteractsWithDialogs.php b/src/Api/Concerns/InteractsWithDialogs.php new file mode 100644 index 00000000..e2bac915 --- /dev/null +++ b/src/Api/Concerns/InteractsWithDialogs.php @@ -0,0 +1,95 @@ +page->onDialog($handler); + + return $this; + } + + /** + * Remove any previously set dialog handler. + */ + public function removeDialogHandler(): self + { + $this->page->removeDialogHandler(); + + return $this; + } + + /** + * Check if a dialog handler is currently set. + */ + public function hasDialogHandler(): bool + { + return $this->page->hasDialogHandler(); + } + + /** + * Set up automatic dialog acceptance for all future dialogs. + */ + public function acceptAllDialogs(?string $promptText = null): self + { + $this->page->onDialog(function (Dialog $dialog) use ($promptText): void { + $dialog->accept($promptText); + }); + + return $this; + } + + /** + * Set up automatic dialog dismissal for all future dialogs. + */ + public function dismissAllDialogs(): self + { + $this->page->onDialog(function (Dialog $dialog): void { + $dialog->dismiss(); + }); + + return $this; + } + + /** + * Set up a dialog handler that accepts confirm dialogs and dismisses all others. + */ + public function acceptingConfirms(): self + { + $this->page->onDialog(function (Dialog $dialog): void { + if ($dialog->type() === 'confirm') { + $dialog->accept(); + } else { + $dialog->dismiss(); + } + }); + + return $this; + } + + /** + * Set up a dialog handler that dismisses confirm dialogs and accepts all others. + */ + public function dismissingConfirms(): self + { + $this->page->onDialog(function (Dialog $dialog): void { + if ($dialog->type() === 'confirm') { + $dialog->dismiss(); + } else { + $dialog->accept(); + } + }); + + return $this; + } +} diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..c27b672e 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -12,6 +12,7 @@ final readonly class Webpage { use Concerns\HasWaitCapabilities, + Concerns\InteractsWithDialogs, Concerns\InteractsWithElements, Concerns\InteractsWithFrames, Concerns\InteractsWithScreen, diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..8dc68586 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -26,6 +26,11 @@ final class Client */ private ?WebsocketConnection $websocketConnection = null; + /** + * Current page instance for handling events. + */ + private ?Page $currentPage = null; + /** * Default timeout for requests in milliseconds. */ @@ -87,8 +92,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, method?: string, params: array{add: string|null, type: string|null, guid: string|null, initializer: array{type: string, message: string, defaultValue: string}|null }, error: array{error: array{message: string|null}}} $response */ $response = json_decode($responseJson, true); if (isset($response['error']['error']['message'])) { @@ -101,6 +108,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'] === 'Dialog' + && isset($response['params']['guid'], $response['params']['initializer'])) { + $this->handleDialogCreation($response['params']['guid'], $response['params']['initializer']); + } + yield $response; if ( @@ -128,6 +141,33 @@ public function timeout(): int return $this->timeout; } + /** + * Sets the current page for event handling. + */ + public function setCurrentPage(Page $page): void + { + $this->currentPage = $page; + } + + /** + * Handles dialog creation events. + * + * @param array{type: string, message: string, defaultValue: string} $initializer + */ + private function handleDialogCreation(string $dialogGuid, array $initializer): void + { + if ($this->currentPage instanceof Page && $this->currentPage->hasDialogHandler()) { + $dialog = new Dialog( + $dialogGuid, + $initializer['type'], + $initializer['message'], + $initializer['defaultValue'] + ); + + $this->currentPage->handleDialogEvent($dialog); + } + } + /** * Fetches the response from the Playwright server. */ diff --git a/src/Playwright/Dialog.php b/src/Playwright/Dialog.php new file mode 100644 index 00000000..ffda9c71 --- /dev/null +++ b/src/Playwright/Dialog.php @@ -0,0 +1,93 @@ +guid; + } + + /** + * Returns the dialog's message. + */ + public function message(): string + { + return $this->message; + } + + /** + * Returns the dialog's type (alert, confirm, prompt). + */ + public function type(): string + { + return $this->type; + } + + /** + * Returns the dialog's default value for prompt dialogs. + */ + public function defaultValue(): string + { + return $this->defaultValue; + } + + /** + * Accepts the dialog. + */ + public function accept(?string $promptText = null): void + { + $params = []; + if ($promptText !== null) { + $params['promptText'] = $promptText; + } + + $response = $this->sendMessage('accept', $params); + $this->processVoidResponse($response); + } + + /** + * Dismisses the dialog. + */ + public function dismiss(): void + { + $response = $this->sendMessage('dismiss'); + $this->processVoidResponse($response); + } + + /** + * Send a message to the dialog via the channel. + * + * @param array $params + */ + private function sendMessage(string $method, array $params = []): Generator + { + return Client::instance()->execute($this->guid, $method, $params); + } +} diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index d97f3db3..73aa1120 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -4,6 +4,8 @@ namespace Pest\Browser\Playwright; +use Closure; +use Exception; use Generator; use Pest\Browser\Execution; use Pest\Browser\Support\ImageDiffView; @@ -32,6 +34,11 @@ final class Page */ private bool $strictLocators = true; + /** + * Dialog event handler. + */ + private ?Closure $dialogHandler = null; + /** * Creates a new page instance. */ @@ -40,7 +47,7 @@ public function __construct( private readonly string $guid, private readonly string $frameGuid, ) { - // + Client::instance()->setCurrentPage($this); } /** @@ -566,6 +573,68 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void } } + /** + * Sets up a dialog handler for this page. + */ + public function onDialog(Closure $handler): void + { + $this->dialogHandler = $handler; + + $response = Client::instance()->execute($this->guid, 'updateSubscription', [ + 'event' => 'dialog', + 'enabled' => true, + ]); + + $this->processVoidResponse($response); + } + + /** + * Removes any previously set dialog handler. + */ + public function removeDialogHandler(): void + { + $this->dialogHandler = null; + + $response = Client::instance()->execute($this->guid, 'updateSubscription', [ + 'event' => 'dialog', + 'enabled' => false, + ]); + + $this->processVoidResponse($response); + } + + /** + * Checks if a dialog handler is currently set. + */ + public function hasDialogHandler(): bool + { + return $this->dialogHandler instanceof Closure; + } + + /** + * Gets the current dialog handler. + */ + public function getDialogHandler(): ?Closure + { + return $this->dialogHandler; + } + + /** + * Handles a dialog event from the Playwright server. + */ + public function handleDialogEvent(Dialog $dialog): void + { + if ($this->dialogHandler instanceof Closure) { + try { + ($this->dialogHandler)($dialog); + } catch (Exception $e) { + $dialog->dismiss(); + + throw $e; + } + } + } + /** * Closes the page. */ diff --git a/tests/Browser/Webpage/DialogTest.php b/tests/Browser/Webpage/DialogTest.php new file mode 100644 index 00000000..714c4216 --- /dev/null +++ b/tests/Browser/Webpage/DialogTest.php @@ -0,0 +1,314 @@ + ' + +
+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + expect($dialog->type())->toBe('alert'); + expect($dialog->message())->toBe('Hello World!'); + + $dialog->accept(); + }); + + $page->click('#alert-btn'); + + expect($page->text('#result'))->toBe('Alert handled'); +}); + +it('can auto dismiss dialog', function (): void { + Route::get('/', fn (): string => ' + +

Normal text on page.

+ '); + + $page = visit('/'); + + $page->assertSee('Normal text on page.'); +}); + +it('can not auto dismiss dialog when interupted', function (): void { + Route::get('/', fn (): string => ' + +

Normal text on page.

+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + // + }); + + $page->click('#alert-btn'); +})->throws(ExpectationFailedException::class); + +it('can handle confirm dialog with acceptance', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + expect($dialog->message())->toBe('Are you sure?'); + + $dialog->accept(); + }); + + $page->click('#confirm-btn'); + + expect($page->text('#result'))->toBe('Confirmed'); +}); + +it('can handle confirm dialog with dismissal', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + expect($dialog->message())->toBe('Are you sure?'); + + $dialog->dismiss(); + }); + + $page->click('#confirm-btn'); + + expect($page->text('#result'))->toBe('Cancelled'); +}); + +it('can handle prompt dialog with custom input', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + expect($dialog->message())->toBe('What is your name?'); + expect($dialog->defaultValue())->toBe('Default Name'); + + $dialog->accept('John Doe'); + }); + + $page->click('#prompt-btn'); + + expect($page->text('#result'))->toBe('John Doe'); +}); + +it('can handle prompt dialog with dismissal', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + $dialog->dismiss(); + }); + + $page->click('#prompt-btn'); + + expect($page->text('#result'))->toBe('No input'); +}); + +it('can auto-accept all dialogs', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->acceptAllDialogs('Test User'); + + $page->click('#multi-btn'); + + expect($page->text('#result'))->toBe('true-Test User'); +}); + +it('can auto-dismiss all dialogs', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->dismissAllDialogs(); + + $page->click('#multi-btn'); + + expect($page->text('#result'))->toBe('false-null'); +}); + +it('can accept one and dismiss all dialogs', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + if ($dialog->type() === 'alert') { + $dialog->accept(); + } + }); + + $page->dismissAllDialogs(); + + $page->click('#multi-btn'); + + expect($page->text('#result'))->toBe('false-null'); +}); + +it('can selectively accept only confirm dialogs', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->acceptingConfirms(); + + $page->click('#mixed-btn'); + + expect($page->text('#result'))->toBe('confirm:true'); +}); + +it('can selectively dismiss only confirm dialogs', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $page = visit('/'); + + $page->dismissingConfirms(); + + $page->click('#mixed-btn'); + + expect($page->text('#result'))->toBe('confirm:false'); +}); + +it('can remove dialog handlers', function (): void { + $page = visit('/'); + + expect($page->hasDialogHandler())->toBeFalse(); + + $page->acceptAllDialogs(); + expect($page->hasDialogHandler())->toBeTrue(); + + $page->removeDialogHandler(); + expect($page->hasDialogHandler())->toBeFalse(); +}); + +it('can handle multiple sequential dialogs with different types', function (): void { + Route::get('/', fn (): string => ' + +
+ '); + + $stepCount = 0; + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog) use (&$stepCount): void { + $stepCount++; + + if ($stepCount === 1) { + expect($dialog->message())->toBe('Step 1: Alert'); + + $dialog->accept(); + } elseif ($stepCount === 2) { + expect($dialog->message())->toBe('Step 2: Confirm?'); + + $dialog->accept(); + } elseif ($stepCount === 3) { + expect($dialog->message())->toBe('Step 3: Your name?'); + + $dialog->accept('Integration Test'); + } + }); + + $page->click('#sequence-btn'); + + expect($stepCount)->toBe(3); + expect($page->text('#result'))->toBe('Hello Integration Test'); +}); + +it('can handle dialog expectation failure', function (): void { + Route::get('/', fn (): string => ' + + '); + + $page = visit('/'); + + $page->onDialog(function (Dialog $dialog): void { + expect($dialog->message())->toBe('Wrong text'); + + $dialog->accept(); + }); + + $page->click('Show Confirm'); +})->throws(ExpectationFailedException::class);