Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions src/Browser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();

Expand Down
6 changes: 6 additions & 0 deletions src/Browser/BrowserProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
90 changes: 47 additions & 43 deletions src/Input/Mouse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.');
}
Expand All @@ -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'];
}

/**
Expand Down
41 changes: 32 additions & 9 deletions src/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace HeadlessChromium;

use Generator;
use HeadlessChromium\Communication\Connection;
use HeadlessChromium\Communication\Message;
use HeadlessChromium\Communication\Session;
use HeadlessChromium\Communication\Target;
Expand Down Expand Up @@ -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;
}

/**
Expand Down
14 changes: 9 additions & 5 deletions tests/BrowserFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
7 changes: 3 additions & 4 deletions tests/DomTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions tests/KeyboardApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down