diff --git a/bin/lib/core.js b/bin/lib/core.js index 969597c..e19578a 100644 --- a/bin/lib/core.js +++ b/bin/lib/core.js @@ -311,6 +311,14 @@ class RouteUtils { }); return { success: true }; } + + // unrouteAll drops the handlers inside Playwright, but the pending Route + // objects we handed out stay in the map and can never be answered again. + static forgetRoutes(routes, ownerId) { + for (const [routeId, info] of routes.entries()) { + if (info?.contextId === ownerId) routes.delete(routeId); + } + } } const logger = new Logger(); diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 988f91f..b6e413c 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -40,6 +40,7 @@ class ContextHandler extends BaseHandler { setDefaultNavigationTimeout: () => context.setDefaultNavigationTimeout(command.timeout), route: () => RouteUtils.setupRoute(context, command.contextId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse), unroute: () => context.unroute(command.url), + unrouteAll: () => this.unrouteAll(context, command.contextId, command.options), cookies: async () => ({ cookies: await context.cookies(command.urls) }), storageState: async () => ({ storageState: await context.storageState(command.options) }), setStorageState: () => context.setStorageState(command.storageState), @@ -110,6 +111,11 @@ class ContextHandler extends BaseHandler { return this.wrapResult(result); } + async unrouteAll(context, contextId, options) { + await context.unrouteAll(options || {}); + RouteUtils.forgetRoutes(this.routes, contextId); + } + setThrottling(command) { if (command.throttling && typeof command.throttling === 'object') { this.contextThrottling.set(command.contextId, { @@ -237,7 +243,15 @@ class PageHandler extends BaseHandler { goto: () => this.goto(page, command), evaluate: () => this.evaluate(page, command), addInitScript: () => page.addInitScript(command.script), + emulateMedia: () => page.emulateMedia(command.options || {}), + requestGC: () => page.requestGC(), waitForResponse: () => this.waitForResponse(page, command), + waitForRequest: () => this.waitForRequest(page, command), + requests: () => this.getRequests(page), + opener: () => this.getOpener(page, command), + consoleMessages: () => this.consoleMessages(page, command), + clearConsoleMessages: () => page.clearConsoleMessages(), + clearPageErrors: () => page.clearPageErrors(), content: async () => ({ content: await page.content() }), setContent: () => page.setContent(command.html, command.options), querySelector: () => this.querySelector(page, command), @@ -260,6 +274,7 @@ class PageHandler extends BaseHandler { handleDialog: () => this.handleDialog(command), route: () => RouteUtils.setupRoute(page, command.pageId, command.url, this.generateId, this.routes, this.extractRequestData, this.sendFramedResponse, () => `route_${++this.routeCounter.value}`), unroute: () => page.unroute(command.url), + unrouteAll: () => this.unrouteAll(page, command.pageId, command.options), goBack: () => this.followNavigationRedirects(command.pageId, page, () => page.goBack(command.options)), goForward: () => this.followNavigationRedirects(command.pageId, page, () => page.goForward(command.options)), reload: () => this.followNavigationRedirects(command.pageId, page, () => page.reload(command.options)), @@ -333,6 +348,52 @@ class PageHandler extends BaseHandler { return { response: this.serializeResponse(response) }; } + async waitForRequest(page, command) { + const jsAction = command.jsAction; + const [request] = await Promise.all([ + page.waitForRequest(command.url, command.options), + jsAction ? page.evaluate(jsAction) : Promise.resolve(), + ]); + return { request: this.extractRequestData(request) }; + } + + async getRequests(page) { + const requests = await page.requests(); + return { requests: requests.map(request => this.extractRequestData(request)) }; + } + + async getOpener(page, command) { + const opener = await page.opener(); + if (!opener) return { pageId: null }; + + for (const [pageId, candidate] of this.pages.entries()) { + if (candidate === opener) return { pageId }; + } + + // The opener predates this server's page map only when nothing ever + // surfaced it to PHP. Adopt it now rather than report "no opener". + const openerPageId = this.generateId('page'); + this.pages.set(openerPageId, opener); + const contextId = this.pageContexts.get(command.pageId); + if (contextId) this.pageContexts.set(openerPageId, contextId); + if (this.setupPageEventListeners) this.setupPageEventListeners(opener, openerPageId); + + return { pageId: openerPageId }; + } + + async consoleMessages(page, command) { + // An empty PHP options bag arrives as [], whose .filter is Array#filter. + // Forwarding that reaches Playwright as a function and fails validation. + const filter = command.options?.filter; + const messages = await page.consoleMessages(typeof filter === 'string' ? { filter } : {}); + return { messages: messages.map(message => this.serializeConsoleMessage(message)) }; + } + + async unrouteAll(page, pageId, options) { + await page.unrouteAll(options || {}); + RouteUtils.forgetRoutes(this.routes, pageId); + } + async querySelector(page, command) { const element = await page.$(command.selector); if (!element) return { elementHandleId: null }; diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 6208a2b..92b5ef0 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -32,6 +32,7 @@ class PlaywrightServer extends BaseHandler { responses: this.responses, routes: this.routes, generateId: this.generateId.bind(this), navigationRedirects: this.navigationRedirects, extractRequestData: this.extractRequestData.bind(this), serializeResponse: this.serializeResponse.bind(this), + serializeConsoleMessage: this.serializeConsoleMessage.bind(this), sendFramedResponse, routeCounter: { value: this.counters.route }, setupPageEventListeners: this.setupPageEventListeners.bind(this) @@ -168,13 +169,7 @@ class PlaywrightServer extends BaseHandler { formatEventParams(eventName, eventData) { const formatters = { - console: () => ({ - type: eventData.type(), - text: eventData.text(), - args: [], - location: eventData.location ? eventData.location() : {}, - timestamp: eventData.timestamp ? eventData.timestamp() : null, - }), + console: () => this.serializeConsoleMessage(eventData), dialog: () => { const dialogId = this.generateId('dialog'); this.dialogs.set(dialogId, eventData); @@ -187,6 +182,18 @@ class PlaywrightServer extends BaseHandler { return formatters[eventName] ? formatters[eventName]() : {}; } + serializeConsoleMessage(message) { + return { + type: message.type(), + text: message.text(), + // JSHandle instances cannot cross the JSON-RPC boundary; the PHP + // ConsoleMessage contract expects the key, so ship an empty snapshot. + args: [], + location: message.location ? message.location() : {}, + timestamp: message.timestamp ? message.timestamp() : null, + }; + } + generateId(prefix) { return prefix === 'dialog' ? `dialog_${Date.now()}_${Math.floor(Math.random() * 1000)}` : `${prefix}_${++this.counters[prefix]}`; } diff --git a/src/Browser/BrowserContext.php b/src/Browser/BrowserContext.php index 1ad6e3c..5e2bb01 100644 --- a/src/Browser/BrowserContext.php +++ b/src/Browser/BrowserContext.php @@ -510,6 +510,19 @@ public function unroute(string $url, ?callable $handler = null): void ]); } + /** + * @param array{behavior?: 'default'|'wait'|'ignoreErrors'} $options + */ + public function unrouteAll(array $options = []): void + { + $this->routeHandlers = []; + $this->transport->send([ + 'action' => 'context.unrouteAll', + 'contextId' => $this->contextId, + 'options' => $options, + ]); + } + public function getEnv(string $name): ?string { $response = $this->transport->send([ diff --git a/src/Browser/BrowserContextInterface.php b/src/Browser/BrowserContextInterface.php index 0a48c08..105c8c3 100644 --- a/src/Browser/BrowserContextInterface.php +++ b/src/Browser/BrowserContextInterface.php @@ -130,6 +130,16 @@ public function route(string $url, callable $handler): void; public function unroute(string $url, ?callable $handler = null): void; + /** + * Removes every route registered on this context in one call. + * + * 'behavior' decides what happens to handlers still running: wait for them, + * wait but swallow their errors, or return without waiting. + * + * @param array{behavior?: 'default'|'wait'|'ignoreErrors'} $options + */ + public function unrouteAll(array $options = []): void; + public function getEnv(string $name): ?string; /** diff --git a/src/Page/Options/EmulateMediaOptions.php b/src/Page/Options/EmulateMediaOptions.php new file mode 100644 index 0000000..35af9f5 --- /dev/null +++ b/src/Page/Options/EmulateMediaOptions.php @@ -0,0 +1,85 @@ + + */ + public function toArray(): array + { + $options = []; + if (null !== $this->colorScheme) { + $options['colorScheme'] = $this->colorScheme; + } + if (null !== $this->contrast) { + $options['contrast'] = $this->contrast; + } + if (null !== $this->forcedColors) { + $options['forcedColors'] = $this->forcedColors; + } + if (null !== $this->media) { + $options['media'] = $this->media; + } + if (null !== $this->reducedMotion) { + $options['reducedMotion'] = $this->reducedMotion; + } + + return $options; + } + + /** + * @param array|self $options + */ + public static function from(array|self $options = []): self + { + if ($options instanceof self) { + return $options; + } + + /** @var 'light'|'dark'|'no-preference'|'no-override'|null $colorScheme */ + $colorScheme = $options['colorScheme'] ?? null; + /** @var 'no-preference'|'more'|'no-override'|null $contrast */ + $contrast = $options['contrast'] ?? null; + /** @var 'active'|'none'|'no-override'|null $forcedColors */ + $forcedColors = $options['forcedColors'] ?? null; + /** @var 'screen'|'print'|'no-override'|null $media */ + $media = $options['media'] ?? null; + /** @var 'reduce'|'no-preference'|'no-override'|null $reducedMotion */ + $reducedMotion = $options['reducedMotion'] ?? null; + + return new self($colorScheme, $contrast, $forcedColors, $media, $reducedMotion); + } +} diff --git a/src/Page/Options/WaitForRequestOptions.php b/src/Page/Options/WaitForRequestOptions.php new file mode 100644 index 0000000..81e2873 --- /dev/null +++ b/src/Page/Options/WaitForRequestOptions.php @@ -0,0 +1,51 @@ + + */ + public function toArray(): array + { + $options = []; + if (null !== $this->timeout) { + $options['timeout'] = $this->timeout; + } + + 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; + + return new self($timeout); + } +} diff --git a/src/Page/Page.php b/src/Page/Page.php index 345a657..8b53234 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -36,6 +36,8 @@ use Playwright\Input\ModifierKey; use Playwright\Input\Mouse; use Playwright\Input\MouseInterface; +use Playwright\Input\Touchscreen; +use Playwright\Input\TouchscreenInterface; use Playwright\Locator\Locator; use Playwright\Locator\LocatorInterface; use Playwright\Locator\Options\GetByRoleOptions; @@ -47,6 +49,7 @@ use Playwright\Network\Route; use Playwright\Page\Options\ClickOptions; use Playwright\Page\Options\DragAndDropOptions; +use Playwright\Page\Options\EmulateMediaOptions; use Playwright\Page\Options\FrameQueryOptions; use Playwright\Page\Options\GotoOptions; use Playwright\Page\Options\NavigationHistoryOptions; @@ -60,6 +63,7 @@ use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForLoadStateOptions; use Playwright\Page\Options\WaitForPopupOptions; +use Playwright\Page\Options\WaitForRequestOptions; use Playwright\Page\Options\WaitForResponseOptions; use Playwright\Page\Options\WaitForSelectorOptions; use Playwright\Page\Options\WaitForUrlOptions; @@ -77,6 +81,8 @@ final class Page implements PageInterface, EventDispatcherInterface public readonly MouseInterface $mouse; + public readonly TouchscreenInterface $touchscreen; + private PageEventHandlerInterface $eventHandler; private ?APIRequestContextInterface $apiRequestContext = null; @@ -97,6 +103,7 @@ public function __construct( ) { $this->keyboard = new Keyboard($this->transport, $this->pageId); $this->mouse = new Mouse($this->transport, $this->pageId); + $this->touchscreen = new Touchscreen($this->transport, $this->pageId); $this->eventHandler = new PageEventHandler(); $this->clock = $this->context->clock(); @@ -184,6 +191,11 @@ public function mouse(): MouseInterface return $this->mouse; } + public function touchscreen(): TouchscreenInterface + { + return $this->touchscreen; + } + public function events(): PageEventHandlerInterface { return $this->eventHandler; @@ -495,6 +507,43 @@ public function content(): ?string return is_string($content) ? $content : null; } + /** + * @param array{filter?: 'all'|'since-navigation'} $options + * + * @return array + */ + public function consoleMessages(array $options = []): array + { + $response = $this->sendCommand('consoleMessages', ['options' => $options]); + $messages = $response['messages'] ?? []; + if (!is_array($messages)) { + throw new ProtocolErrorException('Invalid consoleMessages response from transport', 0); + } + + $result = []; + foreach ($messages as $message) { + if (is_array($message)) { + $result[] = $this->createConsoleMessage($this->validateConsoleMessageData($message)); + } + } + + return $result; + } + + public function clearConsoleMessages(): self + { + $this->sendCommand('clearConsoleMessages'); + + return $this; + } + + public function clearPageErrors(): self + { + $this->sendCommand('clearPageErrors'); + + return $this; + } + public function addInitScript(string $script): self { $this->sendCommand('addInitScript', ['script' => $script]); @@ -510,6 +559,24 @@ public function evaluate(string $expression, mixed $arg = null): mixed return $response['result'] ?? null; } + /** + * @param array|EmulateMediaOptions $options + */ + public function emulateMedia(array|EmulateMediaOptions $options = []): self + { + $options = EmulateMediaOptions::from($options)->toArray(); + $this->sendCommand('emulateMedia', ['options' => $options]); + + return $this; + } + + public function requestGC(): self + { + $this->sendCommand('requestGC'); + + return $this; + } + /** * @param array|WaitForSelectorOptions $options */ @@ -857,6 +924,41 @@ public function setExtraHTTPHeaders(array $headers): self return $this; } + public function opener(): ?PageInterface + { + $response = $this->sendCommand('opener'); + $pageId = $response['pageId'] ?? null; + if (null === $pageId) { + return null; + } + if (!is_string($pageId)) { + throw new ProtocolErrorException('Invalid opener response from transport', 0); + } + + return new self($this->transport, $this->context, $pageId, $this->config, $this->logger); + } + + /** + * @return array + */ + public function requests(): array + { + $response = $this->sendCommand('requests'); + $requests = $response['requests'] ?? []; + if (!is_array($requests)) { + throw new ProtocolErrorException('Invalid requests response from transport', 0); + } + + $result = []; + foreach ($requests as $request) { + if (is_array($request)) { + $result[] = $this->createRequest($request); + } + } + + return $result; + } + /** * @param array|WaitForLoadStateOptions $options */ @@ -912,6 +1014,27 @@ public function waitForResponse($url, array|WaitForResponseOptions $options = [] return $this->createResponse($this->pageId, $response['response']); } + /** + * @param array|WaitForRequestOptions $options + */ + public function waitForRequest(string $url, array|WaitForRequestOptions $options = []): Request + { + $action = null; + if (is_array($options)) { + $action = $options['action'] ?? null; + unset($options['action']); + } + + $options = WaitForRequestOptions::from($options); + $response = $this->sendCommand('waitForRequest', [ + 'url' => $url, + 'options' => $options->toArray(), + 'jsAction' => $action, + ]); + + return $this->createRequest($response['request'] ?? null); + } + public function addScriptTag(array|ScriptTagOptions $options): self { $options = ScriptTagOptions::from($options)->toArray(); @@ -985,6 +1108,14 @@ public function unroute(string $url, ?callable $handler = null): void $this->sendCommand('unroute', ['url' => $url]); } + /** + * @param array{behavior?: 'default'|'wait'|'ignoreErrors'} $options + */ + public function unrouteAll(array $options = []): void + { + $this->sendCommand('unrouteAll', ['options' => $options]); + } + public function handleDialog(string $dialogId, bool $accept, ?string $promptText = null): void { $params = ['dialogId' => $dialogId, 'accept' => $accept]; @@ -1121,6 +1252,26 @@ private function createConsoleMessage(array $params): ConsoleMessage return new ConsoleMessage($params, $this); } + /** + * Helper method to validate transport data for ConsoleMessage creation. + * + * @param array $data + * + * @return array + */ + private function validateConsoleMessageData(array $data): array + { + $result = []; + foreach ($data as $key => $value) { + if (!is_string($key)) { + throw new ProtocolErrorException('Invalid console message data from transport: non-string key', 0); + } + $result[$key] = $value; + } + + return $result; + } + private static function normalizeForPage(string $expression): string { $trimmed = ltrim($expression); diff --git a/src/Page/PageInterface.php b/src/Page/PageInterface.php index 0218b49..ed6e4e8 100644 --- a/src/Page/PageInterface.php +++ b/src/Page/PageInterface.php @@ -16,16 +16,20 @@ use Playwright\API\APIRequestContextInterface; use Playwright\Browser\BrowserContextInterface; +use Playwright\Console\ConsoleMessage; use Playwright\Frame\FrameInterface; use Playwright\Frame\FrameLocatorInterface; use Playwright\Input\KeyboardInterface; use Playwright\Input\MouseInterface; +use Playwright\Input\TouchscreenInterface; use Playwright\Locator\LocatorInterface; use Playwright\Locator\Options\GetByRoleOptions; use Playwright\Locator\Options\LocatorOptions; +use Playwright\Network\RequestInterface; use Playwright\Network\ResponseInterface; use Playwright\Page\Options\ClickOptions; use Playwright\Page\Options\DragAndDropOptions; +use Playwright\Page\Options\EmulateMediaOptions; use Playwright\Page\Options\FrameQueryOptions; use Playwright\Page\Options\GotoOptions; use Playwright\Page\Options\NavigationHistoryOptions; @@ -39,6 +43,7 @@ use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForLoadStateOptions; use Playwright\Page\Options\WaitForPopupOptions; +use Playwright\Page\Options\WaitForRequestOptions; use Playwright\Page\Options\WaitForResponseOptions; use Playwright\Page\Options\WaitForSelectorOptions; use Playwright\Page\Options\WaitForUrlOptions; @@ -143,6 +148,29 @@ public function pdfContent(array|PdfOptions $options = []): string; public function content(): ?string; + /** + * Console messages already recorded for this page, oldest first. + * + * Unlike the console event, this reads history, so messages logged before + * any listener existed are still returned. The default filter keeps only + * what was logged since the last navigation. + * + * @param array{filter?: 'all'|'since-navigation'} $options + * + * @return array + */ + public function consoleMessages(array $options = []): array; + + /** + * Discards the recorded console history without touching event listeners. + */ + public function clearConsoleMessages(): self; + + /** + * Discards the uncaught page errors recorded so far. + */ + public function clearPageErrors(): self; + /** * Queues a script to run before any of the page's own scripts. * @@ -153,6 +181,22 @@ public function addInitScript(string $script): self; public function evaluate(string $expression, mixed $arg = null): mixed; + /** + * Overrides the CSS media type and media features the page reports. + * + * An omitted feature keeps whatever override is in place; pass + * 'no-override' to give it back to the browser default. + * + * @param array|EmulateMediaOptions $options + */ + public function emulateMedia(array|EmulateMediaOptions $options = []): self; + + /** + * Asks the browser to collect garbage, so a WeakRef held by the page can be + * observed as cleared. Collection is best effort, never guaranteed. + */ + public function requestGC(): self; + /** * @param array|WaitForSelectorOptions $options */ @@ -246,12 +290,28 @@ public function keyboard(): KeyboardInterface; public function mouse(): MouseInterface; + /** + * Touch input for this page. The owning context must have been created with + * hasTouch enabled, otherwise the browser ignores the events. + */ + public function touchscreen(): TouchscreenInterface; + public function events(): PageEventHandlerInterface; public function route(string $url, callable $handler): void; public function unroute(string $url, ?callable $handler = null): void; + /** + * Removes every route registered on this page in one call. + * + * 'behavior' decides what happens to handlers still running: wait for them, + * wait but swallow their errors, or return without waiting. + * + * @param array{behavior?: 'default'|'wait'|'ignoreErrors'} $options + */ + public function unrouteAll(array $options = []): void; + public function handleDialog(string $dialogId, bool $accept, ?string $promptText = null): void; public function getPageIdForTransport(): string; @@ -269,6 +329,32 @@ public function waitForPopup(callable $action, array|WaitForPopupOptions $option */ public function waitForResponse($url, array|WaitForResponseOptions $options = []): ResponseInterface; + /** + * Waits for a request whose URL matches the given Playwright glob. + * + * PHP cannot act while this call blocks, so the trigger travels with it: an + * 'action' entry in $options holds a JavaScript expression the bridge + * evaluates once the listener is armed. + * + * @param array|WaitForRequestOptions $options + */ + public function waitForRequest(string $url, array|WaitForRequestOptions $options = []): RequestInterface; + + /** + * The page that opened this one, or null when nothing did. + */ + public function opener(): ?PageInterface; + + /** + * Network requests recorded for this page, oldest first. + * + * These are snapshots taken by the bridge, not live handles: members that + * need the Node object, such as response(), return null on them. + * + * @return array + */ + public function requests(): array; + /** * Set files to an input element with type="file". * diff --git a/tests/Integration/Browser/BrowserContextTest.php b/tests/Integration/Browser/BrowserContextTest.php index 281cd09..2ba18fa 100644 --- a/tests/Integration/Browser/BrowserContextTest.php +++ b/tests/Integration/Browser/BrowserContextTest.php @@ -18,6 +18,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Playwright\Browser\BrowserContext; +use Playwright\Exception\PlaywrightException; use Playwright\Network\RouteInterface; use Playwright\Page\PageInterface; use Playwright\Testing\PlaywrightTestCaseTrait; @@ -147,6 +148,31 @@ public function itSetsExtraHttpHeaders(): void $page->close(); } + #[Test] + public function itRemovesEveryRouteAtOnce(): void + { + $this->context->route('**/*', static function (RouteInterface $route): void { + $route->fulfill(['body' => '

Routed

']); + }); + + $page = $this->context->newPage(); + $page->goto('https://context-unroute-all.example.test/'); + $this->assertSame('Routed', $page->locator('h1')->innerText()); + + $this->context->unrouteAll(); + + $intercepted = true; + try { + $page->goto('https://context-unroute-all.example.test/'); + } catch (PlaywrightException) { + $intercepted = false; + } + + $this->assertFalse($intercepted, 'Requests should no longer be intercepted'); + + $page->close(); + } + #[Test] public function itManagesPermissions(): void { diff --git a/tests/Integration/Page/PagePopupTest.php b/tests/Integration/Page/PagePopupTest.php index 349897b..39b1f03 100644 --- a/tests/Integration/Page/PagePopupTest.php +++ b/tests/Integration/Page/PagePopupTest.php @@ -67,6 +67,11 @@ function openPopup() { $this->assertNotNull($popup); $this->assertNotSame($page, $popup); + $opener = $popup->opener(); + $this->assertInstanceOf(PageInterface::class, $opener); + $this->assertSame($page->getPageIdForTransport(), $opener->getPageIdForTransport()); + $this->assertNull($page->opener()); + // Small delay to ensure popup is fully ready usleep(100000); // 100ms diff --git a/tests/Integration/Page/PageTest.php b/tests/Integration/Page/PageTest.php index f2f15dc..cfbc5e5 100644 --- a/tests/Integration/Page/PageTest.php +++ b/tests/Integration/Page/PageTest.php @@ -17,7 +17,11 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Playwright\Console\ConsoleMessage; +use Playwright\Exception\PlaywrightException; +use Playwright\Network\RequestInterface; use Playwright\Network\ResponseInterface; +use Playwright\Network\RouteInterface; use Playwright\Page\Page; use Playwright\Testing\PlaywrightTestCaseTrait; use Playwright\Tests\Support\RouteServerTestTrait; @@ -198,6 +202,141 @@ public function itWaitsForAResponse(): void $this->assertEquals(200, $response->status()); } + #[Test] + public function itWaitsForAGlobMatchedRequest(): void + { + $request = $this->page->waitForRequest('**/page2.html', ['action' => "document.querySelector('a').click()"]); + + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertStringContainsString('/page2.html', $request->url()); + $this->assertSame('GET', $request->method()); + } + + #[Test] + public function itListsRecentRequestSnapshots(): void + { + $requests = $this->page->requests(); + $urls = array_map(static fn (RequestInterface $request): string => $request->url(), $requests); + + $this->assertContains($this->routeUrl('/index.html'), $urls); + $this->assertContains($this->routeUrl('/script.js'), $urls); + } + + #[Test] + public function itEmulatesMediaFeatures(): void + { + $this->page->emulateMedia([ + 'media' => 'print', + 'colorScheme' => 'dark', + 'reducedMotion' => 'reduce', + ]); + + $this->assertTrue($this->page->evaluate("matchMedia('print').matches")); + $this->assertTrue($this->page->evaluate("matchMedia('(prefers-color-scheme: dark)').matches")); + $this->assertTrue($this->page->evaluate("matchMedia('(prefers-reduced-motion: reduce)').matches")); + + $this->page->emulateMedia(['media' => 'no-override', 'colorScheme' => 'no-override']); + + $this->assertFalse($this->page->evaluate("matchMedia('print').matches")); + } + + #[Test] + public function itRequestsGarbageCollection(): void + { + $this->page->evaluate('() => { globalThis.__collected = new WeakRef({ payload: new Array(1024).fill(0) }); }'); + + $result = $this->page->requestGC(); + + $this->assertSame($this->page, $result); + $this->assertNull($this->page->evaluate('() => globalThis.__collected.deref() ?? null')); + } + + #[Test] + public function itUsesTheTouchscreen(): void + { + $context = $this->browser->newContext(['hasTouch' => true, 'viewport' => ['width' => 200, 'height' => 200]]); + $page = $context->newPage(); + + try { + $page->setContent(<<<'HTML' + + + HTML); + + $page->touchscreen()->tap(20, 20); + + $this->assertTrue($page->evaluate('window.__tapped')); + } finally { + $context->close(); + } + } + + #[Test] + public function itListsStoredConsoleMessages(): void + { + $this->page->evaluate("console.log('stored message from PHP')"); + + $messages = $this->page->consoleMessages(); + $texts = array_map(static fn (ConsoleMessage $message): string => $message->text(), $messages); + $index = array_search('stored message from PHP', $texts, true); + + $this->assertIsInt($index, 'The logged message should be part of the recorded history'); + $this->assertSame('log', $messages[$index]->type()); + $this->assertSame($this->page, $messages[$index]->page()); + } + + #[Test] + public function itClearsStoredConsoleMessages(): void + { + $this->page->evaluate("console.log('about to be dropped')"); + $this->assertNotEmpty($this->page->consoleMessages()); + + $result = $this->page->clearConsoleMessages(); + + $this->assertSame($this->page, $result); + $this->assertSame([], $this->page->consoleMessages()); + } + + #[Test] + public function itClearsStoredPageErrors(): void + { + $this->page->evaluate('() => { setTimeout(() => { throw new Error("boom"); }, 0); }'); + + $result = $this->page->clearPageErrors(); + + $this->assertSame($this->page, $result); + } + + #[Test] + public function itRemovesEveryPageRoute(): void + { + $page = $this->context->newPage(); + + try { + $page->route('**/*', static function (RouteInterface $route): void { + $route->fulfill(['body' => '

Routed

']); + }); + $page->goto('https://page-unroute-all.example.test/'); + $this->assertSame('Routed', $page->locator('h1')->innerText()); + + $page->unrouteAll(); + + $intercepted = true; + try { + $page->goto('https://page-unroute-all.example.test/'); + } catch (PlaywrightException) { + $intercepted = false; + } + + $this->assertFalse($intercepted, 'Requests should no longer be intercepted'); + } finally { + $page->close(); + } + } + #[Test] public function itWaitsForLoadState(): void { @@ -284,7 +423,7 @@ public function itThrowsExceptionOnWaitForFunctionTimeout(): void #[Test] public function itThrowsExceptionOnInvalidWaitForFunctionPolling(): void { - $this->expectException(\Playwright\Exception\PlaywrightException::class); + $this->expectException(PlaywrightException::class); $this->expectExceptionMessage('Unknown polling option: invalid'); $this->page->waitForFunction( diff --git a/tests/Unit/Browser/BrowserContextTest.php b/tests/Unit/Browser/BrowserContextTest.php index 5376a3f..bd33422 100644 --- a/tests/Unit/Browser/BrowserContextTest.php +++ b/tests/Unit/Browser/BrowserContextTest.php @@ -476,6 +476,20 @@ public function testUnroute(): void $this->context->unroute('**/*.api'); } + public function testUnrouteAll(): void + { + $this->mockTransport + ->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'context.unrouteAll', + 'contextId' => 'context_1', + 'options' => ['behavior' => 'ignoreErrors'], + ]); + + $this->context->unrouteAll(['behavior' => 'ignoreErrors']); + } + public function testTracingReturnsTheSameInstance(): void { $tracing = $this->context->tracing(); diff --git a/tests/Unit/Page/Options/EmulateMediaOptionsTest.php b/tests/Unit/Page/Options/EmulateMediaOptionsTest.php new file mode 100644 index 0000000..2940499 --- /dev/null +++ b/tests/Unit/Page/Options/EmulateMediaOptionsTest.php @@ -0,0 +1,73 @@ +assertSame($options, EmulateMediaOptions::from($options)); + } + + public function testItCreatesFromArray(): void + { + $options = EmulateMediaOptions::from([ + 'colorScheme' => 'dark', + 'contrast' => 'more', + 'forcedColors' => 'active', + 'media' => 'print', + 'reducedMotion' => 'reduce', + ]); + + $this->assertSame('dark', $options->colorScheme); + $this->assertSame('more', $options->contrast); + $this->assertSame('active', $options->forcedColors); + $this->assertSame('print', $options->media); + $this->assertSame('reduce', $options->reducedMotion); + } + + public function testToReturnArray(): void + { + $options = new EmulateMediaOptions( + colorScheme: 'light', + contrast: 'no-preference', + forcedColors: 'none', + media: 'screen', + reducedMotion: 'no-preference', + ); + + $this->assertSame([ + 'colorScheme' => 'light', + 'contrast' => 'no-preference', + 'forcedColors' => 'none', + 'media' => 'screen', + 'reducedMotion' => 'no-preference', + ], $options->toArray()); + } + + public function testItOmitsUnsetFeatures(): void + { + $options = new EmulateMediaOptions(media: 'no-override'); + + $this->assertSame(['media' => 'no-override'], $options->toArray()); + } +} diff --git a/tests/Unit/Page/Options/WaitForRequestOptionsTest.php b/tests/Unit/Page/Options/WaitForRequestOptionsTest.php new file mode 100644 index 0000000..d52fc96 --- /dev/null +++ b/tests/Unit/Page/Options/WaitForRequestOptionsTest.php @@ -0,0 +1,50 @@ +assertSame(1000.0, $options->timeout); + $this->assertSame(['timeout' => 1000.0], $options->toArray()); + } + + public function testCanBeCreatedFromArray(): void + { + $options = WaitForRequestOptions::from(['timeout' => 250.0]); + + $this->assertSame(250.0, $options->timeout); + } + + public function testCanBeCreatedFromSelf(): void + { + $original = new WaitForRequestOptions(timeout: 1000.0); + + $this->assertSame($original, WaitForRequestOptions::from($original)); + } + + public function testItOmitsAnUnsetTimeout(): void + { + $this->assertSame([], (new WaitForRequestOptions())->toArray()); + } +} diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 41baf14..50746fe 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -21,14 +21,19 @@ use Playwright\API\APIRequestContextInterface; use Playwright\Browser\BrowserContextInterface; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Console\ConsoleMessage; +use Playwright\Exception\ProtocolErrorException; use Playwright\Exception\RuntimeException; use Playwright\Exception\TimeoutException; use Playwright\Input\KeyboardInterface; use Playwright\Input\MouseInterface; +use Playwright\Input\TouchscreenInterface; use Playwright\Locator\Locator; use Playwright\Locator\Options\GetByRoleOptions; use Playwright\Locator\Options\LocatorOptions; use Playwright\Page\Options\DragAndDropOptions; +use Playwright\Page\Options\EmulateMediaOptions; +use Playwright\Page\Options\WaitForRequestOptions; use Playwright\Page\Page; use Playwright\Page\PageEventHandlerInterface; use Playwright\Regex; @@ -63,6 +68,13 @@ public function testGetMouse(): void $this->assertInstanceOf(MouseInterface::class, $mouse); } + public function testGetTouchscreen(): void + { + $touchscreen = $this->page->touchscreen(); + + $this->assertInstanceOf(TouchscreenInterface::class, $touchscreen); + } + public function testGetEvents(): void { $events = $this->page->events(); @@ -888,6 +900,260 @@ public function testRequestIsCached(): void $this->assertSame($first, $second, 'Page::request should return cached instance'); } + public function testConsoleMessagesSendsFilterAndHydratesSnapshots(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'options' => ['filter' => 'all'], + 'action' => 'page.consoleMessages', + 'pageId' => 'page-id', + ]) + ->willReturn(['messages' => [ + [ + 'type' => 'warning', + 'text' => 'Be careful', + 'args' => [], + 'location' => ['url' => 'https://example.test/app.js'], + 'timestamp' => 123.4, + ], + 'not-a-message', + ]]); + + $messages = $this->page->consoleMessages(['filter' => 'all']); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(ConsoleMessage::class, $messages[0]); + $this->assertSame('warning', $messages[0]->type()); + $this->assertSame('Be careful', $messages[0]->text()); + $this->assertSame($this->page, $messages[0]->page()); + } + + public function testConsoleMessagesRejectsANonListResponse(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->willReturn(['messages' => 'nope']); + + $this->expectException(ProtocolErrorException::class); + + $this->page->consoleMessages(); + } + + public function testConsoleMessagesRejectsNonStringKeys(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->willReturn(['messages' => [[0 => 'log']]]); + + $this->expectException(ProtocolErrorException::class); + + $this->page->consoleMessages(); + } + + public function testClearConsoleMessagesSendsCommandAndReturnsSelf(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'page.clearConsoleMessages', + 'pageId' => 'page-id', + ]) + ->willReturn([]); + + $this->assertSame($this->page, $this->page->clearConsoleMessages()); + } + + public function testClearPageErrorsSendsCommandAndReturnsSelf(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'page.clearPageErrors', + 'pageId' => 'page-id', + ]) + ->willReturn([]); + + $this->assertSame($this->page, $this->page->clearPageErrors()); + } + + public function testRequestsHydratesRequestSnapshots(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'page.requests', + 'pageId' => 'page-id', + ]) + ->willReturn(['requests' => [ + [ + 'url' => 'https://example.test/resource.js', + 'method' => 'GET', + 'headers' => [], + 'postData' => null, + 'resourceType' => 'script', + ], + 'not-a-request', + ]]); + + $requests = $this->page->requests(); + + $this->assertCount(1, $requests); + $this->assertSame('https://example.test/resource.js', $requests[0]->url()); + $this->assertSame('script', $requests[0]->resourceType()); + } + + public function testRequestsRejectsANonListResponse(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->willReturn(['requests' => 'nope']); + + $this->expectException(ProtocolErrorException::class); + + $this->page->requests(); + } + + public function testWaitForRequestSendsTheGlobAndJavaScriptAction(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'url' => '**/page2.html', + 'options' => ['timeout' => 500.0], + 'jsAction' => "document.querySelector('a').click()", + 'action' => 'page.waitForRequest', + 'pageId' => 'page-id', + ]) + ->willReturn(['request' => [ + 'url' => 'https://example.test/page2.html', + 'method' => 'GET', + 'headers' => [], + 'resourceType' => 'document', + ]]); + + $request = $this->page->waitForRequest('**/page2.html', [ + 'timeout' => 500.0, + 'action' => "document.querySelector('a').click()", + ]); + + $this->assertSame('https://example.test/page2.html', $request->url()); + } + + public function testWaitForRequestAcceptsAnOptionsObject(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'url' => '**/*.css', + 'options' => ['timeout' => 20.0], + 'jsAction' => null, + 'action' => 'page.waitForRequest', + 'pageId' => 'page-id', + ]) + ->willReturn(['request' => [ + 'url' => 'https://example.test/asset.css', + 'method' => 'GET', + 'headers' => [], + 'resourceType' => 'stylesheet', + ]]); + + $request = $this->page->waitForRequest('**/*.css', new WaitForRequestOptions(20.0)); + + $this->assertSame('stylesheet', $request->resourceType()); + } + + public function testEmulateMediaSendsOptionsAndReturnsSelf(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'options' => ['colorScheme' => 'dark', 'media' => 'print'], + 'action' => 'page.emulateMedia', + 'pageId' => 'page-id', + ]) + ->willReturn([]); + + $this->assertSame($this->page, $this->page->emulateMedia(['media' => 'print', 'colorScheme' => 'dark'])); + } + + public function testEmulateMediaAcceptsAnOptionsObject(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'options' => ['reducedMotion' => 'reduce'], + 'action' => 'page.emulateMedia', + 'pageId' => 'page-id', + ]) + ->willReturn([]); + + $this->page->emulateMedia(new EmulateMediaOptions(reducedMotion: 'reduce')); + } + + public function testRequestGCSendsCommandAndReturnsSelf(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'page.requestGC', + 'pageId' => 'page-id', + ]) + ->willReturn([]); + + $this->assertSame($this->page, $this->page->requestGC()); + } + + public function testOpenerHydratesAPageFromTheTransportId(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'page.opener', + 'pageId' => 'page-id', + ]) + ->willReturn(['pageId' => 'page-opener']); + + $opener = $this->page->opener(); + + $this->assertInstanceOf(Page::class, $opener); + $this->assertSame('page-opener', $opener->getPageIdForTransport()); + } + + public function testOpenerReturnsNullWithoutAnOpenerPage(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->willReturn(['pageId' => null]); + + $this->assertNull($this->page->opener()); + } + + public function testOpenerRejectsANonStringPageId(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->willReturn(['pageId' => 42]); + + $this->expectException(ProtocolErrorException::class); + + $this->page->opener(); + } + + public function testUnrouteAllSendsItsBehavior(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'options' => ['behavior' => 'wait'], + 'action' => 'page.unrouteAll', + 'pageId' => 'page-id', + ]) + ->willReturn([]); + + $this->page->unrouteAll(['behavior' => 'wait']); + } + private function createPage(string $pageId = 'page-1'): Page { return new Page($this->transport, $this->context, $pageId, new PlaywrightConfig());