diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 9bb5ee6..176afc7 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -12,7 +12,7 @@ class ContextHandler extends BaseHandler { setOffline: () => context.setOffline(!!command.offline), setGeolocation: () => context.setGeolocation(command.geolocation), addCookies: () => context.addCookies(command.cookies), - clearCookies: () => context.clearCookies(), + clearCookies: () => context.clearCookies(command.options || {}), grantPermissions: () => context.grantPermissions(command.permissions, command.origin ? { origin: command.origin } : undefined), clearPermissions: () => context.clearPermissions(), startTracing: () => context.tracing.start(command.options || {}), @@ -222,6 +222,7 @@ class PageHandler extends BaseHandler { viewportSize: () => this.createValueResult(page.viewportSize()), waitForURL: () => page.waitForURL(command.url, command.options), waitForSelector: () => page.waitForSelector(command.selector, command.options), + waitForFunction: () => this.waitForFunction(page, command), screenshot: () => PromiseUtils.wrapBinary(page.screenshot(command.options)), pdf: () => PromiseUtils.wrapBinary(page.pdf(command.options || {})), evaluateHandle: () => this.evaluateHandle(page, command), @@ -272,6 +273,22 @@ class PageHandler extends BaseHandler { } } + async waitForFunction(page, command) { + try { + let func; + try { + func = eval(`(${command.pageFunction})`); + } catch (e) { + func = command.pageFunction; + } + + await page.waitForFunction(func, command.arg, command.options); + } catch (error) { + logger.error('PAGE WAITFORFUNCTION ERROR', { message: error.message }); + throw error; + } + } + async waitForResponse(page, command) { const jsAction = command.jsAction; const [response] = await Promise.all([ diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index 8c63f98..c2eb9e3 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -402,7 +402,7 @@ public function loadStorageState(string $filePath): void $this->setStorageState($storageState); } - public function setGeolocation(?float $latitude, ?float $longitude, ?float $accuracy = null): void + public function setGeolocation(?float $latitude, ?float $longitude, ?float $accuracy = 0): void { $this->transport->send([ 'action' => 'context.setGeolocation', diff --git a/src/Page/Options/WaitForFunctionOptions.php b/src/Page/Options/WaitForFunctionOptions.php new file mode 100644 index 0000000..0a63795 --- /dev/null +++ b/src/Page/Options/WaitForFunctionOptions.php @@ -0,0 +1,64 @@ + + */ + public function toArray(): array + { + $options = []; + + if (null !== $this->timeout) { + $options['timeout'] = $this->timeout; + } + + if (null !== $this->polling) { + $options['polling'] = $this->polling; + } + + return $options; + } + + /** + * @param array|self $options + */ + public static function from(array|self $options = []): self + { + if ($options instanceof self) { + return $options; + } + + /** @var float|null $timeout */ + $timeout = $options['timeout'] ?? null; + + /** @var float|'raf'|null $polling */ + $polling = $options['polling'] ?? null; + + return new self($timeout, $polling); + } +} diff --git a/src/Page/Page.php b/src/Page/Page.php index da74311..e5d674a 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -57,6 +57,7 @@ use Playwright\Page\Options\SetInputFilesOptions; use Playwright\Page\Options\StyleTagOptions; use Playwright\Page\Options\TypeOptions; +use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForLoadStateOptions; use Playwright\Page\Options\WaitForPopupOptions; use Playwright\Page\Options\WaitForResponseOptions; @@ -836,6 +837,19 @@ public function waitForLoadState(string $state = 'load', array|WaitForLoadStateO return $this; } + /** + * @param array|WaitForFunctionOptions $options + */ + public function waitForFunction(string $pageFunction, mixed $arg = null, array|WaitForFunctionOptions $options = []): self + { + $options = WaitForFunctionOptions::from($options)->toArray(); + + $normalized = self::normalizeForPage($pageFunction); + $this->sendCommand('waitForFunction', ['pageFunction' => $normalized, 'arg' => $arg, 'options' => $options]); + + return $this; + } + /** * @param array|WaitForUrlOptions $options */ diff --git a/src/Page/PageInterface.php b/src/Page/PageInterface.php index 3b79181..3fe8205 100644 --- a/src/Page/PageInterface.php +++ b/src/Page/PageInterface.php @@ -35,6 +35,7 @@ use Playwright\Page\Options\SetInputFilesOptions; use Playwright\Page\Options\StyleTagOptions; use Playwright\Page\Options\TypeOptions; +use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForLoadStateOptions; use Playwright\Page\Options\WaitForPopupOptions; use Playwright\Page\Options\WaitForResponseOptions; @@ -194,6 +195,11 @@ public function setDefaultTimeout(int $timeout): self; */ public function waitForLoadState(string $state = 'load', array|WaitForLoadStateOptions $options = []): self; + /** + * @param array|WaitForFunctionOptions $options + */ + public function waitForFunction(string $pageFunction, mixed $arg = null, array|WaitForFunctionOptions $options = []): self; + /** * @param string|callable $url * @param array|WaitForUrlOptions $options diff --git a/tests/Integration/Browser/BrowserContextTest.php b/tests/Integration/Browser/BrowserContextTest.php index 93f88b3..1563470 100644 --- a/tests/Integration/Browser/BrowserContextTest.php +++ b/tests/Integration/Browser/BrowserContextTest.php @@ -170,23 +170,9 @@ public function itSetsGeolocation(): void $page->click('button'); - // Poll for either coordinates or error text since Page::waitForFunction is not available - $deadline = microtime(true) + 5.0; // 5 seconds - do { - $content = $page->content() ?? ''; - $hasCoordinates = str_contains($content, '59.95,30.31667'); - $hasError = str_contains($content, 'Error'); - if ($hasCoordinates || $hasError) { - break; - } - usleep(100 * 1000); // 100ms - } while (microtime(true) < $deadline); - - $content = $page->content(); - $hasCoordinates = str_contains($content, '59.95,30.31667'); - $hasError = str_contains($content, 'Error:'); - - $this->assertTrue($hasCoordinates || $hasError, 'Geolocation API should respond with either coordinates or error message'); + $page->waitForFunction("() => document.body.innerText.includes('59.95') || document.body.innerText.includes('Error: ')", options: ['timeout' => 200]); + + $this->assertStringContainsString('59.95,30.31667', $page->content(), 'Geolocation API should respond with either coordinates or error message'); $page->close(); } diff --git a/tests/Integration/Page/PageTest.php b/tests/Integration/Page/PageTest.php index 57cc1f0..0cd7f1c 100644 --- a/tests/Integration/Page/PageTest.php +++ b/tests/Integration/Page/PageTest.php @@ -165,4 +165,91 @@ public function itWaitsForLoadState(): void $this->assertStringContainsString('/page2.html', $this->page->url()); } + + #[Test] + public function itWaitsForFunction(): void + { + $this->page->setViewportSize(500, 500); + + $this->page->waitForFunction( + 'window.innerWidth < 600', + null, + ['timeout' => 100, 'polling' => 50] + ); + + $this->assertSame(['width' => 500, 'height' => 500], $this->page->viewportSize()); + } + + #[Test] + public function itWaitsForFunctionSetTimeout(): void + { + $this->page->setContent('
loading
'); + $this->page->evaluate('() => setTimeout(() => { document.getElementById("status").textContent = "ready"; }, 200)'); + + $this->page->waitForFunction( + 'document.querySelector("#status").textContent === "ready"', + null, + ['timeout' => 300, 'polling' => 50] + ); + + $this->assertSame('ready', $this->page->evaluate('() => document.querySelector("#status").textContent')); + } + + #[Test] + public function itWaitsForFunctionWithRafPolling(): void + { + $this->page->setContent('
0
'); + + $this->page->waitForFunction( + '() => document.querySelector("#x") && document.querySelector("#x").textContent === "1"', + null, + ['timeout' => 500, 'polling' => 'raf'] + ); + + $this->assertSame('1', $this->page->evaluate('() => document.querySelector("#x").textContent')); + } + + #[Test] + public function itWaitsForFunctionWithArgument(): void + { + $this->page->setContent('
loading
'); + $this->page->evaluate('() => setTimeout(() => { document.getElementById("status").textContent = "ready"; }, 100)'); + + $this->page->waitForFunction( + 'arg => { + const el = document.getElementById(arg.selector); + return !!el && el.textContent === arg.text; + }', + ['selector' => 'status', 'text' => 'ready'], + ['timeout' => 300, 'polling' => 50] + ); + + $this->assertSame('ready', $this->page->evaluate('() => document.querySelector("#status").textContent')); + } + + #[Test] + public function itThrowsExceptionOnWaitForFunctionTimeout(): void + { + $this->expectException(\Playwright\Exception\TimeoutException::class); + $this->expectExceptionMessage('page.waitForFunction: Timeout 100ms exceeded.'); + + $this->page->waitForFunction( + '() => false', + null, + ['timeout' => 100] + ); + } + + #[Test] + public function itThrowsExceptionOnInvalidWaitForFunctionPolling(): void + { + $this->expectException(\Playwright\Exception\PlaywrightException::class); + $this->expectExceptionMessage('Unknown polling option: invalid'); + + $this->page->waitForFunction( + '() => true', + null, + ['polling' => 'invalid'] + ); + } } diff --git a/tests/Unit/Page/Options/WaitForFunctionOptionsTest.php b/tests/Unit/Page/Options/WaitForFunctionOptionsTest.php new file mode 100644 index 0000000..ed553ed --- /dev/null +++ b/tests/Unit/Page/Options/WaitForFunctionOptionsTest.php @@ -0,0 +1,77 @@ +assertSame($options, WaitForFunctionOptions::from($options)); + } + + public function testItCreatesFromArray(): void + { + $options = WaitForFunctionOptions::from(['timeout' => 5000.0, 'polling' => 100.0]); + $this->assertSame(5000.0, $options->timeout); + $this->assertSame(100.0, $options->polling); + } + + public function testItCreatesFromArrayWithRaf(): void + { + $options = WaitForFunctionOptions::from(['polling' => 'raf']); + $this->assertSame('raf', $options->polling); + } + + public function testItThrowsExceptionForInvalidInput(): void + { + $this->expectException(\TypeError::class); + + WaitForFunctionOptions::from('invalid'); + } + + public function testToReturnArray(): void + { + $options = new WaitForFunctionOptions( + timeout: 5000.0, + polling: 100.0 + ); + + $expected = [ + 'timeout' => 5000.0, + 'polling' => 100.0, + ]; + + $this->assertSame($expected, $options->toArray()); + } + + public function testToReturnArrayWithRaf(): void + { + $options = new WaitForFunctionOptions( + polling: 'raf' + ); + + $expected = [ + 'polling' => 'raf', + ]; + + $this->assertSame($expected, $options->toArray()); + } +}