/** * Copyright 2019 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import {Protocol} from 'devtools-protocol'; import {assert} from '../util/assert.js'; import {ExecutionContext} from './ExecutionContext.js'; import {Frame} from './Frame.js'; import {FrameManager} from './FrameManager.js'; import {WaitForSelectorOptions} from './IsolatedWorld.js'; import { BoundingBox, BoxModel, ClickOptions, JSHandle, Offset, Point, PressOptions, } from './JSHandle.js'; import {Page, ScreenshotOptions} from '../api/Page.js'; import {getQueryHandlerAndSelector} from './QueryHandler.js'; import {EvaluateFunc, HandleFor, NodeFor} from './types.js'; import {KeyInput} from './USKeyboardLayout.js'; import {debugError, isString} from './util.js'; import {CDPPage} from './Page.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. * * ```ts * 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 * * ```ts * 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(this: ElementHandle): 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 * * ```ts * 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: * * ```ts * 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( this: ElementHandle, 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(); if ( viewport && (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 && viewport) { await this.#page.setViewport(viewport); } return imageData; } /** * Resolves to true if the element is visible in the current viewport. */ async isIntersectingViewport( this: ElementHandle, 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); } } function computeQuadArea(quad: Point[]): number { /* Compute sum of all directed areas of adjacent triangles https://en.wikipedia.org/wiki/Polygon#Simple_polygons */ let area = 0; for (let i = 0; i < quad.length; ++i) { const p1 = quad[i]!; const p2 = quad[(i + 1) % quad.length]!; area += (p1.x * p2.y - p2.x * p1.y) / 2; } return Math.abs(area); }