From f53456aa4b39a97074cc694e6cb23358439b647a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 06:20:04 +0200 Subject: [PATCH 1/3] Add page back-references to frames and locators --- src/Frame/Frame.php | 24 +++++++--- src/Frame/FrameInterface.php | 10 ++++ src/Frame/FrameLocator.php | 12 +++-- src/Locator/Locator.php | 26 ++++++++--- src/Locator/LocatorInterface.php | 10 ++++ src/Page/Page.php | 11 +++-- .../Frame/FrameIntegrationTest.php | 7 +++ tests/Integration/Locator/LocatorTest.php | 7 +++ tests/Unit/Frame/FrameLocatorTest.php | 13 ++++++ tests/Unit/Frame/FrameTest.php | 46 +++++++++++++++++++ tests/Unit/Locator/LocatorTest.php | 33 +++++++++++++ tests/Unit/Page/PageTest.php | 27 +++++++++++ 12 files changed, 203 insertions(+), 23 deletions(-) diff --git a/src/Frame/Frame.php b/src/Frame/Frame.php index 12a330a..24baad5 100644 --- a/src/Frame/Frame.php +++ b/src/Frame/Frame.php @@ -16,6 +16,7 @@ use Playwright\Exception\PlaywrightException; use Playwright\Exception\ProtocolErrorException; +use Playwright\Exception\RuntimeException; use Playwright\Locator\Locator; use Playwright\Locator\LocatorInterface; use Playwright\Locator\RoleSelectorBuilder; @@ -29,6 +30,7 @@ use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForNavigationOptions; use Playwright\Page\Options\WaitForUrlOptions; +use Playwright\Page\PageInterface; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -42,6 +44,7 @@ public function __construct( private readonly string $pageId, private readonly string $frameSelector, ?LoggerInterface $logger = null, + private readonly ?PageInterface $page = null, ) { $this->logger = $logger ?? new NullLogger(); } @@ -58,7 +61,7 @@ public function locator(string $selector): LocatorInterface 'selector' => $selector, ]); - return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger); + return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger, [], $this->page); } /** @@ -99,7 +102,7 @@ public function getByRole(string $role, array $options = []): LocatorInterface 'selector' => $selector, ]); - return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger, $locatorOptions); + return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger, $locatorOptions, $this->page); } public function getByTestId(string $testId): LocatorInterface @@ -133,7 +136,7 @@ public function frameLocator(string $selector): FrameLocatorInterface 'newSelector' => $newSelector, ]); - return new FrameLocator($this->transport, $this->pageId, $newSelector, $this->logger); + return new FrameLocator($this->transport, $this->pageId, $newSelector, $this->logger, $this->page); } public function owner(): LocatorInterface @@ -142,7 +145,7 @@ public function owner(): LocatorInterface 'frameSelector' => $this->frameSelector, ]); - return new Locator($this->transport, $this->pageId, $this->frameSelector, null, $this->logger); + return new Locator($this->transport, $this->pageId, $this->frameSelector, null, $this->logger, [], $this->page); } public function content(): string @@ -259,6 +262,15 @@ public function evaluate(string $expression, mixed $arg = null): mixed return $response['result'] ?? null; } + public function page(): PageInterface + { + if (null === $this->page) { + throw new RuntimeException('This frame was not created from a page.'); + } + + return $this->page; + } + public function name(): string { $response = $this->sendCommand('frame.name'); @@ -315,7 +327,7 @@ public function parentFrame(): ?FrameInterface $selector = $response['selector'] ?? null; return is_string($selector) - ? new Frame($this->transport, $this->pageId, $selector, $this->logger) + ? new Frame($this->transport, $this->pageId, $selector, $this->logger, $this->page) : null; } @@ -333,7 +345,7 @@ public function childFrames(): array $result = []; foreach ($frames as $frameData) { if (is_array($frameData) && isset($frameData['selector']) && is_string($frameData['selector'])) { - $result[] = new Frame($this->transport, $this->pageId, $frameData['selector'], $this->logger); + $result[] = new Frame($this->transport, $this->pageId, $frameData['selector'], $this->logger, $this->page); } } diff --git a/src/Frame/FrameInterface.php b/src/Frame/FrameInterface.php index d163e10..0b02379 100644 --- a/src/Frame/FrameInterface.php +++ b/src/Frame/FrameInterface.php @@ -24,6 +24,7 @@ use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForNavigationOptions; use Playwright\Page\Options\WaitForUrlOptions; +use Playwright\Page\PageInterface; interface FrameInterface { @@ -129,6 +130,15 @@ public function addStyleTag(array|StyleTagOptions $options = []): self; */ public function evaluate(string $expression, mixed $arg = null): mixed; + /** + * The page this frame belongs to, main frame and nested frames alike. + * + * @throws \Playwright\Exception\RuntimeException if the frame was built + * without a page, which only + * happens when constructed by hand + */ + public function page(): PageInterface; + /** * The frame's name attribute. */ diff --git a/src/Frame/FrameLocator.php b/src/Frame/FrameLocator.php index 920f35b..e11dc6c 100644 --- a/src/Frame/FrameLocator.php +++ b/src/Frame/FrameLocator.php @@ -17,6 +17,7 @@ use Playwright\Locator\Locator; use Playwright\Locator\LocatorInterface; use Playwright\Locator\RoleSelectorBuilder; +use Playwright\Page\PageInterface; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -30,6 +31,7 @@ public function __construct( private readonly string $pageId, private readonly string $frameSelector, ?LoggerInterface $logger = null, + private readonly ?PageInterface $page = null, ) { $this->logger = $logger ?? new NullLogger(); } @@ -41,7 +43,7 @@ public function locator(string $selector): LocatorInterface 'selector' => $selector, ]); - return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger); + return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger, [], $this->page); } /** @@ -82,7 +84,7 @@ public function getByRole(string $role, array $options = []): LocatorInterface 'selector' => $selector, ]); - return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger, $locatorOptions); + return new Locator($this->transport, $this->pageId, $selector, $this->frameSelector, $this->logger, $locatorOptions, $this->page); } public function getByTestId(string $testId): LocatorInterface @@ -126,7 +128,7 @@ public function nth(int $index): self 'newSelector' => $newSelector, ]); - return new FrameLocator($this->transport, $this->pageId, $newSelector, $this->logger); + return new FrameLocator($this->transport, $this->pageId, $newSelector, $this->logger, $this->page); } public function frameLocator(string $selector): self @@ -139,7 +141,7 @@ public function frameLocator(string $selector): self 'newSelector' => $newSelector, ]); - return new FrameLocator($this->transport, $this->pageId, $newSelector, $this->logger); + return new FrameLocator($this->transport, $this->pageId, $newSelector, $this->logger, $this->page); } public function getSelector(): string @@ -153,7 +155,7 @@ public function owner(): LocatorInterface 'frameSelector' => $this->frameSelector, ]); - return new Locator($this->transport, $this->pageId, $this->frameSelector, null, $this->logger); + return new Locator($this->transport, $this->pageId, $this->frameSelector, null, $this->logger, [], $this->page); } public function __toString(): string diff --git a/src/Locator/Locator.php b/src/Locator/Locator.php index a360493..af2edcc 100644 --- a/src/Locator/Locator.php +++ b/src/Locator/Locator.php @@ -16,6 +16,7 @@ use Playwright\Exception\PlaywrightException; use Playwright\Exception\ProtocolErrorException; +use Playwright\Exception\RuntimeException; use Playwright\Exception\TimeoutException; use Playwright\Frame\FrameLocator; use Playwright\Frame\FrameLocatorInterface; @@ -45,6 +46,7 @@ use Playwright\Locator\Options\TypeOptions; use Playwright\Locator\Options\UncheckOptions; use Playwright\Locator\Options\WaitForOptions; +use Playwright\Page\PageInterface; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -70,6 +72,7 @@ public function __construct( private readonly ?string $frameSelector = null, ?LoggerInterface $logger = null, array $options = [], + private readonly ?PageInterface $page = null, ) { $this->logger = $logger ?? new NullLogger(); $this->options = $options; @@ -346,7 +349,7 @@ public function locator(string $selector): self $newSelectorChain = clone $this->selectorChain; $newSelectorChain->append($selector); - return new Locator($this->transport, $this->pageId, $newSelectorChain, $this->frameSelector); + return new Locator($this->transport, $this->pageId, $newSelectorChain, $this->frameSelector, $this->logger, [], $this->page); } /** @@ -638,7 +641,7 @@ public function nth(int $index): self { $newSelector = $this->selectorChain." >> nth=$index"; - return new Locator($this->transport, $this->pageId, $newSelector, $this->frameSelector); + return new Locator($this->transport, $this->pageId, $newSelector, $this->frameSelector, $this->logger, [], $this->page); } public function evaluate(string $expression, mixed $arg = null): mixed @@ -649,6 +652,15 @@ public function evaluate(string $expression, mixed $arg = null): mixed return $response['value'] ?? null; } + public function page(): PageInterface + { + if (null === $this->page) { + throw new RuntimeException('This locator was not created from a page.'); + } + + return $this->page; + } + public function evaluateAll(string $expression, mixed $arg = null): mixed { $response = $this->sendCommand('locator.evaluateAll', [ @@ -698,7 +710,7 @@ public function frameLocator(string $selector): FrameLocatorInterface { $newFrameSelector = $this->selectorChain.' >> '.$selector; - return new FrameLocator($this->transport, $this->pageId, $newFrameSelector); + return new FrameLocator($this->transport, $this->pageId, $newFrameSelector, $this->logger, $this->page); } /** @@ -898,14 +910,14 @@ public function filter(array|FilterOptions $options = []): self $chain->addFilter(\sprintf(':not(:has(%s))', $options->hasNot->getSelector())); } - return new self($this->transport, $this->pageId, $chain, $this->frameSelector, $this->logger); + return new self($this->transport, $this->pageId, $chain, $this->frameSelector, $this->logger, [], $this->page); } public function and(LocatorInterface $locator): self { $chain = $this->selectorChain->append($locator->getSelector()); - return new self($this->transport, $this->pageId, $chain, $this->frameSelector, $this->logger); + return new self($this->transport, $this->pageId, $chain, $this->frameSelector, $this->logger, [], $this->page); } public function or(LocatorInterface $locator): self @@ -913,7 +925,7 @@ public function or(LocatorInterface $locator): self $combined = \sprintf('%s, %s', $this->selectorChain->toString(), $locator->getSelector()); $chain = new SelectorChain($combined); - return new self($this->transport, $this->pageId, $chain, $this->frameSelector, $this->logger); + return new self($this->transport, $this->pageId, $chain, $this->frameSelector, $this->logger, [], $this->page); } public function describe(string $description): self @@ -923,6 +935,6 @@ public function describe(string $description): self public function contentFrame(): FrameLocatorInterface { - return new FrameLocator($this->transport, $this->pageId, $this->selectorChain->toString(), $this->logger); + return new FrameLocator($this->transport, $this->pageId, $this->selectorChain->toString(), $this->logger, $this->page); } } diff --git a/src/Locator/LocatorInterface.php b/src/Locator/LocatorInterface.php index 0e2b472..5ece29c 100644 --- a/src/Locator/LocatorInterface.php +++ b/src/Locator/LocatorInterface.php @@ -41,6 +41,7 @@ use Playwright\Locator\Options\TypeOptions; use Playwright\Locator\Options\UncheckOptions; use Playwright\Locator\Options\WaitForOptions; +use Playwright\Page\PageInterface; interface LocatorInterface { @@ -244,6 +245,15 @@ public function nth(int $index): self; public function evaluate(string $expression, mixed $arg = null): mixed; + /** + * The page this locator resolves against. + * + * @throws \Playwright\Exception\RuntimeException if the locator was built + * without a page, which only + * happens when constructed by hand + */ + public function page(): PageInterface; + /** * Runs the expression once over every matching element, passing them as an array. * diff --git a/src/Page/Page.php b/src/Page/Page.php index 8b53234..5cbd93f 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -212,7 +212,8 @@ public function locator(string $selector, array|LocatorOptions $options = []): L $selector, null, null, - $this->normalizeLocatorOptions($options) + $this->normalizeLocatorOptions($options), + $this, ); } @@ -1053,12 +1054,12 @@ public function addStyleTag(array|StyleTagOptions $options): self public function frameLocator(string $selector): FrameLocatorInterface { - return new FrameLocator($this->transport, $this->pageId, $selector); + return new FrameLocator($this->transport, $this->pageId, $selector, null, $this); } public function mainFrame(): FrameInterface { - return new Frame($this->transport, $this->pageId, ':root'); + return new Frame($this->transport, $this->pageId, ':root', null, $this); } /** @@ -1075,7 +1076,7 @@ public function frames(): array $result = []; foreach ($frames as $frameData) { if (\is_array($frameData) && isset($frameData['selector']) && \is_string($frameData['selector'])) { - $result[] = new Frame($this->transport, $this->pageId, $frameData['selector']); + $result[] = new Frame($this->transport, $this->pageId, $frameData['selector'], null, $this); } } @@ -1091,7 +1092,7 @@ public function frame(array|FrameQueryOptions $options): ?FrameInterface $response = $this->sendCommand('frame', ['options' => $options]); $selector = $response['selector'] ?? null; if (\is_string($selector)) { - return new Frame($this->transport, $this->pageId, $selector); + return new Frame($this->transport, $this->pageId, $selector, null, $this); } return null; diff --git a/tests/Integration/Frame/FrameIntegrationTest.php b/tests/Integration/Frame/FrameIntegrationTest.php index 1b61c5b..283c285 100644 --- a/tests/Integration/Frame/FrameIntegrationTest.php +++ b/tests/Integration/Frame/FrameIntegrationTest.php @@ -264,6 +264,13 @@ public function itUsesFrameLocatorGetByText(): void $this->assertSame('Frame Text', $locator->textContent()); } + #[Test] + public function itExposesThePageOwningTheFrame(): void + { + $this->assertSame($this->page, $this->innerFrame()->page()); + $this->assertSame($this->page, $this->page->mainFrame()->page()); + } + private function innerFrame(): Frame { $frame = $this->page->frame(['urlRegex' => '/inner\\.html$/']); diff --git a/tests/Integration/Locator/LocatorTest.php b/tests/Integration/Locator/LocatorTest.php index 37d548d..93920f5 100644 --- a/tests/Integration/Locator/LocatorTest.php +++ b/tests/Integration/Locator/LocatorTest.php @@ -182,6 +182,13 @@ public function itEvaluatesElementTagNameAndCss(): void $this->assertEquals('50px', $width); } + #[Test] + public function itExposesThePageItResolvesAgainst(): void + { + $this->assertSame($this->page, $this->page->locator('#button-1')->page()); + $this->assertSame($this->page, $this->page->locator('#button-1')->first()->page()); + } + #[Test] public function itBlursAnElement(): void { diff --git a/tests/Unit/Frame/FrameLocatorTest.php b/tests/Unit/Frame/FrameLocatorTest.php index c041e59..9bc339f 100644 --- a/tests/Unit/Frame/FrameLocatorTest.php +++ b/tests/Unit/Frame/FrameLocatorTest.php @@ -21,6 +21,7 @@ use Playwright\Frame\FrameLocator; use Playwright\Locator\Locator; use Playwright\Locator\LocatorInterface; +use Playwright\Page\PageInterface; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; @@ -213,4 +214,16 @@ public function testGetByLabel(): void $this->assertInstanceOf(LocatorInterface::class, $locator); $this->assertSame('label:text-is("Password") >> nth=0', $locator->getSelector()); } + + public function testDescendantsCarryThePageAlong(): void + { + $page = $this->createMock(PageInterface::class); + $frameLocator = new FrameLocator($this->transport, $this->pageId, $this->initialSelector, $this->logger, $page); + + $this->assertSame($page, $frameLocator->locator('button')->page()); + $this->assertSame($page, $frameLocator->getByRole('button')->page()); + $this->assertSame($page, $frameLocator->owner()->page()); + $this->assertSame($page, $frameLocator->nth(1)->locator('button')->page()); + $this->assertSame($page, $frameLocator->frameLocator('#nested')->locator('button')->page()); + } } diff --git a/tests/Unit/Frame/FrameTest.php b/tests/Unit/Frame/FrameTest.php index 4de80e7..0cbe120 100644 --- a/tests/Unit/Frame/FrameTest.php +++ b/tests/Unit/Frame/FrameTest.php @@ -18,12 +18,14 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Playwright\Exception\ProtocolErrorException; +use Playwright\Exception\RuntimeException; use Playwright\Frame\Frame; use Playwright\Frame\FrameInterface; use Playwright\Locator\LocatorInterface; use Playwright\Network\ResponseInterface; use Playwright\Page\Options\GotoOptions; use Playwright\Page\Options\WaitForNavigationOptions; +use Playwright\Page\PageInterface; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; @@ -451,6 +453,50 @@ public function testGotoRejectsAResponsePayloadWithNonStringKeys(): void $frame->goto('https://example.com'); } + public function testPageReturnsTheOwningPage(): void + { + $page = $this->createMock(PageInterface::class); + $frame = new Frame($this->transport, $this->pageId, 'iframe#auth', $this->logger, $page); + + $this->assertSame($page, $frame->page()); + } + + public function testPageRejectsAFrameBuiltWithoutAPage(): void + { + $frame = new Frame($this->transport, $this->pageId, 'iframe#auth', $this->logger); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This frame was not created from a page.'); + + $frame->page(); + } + + public function testDescendantsCarryTheFramePageAlong(): void + { + $page = $this->createMock(PageInterface::class); + $frame = new Frame($this->transport, $this->pageId, 'iframe#auth', $this->logger, $page); + + $this->assertSame($page, $frame->locator('button')->page()); + $this->assertSame($page, $frame->getByRole('button')->page()); + $this->assertSame($page, $frame->owner()->page()); + $this->assertSame($page, $frame->frameLocator('iframe#nested')->locator('button')->page()); + } + + public function testRelatedFramesCarryThePageAlong(): void + { + $page = $this->createMock(PageInterface::class); + $frame = new Frame($this->transport, $this->pageId, 'iframe#auth', $this->logger, $page); + $this->transport->method('send')->willReturnOnConsecutiveCalls( + ['selector' => ':root'], + ['frames' => [['selector' => 'iframe#auth >> iframe#child']]], + ); + + $parent = $frame->parentFrame(); + $this->assertInstanceOf(FrameInterface::class, $parent); + $this->assertSame($page, $parent->page()); + $this->assertSame($page, $frame->childFrames()[0]->page()); + } + public function testEvaluateLeavesABareExpressionUntouched(): void { $sent = []; diff --git a/tests/Unit/Locator/LocatorTest.php b/tests/Unit/Locator/LocatorTest.php index 234b3fc..af181c1 100644 --- a/tests/Unit/Locator/LocatorTest.php +++ b/tests/Unit/Locator/LocatorTest.php @@ -18,6 +18,7 @@ use PHPUnit\Framework\TestCase; use Playwright\Exception\PlaywrightException; use Playwright\Exception\ProtocolErrorException; +use Playwright\Exception\RuntimeException; use Playwright\Exception\TimeoutException; use Playwright\Frame\FrameLocatorInterface; use Playwright\Locator\Locator; @@ -27,6 +28,7 @@ use Playwright\Locator\Options\SelectTextOptions; use Playwright\Locator\Options\SetCheckedOptions; use Playwright\Locator\Options\TapOptions; +use Playwright\Page\PageInterface; use Playwright\Transport\TransportInterface; #[CoversClass(Locator::class)] @@ -1054,6 +1056,37 @@ public function testBoundingBoxRejectsNonNumericCoordinates(): void $this->locator->boundingBox(); } + public function testPageReturnsTheOriginatingPage(): void + { + $page = $this->createMock(PageInterface::class); + $locator = new Locator($this->transport, 'page1', '.element', null, null, [], $page); + + $this->assertSame($page, $locator->page()); + } + + public function testPageRejectsALocatorBuiltWithoutAPage(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This locator was not created from a page.'); + + $this->locator->page(); + } + + public function testDerivedLocatorsCarryThePageAlong(): void + { + $page = $this->createMock(PageInterface::class); + $locator = new Locator($this->transport, 'page1', '.items', null, null, [], $page); + $other = new Locator($this->transport, 'page1', '.other'); + + $this->assertSame($page, $locator->locator('.child')->page()); + $this->assertSame($page, $locator->nth(2)->page()); + $this->assertSame($page, $locator->filter(['hasText' => 'Save'])->page()); + $this->assertSame($page, $locator->and($other)->page()); + $this->assertSame($page, $locator->or($other)->page()); + $this->assertSame($page, $locator->frameLocator('iframe')->locator('.inner')->page()); + $this->assertSame($page, $locator->contentFrame()->locator('.inner')->page()); + } + /** * Rebinds $this->locator to the '.items' selector used by the filter and * combinator tests, which need a different base selector than setUp(). diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 50746fe..ea9514f 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -25,6 +25,7 @@ use Playwright\Exception\ProtocolErrorException; use Playwright\Exception\RuntimeException; use Playwright\Exception\TimeoutException; +use Playwright\Frame\FrameInterface; use Playwright\Input\KeyboardInterface; use Playwright\Input\MouseInterface; use Playwright\Input\TouchscreenInterface; @@ -1154,6 +1155,32 @@ public function testUnrouteAllSendsItsBehavior(): void $this->page->unrouteAll(['behavior' => 'wait']); } + public function testLocatorRetainsItsOriginatingPage(): void + { + $this->assertSame($this->page, $this->page->locator('button.save')->page()); + $this->assertSame($this->page, $this->page->getByRole('button')->page()); + $this->assertSame($this->page, $this->page->frameLocator('iframe')->locator('button')->page()); + } + + public function testMainFrameRetainsItsOriginatingPage(): void + { + $this->assertSame($this->page, $this->page->mainFrame()->page()); + } + + public function testQueriedFramesRetainTheirOriginatingPage(): void + { + $this->transport->method('send')->willReturnOnConsecutiveCalls( + ['frames' => [['selector' => 'iframe#one']]], + ['selector' => 'iframe#one'], + ); + + $this->assertSame($this->page, $this->page->frames()[0]->page()); + + $frame = $this->page->frame(['name' => 'one']); + $this->assertInstanceOf(FrameInterface::class, $frame); + $this->assertSame($this->page, $frame->page()); + } + private function createPage(string $pageId = 'page-1'): Page { return new Page($this->transport, $this->context, $pageId, new PlaywrightConfig()); From 3e69d1c42a39a86d07861ec45786494c18cad950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Fri, 7 Aug 2026 06:21:23 +0200 Subject: [PATCH 2/3] Add handle evaluation APIs --- bin/lib/core.js | 5 + bin/lib/handlers.js | 88 ++++++++++++++++- bin/playwright-server.js | 4 +- src/Frame/Frame.php | 32 ++++++ src/Frame/FrameInterface.php | 18 ++++ src/JSHandle/JSHandle.php | 4 +- src/Locator/Locator.php | 17 ++++ src/Locator/LocatorInterface.php | 10 ++ src/Page/Page.php | 15 +++ src/Page/PageInterface.php | 10 ++ .../Frame/FrameIntegrationTest.php | 18 ++++ tests/Integration/JSHandle/JSHandleTest.php | 99 +++++++++++++++++++ tests/Integration/Locator/LocatorTest.php | 11 +++ tests/Integration/Page/PageTest.php | 18 ++++ tests/Unit/Frame/FrameTest.php | 55 +++++++++++ tests/Unit/JSHandle/JSHandleTest.php | 49 +++++++++ tests/Unit/Locator/LocatorTest.php | 30 ++++++ tests/Unit/Page/PageTest.php | 33 +++++++ 18 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 tests/Integration/JSHandle/JSHandleTest.php create mode 100644 tests/Unit/JSHandle/JSHandleTest.php diff --git a/bin/lib/core.js b/bin/lib/core.js index e19578a..ae77ed7 100644 --- a/bin/lib/core.js +++ b/bin/lib/core.js @@ -184,6 +184,11 @@ class BaseHandler { } wrapResult(value) { return value === undefined || value === null ? { success: true } : value; } createValueResult(value) { return { value }; } + storeHandle(handle) { + const handleId = this.generateId('element'); + this.elementHandles.set(handleId, handle); + return { handleId }; + } async executeWithRegistry(registry, method, ...args) { return await registry.execute(method, ...args); } async followNavigationRedirects(pageId, page, action) { let nextAction = action; diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index b6e413c..e5b0b8b 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -2,6 +2,24 @@ const { logger, ErrorHandler, CommandRegistry, BaseHandler, PromiseUtils, FrameU const { globalCoordinator } = require('./coordination'); const { PopupCoordinator } = require('./popup-coordinator'); +// The two callbacks below never run in this Node process: Playwright ships +// their source to the browser, so their eval() has page scope only, exactly +// like the callbacks passed to page.evaluate() elsewhere in this file. +// +// They are needed because evaluateHandle() does not accept a function as a +// string. Playwright evaluates a string pageFunction as a plain expression and +// never calls it, so forwarding "() => value" would hand back a handle to the +// function itself rather than to its result. +const evaluateHandleInPage = async ({ expression, arg }) => { + const value = eval(`(${expression})`); + return typeof value === 'function' ? await value(arg) : await value; +}; + +const evaluateHandleOnTarget = async (target, { expression, arg }) => { + const value = eval(`(${expression})`); + return typeof value === 'function' ? await value(target, arg) : await value; +}; + class ContextHandler extends BaseHandler { async handle(command, method) { const context = this.validateResource(this.contexts, command.contextId, 'Context')?.context; @@ -403,10 +421,10 @@ class PageHandler extends BaseHandler { } async evaluateHandle(page, command) { - const handle = await page.evaluateHandle(command.expression, command.arg); - const handleId = this.generateId('element'); - this.elementHandles.set(handleId, handle); - return { elementHandleId: handleId }; + return this.storeHandle(await page.evaluateHandle(evaluateHandleInPage, { + expression: command.expression, + arg: command.arg, + })); } async handleDialog(command) { @@ -595,6 +613,7 @@ class LocatorHandler extends BaseHandler { selectOption: () => PromiseUtils.wrapValues(locator.selectOption(command.values, command.options)), screenshot: () => PromiseUtils.wrapBinary(locator.screenshot(command.options)), evaluate: () => this.evaluateLocator(locator, command), + evaluateHandle: () => this.evaluateHandle(locator, command), dragAndDrop: () => this.handleDragAndDrop(page, command) }); @@ -657,6 +676,13 @@ class LocatorHandler extends BaseHandler { } } + async evaluateHandle(locator, command) { + return this.storeHandle(await locator.evaluateHandle(evaluateHandleOnTarget, { + expression: command.expression, + arg: command.arg, + })); + } + async handleDragAndDrop(page, command) { logger.debug('Handling drag and drop', { selector: command.selector, @@ -738,6 +764,8 @@ class FrameHandler extends BaseHandler { addStyleTag: async () => { await (await nativeFrame()).addStyleTag(command.options); return { success: true }; }, name: () => evalInFrame(() => window.name || '').then(v => this.createValueResult(v ?? '')), evaluate: () => FrameUtils.evaluateInFrame(page, frameLocator, isMainFrame, command.expression, command.arg).then(result => ({ result })), + evaluateHandle: () => this.evaluateHandle(nativeFrame, command), + frameElement: () => this.frameElement(nativeFrame), title: () => evalInFrame(() => document.title).then(v => this.createValueResult(v ?? '')), url: () => evalInFrame(() => document.location.href).then(v => this.createValueResult(v ?? '')), isDetached: () => this.checkDetached(isMainFrame, frameLocator), @@ -756,6 +784,19 @@ class FrameHandler extends BaseHandler { return { success: true }; } + async evaluateHandle(nativeFrame, command) { + const frame = await nativeFrame(); + return this.storeHandle(await frame.evaluateHandle(evaluateHandleInPage, { + expression: command.expression, + arg: command.arg, + })); + } + + async frameElement(nativeFrame) { + const frame = await nativeFrame(); + return this.storeHandle(await frame.frameElement()); + } + async checkDetached(isMainFrame, frameLocator) { if (isMainFrame) return this.createValueResult(false); const count = await frameLocator.locator('html').count(); @@ -800,6 +841,43 @@ class FrameHandler extends BaseHandler { } } +class JSHandleHandler extends BaseHandler { + async handle(command, method) { + const handle = this.validateResource(this.elementHandles, command.handleId, 'JSHandle'); + + const registry = CommandRegistry.create({ + asElement: () => this.asElement(handle), + dispose: () => this.dispose(handle, command.handleId), + evaluate: async () => ({ result: await handle.evaluate(evaluateHandleOnTarget, { expression: command.expression, arg: command.arg }) }), + evaluateHandle: async () => this.storeHandle(await handle.evaluateHandle(evaluateHandleOnTarget, { expression: command.expression, arg: command.arg })), + getProperties: () => this.getProperties(handle), + getProperty: async () => this.storeHandle(await handle.getProperty(command.propertyName)), + jsonValue: () => PromiseUtils.wrapValue(handle.jsonValue()) + }); + + return await this.executeWithRegistry(registry, method); + } + + async dispose(handle, handleId) { + await handle.dispose(); + this.elementHandles.delete(handleId); + return { success: true }; + } + + asElement(handle) { + const element = handle.asElement(); + return element ? this.storeHandle(element) : { handleId: null }; + } + + async getProperties(handle) { + const properties = {}; + for (const [name, value] of (await handle.getProperties()).entries()) { + properties[name] = this.storeHandle(value).handleId; + } + return { properties }; + } +} + class SelectorsHandler extends BaseHandler { async handle(command, method) { const { playwright } = require('playwright'); @@ -822,4 +900,4 @@ class SelectorsHandler extends BaseHandler { } } -module.exports = { ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, SelectorsHandler }; +module.exports = { ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler }; diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 92b5ef0..aa43d9a 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -1,6 +1,6 @@ const {chromium, firefox, webkit} = require('playwright'); const { logger, ErrorHandler, LspFraming, sendFramedResponse, CommandRegistry, BaseHandler } = require('./lib/core'); -const { ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, SelectorsHandler } = require('./lib/handlers'); +const { ContextHandler, PageHandler, LocatorHandler, InteractionHandler, FrameHandler, JSHandleHandler, SelectorsHandler } = require('./lib/handlers'); const { globalCoordinator } = require('./lib/coordination'); class PlaywrightServer extends BaseHandler { @@ -42,6 +42,7 @@ class PlaywrightServer extends BaseHandler { this.locatorHandler = new LocatorHandler(deps); this.interactionHandler = new InteractionHandler(deps); this.frameHandler = new FrameHandler(deps); + this.jsHandleHandler = new JSHandleHandler(deps); this.selectorsHandler = new SelectorsHandler(deps); } @@ -75,6 +76,7 @@ class PlaywrightServer extends BaseHandler { keyboard: () => this.interactionHandler.handleKeyboard(command, actionMethod), touchscreen: () => this.interactionHandler.handleTouchscreen(command, actionMethod), frame: () => this.frameHandler.handle(command, actionMethod), + jsHandle: () => this.jsHandleHandler.handle(command, actionMethod), browserServer: () => this.handleBrowserServer(command, actionMethod), selectors: () => this.selectorsHandler.handle(command, actionMethod), clock: () => this.contextHandler.handleClock(command, actionMethod), diff --git a/src/Frame/Frame.php b/src/Frame/Frame.php index 24baad5..5d5f5a7 100644 --- a/src/Frame/Frame.php +++ b/src/Frame/Frame.php @@ -17,6 +17,8 @@ use Playwright\Exception\PlaywrightException; use Playwright\Exception\ProtocolErrorException; use Playwright\Exception\RuntimeException; +use Playwright\JSHandle\JSHandle; +use Playwright\JSHandle\JSHandleInterface; use Playwright\Locator\Locator; use Playwright\Locator\LocatorInterface; use Playwright\Locator\RoleSelectorBuilder; @@ -262,6 +264,23 @@ public function evaluate(string $expression, mixed $arg = null): mixed return $response['result'] ?? null; } + public function evaluateHandle(string $expression, mixed $arg = null): JSHandleInterface + { + $response = $this->sendCommand('frame.evaluateHandle', [ + 'expression' => self::normalizeForPage($expression), + 'arg' => $arg, + ]); + + return $this->createHandle($response, 'frame.evaluateHandle'); + } + + public function frameElement(): JSHandleInterface + { + $response = $this->sendCommand('frame.frameElement'); + + return $this->createHandle($response, 'frame.frameElement'); + } + public function page(): PageInterface { if (null === $this->page) { @@ -376,6 +395,19 @@ private function sendCommand(string $action, array $params = []): array return $response; } + /** + * @param array $response + */ + private function createHandle(array $response, string $action): JSHandleInterface + { + $handleId = $response['handleId'] ?? null; + if (!is_string($handleId)) { + throw new ProtocolErrorException(\sprintf('Invalid %s response', $action), 0); + } + + return new JSHandle($this->transport, $handleId); + } + private function createResponse(mixed $data): ?ResponseInterface { if (null === $data) { diff --git a/src/Frame/FrameInterface.php b/src/Frame/FrameInterface.php index 0b02379..d086b47 100644 --- a/src/Frame/FrameInterface.php +++ b/src/Frame/FrameInterface.php @@ -14,6 +14,7 @@ namespace Playwright\Frame; +use Playwright\JSHandle\JSHandleInterface; use Playwright\Locator\LocatorInterface; use Playwright\Network\ResponseInterface; use Playwright\Page\Options\DragAndDropOptions; @@ -130,6 +131,23 @@ public function addStyleTag(array|StyleTagOptions $options = []): self; */ public function evaluate(string $expression, mixed $arg = null): mixed; + /** + * Returns a handle to the result instead of a serialized copy, so a DOM node + * or a live object survives the round trip. + * + * Dispose the handle once done with it, otherwise it pins its target until + * the page closes. + */ + public function evaluateHandle(string $expression, mixed $arg = null): JSHandleInterface; + + /** + * A handle to the