From ebcb8a276076f307286cd9d3ed7bd904cc2e1d88 Mon Sep 17 00:00:00 2001 From: jrandolf <101637635+jrandolf@users.noreply.github.com> Date: Thu, 23 Jun 2022 11:31:43 +0200 Subject: [PATCH] chore: split `JSHandle.ts` (#8551) --- src/common/Accessibility.ts | 4 +- src/common/AriaQueryHandler.ts | 7 +- src/common/DOMWorld.ts | 3 +- src/common/ElementHandle.ts | 1048 ++++++++++++++++++++++++++++ src/common/ExecutionContext.ts | 10 +- src/common/FileChooser.ts | 2 +- src/common/FrameManager.ts | 2 +- src/common/JSHandle.ts | 1063 +---------------------------- src/common/Page.ts | 4 +- src/common/QueryHandler.ts | 3 +- src/common/types.ts | 3 +- src/common/util.ts | 25 + test/src/ariaqueryhandler.spec.ts | 2 +- test/src/idle_override.spec.ts | 2 +- 14 files changed, 1103 insertions(+), 1075 deletions(-) create mode 100644 src/common/ElementHandle.ts diff --git a/src/common/Accessibility.ts b/src/common/Accessibility.ts index 0a496376508..2881040e93d 100644 --- a/src/common/Accessibility.ts +++ b/src/common/Accessibility.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import {CDPSession} from './Connection.js'; -import {ElementHandle} from './JSHandle.js'; import {Protocol} from 'devtools-protocol'; +import {CDPSession} from './Connection.js'; +import {ElementHandle} from './ElementHandle.js'; /** * Represents a Node and the properties of it that are relevant to Accessibility. diff --git a/src/common/AriaQueryHandler.ts b/src/common/AriaQueryHandler.ts index f7cb4229250..1e323078238 100644 --- a/src/common/AriaQueryHandler.ts +++ b/src/common/AriaQueryHandler.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import {InternalQueryHandler} from './QueryHandler.js'; -import {ElementHandle, JSHandle} from './JSHandle.js'; import {Protocol} from 'devtools-protocol'; +import {assert} from './assert.js'; import {CDPSession} from './Connection.js'; import {DOMWorld, PageBinding, WaitForSelectorOptions} from './DOMWorld.js'; -import {assert} from './assert.js'; +import {ElementHandle} from './ElementHandle.js'; +import {JSHandle} from './JSHandle.js'; +import {InternalQueryHandler} from './QueryHandler.js'; async function queryAXTree( client: CDPSession, diff --git a/src/common/DOMWorld.ts b/src/common/DOMWorld.ts index bb1813f105a..ca8bcdf8bfc 100644 --- a/src/common/DOMWorld.ts +++ b/src/common/DOMWorld.ts @@ -17,11 +17,12 @@ import {Protocol} from 'devtools-protocol'; import {assert} from './assert.js'; import {CDPSession} from './Connection.js'; +import {ElementHandle} from './ElementHandle.js'; import {TimeoutError} from './Errors.js'; import {ExecutionContext} from './ExecutionContext.js'; import {Frame, FrameManager} from './FrameManager.js'; import {MouseButton} from './Input.js'; -import {ElementHandle, JSHandle} from './JSHandle.js'; +import {JSHandle} from './JSHandle.js'; import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; import {_getQueryHandlerAndSelector} from './QueryHandler.js'; import {TimeoutSettings} from './TimeoutSettings.js'; diff --git a/src/common/ElementHandle.ts b/src/common/ElementHandle.ts new file mode 100644 index 00000000000..aaf06c2352f --- /dev/null +++ b/src/common/ElementHandle.ts @@ -0,0 +1,1048 @@ +import {Protocol} from 'devtools-protocol'; +import {assert} from './assert.js'; +import {CDPSession} from './Connection.js'; +import {WaitForSelectorOptions} from './DOMWorld.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {Frame, FrameManager} from './FrameManager.js'; +import { + BoundingBox, + BoxModel, + ClickOptions, + computeQuadArea, + JSHandle, + Offset, + Point, + PressOptions, +} from './JSHandle.js'; +import {Page, ScreenshotOptions} from './Page.js'; +import {_getQueryHandlerAndSelector} from './QueryHandler.js'; +import {EvaluateFunc, EvaluateParams} from './types.js'; +import {KeyInput} from './USKeyboardLayout.js'; +import {debugError, isString} from './util.js'; + +const applyOffsetsToQuad = ( + quad: Point[], + offsetX: number, + offsetY: number +) => { + return quad.map(part => { + return {x: part.x + offsetX, y: part.y + offsetY}; + }); +}; + +/** + * ElementHandle represents an in-page DOM element. + * + * @remarks + * + * ElementHandles can be created with the {@link Page.$} method. + * + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * const hrefElement = await page.$('a'); + * await hrefElement.click(); + * // ... + * })(); + * ``` + * + * ElementHandle prevents the DOM element from being garbage-collected unless the + * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed + * when their origin frame gets navigated. + * + * ElementHandle instances can be used as arguments in {@link Page.$eval} and + * {@link Page.evaluate} methods. + * + * If you're using TypeScript, ElementHandle takes a generic argument that + * denotes the type of element the handle is holding within. For example, if you + * have a handle to a `` element matching `selector`, the method + * throws an error. + * + * @example + * ```js + * handle.select('blue'); // single selection + * handle.select('red', 'green', 'blue'); // multiple selections + * ``` + * @param values - Values of options to select. If the ` element.'); + } + + const selectedValues = new Set(); + if (!element.multiple) { + for (const option of element.options) { + option.selected = false; + } + for (const option of element.options) { + if (values.has(option.value)) { + option.selected = true; + selectedValues.add(option.value); + break; + } + } + } else { + for (const option of element.options) { + option.selected = values.has(option.value); + if (option.selected) { + selectedValues.add(option.value); + } + } + } + element.dispatchEvent(new Event('input', {bubbles: true})); + element.dispatchEvent(new Event('change', {bubbles: true})); + return [...selectedValues.values()]; + }, values); + } + + /** + * This method expects `elementHandle` to point to an + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element}. + * + * @param filePaths - Sets the value of the file input to these paths. + * If a path is relative, then it is resolved against the + * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + * Note for locals script connecting to remote chrome environments, + * paths must be absolute. + */ + async uploadFile( + this: ElementHandle, + ...filePaths: string[] + ): Promise { + const isMultiple = await this.evaluate(element => { + return element.multiple; + }); + assert( + filePaths.length <= 1 || isMultiple, + 'Multiple file uploads only work with ' + ); + + // Locate all files and confirm that they exist. + let path: typeof import('path'); + try { + path = await import('path'); + } catch (error) { + if (error instanceof TypeError) { + throw new Error( + `JSHandle#uploadFile can only be used in Node-like environments.` + ); + } + throw error; + } + const files = filePaths.map(filePath => { + if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) { + return filePath; + } else { + return path.resolve(filePath); + } + }); + const {objectId} = this._remoteObject; + const {node} = await this._client.send('DOM.describeNode', {objectId}); + const {backendNodeId} = node; + + /* The zero-length array is a special case, it seems that + DOM.setFileInputFiles does not actually update the files in that case, + so the solution is to eval the element value to a new FileList directly. + */ + if (files.length === 0) { + await this.evaluate(element => { + element.files = new DataTransfer().files; + + // Dispatch events for this case because it should behave akin to a user action. + element.dispatchEvent(new Event('input', {bubbles: true})); + element.dispatchEvent(new Event('change', {bubbles: true})); + }); + } else { + await this._client.send('DOM.setFileInputFiles', { + objectId, + files, + backendNodeId, + }); + } + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Touchscreen.tap} to tap in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async tap(): Promise { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.tap(x, y); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. + */ + async focus(): Promise { + await this.evaluate(element => { + if (!(element instanceof HTMLElement)) { + throw new Error('Cannot focus non-HTMLElement'); + } + return element.focus(); + }); + } + + /** + * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and + * `keyup` event for each character in the text. + * + * To press a special key, like `Control` or `ArrowDown`, + * use {@link ElementHandle.press}. + * + * @example + * ```js + * await elementHandle.type('Hello'); // Types instantly + * await elementHandle.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @example + * An example of typing into a text field and then submitting the form: + * + * ```js + * const elementHandle = await page.$('input'); + * await elementHandle.type('some text'); + * await elementHandle.press('Enter'); + * ``` + */ + async type(text: string, options?: {delay: number}): Promise { + await this.focus(); + await this.#page.keyboard.type(text, options); + } + + /** + * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also be generated. + * The `text` option can be specified to force an input event to be generated. + * + * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` + * will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + */ + async press(key: KeyInput, options?: PressOptions): Promise { + await this.focus(); + await this.#page.keyboard.press(key, options); + } + + /** + * This method returns the bounding box of the element (relative to the main frame), + * or `null` if the element is not visible. + */ + async boundingBox(): Promise { + const result = await this.#getBoxModel(); + + if (!result) { + return null; + } + + const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); + const quad = result.model.border; + const x = Math.min(quad[0]!, quad[2]!, quad[4]!, quad[6]!); + const y = Math.min(quad[1]!, quad[3]!, quad[5]!, quad[7]!); + const width = Math.max(quad[0]!, quad[2]!, quad[4]!, quad[6]!) - x; + const height = Math.max(quad[1]!, quad[3]!, quad[5]!, quad[7]!) - y; + + return {x: x + offsetX, y: y + offsetY, width, height}; + } + + /** + * This method returns boxes of the element, or `null` if the element is not visible. + * + * @remarks + * + * Boxes are represented as an array of points; + * Each Point is an object `{x, y}`. Box points are sorted clock-wise. + */ + async boxModel(): Promise { + const result = await this.#getBoxModel(); + + if (!result) { + return null; + } + + const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); + + const {content, padding, border, margin, width, height} = result.model; + return { + content: applyOffsetsToQuad( + this.#fromProtocolQuad(content), + offsetX, + offsetY + ), + padding: applyOffsetsToQuad( + this.#fromProtocolQuad(padding), + offsetX, + offsetY + ), + border: applyOffsetsToQuad( + this.#fromProtocolQuad(border), + offsetX, + offsetY + ), + margin: applyOffsetsToQuad( + this.#fromProtocolQuad(margin), + offsetX, + offsetY + ), + width, + height, + }; + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Page.screenshot} to take a screenshot of the element. + * If the element is detached from DOM, the method throws an error. + */ + async screenshot(options: ScreenshotOptions = {}): Promise { + let needsViewportReset = false; + + let boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + + const viewport = this.#page.viewport(); + assert(viewport); + + if ( + boundingBox.width > viewport.width || + boundingBox.height > viewport.height + ) { + const newViewport = { + width: Math.max(viewport.width, Math.ceil(boundingBox.width)), + height: Math.max(viewport.height, Math.ceil(boundingBox.height)), + }; + await this.#page.setViewport(Object.assign({}, viewport, newViewport)); + + needsViewportReset = true; + } + + await this.#scrollIntoViewIfNeeded(); + + boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + assert(boundingBox.width !== 0, 'Node has 0 width.'); + assert(boundingBox.height !== 0, 'Node has 0 height.'); + + const layoutMetrics = await this._client.send('Page.getLayoutMetrics'); + // Fallback to `layoutViewport` in case of using Firefox. + const {pageX, pageY} = + layoutMetrics.cssVisualViewport || layoutMetrics.layoutViewport; + + const clip = Object.assign({}, boundingBox); + clip.x += pageX; + clip.y += pageY; + + const imageData = await this.#page.screenshot( + Object.assign( + {}, + { + clip, + }, + options + ) + ); + + if (needsViewportReset) { + await this.#page.setViewport(viewport); + } + + return imageData; + } + + /** + * Runs `element.querySelector` within the page. + * + * @param selector - The selector to query with. + * @returns `null` if no element matches the selector. + * @throws `Error` if the selector has no associated query handler. + */ + async $( + selector: Selector + ): Promise | null>; + async $(selector: string): Promise; + async $(selector: string): Promise { + const {updatedSelector, queryHandler} = + _getQueryHandlerAndSelector(selector); + assert( + queryHandler.queryOne, + 'Cannot handle queries for a single element with the given selector' + ); + return queryHandler.queryOne(this, updatedSelector); + } + + /** + * Runs `element.querySelectorAll` within the page. If no elements match the selector, + * the return value resolves to `[]`. + */ + /** + * Runs `element.querySelectorAll` within the page. + * + * @param selector - The selector to query with. + * @returns `[]` if no element matches the selector. + * @throws `Error` if the selector has no associated query handler. + */ + async $$( + selector: Selector + ): Promise[]>; + async $$(selector: string): Promise; + async $$(selector: string): Promise { + const {updatedSelector, queryHandler} = + _getQueryHandlerAndSelector(selector); + assert( + queryHandler.queryAll, + 'Cannot handle queries for a multiple element with the given selector' + ); + return queryHandler.queryAll(this, updatedSelector); + } + + /** + * This method runs `document.querySelector` within the element and passes it as + * the first argument to `pageFunction`. If there's no element matching `selector`, + * the method throws an error. + * + * If `pageFunction` returns a Promise, then `frame.$eval` would wait for the promise + * to resolve and return its value. + * + * @example + * ```js + * const tweetHandle = await page.$('.tweet'); + * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe('100'); + * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe('10'); + * ``` + */ + async $eval< + Selector extends keyof HTMLElementTagNameMap, + Params extends unknown[], + Func extends EvaluateFunc< + [HTMLElementTagNameMap[Selector], ...Params] + > = EvaluateFunc<[HTMLElementTagNameMap[Selector], ...Params]> + >( + selector: Selector, + pageFunction: Func | string, + ...args: EvaluateParams + ): Promise>>; + async $eval< + Params extends unknown[], + Func extends EvaluateFunc<[Element, ...Params]> = EvaluateFunc< + [Element, ...Params] + > + >( + selector: string, + pageFunction: Func | string, + ...args: EvaluateParams + ): Promise>>; + async $eval< + Params extends unknown[], + Func extends EvaluateFunc<[Element, ...Params]> = EvaluateFunc< + [Element, ...Params] + > + >( + selector: string, + pageFunction: Func | string, + ...args: EvaluateParams + ): Promise>> { + const elementHandle = await this.$(selector); + if (!elementHandle) { + throw new Error( + `Error: failed to find element matching selector "${selector}"` + ); + } + const result = await elementHandle.evaluate(pageFunction, ...args); + await elementHandle.dispose(); + return result; + } + + /** + * This method runs `document.querySelectorAll` within the element and passes it as + * the first argument to `pageFunction`. If there's no element matching `selector`, + * the method throws an error. + * + * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for the + * promise to resolve and return its value. + * + * @example + * ```html + *
+ *
Hello!
+ *
Hi!
+ *
+ * ``` + * + * @example + * ```js + * const feedHandle = await page.$('.feed'); + * expect(await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))) + * .toEqual(['Hello!', 'Hi!']); + * ``` + */ + async $$eval< + Selector extends keyof HTMLElementTagNameMap, + Params extends unknown[], + Func extends EvaluateFunc< + [HTMLElementTagNameMap[Selector][], ...Params] + > = EvaluateFunc<[HTMLElementTagNameMap[Selector][], ...Params]> + >( + selector: Selector, + pageFunction: Func | string, + ...args: EvaluateParams + ): Promise>>; + async $$eval< + Params extends unknown[], + Func extends EvaluateFunc<[Element[], ...Params]> = EvaluateFunc< + [Element[], ...Params] + > + >( + selector: string, + pageFunction: Func | string, + ...args: EvaluateParams + ): Promise>>; + async $$eval< + Params extends unknown[], + Func extends EvaluateFunc<[Element[], ...Params]> = EvaluateFunc< + [Element[], ...Params] + > + >( + selector: string, + pageFunction: Func | string, + ...args: EvaluateParams + ): Promise>> { + const {updatedSelector, queryHandler} = + _getQueryHandlerAndSelector(selector); + assert(queryHandler.queryAllArray); + const arrayHandle = await queryHandler.queryAllArray(this, updatedSelector); + const result = await arrayHandle.evaluate(pageFunction, ...args); + await arrayHandle.dispose(); + return result; + } + + /** + * The method evaluates the XPath expression relative to the elementHandle. + * If there are no such elements, the method will resolve to an empty array. + * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} + */ + async $x(expression: string): Promise { + const arrayHandle = await this.evaluateHandle((element, expression) => { + const document = element.ownerDocument || element; + const iterator = document.evaluate( + expression, + element, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const array = []; + let item; + while ((item = iterator.iterateNext())) { + array.push(item); + } + return array; + }, expression); + const properties = await arrayHandle.getProperties(); + await arrayHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) { + result.push(elementHandle); + } + } + return result; + } + + /** + * Resolves to true if the element is visible in the current viewport. + */ + async isIntersectingViewport(options?: { + threshold?: number; + }): Promise { + const {threshold = 0} = options ?? {}; + return await this.evaluate(async (element, threshold) => { + const visibleRatio = await new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0]!.intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + return threshold === 1 ? visibleRatio === 1 : visibleRatio > threshold; + }, threshold); + } +} diff --git a/src/common/ExecutionContext.ts b/src/common/ExecutionContext.ts index b32381ca540..fd02fd9d079 100644 --- a/src/common/ExecutionContext.ts +++ b/src/common/ExecutionContext.ts @@ -20,8 +20,14 @@ import {CDPSession} from './Connection.js'; import {DOMWorld} from './DOMWorld.js'; import {EvaluateFunc, HandleFor, EvaluateParams} from './types.js'; import {Frame} from './FrameManager.js'; -import {ElementHandle, JSHandle, _createJSHandle} from './JSHandle.js'; -import {getExceptionMessage, isString, valueFromRemoteObject} from './util.js'; +import {JSHandle} from './JSHandle.js'; +import {ElementHandle} from './ElementHandle.js'; +import { + getExceptionMessage, + _createJSHandle, + isString, + valueFromRemoteObject, +} from './util.js'; /** * @public diff --git a/src/common/FileChooser.ts b/src/common/FileChooser.ts index 4ce632d4aa3..7209818bcf5 100644 --- a/src/common/FileChooser.ts +++ b/src/common/FileChooser.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import {ElementHandle} from './JSHandle.js'; import {Protocol} from 'devtools-protocol'; import {assert} from './assert.js'; +import {ElementHandle} from './ElementHandle.js'; /** * File choosers let you react to the page requesting for a file. diff --git a/src/common/FrameManager.ts b/src/common/FrameManager.ts index c2454383ef6..b744f333938 100644 --- a/src/common/FrameManager.ts +++ b/src/common/FrameManager.ts @@ -22,7 +22,7 @@ import {EventEmitter} from './EventEmitter.js'; import {EVALUATION_SCRIPT_URL, ExecutionContext} from './ExecutionContext.js'; import {HTTPResponse} from './HTTPResponse.js'; import {MouseButton} from './Input.js'; -import {ElementHandle} from './JSHandle.js'; +import {ElementHandle} from './ElementHandle.js'; import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; import {NetworkManager} from './NetworkManager.js'; import {Page} from './Page.js'; diff --git a/src/common/JSHandle.ts b/src/common/JSHandle.ts index 11364fd7838..7cd17f2bbb7 100644 --- a/src/common/JSHandle.ts +++ b/src/common/JSHandle.ts @@ -19,18 +19,9 @@ import {assert} from './assert.js'; import {CDPSession} from './Connection.js'; import {EvaluateFunc, EvaluateParams, HandleFor, HandleOr} from './types.js'; import {ExecutionContext} from './ExecutionContext.js'; -import {Frame, FrameManager} from './FrameManager.js'; import {MouseButton} from './Input.js'; -import {Page, ScreenshotOptions} from './Page.js'; -import {_getQueryHandlerAndSelector} from './QueryHandler.js'; -import {KeyInput} from './USKeyboardLayout.js'; -import { - debugError, - isString, - releaseObject, - valueFromRemoteObject, -} from './util.js'; -import {WaitForSelectorOptions} from './DOMWorld.js'; +import {releaseObject, valueFromRemoteObject, _createJSHandle} from './util.js'; +import type {ElementHandle} from './ElementHandle.js'; /** * @public @@ -58,38 +49,6 @@ export interface BoundingBox extends Point { height: number; } -/** - * @internal - */ -export function _createJSHandle( - context: ExecutionContext, - remoteObject: Protocol.Runtime.RemoteObject -): JSHandle | ElementHandle { - const frame = context.frame(); - if (remoteObject.subtype === 'node' && frame) { - const frameManager = frame._frameManager; - return new ElementHandle( - context, - context._client, - remoteObject, - frame, - frameManager.page(), - frameManager - ); - } - return new JSHandle(context, context._client, remoteObject); -} - -const applyOffsetsToQuad = ( - quad: Point[], - offsetX: number, - offsetY: number -) => { - return quad.map(part => { - return {x: part.x + offsetX, y: part.y + offsetY}; - }); -}; - /** * Represents an in-page JavaScript object. JSHandles can be created with the * {@link Page.evaluateHandle | page.evaluateHandle} method. @@ -319,1022 +278,6 @@ export class JSHandle { } } -/** - * ElementHandle represents an in-page DOM element. - * - * @remarks - * - * ElementHandles can be created with the {@link Page.$} method. - * - * ```js - * const puppeteer = require('puppeteer'); - * - * (async () => { - * const browser = await puppeteer.launch(); - * const page = await browser.newPage(); - * await page.goto('https://example.com'); - * const hrefElement = await page.$('a'); - * await hrefElement.click(); - * // ... - * })(); - * ``` - * - * ElementHandle prevents the DOM element from being garbage-collected unless the - * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed - * when their origin frame gets navigated. - * - * ElementHandle instances can be used as arguments in {@link Page.$eval} and - * {@link Page.evaluate} methods. - * - * If you're using TypeScript, ElementHandle takes a generic argument that - * denotes the type of element the handle is holding within. For example, if you - * have a handle to a `` element matching `selector`, the method - * throws an error. - * - * @example - * ```js - * handle.select('blue'); // single selection - * handle.select('red', 'green', 'blue'); // multiple selections - * ``` - * @param values - Values of options to select. If the ` element.'); - } - - const selectedValues = new Set(); - if (!element.multiple) { - for (const option of element.options) { - option.selected = false; - } - for (const option of element.options) { - if (values.has(option.value)) { - option.selected = true; - selectedValues.add(option.value); - break; - } - } - } else { - for (const option of element.options) { - option.selected = values.has(option.value); - if (option.selected) { - selectedValues.add(option.value); - } - } - } - element.dispatchEvent(new Event('input', {bubbles: true})); - element.dispatchEvent(new Event('change', {bubbles: true})); - return [...selectedValues.values()]; - }, values); - } - - /** - * This method expects `elementHandle` to point to an - * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element}. - * - * @param filePaths - Sets the value of the file input to these paths. - * If a path is relative, then it is resolved against the - * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. - * Note for locals script connecting to remote chrome environments, - * paths must be absolute. - */ - async uploadFile( - this: ElementHandle, - ...filePaths: string[] - ): Promise { - const isMultiple = await this.evaluate(element => { - return element.multiple; - }); - assert( - filePaths.length <= 1 || isMultiple, - 'Multiple file uploads only work with ' - ); - - // Locate all files and confirm that they exist. - let path: typeof import('path'); - try { - path = await import('path'); - } catch (error) { - if (error instanceof TypeError) { - throw new Error( - `JSHandle#uploadFile can only be used in Node-like environments.` - ); - } - throw error; - } - const files = filePaths.map(filePath => { - if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) { - return filePath; - } else { - return path.resolve(filePath); - } - }); - const {objectId} = this._remoteObject; - const {node} = await this._client.send('DOM.describeNode', {objectId}); - const {backendNodeId} = node; - - /* The zero-length array is a special case, it seems that - DOM.setFileInputFiles does not actually update the files in that case, - so the solution is to eval the element value to a new FileList directly. - */ - if (files.length === 0) { - await this.evaluate(element => { - element.files = new DataTransfer().files; - - // Dispatch events for this case because it should behave akin to a user action. - element.dispatchEvent(new Event('input', {bubbles: true})); - element.dispatchEvent(new Event('change', {bubbles: true})); - }); - } else { - await this._client.send('DOM.setFileInputFiles', { - objectId, - files, - backendNodeId, - }); - } - } - - /** - * This method scrolls element into view if needed, and then uses - * {@link Touchscreen.tap} to tap in the center of the element. - * If the element is detached from DOM, the method throws an error. - */ - async tap(): Promise { - await this.#scrollIntoViewIfNeeded(); - const {x, y} = await this.clickablePoint(); - await this.#page.touchscreen.tap(x, y); - } - - /** - * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. - */ - async focus(): Promise { - await this.evaluate(element => { - if (!(element instanceof HTMLElement)) { - throw new Error('Cannot focus non-HTMLElement'); - } - return element.focus(); - }); - } - - /** - * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and - * `keyup` event for each character in the text. - * - * To press a special key, like `Control` or `ArrowDown`, - * use {@link ElementHandle.press}. - * - * @example - * ```js - * await elementHandle.type('Hello'); // Types instantly - * await elementHandle.type('World', {delay: 100}); // Types slower, like a user - * ``` - * - * @example - * An example of typing into a text field and then submitting the form: - * - * ```js - * const elementHandle = await page.$('input'); - * await elementHandle.type('some text'); - * await elementHandle.press('Enter'); - * ``` - */ - async type(text: string, options?: {delay: number}): Promise { - await this.focus(); - await this.#page.keyboard.type(text, options); - } - - /** - * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. - * - * @remarks - * If `key` is a single character and no modifier keys besides `Shift` - * are being held down, a `keypress`/`input` event will also be generated. - * The `text` option can be specified to force an input event to be generated. - * - * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` - * will type the text in upper case. - * - * @param key - Name of key to press, such as `ArrowLeft`. - * See {@link KeyInput} for a list of all key names. - */ - async press(key: KeyInput, options?: PressOptions): Promise { - await this.focus(); - await this.#page.keyboard.press(key, options); - } - - /** - * This method returns the bounding box of the element (relative to the main frame), - * or `null` if the element is not visible. - */ - async boundingBox(): Promise { - const result = await this.#getBoxModel(); - - if (!result) { - return null; - } - - const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); - const quad = result.model.border; - const x = Math.min(quad[0]!, quad[2]!, quad[4]!, quad[6]!); - const y = Math.min(quad[1]!, quad[3]!, quad[5]!, quad[7]!); - const width = Math.max(quad[0]!, quad[2]!, quad[4]!, quad[6]!) - x; - const height = Math.max(quad[1]!, quad[3]!, quad[5]!, quad[7]!) - y; - - return {x: x + offsetX, y: y + offsetY, width, height}; - } - - /** - * This method returns boxes of the element, or `null` if the element is not visible. - * - * @remarks - * - * Boxes are represented as an array of points; - * Each Point is an object `{x, y}`. Box points are sorted clock-wise. - */ - async boxModel(): Promise { - const result = await this.#getBoxModel(); - - if (!result) { - return null; - } - - const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); - - const {content, padding, border, margin, width, height} = result.model; - return { - content: applyOffsetsToQuad( - this.#fromProtocolQuad(content), - offsetX, - offsetY - ), - padding: applyOffsetsToQuad( - this.#fromProtocolQuad(padding), - offsetX, - offsetY - ), - border: applyOffsetsToQuad( - this.#fromProtocolQuad(border), - offsetX, - offsetY - ), - margin: applyOffsetsToQuad( - this.#fromProtocolQuad(margin), - offsetX, - offsetY - ), - width, - height, - }; - } - - /** - * This method scrolls element into view if needed, and then uses - * {@link Page.screenshot} to take a screenshot of the element. - * If the element is detached from DOM, the method throws an error. - */ - async screenshot(options: ScreenshotOptions = {}): Promise { - let needsViewportReset = false; - - let boundingBox = await this.boundingBox(); - assert(boundingBox, 'Node is either not visible or not an HTMLElement'); - - const viewport = this.#page.viewport(); - assert(viewport); - - if ( - boundingBox.width > viewport.width || - boundingBox.height > viewport.height - ) { - const newViewport = { - width: Math.max(viewport.width, Math.ceil(boundingBox.width)), - height: Math.max(viewport.height, Math.ceil(boundingBox.height)), - }; - await this.#page.setViewport(Object.assign({}, viewport, newViewport)); - - needsViewportReset = true; - } - - await this.#scrollIntoViewIfNeeded(); - - boundingBox = await this.boundingBox(); - assert(boundingBox, 'Node is either not visible or not an HTMLElement'); - assert(boundingBox.width !== 0, 'Node has 0 width.'); - assert(boundingBox.height !== 0, 'Node has 0 height.'); - - const layoutMetrics = await this._client.send('Page.getLayoutMetrics'); - // Fallback to `layoutViewport` in case of using Firefox. - const {pageX, pageY} = - layoutMetrics.cssVisualViewport || layoutMetrics.layoutViewport; - - const clip = Object.assign({}, boundingBox); - clip.x += pageX; - clip.y += pageY; - - const imageData = await this.#page.screenshot( - Object.assign( - {}, - { - clip, - }, - options - ) - ); - - if (needsViewportReset) { - await this.#page.setViewport(viewport); - } - - return imageData; - } - - /** - * Runs `element.querySelector` within the page. - * - * @param selector - The selector to query with. - * @returns `null` if no element matches the selector. - * @throws `Error` if the selector has no associated query handler. - */ - async $( - selector: Selector - ): Promise | null>; - async $(selector: string): Promise; - async $(selector: string): Promise { - const {updatedSelector, queryHandler} = - _getQueryHandlerAndSelector(selector); - assert( - queryHandler.queryOne, - 'Cannot handle queries for a single element with the given selector' - ); - return queryHandler.queryOne(this, updatedSelector); - } - - /** - * Runs `element.querySelectorAll` within the page. If no elements match the selector, - * the return value resolves to `[]`. - */ - /** - * Runs `element.querySelectorAll` within the page. - * - * @param selector - The selector to query with. - * @returns `[]` if no element matches the selector. - * @throws `Error` if the selector has no associated query handler. - */ - async $$( - selector: Selector - ): Promise[]>; - async $$(selector: string): Promise; - async $$(selector: string): Promise { - const {updatedSelector, queryHandler} = - _getQueryHandlerAndSelector(selector); - assert( - queryHandler.queryAll, - 'Cannot handle queries for a multiple element with the given selector' - ); - return queryHandler.queryAll(this, updatedSelector); - } - - /** - * This method runs `document.querySelector` within the element and passes it as - * the first argument to `pageFunction`. If there's no element matching `selector`, - * the method throws an error. - * - * If `pageFunction` returns a Promise, then `frame.$eval` would wait for the promise - * to resolve and return its value. - * - * @example - * ```js - * const tweetHandle = await page.$('.tweet'); - * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe('100'); - * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe('10'); - * ``` - */ - async $eval< - Selector extends keyof HTMLElementTagNameMap, - Params extends unknown[], - Func extends EvaluateFunc< - [HTMLElementTagNameMap[Selector], ...Params] - > = EvaluateFunc<[HTMLElementTagNameMap[Selector], ...Params]> - >( - selector: Selector, - pageFunction: Func | string, - ...args: EvaluateParams - ): Promise>>; - async $eval< - Params extends unknown[], - Func extends EvaluateFunc<[Element, ...Params]> = EvaluateFunc< - [Element, ...Params] - > - >( - selector: string, - pageFunction: Func | string, - ...args: EvaluateParams - ): Promise>>; - async $eval< - Params extends unknown[], - Func extends EvaluateFunc<[Element, ...Params]> = EvaluateFunc< - [Element, ...Params] - > - >( - selector: string, - pageFunction: Func | string, - ...args: EvaluateParams - ): Promise>> { - const elementHandle = await this.$(selector); - if (!elementHandle) { - throw new Error( - `Error: failed to find element matching selector "${selector}"` - ); - } - const result = await elementHandle.evaluate(pageFunction, ...args); - await elementHandle.dispose(); - return result; - } - - /** - * This method runs `document.querySelectorAll` within the element and passes it as - * the first argument to `pageFunction`. If there's no element matching `selector`, - * the method throws an error. - * - * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for the - * promise to resolve and return its value. - * - * @example - * ```html - *
- *
Hello!
- *
Hi!
- *
- * ``` - * - * @example - * ```js - * const feedHandle = await page.$('.feed'); - * expect(await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))) - * .toEqual(['Hello!', 'Hi!']); - * ``` - */ - async $$eval< - Selector extends keyof HTMLElementTagNameMap, - Params extends unknown[], - Func extends EvaluateFunc< - [HTMLElementTagNameMap[Selector][], ...Params] - > = EvaluateFunc<[HTMLElementTagNameMap[Selector][], ...Params]> - >( - selector: Selector, - pageFunction: Func | string, - ...args: EvaluateParams - ): Promise>>; - async $$eval< - Params extends unknown[], - Func extends EvaluateFunc<[Element[], ...Params]> = EvaluateFunc< - [Element[], ...Params] - > - >( - selector: string, - pageFunction: Func | string, - ...args: EvaluateParams - ): Promise>>; - async $$eval< - Params extends unknown[], - Func extends EvaluateFunc<[Element[], ...Params]> = EvaluateFunc< - [Element[], ...Params] - > - >( - selector: string, - pageFunction: Func | string, - ...args: EvaluateParams - ): Promise>> { - const {updatedSelector, queryHandler} = - _getQueryHandlerAndSelector(selector); - assert(queryHandler.queryAllArray); - const arrayHandle = await queryHandler.queryAllArray(this, updatedSelector); - const result = await arrayHandle.evaluate(pageFunction, ...args); - await arrayHandle.dispose(); - return result; - } - - /** - * The method evaluates the XPath expression relative to the elementHandle. - * If there are no such elements, the method will resolve to an empty array. - * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} - */ - async $x(expression: string): Promise { - const arrayHandle = await this.evaluateHandle((element, expression) => { - const document = element.ownerDocument || element; - const iterator = document.evaluate( - expression, - element, - null, - XPathResult.ORDERED_NODE_ITERATOR_TYPE - ); - const array = []; - let item; - while ((item = iterator.iterateNext())) { - array.push(item); - } - return array; - }, expression); - const properties = await arrayHandle.getProperties(); - await arrayHandle.dispose(); - const result = []; - for (const property of properties.values()) { - const elementHandle = property.asElement(); - if (elementHandle) { - result.push(elementHandle); - } - } - return result; - } - - /** - * Resolves to true if the element is visible in the current viewport. - */ - async isIntersectingViewport(options?: { - threshold?: number; - }): Promise { - const {threshold = 0} = options ?? {}; - return await this.evaluate(async (element, threshold) => { - const visibleRatio = await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0]!.intersectionRatio); - observer.disconnect(); - }); - observer.observe(element); - }); - return threshold === 1 ? visibleRatio === 1 : visibleRatio > threshold; - }, threshold); - } -} - /** * @public */ @@ -1395,7 +338,7 @@ export interface Point { y: number; } -function computeQuadArea(quad: Point[]): number { +export function computeQuadArea(quad: Point[]): number { /* Compute sum of all directed areas of adjacent triangles https://en.wikipedia.org/wiki/Polygon#Simple_polygons */ diff --git a/src/common/Page.ts b/src/common/Page.ts index eea64080580..a58a9bed158 100644 --- a/src/common/Page.ts +++ b/src/common/Page.ts @@ -24,6 +24,7 @@ import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js'; import {Coverage} from './Coverage.js'; import {Dialog} from './Dialog.js'; import {WaitForSelectorOptions} from './DOMWorld.js'; +import {ElementHandle} from './ElementHandle.js'; import {EmulationManager} from './EmulationManager.js'; import {EventEmitter, Handler} from './EventEmitter.js'; import {FileChooser} from './FileChooser.js'; @@ -35,7 +36,7 @@ import { import {HTTPRequest} from './HTTPRequest.js'; import {HTTPResponse} from './HTTPResponse.js'; import {Keyboard, Mouse, MouseButton, Touchscreen} from './Input.js'; -import {ElementHandle, JSHandle, _createJSHandle} from './JSHandle.js'; +import {JSHandle} from './JSHandle.js'; import {PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; import { Credentials, @@ -66,6 +67,7 @@ import { valueFromRemoteObject, waitForEvent, waitWithTimeout, + _createJSHandle, } from './util.js'; import {WebWorker} from './WebWorker.js'; diff --git a/src/common/QueryHandler.ts b/src/common/QueryHandler.ts index 0ffb2345210..b66385f47db 100644 --- a/src/common/QueryHandler.ts +++ b/src/common/QueryHandler.ts @@ -15,8 +15,9 @@ */ import {WaitForSelectorOptions, DOMWorld} from './DOMWorld.js'; -import {ElementHandle, JSHandle} from './JSHandle.js'; +import {JSHandle} from './JSHandle.js'; import {ariaHandler} from './AriaQueryHandler.js'; +import {ElementHandle} from './ElementHandle.js'; /** * @internal diff --git a/src/common/types.ts b/src/common/types.ts index fc89417fd2b..01c31d7f822 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import {JSHandle, ElementHandle} from './JSHandle.js'; +import {JSHandle} from './JSHandle.js'; +import {ElementHandle} from './ElementHandle.js'; export type Awaitable = T | PromiseLike; diff --git a/src/common/util.ts b/src/common/util.ts index d45ed128eba..0ca5715f09b 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -20,8 +20,11 @@ import {isNode} from '../environment.js'; import {assert} from './assert.js'; import {CDPSession} from './Connection.js'; import {debug} from './Debug.js'; +import {ElementHandle} from './ElementHandle.js'; import {TimeoutError} from './Errors.js'; import {CommonEventEmitter} from './EventEmitter.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {JSHandle} from './JSHandle.js'; export const debugError = debug('puppeteer:error'); @@ -176,6 +179,28 @@ export async function waitForEvent( return result; } +/** + * @internal + */ +export function _createJSHandle( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject +): JSHandle | ElementHandle { + const frame = context.frame(); + if (remoteObject.subtype === 'node' && frame) { + const frameManager = frame._frameManager; + return new ElementHandle( + context, + context._client, + remoteObject, + frame, + frameManager.page(), + frameManager + ); + } + return new JSHandle(context, context._client, remoteObject); +} + export function evaluationString( fun: Function | string, ...args: unknown[] diff --git a/test/src/ariaqueryhandler.spec.ts b/test/src/ariaqueryhandler.spec.ts index b25dd35ca64..f5ecfdd666c 100644 --- a/test/src/ariaqueryhandler.spec.ts +++ b/test/src/ariaqueryhandler.spec.ts @@ -22,7 +22,7 @@ import { describeChromeOnly, } from './mocha-utils.js'; -import {ElementHandle} from '../../lib/cjs/puppeteer/common/JSHandle.js'; +import {ElementHandle} from '../../lib/cjs/puppeteer/common/ElementHandle.js'; import utils from './utils.js'; import assert from 'assert'; diff --git a/test/src/idle_override.spec.ts b/test/src/idle_override.spec.ts index 1b397209a83..89d8ecf9683 100644 --- a/test/src/idle_override.spec.ts +++ b/test/src/idle_override.spec.ts @@ -15,7 +15,7 @@ */ import expect from 'expect'; -import {ElementHandle} from '../../lib/cjs/puppeteer/common/JSHandle.js'; +import {ElementHandle} from '../../lib/cjs/puppeteer/common/ElementHandle.js'; import { getTestState, setupTestBrowserHooks,