diff --git a/src/Input/Mouse.php b/src/Input/Mouse.php index 5aae49f..915e699 100644 --- a/src/Input/Mouse.php +++ b/src/Input/Mouse.php @@ -141,6 +141,7 @@ public function click(?array $options = null) * * @throws \HeadlessChromium\Exception\CommunicationException * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut * * @return $this */ @@ -156,6 +157,7 @@ public function scrollUp(int $distance) * * @throws \HeadlessChromium\Exception\CommunicationException * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut * * @return $this */ @@ -180,38 +182,79 @@ private function scroll(int $distanceY, int $distanceX = 0): self { $this->page->assertNotClosed(); + // make sure the mouse is on the screen + $this->move($this->x, $this->y); + + $distances = $this->getScrollDistances($distanceY, $distanceX); // Calculated distances to scroll. + $startPos = $this->getCurrentScrollPosition(); // Remember positions before scrolling started. + + if ($distances['x'] === 0 && $distances['y'] === 0) { + return $this; + } + + $this->sendScrollMessage($distances); + + // Wait until the scroll position settles (i.e. it stops changing). + Utils::tryWithTimeout( + 10_000_000, + $this->waitForScrollToSettle($distanceY, $distanceX, $startPos['x'], $startPos['y']) + ); + + // set new position after move + $endPos = $this->getCurrentScrollPosition(); + $this->x += $endPos['x'] - $startPos['x']; + $this->y += $endPos['y'] - $startPos['y']; + + return $this; + } + + /** + * Get the distances to actually scroll on each axis, clamped to the page's + * current scroll boundaries. + * + * @throws \HeadlessChromium\Exception\OperationTimedOut + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * + * @return array{ x: int, y: int } + */ + private function getScrollDistances(int $distanceY, int $distanceX = 0): array + { $scrollableArea = $this->page->getLayoutMetrics()->getCssContentSize(); $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); $maximumX = $scrollableArea['width'] - $visibleArea['clientWidth']; $maximumY = $scrollableArea['height'] - $visibleArea['clientHeight']; - $distanceX = $this->getMaximumDistance($distanceX, $visibleArea['pageX'], $maximumX); - $distanceY = $this->getMaximumDistance($distanceY, $visibleArea['pageY'], $maximumY); + $distanceX = $this->getMaximumDistance($distanceX, (int) $visibleArea['pageX'], (int) $maximumX); + $distanceY = $this->getMaximumDistance($distanceY, (int) $visibleArea['pageY'], (int) $maximumY); - $targetX = $visibleArea['pageX'] + $distanceX; - $targetY = $visibleArea['pageY'] + $distanceY; + return ['x' => $distanceX, 'y' => $distanceY]; + } - // make sure the mouse is on the screen - $this->move($this->x, $this->y); + private function getCurrentScrollPosition(): array + { + $viewport = $this->page->getLayoutMetrics()->getCssVisualViewport(); - // scroll + return ['x' => (int) $viewport['pageX'], 'y' => (int) $viewport['pageY']]; + } + + /** + * @param array{ x: int, y: int } $distances + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + */ + private function sendScrollMessage(array $distances): void + { $this->page->getSession()->sendMessageSync(new Message('Input.dispatchMouseEvent', [ 'type' => 'mouseWheel', 'x' => $this->x, 'y' => $this->y, - 'deltaX' => $distanceX, - 'deltaY' => $distanceY, + 'deltaX' => $distances['x'], + 'deltaY' => $distances['y'], ])); - - // wait until the scroll is done - Utils::tryWithTimeout(30000 * 1000, $this->waitForScroll($targetX, $targetY)); - - // set new position after move - $this->x += $distanceX; - $this->y += $distanceY; - - return $this; } /** @@ -351,32 +394,78 @@ private function getMaximumDistance(int $distance, int $current, int $maximum): } /** - * Wait for the browser to process the scroll command. + * Wait for the scroll position to settle (stop changing). * - * Return the number of microseconds to wait before trying again or true in case of success. + * Rather than waiting for an exact target position, this polls the live + * scroll position and returns once it has been stable for a few consecutive + * reads. A settled position is only accepted once we have either observed + * actual movement or reached a scroll boundary - this guards against + * accepting the start position before the scroll has even begun + * (the start-of-scroll race). * - * @see \HeadlessChromium\Utils::tryWithTimeout + * Yields the number of microseconds to wait before trying again or + * returns in case of success. * - * @param int $targetX - * @param int $targetY + * @see Utils::tryWithTimeout * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable * @throws \HeadlessChromium\Exception\OperationTimedOut - * - * @return bool|\Generator */ - private function waitForScroll(int $targetX, int $targetY) + private function waitForScrollToSettle(int $distanceY, int $distanceX, int $startX, int $startY): \Generator { - while (true) { - $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); + $requiredStableReads = 5; + $stableReads = 0; + $lastX = $lastY = null; + $moved = false; - if ($visibleArea['pageX'] === $targetX && $visibleArea['pageY'] === $targetY) { - return true; + while (true) { + $pos = $this->getCurrentScrollPosition(); + $moved = $moved || ($pos['x'] !== $startX || $pos['y'] !== $startY); + + if ($pos['x'] === $lastX && $pos['y'] === $lastY) { + ++$stableReads; + + if ( + $stableReads >= $requiredStableReads && + ($moved || $this->isAtScrollBoundary($distanceY, $distanceX, $pos['x'], $pos['y'])) + ) { + return; + } + } else { + $stableReads = 0; + $lastX = $pos['x']; + $lastY = $pos['y']; } - yield 1000; + yield 16_000; // Time between two frames at 60Hz. } } + /** + * Whether the page can no longer be scrolled further in the requested + * direction (so a non-moving position is a legitimate end state). + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\CommunicationException\ResponseHasError + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + private function isAtScrollBoundary(int $distanceY, int $distanceX, int $x, int $y): bool + { + $scrollableArea = $this->page->getLayoutMetrics()->getCssContentSize(); + $visibleArea = $this->page->getLayoutMetrics()->getCssVisualViewport(); + + $maximumX = (int) ($scrollableArea['width'] - $visibleArea['clientWidth']); + $maximumY = (int) ($scrollableArea['height'] - $visibleArea['clientHeight']); + + $atBoundaryX = $distanceX > 0 ? $x >= $maximumX : ($distanceX < 0 ? $x <= 0 : true); + $atBoundaryY = $distanceY > 0 ? $y >= $maximumY : ($distanceY < 0 ? $y <= 0 : true); + + return $atBoundaryX && $atBoundaryY; + } + /** * Get the current mouse position. * diff --git a/tests/MouseApiTest.php b/tests/MouseApiTest.php index 5413a7b..dabcf40 100644 --- a/tests/MouseApiTest.php +++ b/tests/MouseApiTest.php @@ -101,6 +101,80 @@ public function testScroll(): void self::assertLessThan(10000, $page->mouse()->getPosition()['y']); } + /** + * Scrolling works when scrollable area shrinks immediatly after scroll event. + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + public function testScrollDoesNotTimeOutWhenScrollableAreaShrinks(): void + { + $page = $this->openSitePage('scrollShrink.html'); + + $page->mouse()->scrollDown(4000); // Before patch this threw an OperationTimedOut Exception. + + $windowScrollY = $page->evaluate('window.scrollY')->getReturnValue(); + $maximumY = $page + ->evaluate('document.documentElement.scrollHeight - window.innerHeight') + ->getReturnValue(); + + // We asked to scroll further than the shrunken page allows, so we end up + // exactly at the new (smaller) maximum. The actual regression guarantee + // is that scrollDown() returns at all instead of timing out. + self::assertSame($maximumY, $windowScrollY); + } + + /** + * Scrolling works when an overlay pops up and locks scrolling immediatly after scroll event. + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + public function testScrollDoesNotTimeOutWhenModalLocksScrolling(): void + { + $page = $this->openSitePage('scrollLock.html'); + + $page->mouse()->scrollDown(4000); // Before patch this threw an OperationTimedOut Exception. + + $windowScrollY = $page->evaluate('window.scrollY')->getReturnValue(); + $maximumY = $page + ->evaluate('document.documentElement.scrollHeight - window.innerHeight') + ->getReturnValue(); + + // Scrolling got locked, so the page can no longer be scrolled at all and + // we stay at the top. The guarantee the fix restores is that scrollDown() + // returns instead of timing out. + self::assertSame(0, $maximumY); + self::assertSame(0, $windowScrollY); + } + + /** + * Scrolling works when the layout shifts (e.g. due to lazy loaded image) during scrolling. + * + * @throws \HeadlessChromium\Exception\CommunicationException + * @throws \HeadlessChromium\Exception\NoResponseAvailable + * @throws \HeadlessChromium\Exception\OperationTimedOut + */ + public function testScrollDoesNotTimeOutWhenLayoutShiftsDuringScroll(): void + { + $page = $this->openSitePage('infiniteScroll.html'); + + $page->mouse()->scrollDown(4000); // Before patch this threw an OperationTimedOut Exception. + + $windowScrollY = $page->evaluate('window.scrollY')->getReturnValue(); + $maximumY = $page + ->evaluate('document.documentElement.scrollHeight - window.innerHeight') + ->getReturnValue(); + + // The page grew (it did not shrink), so we scrolled and ended up at a + // valid position within the new bounds rather than timing out. The exact + // position is timing-dependent. + self::assertGreaterThan(0, $windowScrollY); + self::assertLessThanOrEqual($maximumY, $windowScrollY); + } + /** * @dataProvider providerFindElementWithSingleElement * diff --git a/tests/resources/static-web/infiniteScroll.html b/tests/resources/static-web/infiniteScroll.html new file mode 100644 index 0000000..2a8716f --- /dev/null +++ b/tests/resources/static-web/infiniteScroll.html @@ -0,0 +1,40 @@ + + + + + infinite-scroll issue (lazy image above) + + + + + +
feed content
+ + + diff --git a/tests/resources/static-web/scrollLock.html b/tests/resources/static-web/scrollLock.html new file mode 100644 index 0000000..85cdefc --- /dev/null +++ b/tests/resources/static-web/scrollLock.html @@ -0,0 +1,65 @@ + + + + + scroll-lock issue + + + +
scroll me
+
+
+

A modal popped up

+

…and locked scrolling (the page is now position:fixed)

+
+
+ + + diff --git a/tests/resources/static-web/scrollShrink.html b/tests/resources/static-web/scrollShrink.html new file mode 100644 index 0000000..bd9e0ef --- /dev/null +++ b/tests/resources/static-web/scrollShrink.html @@ -0,0 +1,30 @@ + + + + + scroll timeout issue + + + +
scroll me
+ + +