diff --git a/composer.json b/composer.json index c1df8f2f..206d0a40 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ ], "require": { "php": "^7.4.15 || ^8.0.2", - "chrome-php/wrench": "^1.6", + "chrome-php/wrench": "^1.7", "evenement/evenement": "^3.0.1", "monolog/monolog": "^1.27.1 || ^2.8 || ^3.2", "psr/log": "^1.1 || ^2.0 || ^3.0", diff --git a/src/Communication/Connection.php b/src/Communication/Connection.php index 0df55167..ec61b0c8 100644 --- a/src/Communication/Connection.php +++ b/src/Communication/Connection.php @@ -13,9 +13,11 @@ use Evenement\EventEmitter; use HeadlessChromium\Communication\Socket\SocketInterface; +use HeadlessChromium\Communication\Socket\WaitForDataInterface; use HeadlessChromium\Communication\Socket\Wrench; use HeadlessChromium\Exception\CommunicationException; use HeadlessChromium\Exception\CommunicationException\CannotReadResponse; +use HeadlessChromium\Exception\CommunicationException\CantSyncEventsException; use HeadlessChromium\Exception\CommunicationException\InvalidResponse; use HeadlessChromium\Exception\OperationTimedOut; use HeadlessChromium\Exception\TargetDestroyed; @@ -346,6 +348,19 @@ public function readLine() return false; } + public function processAllEvents(): void + { + if (false === $this->wsClient instanceof WaitForDataInterface) { + throw new CantSyncEventsException(); + } + + $hasData = $this->wsClient->waitForData(0); + + if ($hasData) { + $this->receiveData(); + } + } + /** * Dispatches the message and either stores the response or emits an event. * diff --git a/src/Communication/Socket/WaitForDataInterface.php b/src/Communication/Socket/WaitForDataInterface.php new file mode 100644 index 00000000..a7793439 --- /dev/null +++ b/src/Communication/Socket/WaitForDataInterface.php @@ -0,0 +1,8 @@ +client->waitForData($maxSeconds); + } } diff --git a/src/Dom/Dom.php b/src/Dom/Dom.php index c685f4df..ac73759a 100644 --- a/src/Dom/Dom.php +++ b/src/Dom/Dom.php @@ -11,10 +11,7 @@ class Dom extends Node { public function __construct(Page $page) { - $message = new Message('DOM.getDocument'); - $response = $page->getSession()->sendMessageSync($message); - - $rootNodeId = $response->getResultData('root')['nodeId']; + $rootNodeId = $this->getRootNodeId($page); parent::__construct($page, $rootNodeId); } @@ -24,6 +21,8 @@ public function __construct(Page $page) */ public function search(string $selector): array { + $this->prepareForRequest(); + $message = new Message('DOM.performSearch', [ 'query' => $selector, ]); @@ -55,4 +54,23 @@ public function search(string $selector): array return $nodes; } + + public function prepareForRequest(bool $throw = true): void + { + $this->page->assertNotClosed(); + + $this->page->getSession()->getConnection()->processAllEvents(); + + if ($this->isStale) { + $this->nodeId = $this->getRootNodeId($this->page); + } + } + + public function getRootNodeId(Page $page) + { + $message = new Message('DOM.getDocument'); + $response = $page->getSession()->sendMessageSync($message); + + return $response->getResultData('root')['nodeId']; + } } diff --git a/src/Dom/Node.php b/src/Dom/Node.php index bf8df5c5..c55d5ed5 100644 --- a/src/Dom/Node.php +++ b/src/Dom/Node.php @@ -7,6 +7,7 @@ use HeadlessChromium\Communication\Message; use HeadlessChromium\Communication\Response; use HeadlessChromium\Exception\DomException; +use HeadlessChromium\Exception\StaleElementException; use HeadlessChromium\Page; class Node @@ -21,16 +22,37 @@ class Node */ protected $nodeId; + /** + * @var bool + */ + protected bool $isStale = false; + public function __construct(Page $page, int $nodeId) { $this->page = $page; $this->nodeId = $nodeId; + + $page->getSession()->on('method:DOM.documentUpdated', function (...$event): void { + $this->isStale = true; + }); + } + + public function getNodeId(): int + { + return $this->nodeId; + } + + public function getNodeIdForRequest(): int + { + $this->prepareForRequest(); + + return $this->getNodeId(); } public function getAttributes(): NodeAttributes { $message = new Message('DOM.getAttributes', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -44,7 +66,7 @@ public function getAttributes(): NodeAttributes public function setAttributeValue(string $name, string $value): void { $message = new Message('DOM.setAttributeValue', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), 'name' => $name, 'value' => $value, ]); @@ -56,7 +78,7 @@ public function setAttributeValue(string $name, string $value): void public function querySelector(string $selector): ?self { $message = new Message('DOM.querySelector', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), 'selector' => $selector, ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -74,7 +96,7 @@ public function querySelector(string $selector): ?self public function querySelectorAll(string $selector): array { $message = new Message('DOM.querySelectorAll', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), 'selector' => $selector, ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -93,7 +115,7 @@ public function querySelectorAll(string $selector): array public function focus(): void { $message = new Message('DOM.focus', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -108,7 +130,7 @@ public function getAttribute(string $name): ?string public function getPosition(): ?NodePosition { $message = new Message('DOM.getBoxModel', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -131,7 +153,7 @@ public function hasPosition(): bool public function getHTML(): string { $message = new Message('DOM.getOuterHTML', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -143,7 +165,7 @@ public function getHTML(): string public function setHTML(string $outerHTML): void { $message = new Message('DOM.setOuterHTML', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), 'outerHTML' => $outerHTML, ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -159,7 +181,7 @@ public function getText(): string public function scrollIntoView(): void { $message = new Message('DOM.scrollIntoViewIfNeeded', [ - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -198,7 +220,7 @@ public function sendFiles(array $filePaths): void { $message = new Message('DOM.setFileInputFiles', [ 'files' => $filePaths, - 'nodeId' => $this->nodeId, + 'nodeId' => $this->getNodeIdForRequest(), ]); $response = $this->page->getSession()->sendMessageSync($message); @@ -214,4 +236,15 @@ public function assertNotError(Response $response): void throw new DOMException($response->getErrorMessage()); } } + + protected function prepareForRequest(): void + { + $this->page->assertNotClosed(); + + $this->page->getSession()->getConnection()->processAllEvents(); + + if ($this->isStale) { + throw new StaleElementException(); + } + } } diff --git a/src/Exception/CommunicationException/CantSyncEventsException.php b/src/Exception/CommunicationException/CantSyncEventsException.php new file mode 100644 index 00000000..68253169 --- /dev/null +++ b/src/Exception/CommunicationException/CantSyncEventsException.php @@ -0,0 +1,9 @@ +assertNotClosed(); + + if (null === $this->dom) { + $this->dom = new Dom($this); + } + + return $this->dom; } /** diff --git a/tests/DomTest.php b/tests/DomTest.php index 8204e54b..3a5a5fdd 100644 --- a/tests/DomTest.php +++ b/tests/DomTest.php @@ -4,6 +4,7 @@ use HeadlessChromium\Browser; use HeadlessChromium\BrowserFactory; +use HeadlessChromium\Exception\StaleElementException; /** * @covers \HeadlessChromium\Dom\Dom @@ -187,4 +188,56 @@ public function testSetHTML(): void self::assertEquals('hello', $value); } + + public function testDomDoesReturnsTheSameObject(): void + { + $page = $this->openSitePage('domForm.html'); + + $firstDom = $page->dom(); + + $element = $firstDom->querySelector('#myinput'); + + $secondDom = $page->dom(); + + $element->focus(); + + $this->assertEquals($firstDom, $secondDom); + } + + public function testRootNodeIdIsUpdatedAfterReload(): void + { + $page = $this->openSitePage('domForm.html'); + + $dom = $page->dom(); + + $nodeId = $dom->getNodeId(); + + $reloadBtn = $dom->querySelector('#reload-btn'); + $reloadBtn->click(); + + $page->waitForReload(); + + $reloadBtn = $dom->querySelector('#reload-btn'); + $this->assertNotNull($reloadBtn); + + $this->assertNotEquals($nodeId, $page->dom()->getNodeId()); + } + + public function testRegularNodeIsMarkedAsStaleAfterReload(): void + { + $page = $this->openSitePage('domForm.html'); + + $dom = $page->dom(); + + $inputNode = $dom->querySelector('#myinput'); + + $reloadBtn = $dom->querySelector('#reload-btn'); + $reloadBtn->click(); + + $page->waitForReload(); + + $this->expectException(StaleElementException::class); + + $inputNode->sendKeys('test'); + } } diff --git a/tests/resources/static-web/domForm.html b/tests/resources/static-web/domForm.html index 7eff87f1..5590519a 100644 --- a/tests/resources/static-web/domForm.html +++ b/tests/resources/static-web/domForm.html @@ -17,5 +17,7 @@

Form

bar
+ +