diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts')
-rw-r--r-- | remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts | 777 |
1 files changed, 777 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts new file mode 100644 index 0000000000..351d7057bf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ElementHandle.ts @@ -0,0 +1,777 @@ +/** + * 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 { + BoundingBox, + BoxModel, + ClickOptions, + ElementHandle, + Offset, + Point, + PressOptions, +} from '../api/ElementHandle.js'; +import {Page, ScreenshotOptions} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {CDPSession} from './Connection.js'; +import {ExecutionContext} from './ExecutionContext.js'; +import {Frame} from './Frame.js'; +import {FrameManager} from './FrameManager.js'; +import {getQueryHandlerAndSelector} from './GetQueryHandler.js'; +import {WaitForSelectorOptions} from './IsolatedWorld.js'; +import {PUPPETEER_WORLD} from './IsolatedWorlds.js'; +import {CDPJSHandle} from './JSHandle.js'; +import {LazyArg} from './LazyArg.js'; +import {CDPPage} from './Page.js'; +import {ElementFor, EvaluateFuncWith, HandleFor, NodeFor} 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}; + }); +}; + +/** + * The CDPElementHandle extends ElementHandle now to keep compatibility + * with `instanceof` because of that we need to have methods for + * CDPJSHandle to in this implementation as well. + * + * @internal + */ +export class CDPElementHandle< + ElementType extends Node = Element +> extends ElementHandle<ElementType> { + #frame: Frame; + declare handle: CDPJSHandle<ElementType>; + + constructor( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject, + frame: Frame + ) { + super(new CDPJSHandle(context, remoteObject)); + this.#frame = frame; + } + + /** + * @internal + */ + override executionContext(): ExecutionContext { + return this.handle.executionContext(); + } + + /** + * @internal + */ + override get client(): CDPSession { + return this.handle.client; + } + + override remoteObject(): Protocol.Runtime.RemoteObject { + return this.handle.remoteObject(); + } + + get #frameManager(): FrameManager { + return this.#frame._frameManager; + } + + get #page(): Page { + return this.#frame.page(); + } + + override get frame(): Frame { + return this.#frame; + } + + override async $<Selector extends string>( + selector: Selector + ): Promise<CDPElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.queryOne( + this, + updatedSelector + )) as CDPElementHandle<NodeFor<Selector>> | null; + } + + override async $$<Selector extends string>( + selector: Selector + ): Promise<Array<CDPElementHandle<NodeFor<Selector>>>> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return AsyncIterableUtil.collect( + QueryHandler.queryAll(this, updatedSelector) + ) as Promise<Array<CDPElementHandle<NodeFor<Selector>>>>; + } + + override async $eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith< + NodeFor<Selector>, + Params + > + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + 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; + } + + override async $$eval< + Selector extends string, + Params extends unknown[], + Func extends EvaluateFuncWith< + Array<NodeFor<Selector>>, + Params + > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params> + >( + selector: Selector, + pageFunction: Func | string, + ...args: Params + ): Promise<Awaited<ReturnType<Func>>> { + const results = await this.$$(selector); + const elements = await this.evaluateHandle((_, ...elements) => { + return elements; + }, ...results); + const [result] = await Promise.all([ + elements.evaluate(pageFunction, ...args), + ...results.map(results => { + return results.dispose(); + }), + ]); + await elements.dispose(); + return result; + } + + override async $x( + expression: string + ): Promise<Array<CDPElementHandle<Node>>> { + if (expression.startsWith('//')) { + expression = `.${expression}`; + } + return this.$$(`xpath/${expression}`); + } + + override async waitForSelector<Selector extends string>( + selector: Selector, + options: WaitForSelectorOptions = {} + ): Promise<CDPElementHandle<NodeFor<Selector>> | null> { + const {updatedSelector, QueryHandler} = + getQueryHandlerAndSelector(selector); + return (await QueryHandler.waitFor( + this, + updatedSelector, + options + )) as CDPElementHandle<NodeFor<Selector>> | null; + } + + override async waitForXPath( + xpath: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<CDPElementHandle<Node> | null> { + if (xpath.startsWith('//')) { + xpath = `.${xpath}`; + } + return this.waitForSelector(`xpath/${xpath}`, options); + } + + async #checkVisibility(visibility: boolean): Promise<boolean> { + const element = await this.frame.worlds[PUPPETEER_WORLD].adoptHandle(this); + try { + return await this.frame.worlds[PUPPETEER_WORLD].evaluate( + async (PuppeteerUtil, element, visibility) => { + return Boolean(PuppeteerUtil.checkVisibility(element, visibility)); + }, + LazyArg.create(context => { + return context.puppeteerUtil; + }), + element, + visibility + ); + } finally { + await element.dispose(); + } + } + + override async isVisible(): Promise<boolean> { + return this.#checkVisibility(true); + } + + override async isHidden(): Promise<boolean> { + return this.#checkVisibility(false); + } + + override async toElement< + K extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap + >(tagName: K): Promise<HandleFor<ElementFor<K>>> { + const isMatchingTagName = await this.evaluate((node, tagName) => { + return node.nodeName === tagName.toUpperCase(); + }, tagName); + if (!isMatchingTagName) { + throw new Error(`Element is not a(n) \`${tagName}\` element`); + } + return this as unknown as HandleFor<ElementFor<K>>; + } + + override async contentFrame(): Promise<Frame | null> { + const nodeInfo = await this.client.send('DOM.describeNode', { + objectId: this.remoteObject().objectId, + }); + if (typeof nodeInfo.node.frameId !== 'string') { + return null; + } + return this.#frameManager.frame(nodeInfo.node.frameId); + } + + override async scrollIntoView( + this: CDPElementHandle<Element> + ): Promise<void> { + await this.assertConnectedElement(); + + try { + await this.client.send('DOM.scrollIntoViewIfNeeded', { + objectId: this.remoteObject().objectId, + }); + } catch (error) { + debugError(error); + // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported + await this.evaluate(async (element): Promise<void> => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + }); + } + } + + async #scrollIntoViewIfNeeded( + this: CDPElementHandle<Element> + ): Promise<void> { + if ( + await this.isIntersectingViewport({ + threshold: 1, + }) + ) { + return; + } + await this.scrollIntoView(); + } + + async #getOOPIFOffsets( + frame: Frame + ): Promise<{offsetX: number; offsetY: number}> { + let offsetX = 0; + let offsetY = 0; + let currentFrame: Frame | null = frame; + while (currentFrame && currentFrame.parentFrame()) { + const parent = currentFrame.parentFrame(); + if (!currentFrame.isOOPFrame() || !parent) { + currentFrame = parent; + continue; + } + const {backendNodeId} = await parent._client().send('DOM.getFrameOwner', { + frameId: currentFrame._id, + }); + const result = await parent._client().send('DOM.getBoxModel', { + backendNodeId: backendNodeId, + }); + if (!result) { + break; + } + const contentBoxQuad = result.model.content; + const topLeftCorner = this.#fromProtocolQuad(contentBoxQuad)[0]; + offsetX += topLeftCorner!.x; + offsetY += topLeftCorner!.y; + currentFrame = parent; + } + return {offsetX, offsetY}; + } + + override async clickablePoint(offset?: Offset): Promise<Point> { + const [result, layoutMetrics] = await Promise.all([ + this.client + .send('DOM.getContentQuads', { + objectId: this.remoteObject().objectId, + }) + .catch(debugError), + (this.#page as CDPPage)._client().send('Page.getLayoutMetrics'), + ]); + if (!result || !result.quads.length) { + throw new Error('Node is either not clickable or not an HTMLElement'); + } + // Filter out quads that have too small area to click into. + // Fallback to `layoutViewport` in case of using Firefox. + const {clientWidth, clientHeight} = + layoutMetrics.cssLayoutViewport || layoutMetrics.layoutViewport; + const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame); + const quads = result.quads + .map(quad => { + return this.#fromProtocolQuad(quad); + }) + .map(quad => { + return applyOffsetsToQuad(quad, offsetX, offsetY); + }) + .map(quad => { + return this.#intersectQuadWithViewport(quad, clientWidth, clientHeight); + }) + .filter(quad => { + return computeQuadArea(quad) > 1; + }); + if (!quads.length) { + throw new Error('Node is either not clickable or not an HTMLElement'); + } + const quad = quads[0]!; + if (offset) { + // Return the point of the first quad identified by offset. + let minX = Number.MAX_SAFE_INTEGER; + let minY = Number.MAX_SAFE_INTEGER; + for (const point of quad) { + if (point.x < minX) { + minX = point.x; + } + if (point.y < minY) { + minY = point.y; + } + } + if ( + minX !== Number.MAX_SAFE_INTEGER && + minY !== Number.MAX_SAFE_INTEGER + ) { + return { + x: minX + offset.x, + y: minY + offset.y, + }; + } + } + // Return the middle point of the first quad. + let x = 0; + let y = 0; + for (const point of quad) { + x += point.x; + y += point.y; + } + return { + x: x / 4, + y: y / 4, + }; + } + + #getBoxModel(): Promise<void | Protocol.DOM.GetBoxModelResponse> { + const params: Protocol.DOM.GetBoxModelRequest = { + objectId: this.id, + }; + return this.client.send('DOM.getBoxModel', params).catch(error => { + return debugError(error); + }); + } + + #fromProtocolQuad(quad: number[]): Point[] { + return [ + {x: quad[0]!, y: quad[1]!}, + {x: quad[2]!, y: quad[3]!}, + {x: quad[4]!, y: quad[5]!}, + {x: quad[6]!, y: quad[7]!}, + ]; + } + + #intersectQuadWithViewport( + quad: Point[], + width: number, + height: number + ): Point[] { + return quad.map(point => { + return { + x: Math.min(Math.max(point.x, 0), width), + y: Math.min(Math.max(point.y, 0), height), + }; + }); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to hover over the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + override async hover(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.mouse.move(x, y); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to click in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + override async click( + this: CDPElementHandle<Element>, + options: Readonly<ClickOptions> = {} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(options.offset); + await this.#page.mouse.click(x, y, options); + } + + /** + * This method creates and captures a dragevent from the element. + */ + override async drag( + this: CDPElementHandle<Element>, + target: Point + ): Promise<Protocol.Input.DragData> { + assert( + this.#page.isDragInterceptionEnabled(), + 'Drag Interception is not enabled!' + ); + await this.#scrollIntoViewIfNeeded(); + const start = await this.clickablePoint(); + return await this.#page.mouse.drag(start, target); + } + + override async dragEnter( + this: CDPElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await this.#page.mouse.dragEnter(target, data); + } + + override async dragOver( + this: CDPElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const target = await this.clickablePoint(); + await this.#page.mouse.dragOver(target, data); + } + + override async drop( + this: CDPElementHandle<Element>, + data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const destination = await this.clickablePoint(); + await this.#page.mouse.drop(destination, data); + } + + override async dragAndDrop( + this: CDPElementHandle<Element>, + target: CDPElementHandle<Node>, + options?: {delay: number} + ): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const startPoint = await this.clickablePoint(); + const targetPoint = await target.clickablePoint(); + await this.#page.mouse.dragAndDrop(startPoint, targetPoint, options); + } + + override async select(...values: string[]): Promise<string[]> { + for (const value of values) { + assert( + isString(value), + 'Values must be strings. Found value "' + + value + + '" of type "' + + typeof value + + '"' + ); + } + + return this.evaluate((element, vals): string[] => { + const values = new Set(vals); + if (!(element instanceof HTMLSelectElement)) { + throw new Error('Element is not a <select> element.'); + } + + const selectedValues = new Set<string>(); + 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); + } + + override async uploadFile( + this: CDPElementHandle<HTMLInputElement>, + ...filePaths: string[] + ): Promise<void> { + const isMultiple = await this.evaluate(element => { + return element.multiple; + }); + assert( + filePaths.length <= 1 || isMultiple, + 'Multiple file uploads only work with <input type=file multiple>' + ); + + // 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, + }); + } + } + + override async tap(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.touchStart(x, y); + await this.#page.touchscreen.touchEnd(); + } + + override async touchStart(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.touchStart(x, y); + } + + override async touchMove(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + const {x, y} = await this.clickablePoint(); + await this.#page.touchscreen.touchMove(x, y); + } + + override async touchEnd(this: CDPElementHandle<Element>): Promise<void> { + await this.#scrollIntoViewIfNeeded(); + await this.#page.touchscreen.touchEnd(); + } + + override async focus(): Promise<void> { + await this.evaluate(element => { + if (!(element instanceof HTMLElement)) { + throw new Error('Cannot focus non-HTMLElement'); + } + return element.focus(); + }); + } + + override async type(text: string, options?: {delay: number}): Promise<void> { + await this.focus(); + await this.#page.keyboard.type(text, options); + } + + override async press(key: KeyInput, options?: PressOptions): Promise<void> { + await this.focus(); + await this.#page.keyboard.press(key, options); + } + + override async boundingBox(): Promise<BoundingBox | null> { + 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}; + } + + override async boxModel(): Promise<BoxModel | null> { + 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, + }; + } + + override async screenshot( + this: CDPElementHandle<Element>, + options: ScreenshotOptions = {} + ): Promise<string | Buffer> { + 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; + } +} + +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); +} |