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..c59b8bc 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,8 @@ 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), + waitForFunction: () => this.waitForFunction(locator, command), dragAndDrop: () => this.handleDragAndDrop(page, command) }); @@ -657,6 +677,20 @@ class LocatorHandler extends BaseHandler { } } + async evaluateHandle(locator, command) { + return this.storeHandle(await locator.evaluateHandle(evaluateHandleOnTarget, { + expression: command.expression, + arg: command.arg, + })); + } + + async waitForFunction(locator, command) { + // Forwarded as a string: Playwright evaluates it in the page. Turning it + // into a function here would run it in this Node process instead. + await locator.waitForFunction(command.pageFunction, command.arg, command.options); + return { success: true }; + } + async handleDragAndDrop(page, command) { logger.debug('Handling drag and drop', { selector: command.selector, @@ -738,6 +772,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 +792,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 +849,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 +908,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 12a330a..5d5f5a7 100644 --- a/src/Frame/Frame.php +++ b/src/Frame/Frame.php @@ -16,6 +16,9 @@ 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; @@ -29,6 +32,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 +46,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 +63,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 +104,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 +138,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 +147,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 +264,32 @@ 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) { + 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 +346,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 +364,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); } } @@ -364,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 d163e10..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; @@ -24,6 +25,7 @@ use Playwright\Page\Options\WaitForFunctionOptions; use Playwright\Page\Options\WaitForNavigationOptions; use Playwright\Page\Options\WaitForUrlOptions; +use Playwright\Page\PageInterface; interface FrameInterface { @@ -129,6 +131,32 @@ 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