diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8abad307..e226b023 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ permissions: jobs: tests: - name: PHP ${{ matrix.php }}; Symfony ${{ matrix.symfony }} + name: PHP ${{ matrix.php }}; Symfony ${{ matrix.symfony }}; Chrome ${{ matrix.chrome }} runs-on: ubuntu-24.04 strategy: @@ -39,6 +39,15 @@ jobs: symfony: '8' - php: '8.5' symfony: '5' + include: + - symfony: '5' + chrome: '122' + - symfony: '6' + chrome: '130' + - symfony: '7' + chrome: '140' + - symfony: '8' + chrome: '150' steps: - name: Checkout Code @@ -48,7 +57,7 @@ jobs: id: setup-chrome uses: browser-actions/setup-chrome@v2 with: - chrome-version: 122 + chrome-version: ${{ matrix.chrome }} - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff87521..5ed53eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * Return floats from `Mouse::getPosition()` * Wait for socket data instead of polling for responses * Require `chrome-php/wrench` `^1.9` +* Support newer versions of Chrome ## 1.15.1 (2026-07-06) diff --git a/src/Browser.php b/src/Browser.php index 616f7b07..60c1d11d 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -45,6 +45,13 @@ class Browser */ protected $pagePreScript; + /** + * A viewport size to be automatically set on every new page. + * + * @var array{0: int, 1: int}|null + */ + protected $pageViewportSize; + /** * Whether JavaScript execution must be disabled on every new page. * @@ -112,6 +119,17 @@ public function setPagePreScript(?string $script = null): void $this->pagePreScript = $script; } + /** + * Set a viewport size to be set on every new page. + * Use null to keep the natural viewport size. + * + * @param array{0: int, 1: int}|null $size width and height + */ + public function setPageViewportSize(?array $size = null): void + { + $this->pageViewportSize = $size; + } + /** * Disable or enable JavaScript execution for existing pages and pages created later. * @@ -280,6 +298,11 @@ public function getPage($targetId) // Page.setLifecycleEventsEnabled $page->getSession()->sendMessageSync(new Message('Page.setLifecycleEventsEnabled', ['enabled' => true])); + // set the viewport size + if ($this->pageViewportSize) { + $page->setViewport($this->pageViewportSize[0], $this->pageViewportSize[1])->await(); + } + // set up http headers $headers = $this->connection->getConnectionHttpHeaders(); diff --git a/src/Browser/BrowserProcess.php b/src/Browser/BrowserProcess.php index 9fd606e3..6ece66da 100644 --- a/src/Browser/BrowserProcess.php +++ b/src/Browser/BrowserProcess.php @@ -163,6 +163,12 @@ public function start($binary, $options): void // create browser instance $this->browser = new ProcessAwareBrowser($connection, $this); + // pin the viewport size in headless mode: chrome 128 to 143 subtract window + // decorations from the headless viewport and resize it shortly after startup + if (!\array_key_exists('headless', $options) || $options['headless']) { + $this->browser->setPageViewportSize($options['windowSize'] ?? [800, 600]); + } + // disable javascript execution for new pages if requested if (\array_key_exists('disableJavascript', $options) && (true === $options['disableJavascript'])) { $this->browser->setPageScriptExecutionDisabled(true); diff --git a/src/Input/Mouse.php b/src/Input/Mouse.php index aae2f543..0797192c 100644 --- a/src/Input/Mouse.php +++ b/src/Input/Mouse.php @@ -36,6 +36,8 @@ class Mouse private const SCROLL_POLL_INTERVAL = 16_000; // one frame at 60Hz, in microseconds private const SCROLL_SETTLE_READS = 5; private const SCROLL_UNMOVED_SETTLE_READS = 15; // covers compositor-to-main commit latency when nothing scrolls + private const FIND_ELEMENT_MAX_ATTEMPTS = 50; + private const FIND_ELEMENT_POLL_INTERVAL = 20_000; // in microseconds /** * @var Page @@ -241,34 +243,6 @@ private function scroll(int $distanceY, int $distanceX = 0): self return $this; } - /** - * Scroll in both X and Y axis until the given boundaries fit in the screen. - * - * This method currently scrolls only to right and bottom. If the desired element is outside the visible screen - * to the left or top, thie method will not work. Its visibility will stay private until it works for both cases. - * - * @param int $right The element right boundary - * @param int $bottom The element bottom boundary - * - * @return $this - */ - private function scrollToBoundary(int $right, int $bottom): self - { - $visibleArea = $this->page->getLayoutMetrics()->getCssLayoutViewport(); - - $distanceX = $distanceY = 0; - - if ($right > $visibleArea['clientWidth']) { - $distanceX = $right - $visibleArea['clientWidth']; - } - - if ($bottom > $visibleArea['clientHeight']) { - $distanceY = $bottom - $visibleArea['clientHeight']; - } - - return $this->scroll($distanceY, $distanceX); - } - /** * Find an element and move the mouse to a random position over it. * @@ -324,7 +298,35 @@ public function findElement(Selector $selector, int $position = 1): self $this->page->assertNotClosed(); try { - $element = Utils::getElementPositionFromPage($this->page, $selector, $position); + $elementCount = $this->page + ->evaluate(\sprintf('JSON.parse(JSON.stringify(%s));', $selector->expressionCount())) + ->getReturnValue(); + + $position = \max(1, \min($position, (int) $elementCount)); + + // scroll the element into view and read its position relative to the viewport, + // repeating until the position is stable and visible: wheel events cannot be + // used here, and a single scroll may not be enough, because the viewport of + // chrome 128 to 143 resizes itself shortly after startup, invalidating any + // scroll target computed against the layout from before the resize + $element = null; + + for ($attempt = 0; $attempt < self::FIND_ELEMENT_MAX_ATTEMPTS; ++$attempt) { + $previous = $element; + + $element = $this->page + ->evaluate(\sprintf( + '(function () { var element = %s; element.scrollIntoView({block: "nearest", inline: "nearest"}); var rect = element.getBoundingClientRect(); return {x: rect.x, left: rect.left, top: rect.top, width: rect.width, height: rect.height, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight}; })();', + $selector->expressionFindOne($position) + )) + ->getReturnValue(); + + if ($element === $previous && $this->isElementCenterVisible($element)) { + break; + } + + \usleep(self::FIND_ELEMENT_POLL_INTERVAL); + } } catch (JavascriptException $exception) { throw new ElementNotFoundException('The search for "'.$selector->expressionCount().'" returned no result.'); } @@ -333,24 +335,26 @@ public function findElement(Selector $selector, int $position = 1): self throw new ElementNotFoundException('The search for "'.$selector->expressionFindOne($position).'" returned an element with no position.'); } - $rightBoundary = \floor($element['right']); - $bottomBoundary = \floor($element['bottom']); - - $this->scrollToBoundary((int) $rightBoundary, (int) $bottomBoundary); + // move the mouse to the center of the element + $this->move($element['left'] + $element['width'] / 2, $element['top'] + $element['height'] / 2); - $visibleArea = $this->page->getLayoutMetrics()->getLayoutViewport(); - - $offsetX = $visibleArea['pageX']; - $offsetY = $visibleArea['pageY']; - $minX = $element['left'] - $offsetX; - $minY = $element['top'] - $offsetY; + return $this; + } - $positionX = $minX + (($rightBoundary - $offsetX) - $minX) / 2; - $positionY = $minY + (($bottomBoundary - $offsetY) - $minY) / 2; + /** + * Check that the center of an element position is within the viewport. + */ + private function isElementCenterVisible(array $element): bool + { + if (false === \array_key_exists('x', $element)) { + return false; + } - $this->move($positionX, $positionY); + $centerX = $element['left'] + $element['width'] / 2; + $centerY = $element['top'] + $element['height'] / 2; - return $this; + return $centerX >= 0 && $centerX < $element['viewportWidth'] + && $centerY >= 0 && $centerY < $element['viewportHeight']; } /** diff --git a/src/Page.php b/src/Page.php index 149b59fc..16282eb7 100644 --- a/src/Page.php +++ b/src/Page.php @@ -12,6 +12,7 @@ namespace HeadlessChromium; use Generator; +use HeadlessChromium\Communication\Connection; use HeadlessChromium\Communication\Message; use HeadlessChromium\Communication\Session; use HeadlessChromium\Communication\Target; @@ -1024,21 +1025,43 @@ public function dom(): Dom * Request to close the page. * * @throws CommunicationException + * @throws OperationTimedOut */ public function close(): void { $this->assertNotClosed(); - $this->getSession() - ->getConnection() - ->sendMessageSync( - new Message( - 'Target.closeTarget', - ['targetId' => $this->getSession()->getTargetId()] - ) - ); + $connection = $this->getSession()->getConnection(); + + $connection->sendMessageSync( + new Message( + 'Target.closeTarget', + ['targetId' => $this->getSession()->getTargetId()] + ) + ); + + // chrome 128 and later may respond to Target.closeTarget before emitting Target.targetDestroyed, + // so wait for the target to be destroyed before returning + Utils::tryWithTimeout($connection->getSendSyncDefaultTimeout() * 1000, $this->waitForCloseGenerator($connection)); + } + + /** + * @throws CommunicationException\CannotReadResponse + * @throws CommunicationException\InvalidResponse + * + * @return bool|Generator + * + * @internal + */ + private function waitForCloseGenerator(Connection $connection) + { + while (!$this->target->isDestroyed()) { + yield 500; + + $connection->readData(); + } - // TODO return close waiter + return true; } /** diff --git a/tests/BrowserFactoryTest.php b/tests/BrowserFactoryTest.php index 33ee7423..33549f08 100644 --- a/tests/BrowserFactoryTest.php +++ b/tests/BrowserFactoryTest.php @@ -40,9 +40,9 @@ public function testWindowSizeOption(): void $page = $browser->createPage(); - $response = $page->evaluate('[window.outerHeight, window.outerWidth]')->getReturnValue(); + $response = $page->evaluate('[window.outerHeight, window.outerWidth, window.innerHeight, window.innerWidth]')->getReturnValue(); - self::assertEquals([333, 1212], $response); + self::assertEquals([333, 1212, 333, 1212], $response); } public function testUserAgentOption(): void @@ -190,11 +190,15 @@ public function testConnectToBrowser(): void $page2 = $browser->createPage(); $page2TargetId = $page2->getSession()->getTargetId(); - // update 2d browser - $browser2->getConnection()->readData(); + // update 2d browser, waiting for the target created event to arrive + $target = null; + for ($i = 0; $i < 100 && null === $target; ++$i) { + \usleep(10000); + $browser2->getConnection()->readData(); + $target = $browser2->getTarget($page2TargetId); + } // make sure 2nd browser received the new page - $target = $browser2->getTarget($page2TargetId); self::assertInstanceOf(Target::class, $target); } } diff --git a/tests/DomTest.php b/tests/DomTest.php index 18fb0491..75482085 100644 --- a/tests/DomTest.php +++ b/tests/DomTest.php @@ -257,17 +257,16 @@ public function testRootNodeIdIsUpdatedAfterReload(): void $dom = $page->dom(); - $nodeId = $dom->getNodeId(); - $reloadBtn = $dom->querySelector('#reload-btn'); $reloadBtn->click(); $page->waitForReload(); + // the root node id must have been refetched for the query to find the button: + // chrome 142 and later may reuse the node id of the old document root, and older + // versions renumber the document on every fetch, so the ids cannot be compared $reloadBtn = $dom->querySelector('#reload-btn'); $this->assertNotNull($reloadBtn); - - $this->assertNotEquals($nodeId, $page->dom()->getNodeId()); } public function testRegularNodeIsMarkedAsStaleAfterReload(): void diff --git a/tests/KeyboardApiTest.php b/tests/KeyboardApiTest.php index a225625d..4694887d 100644 --- a/tests/KeyboardApiTest.php +++ b/tests/KeyboardApiTest.php @@ -64,8 +64,9 @@ public function testTypeText(): void ->evaluate('document.querySelector("#textarea").value;') ->getReturnValue(); - // checks if the input contains the typed text - self::assertSame($text, $value); + // checks if the input contains the typed text, comparing carriage returns as + // newlines because chrome 149 and later normalize them while typing + self::assertSame(\str_replace("\r", "\n", $text), \str_replace("\r", "\n", $value)); } /**