1/** 2 * Copyright 2019 Google Inc. All rights reserved. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import { assert } from './assert.js'; 18import { helper, debugError } from './helper.js'; 19import { ExecutionContext } from './ExecutionContext.js'; 20import { Page } from './Page.js'; 21import { CDPSession } from './Connection.js'; 22import { KeyInput } from './USKeyboardLayout.js'; 23import { FrameManager, Frame } from './FrameManager.js'; 24import { getQueryHandlerAndSelector } from './QueryHandler.js'; 25import { Protocol } from 'devtools-protocol'; 26import { 27 EvaluateFn, 28 SerializableOrJSHandle, 29 EvaluateFnReturnType, 30 EvaluateHandleFn, 31 WrapElementHandle, 32 UnwrapPromiseLike, 33} from './EvalTypes.js'; 34import { isNode } from '../environment.js'; 35/** 36 * @public 37 */ 38export interface BoxModel { 39 content: Array<{ x: number; y: number }>; 40 padding: Array<{ x: number; y: number }>; 41 border: Array<{ x: number; y: number }>; 42 margin: Array<{ x: number; y: number }>; 43 width: number; 44 height: number; 45} 46 47/** 48 * @public 49 */ 50export interface BoundingBox { 51 /** 52 * the x coordinate of the element in pixels. 53 */ 54 x: number; 55 /** 56 * the y coordinate of the element in pixels. 57 */ 58 y: number; 59 /** 60 * the width of the element in pixels. 61 */ 62 width: number; 63 /** 64 * the height of the element in pixels. 65 */ 66 height: number; 67} 68 69/** 70 * @internal 71 */ 72export function createJSHandle( 73 context: ExecutionContext, 74 remoteObject: Protocol.Runtime.RemoteObject 75): JSHandle { 76 const frame = context.frame(); 77 if (remoteObject.subtype === 'node' && frame) { 78 const frameManager = frame._frameManager; 79 return new ElementHandle( 80 context, 81 context._client, 82 remoteObject, 83 frameManager.page(), 84 frameManager 85 ); 86 } 87 return new JSHandle(context, context._client, remoteObject); 88} 89 90/** 91 * Represents an in-page JavaScript object. JSHandles can be created with the 92 * {@link Page.evaluateHandle | page.evaluateHandle} method. 93 * 94 * @example 95 * ```js 96 * const windowHandle = await page.evaluateHandle(() => window); 97 * ``` 98 * 99 * JSHandle prevents the referenced JavaScript object from being garbage-collected 100 * unless the handle is {@link JSHandle.dispose | disposed}. JSHandles are auto- 101 * disposed when their origin frame gets navigated or the parent context gets destroyed. 102 * 103 * JSHandle instances can be used as arguments for {@link Page.$eval}, 104 * {@link Page.evaluate}, and {@link Page.evaluateHandle}. 105 * 106 * @public 107 */ 108export class JSHandle<HandleObjectType = unknown> { 109 /** 110 * @internal 111 */ 112 _context: ExecutionContext; 113 /** 114 * @internal 115 */ 116 _client: CDPSession; 117 /** 118 * @internal 119 */ 120 _remoteObject: Protocol.Runtime.RemoteObject; 121 /** 122 * @internal 123 */ 124 _disposed = false; 125 126 /** 127 * @internal 128 */ 129 constructor( 130 context: ExecutionContext, 131 client: CDPSession, 132 remoteObject: Protocol.Runtime.RemoteObject 133 ) { 134 this._context = context; 135 this._client = client; 136 this._remoteObject = remoteObject; 137 } 138 139 /** Returns the execution context the handle belongs to. 140 */ 141 executionContext(): ExecutionContext { 142 return this._context; 143 } 144 145 /** 146 * This method passes this handle as the first argument to `pageFunction`. 147 * If `pageFunction` returns a Promise, then `handle.evaluate` would wait 148 * for the promise to resolve and return its value. 149 * 150 * @example 151 * ```js 152 * const tweetHandle = await page.$('.tweet .retweets'); 153 * expect(await tweetHandle.evaluate(node => node.innerText)).toBe('10'); 154 * ``` 155 */ 156 157 async evaluate<T extends EvaluateFn<HandleObjectType>>( 158 pageFunction: T | string, 159 ...args: SerializableOrJSHandle[] 160 ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { 161 return await this.executionContext().evaluate< 162 UnwrapPromiseLike<EvaluateFnReturnType<T>> 163 >(pageFunction, this, ...args); 164 } 165 166 /** 167 * This method passes this handle as the first argument to `pageFunction`. 168 * 169 * @remarks 170 * 171 * The only difference between `jsHandle.evaluate` and 172 * `jsHandle.evaluateHandle` is that `jsHandle.evaluateHandle` 173 * returns an in-page object (JSHandle). 174 * 175 * If the function passed to `jsHandle.evaluateHandle` returns a Promise, 176 * then `evaluateHandle.evaluateHandle` waits for the promise to resolve and 177 * returns its value. 178 * 179 * See {@link Page.evaluateHandle} for more details. 180 */ 181 async evaluateHandle<HandleType extends JSHandle = JSHandle>( 182 pageFunction: EvaluateHandleFn, 183 ...args: SerializableOrJSHandle[] 184 ): Promise<HandleType> { 185 return await this.executionContext().evaluateHandle( 186 pageFunction, 187 this, 188 ...args 189 ); 190 } 191 192 /** Fetches a single property from the referenced object. 193 */ 194 async getProperty(propertyName: string): Promise<JSHandle | undefined> { 195 const objectHandle = await this.evaluateHandle( 196 (object: Element, propertyName: string) => { 197 const result = { __proto__: null }; 198 result[propertyName] = object[propertyName]; 199 return result; 200 }, 201 propertyName 202 ); 203 const properties = await objectHandle.getProperties(); 204 const result = properties.get(propertyName) || null; 205 await objectHandle.dispose(); 206 return result; 207 } 208 209 /** 210 * The method returns a map with property names as keys and JSHandle 211 * instances for the property values. 212 * 213 * @example 214 * ```js 215 * const listHandle = await page.evaluateHandle(() => document.body.children); 216 * const properties = await listHandle.getProperties(); 217 * const children = []; 218 * for (const property of properties.values()) { 219 * const element = property.asElement(); 220 * if (element) 221 * children.push(element); 222 * } 223 * children; // holds elementHandles to all children of document.body 224 * ``` 225 */ 226 async getProperties(): Promise<Map<string, JSHandle>> { 227 const response = await this._client.send('Runtime.getProperties', { 228 objectId: this._remoteObject.objectId, 229 ownProperties: true, 230 }); 231 const result = new Map<string, JSHandle>(); 232 for (const property of response.result) { 233 if (!property.enumerable) continue; 234 result.set(property.name, createJSHandle(this._context, property.value)); 235 } 236 return result; 237 } 238 239 /** 240 * @returns Returns a JSON representation of the object.If the object has a 241 * `toJSON` function, it will not be called. 242 * @remarks 243 * 244 * The JSON is generated by running {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify | JSON.stringify} 245 * on the object in page and consequent {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse | JSON.parse} in puppeteer. 246 * **NOTE** The method throws if the referenced object is not stringifiable. 247 */ 248 async jsonValue<T = unknown>(): Promise<T> { 249 if (this._remoteObject.objectId) { 250 const response = await this._client.send('Runtime.callFunctionOn', { 251 functionDeclaration: 'function() { return this; }', 252 objectId: this._remoteObject.objectId, 253 returnByValue: true, 254 awaitPromise: true, 255 }); 256 return helper.valueFromRemoteObject(response.result) as T; 257 } 258 return helper.valueFromRemoteObject(this._remoteObject) as T; 259 } 260 261 /** 262 * @returns Either `null` or the object handle itself, if the object 263 * handle is an instance of {@link ElementHandle}. 264 */ 265 asElement(): ElementHandle | null { 266 /* This always returns null, but subclasses can override this and return an 267 ElementHandle. 268 */ 269 return null; 270 } 271 272 /** 273 * Stops referencing the element handle, and resolves when the object handle is 274 * successfully disposed of. 275 */ 276 async dispose(): Promise<void> { 277 if (this._disposed) return; 278 this._disposed = true; 279 await helper.releaseObject(this._client, this._remoteObject); 280 } 281 282 /** 283 * Returns a string representation of the JSHandle. 284 * 285 * @remarks Useful during debugging. 286 */ 287 toString(): string { 288 if (this._remoteObject.objectId) { 289 const type = this._remoteObject.subtype || this._remoteObject.type; 290 return 'JSHandle@' + type; 291 } 292 return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject); 293 } 294} 295 296/** 297 * ElementHandle represents an in-page DOM element. 298 * 299 * @remarks 300 * 301 * ElementHandles can be created with the {@link Page.$} method. 302 * 303 * ```js 304 * const puppeteer = require('puppeteer'); 305 * 306 * (async () => { 307 * const browser = await puppeteer.launch(); 308 * const page = await browser.newPage(); 309 * await page.goto('https://example.com'); 310 * const hrefElement = await page.$('a'); 311 * await hrefElement.click(); 312 * // ... 313 * })(); 314 * ``` 315 * 316 * ElementHandle prevents the DOM element from being garbage-collected unless the 317 * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed 318 * when their origin frame gets navigated. 319 * 320 * ElementHandle instances can be used as arguments in {@link Page.$eval} and 321 * {@link Page.evaluate} methods. 322 * 323 * If you're using TypeScript, ElementHandle takes a generic argument that 324 * denotes the type of element the handle is holding within. For example, if you 325 * have a handle to a `<select>` element, you can type it as 326 * `ElementHandle<HTMLSelectElement>` and you get some nicer type checks. 327 * 328 * @public 329 */ 330export class ElementHandle< 331 ElementType extends Element = Element 332> extends JSHandle<ElementType> { 333 private _page: Page; 334 private _frameManager: FrameManager; 335 336 /** 337 * @internal 338 */ 339 constructor( 340 context: ExecutionContext, 341 client: CDPSession, 342 remoteObject: Protocol.Runtime.RemoteObject, 343 page: Page, 344 frameManager: FrameManager 345 ) { 346 super(context, client, remoteObject); 347 this._client = client; 348 this._remoteObject = remoteObject; 349 this._page = page; 350 this._frameManager = frameManager; 351 } 352 353 asElement(): ElementHandle<ElementType> | null { 354 return this; 355 } 356 357 /** 358 * Resolves to the content frame for element handles referencing 359 * iframe nodes, or null otherwise 360 */ 361 async contentFrame(): Promise<Frame | null> { 362 const nodeInfo = await this._client.send('DOM.describeNode', { 363 objectId: this._remoteObject.objectId, 364 }); 365 if (typeof nodeInfo.node.frameId !== 'string') return null; 366 return this._frameManager.frame(nodeInfo.node.frameId); 367 } 368 369 private async _scrollIntoViewIfNeeded(): Promise<void> { 370 const error = await this.evaluate< 371 ( 372 element: Element, 373 pageJavascriptEnabled: boolean 374 ) => Promise<string | false> 375 >(async (element, pageJavascriptEnabled) => { 376 if (!element.isConnected) return 'Node is detached from document'; 377 if (element.nodeType !== Node.ELEMENT_NODE) 378 return 'Node is not of type HTMLElement'; 379 // force-scroll if page's javascript is disabled. 380 if (!pageJavascriptEnabled) { 381 element.scrollIntoView({ 382 block: 'center', 383 inline: 'center', 384 // @ts-expect-error Chrome still supports behavior: instant but 385 // it's not in the spec so TS shouts We don't want to make this 386 // breaking change in Puppeteer yet so we'll ignore the line. 387 behavior: 'instant', 388 }); 389 return false; 390 } 391 const visibleRatio = await new Promise((resolve) => { 392 const observer = new IntersectionObserver((entries) => { 393 resolve(entries[0].intersectionRatio); 394 observer.disconnect(); 395 }); 396 observer.observe(element); 397 }); 398 if (visibleRatio !== 1.0) { 399 element.scrollIntoView({ 400 block: 'center', 401 inline: 'center', 402 // @ts-expect-error Chrome still supports behavior: instant but 403 // it's not in the spec so TS shouts We don't want to make this 404 // breaking change in Puppeteer yet so we'll ignore the line. 405 behavior: 'instant', 406 }); 407 } 408 return false; 409 }, this._page.isJavaScriptEnabled()); 410 411 if (error) throw new Error(error); 412 } 413 414 private async _clickablePoint(): Promise<{ x: number; y: number }> { 415 const [result, layoutMetrics] = await Promise.all([ 416 this._client 417 .send('DOM.getContentQuads', { 418 objectId: this._remoteObject.objectId, 419 }) 420 .catch(debugError), 421 this._client.send('Page.getLayoutMetrics'), 422 ]); 423 if (!result || !result.quads.length) 424 throw new Error('Node is either not visible or not an HTMLElement'); 425 // Filter out quads that have too small area to click into. 426 const { clientWidth, clientHeight } = layoutMetrics.layoutViewport; 427 const quads = result.quads 428 .map((quad) => this._fromProtocolQuad(quad)) 429 .map((quad) => 430 this._intersectQuadWithViewport(quad, clientWidth, clientHeight) 431 ) 432 .filter((quad) => computeQuadArea(quad) > 1); 433 if (!quads.length) 434 throw new Error('Node is either not visible or not an HTMLElement'); 435 // Return the middle point of the first quad. 436 const quad = quads[0]; 437 let x = 0; 438 let y = 0; 439 for (const point of quad) { 440 x += point.x; 441 y += point.y; 442 } 443 return { 444 x: x / 4, 445 y: y / 4, 446 }; 447 } 448 449 private _getBoxModel(): Promise<void | Protocol.DOM.GetBoxModelResponse> { 450 const params: Protocol.DOM.GetBoxModelRequest = { 451 objectId: this._remoteObject.objectId, 452 }; 453 return this._client 454 .send('DOM.getBoxModel', params) 455 .catch((error) => debugError(error)); 456 } 457 458 private _fromProtocolQuad(quad: number[]): Array<{ x: number; y: number }> { 459 return [ 460 { x: quad[0], y: quad[1] }, 461 { x: quad[2], y: quad[3] }, 462 { x: quad[4], y: quad[5] }, 463 { x: quad[6], y: quad[7] }, 464 ]; 465 } 466 467 private _intersectQuadWithViewport( 468 quad: Array<{ x: number; y: number }>, 469 width: number, 470 height: number 471 ): Array<{ x: number; y: number }> { 472 return quad.map((point) => ({ 473 x: Math.min(Math.max(point.x, 0), width), 474 y: Math.min(Math.max(point.y, 0), height), 475 })); 476 } 477 478 /** 479 * This method scrolls element into view if needed, and then 480 * uses {@link Page.mouse} to hover over the center of the element. 481 * If the element is detached from DOM, the method throws an error. 482 */ 483 async hover(): Promise<void> { 484 await this._scrollIntoViewIfNeeded(); 485 const { x, y } = await this._clickablePoint(); 486 await this._page.mouse.move(x, y); 487 } 488 489 /** 490 * This method scrolls element into view if needed, and then 491 * uses {@link Page.mouse} to click in the center of the element. 492 * If the element is detached from DOM, the method throws an error. 493 */ 494 async click(options: ClickOptions = {}): Promise<void> { 495 await this._scrollIntoViewIfNeeded(); 496 const { x, y } = await this._clickablePoint(); 497 await this._page.mouse.click(x, y, options); 498 } 499 500 /** 501 * Triggers a `change` and `input` event once all the provided options have been 502 * selected. If there's no `<select>` element matching `selector`, the method 503 * throws an error. 504 * 505 * @example 506 * ```js 507 * handle.select('blue'); // single selection 508 * handle.select('red', 'green', 'blue'); // multiple selections 509 * ``` 510 * @param values - Values of options to select. If the `<select>` has the 511 * `multiple` attribute, all values are considered, otherwise only the first 512 * one is taken into account. 513 */ 514 async select(...values: string[]): Promise<string[]> { 515 for (const value of values) 516 assert( 517 helper.isString(value), 518 'Values must be strings. Found value "' + 519 value + 520 '" of type "' + 521 typeof value + 522 '"' 523 ); 524 525 return this.evaluate<(element: Element, values: string[]) => string[]>( 526 (element, values) => { 527 if (!(element instanceof HTMLSelectElement)) 528 throw new Error('Element is not a <select> element.'); 529 530 const options = Array.from(element.options); 531 element.value = undefined; 532 for (const option of options) { 533 option.selected = values.includes(option.value); 534 if (option.selected && !element.multiple) break; 535 } 536 element.dispatchEvent(new Event('input', { bubbles: true })); 537 element.dispatchEvent(new Event('change', { bubbles: true })); 538 return options 539 .filter((option) => option.selected) 540 .map((option) => option.value); 541 }, 542 values 543 ); 544 } 545 546 /** 547 * This method expects `elementHandle` to point to an 548 * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element}. 549 * @param filePaths - Sets the value of the file input to these paths. 550 * If some of the `filePaths` are relative paths, then they are resolved 551 * relative to the {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory} 552 */ 553 async uploadFile(...filePaths: string[]): Promise<void> { 554 const isMultiple = await this.evaluate<(element: Element) => boolean>( 555 (element) => { 556 if (!(element instanceof HTMLInputElement)) { 557 throw new Error('uploadFile can only be called on an input element.'); 558 } 559 return element.multiple; 560 } 561 ); 562 assert( 563 filePaths.length <= 1 || isMultiple, 564 'Multiple file uploads only work with <input type=file multiple>' 565 ); 566 567 if (!isNode) { 568 throw new Error( 569 `JSHandle#uploadFile can only be used in Node environments.` 570 ); 571 } 572 /* 573 This import is only needed for `uploadFile`, so keep it scoped here to 574 avoid paying the cost unnecessarily. 575 */ 576 const path = await import('path'); 577 const fs = await helper.importFSModule(); 578 // Locate all files and confirm that they exist. 579 const files = await Promise.all( 580 filePaths.map(async (filePath) => { 581 const resolvedPath: string = path.resolve(filePath); 582 try { 583 await fs.promises.access(resolvedPath, fs.constants.R_OK); 584 } catch (error) { 585 if (error.code === 'ENOENT') 586 throw new Error(`${filePath} does not exist or is not readable`); 587 } 588 589 return resolvedPath; 590 }) 591 ); 592 const { objectId } = this._remoteObject; 593 const { node } = await this._client.send('DOM.describeNode', { objectId }); 594 const { backendNodeId } = node; 595 596 /* The zero-length array is a special case, it seems that 597 DOM.setFileInputFiles does not actually update the files in that case, 598 so the solution is to eval the element value to a new FileList directly. 599 */ 600 if (files.length === 0) { 601 await (this as ElementHandle<HTMLInputElement>).evaluate((element) => { 602 element.files = new DataTransfer().files; 603 604 // Dispatch events for this case because it should behave akin to a user action. 605 element.dispatchEvent(new Event('input', { bubbles: true })); 606 element.dispatchEvent(new Event('change', { bubbles: true })); 607 }); 608 } else { 609 await this._client.send('DOM.setFileInputFiles', { 610 objectId, 611 files, 612 backendNodeId, 613 }); 614 } 615 } 616 617 /** 618 * This method scrolls element into view if needed, and then uses 619 * {@link Touchscreen.tap} to tap in the center of the element. 620 * If the element is detached from DOM, the method throws an error. 621 */ 622 async tap(): Promise<void> { 623 await this._scrollIntoViewIfNeeded(); 624 const { x, y } = await this._clickablePoint(); 625 await this._page.touchscreen.tap(x, y); 626 } 627 628 /** 629 * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. 630 */ 631 async focus(): Promise<void> { 632 await (this as ElementHandle<HTMLElement>).evaluate((element) => 633 element.focus() 634 ); 635 } 636 637 /** 638 * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and 639 * `keyup` event for each character in the text. 640 * 641 * To press a special key, like `Control` or `ArrowDown`, 642 * use {@link ElementHandle.press}. 643 * 644 * @example 645 * ```js 646 * await elementHandle.type('Hello'); // Types instantly 647 * await elementHandle.type('World', {delay: 100}); // Types slower, like a user 648 * ``` 649 * 650 * @example 651 * An example of typing into a text field and then submitting the form: 652 * 653 * ```js 654 * const elementHandle = await page.$('input'); 655 * await elementHandle.type('some text'); 656 * await elementHandle.press('Enter'); 657 * ``` 658 */ 659 async type(text: string, options?: { delay: number }): Promise<void> { 660 await this.focus(); 661 await this._page.keyboard.type(text, options); 662 } 663 664 /** 665 * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. 666 * 667 * @remarks 668 * If `key` is a single character and no modifier keys besides `Shift` 669 * are being held down, a `keypress`/`input` event will also be generated. 670 * The `text` option can be specified to force an input event to be generated. 671 * 672 * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` 673 * will type the text in upper case. 674 * 675 * @param key - Name of key to press, such as `ArrowLeft`. 676 * See {@link KeyInput} for a list of all key names. 677 */ 678 async press(key: KeyInput, options?: PressOptions): Promise<void> { 679 await this.focus(); 680 await this._page.keyboard.press(key, options); 681 } 682 683 /** 684 * This method returns the bounding box of the element (relative to the main frame), 685 * or `null` if the element is not visible. 686 */ 687 async boundingBox(): Promise<BoundingBox | null> { 688 const result = await this._getBoxModel(); 689 690 if (!result) return null; 691 692 const quad = result.model.border; 693 const x = Math.min(quad[0], quad[2], quad[4], quad[6]); 694 const y = Math.min(quad[1], quad[3], quad[5], quad[7]); 695 const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; 696 const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; 697 698 return { x, y, width, height }; 699 } 700 701 /** 702 * This method returns boxes of the element, or `null` if the element is not visible. 703 * 704 * @remarks 705 * 706 * Boxes are represented as an array of points; 707 * Each Point is an object `{x, y}`. Box points are sorted clock-wise. 708 */ 709 async boxModel(): Promise<BoxModel | null> { 710 const result = await this._getBoxModel(); 711 712 if (!result) return null; 713 714 const { content, padding, border, margin, width, height } = result.model; 715 return { 716 content: this._fromProtocolQuad(content), 717 padding: this._fromProtocolQuad(padding), 718 border: this._fromProtocolQuad(border), 719 margin: this._fromProtocolQuad(margin), 720 width, 721 height, 722 }; 723 } 724 725 /** 726 * This method scrolls element into view if needed, and then uses 727 * {@link Page.screenshot} to take a screenshot of the element. 728 * If the element is detached from DOM, the method throws an error. 729 */ 730 async screenshot(options = {}): Promise<string | Buffer | void> { 731 let needsViewportReset = false; 732 733 let boundingBox = await this.boundingBox(); 734 assert(boundingBox, 'Node is either not visible or not an HTMLElement'); 735 736 const viewport = this._page.viewport(); 737 738 if ( 739 viewport && 740 (boundingBox.width > viewport.width || 741 boundingBox.height > viewport.height) 742 ) { 743 const newViewport = { 744 width: Math.max(viewport.width, Math.ceil(boundingBox.width)), 745 height: Math.max(viewport.height, Math.ceil(boundingBox.height)), 746 }; 747 await this._page.setViewport(Object.assign({}, viewport, newViewport)); 748 749 needsViewportReset = true; 750 } 751 752 await this._scrollIntoViewIfNeeded(); 753 754 boundingBox = await this.boundingBox(); 755 assert(boundingBox, 'Node is either not visible or not an HTMLElement'); 756 assert(boundingBox.width !== 0, 'Node has 0 width.'); 757 assert(boundingBox.height !== 0, 'Node has 0 height.'); 758 759 const { 760 layoutViewport: { pageX, pageY }, 761 } = await this._client.send('Page.getLayoutMetrics'); 762 763 const clip = Object.assign({}, boundingBox); 764 clip.x += pageX; 765 clip.y += pageY; 766 767 const imageData = await this._page.screenshot( 768 Object.assign( 769 {}, 770 { 771 clip, 772 }, 773 options 774 ) 775 ); 776 777 if (needsViewportReset) await this._page.setViewport(viewport); 778 779 return imageData; 780 } 781 782 /** 783 * Runs `element.querySelector` within the page. If no element matches the selector, 784 * the return value resolves to `null`. 785 */ 786 async $<T extends Element = Element>( 787 selector: string 788 ): Promise<ElementHandle<T> | null> { 789 const { updatedSelector, queryHandler } = 790 getQueryHandlerAndSelector(selector); 791 return queryHandler.queryOne(this, updatedSelector); 792 } 793 794 /** 795 * Runs `element.querySelectorAll` within the page. If no elements match the selector, 796 * the return value resolves to `[]`. 797 */ 798 async $$<T extends Element = Element>( 799 selector: string 800 ): Promise<Array<ElementHandle<T>>> { 801 const { updatedSelector, queryHandler } = 802 getQueryHandlerAndSelector(selector); 803 return queryHandler.queryAll(this, updatedSelector); 804 } 805 806 /** 807 * This method runs `document.querySelector` within the element and passes it as 808 * the first argument to `pageFunction`. If there's no element matching `selector`, 809 * the method throws an error. 810 * 811 * If `pageFunction` returns a Promise, then `frame.$eval` would wait for the promise 812 * to resolve and return its value. 813 * 814 * @example 815 * ```js 816 * const tweetHandle = await page.$('.tweet'); 817 * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe('100'); 818 * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe('10'); 819 * ``` 820 */ 821 async $eval<ReturnType>( 822 selector: string, 823 pageFunction: ( 824 element: Element, 825 ...args: unknown[] 826 ) => ReturnType | Promise<ReturnType>, 827 ...args: SerializableOrJSHandle[] 828 ): Promise<WrapElementHandle<ReturnType>> { 829 const elementHandle = await this.$(selector); 830 if (!elementHandle) 831 throw new Error( 832 `Error: failed to find element matching selector "${selector}"` 833 ); 834 const result = await elementHandle.evaluate< 835 ( 836 element: Element, 837 ...args: SerializableOrJSHandle[] 838 ) => ReturnType | Promise<ReturnType> 839 >(pageFunction, ...args); 840 await elementHandle.dispose(); 841 842 /** 843 * This `as` is a little unfortunate but helps TS understand the behavior of 844 * `elementHandle.evaluate`. If evaluate returns an element it will return an 845 * ElementHandle instance, rather than the plain object. All the 846 * WrapElementHandle type does is wrap ReturnType into 847 * ElementHandle<ReturnType> if it is an ElementHandle, or leave it alone as 848 * ReturnType if it isn't. 849 */ 850 return result as WrapElementHandle<ReturnType>; 851 } 852 853 /** 854 * This method runs `document.querySelectorAll` within the element and passes it as 855 * the first argument to `pageFunction`. If there's no element matching `selector`, 856 * the method throws an error. 857 * 858 * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for the 859 * promise to resolve and return its value. 860 * 861 * @example 862 * ```html 863 * <div class="feed"> 864 * <div class="tweet">Hello!</div> 865 * <div class="tweet">Hi!</div> 866 * </div> 867 * ``` 868 * 869 * @example 870 * ```js 871 * const feedHandle = await page.$('.feed'); 872 * expect(await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))) 873 * .toEqual(['Hello!', 'Hi!']); 874 * ``` 875 */ 876 async $$eval<ReturnType>( 877 selector: string, 878 pageFunction: ( 879 elements: Element[], 880 ...args: unknown[] 881 ) => ReturnType | Promise<ReturnType>, 882 ...args: SerializableOrJSHandle[] 883 ): Promise<WrapElementHandle<ReturnType>> { 884 const { updatedSelector, queryHandler } = 885 getQueryHandlerAndSelector(selector); 886 const arrayHandle = await queryHandler.queryAllArray(this, updatedSelector); 887 const result = await arrayHandle.evaluate< 888 ( 889 elements: Element[], 890 ...args: unknown[] 891 ) => ReturnType | Promise<ReturnType> 892 >(pageFunction, ...args); 893 await arrayHandle.dispose(); 894 /* This `as` exists for the same reason as the `as` in $eval above. 895 * See the comment there for a full explanation. 896 */ 897 return result as WrapElementHandle<ReturnType>; 898 } 899 900 /** 901 * The method evaluates the XPath expression relative to the elementHandle. 902 * If there are no such elements, the method will resolve to an empty array. 903 * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} 904 */ 905 async $x(expression: string): Promise<ElementHandle[]> { 906 const arrayHandle = await this.evaluateHandle( 907 (element: Document, expression: string) => { 908 const document = element.ownerDocument || element; 909 const iterator = document.evaluate( 910 expression, 911 element, 912 null, 913 XPathResult.ORDERED_NODE_ITERATOR_TYPE 914 ); 915 const array = []; 916 let item; 917 while ((item = iterator.iterateNext())) array.push(item); 918 return array; 919 }, 920 expression 921 ); 922 const properties = await arrayHandle.getProperties(); 923 await arrayHandle.dispose(); 924 const result = []; 925 for (const property of properties.values()) { 926 const elementHandle = property.asElement(); 927 if (elementHandle) result.push(elementHandle); 928 } 929 return result; 930 } 931 932 /** 933 * Resolves to true if the element is visible in the current viewport. 934 */ 935 async isIntersectingViewport(): Promise<boolean> { 936 return await this.evaluate<(element: Element) => Promise<boolean>>( 937 async (element) => { 938 const visibleRatio = await new Promise((resolve) => { 939 const observer = new IntersectionObserver((entries) => { 940 resolve(entries[0].intersectionRatio); 941 observer.disconnect(); 942 }); 943 observer.observe(element); 944 }); 945 return visibleRatio > 0; 946 } 947 ); 948 } 949} 950 951/** 952 * @public 953 */ 954export interface ClickOptions { 955 /** 956 * Time to wait between `mousedown` and `mouseup` in milliseconds. 957 * 958 * @defaultValue 0 959 */ 960 delay?: number; 961 /** 962 * @defaultValue 'left' 963 */ 964 button?: 'left' | 'right' | 'middle'; 965 /** 966 * @defaultValue 1 967 */ 968 clickCount?: number; 969} 970 971/** 972 * @public 973 */ 974export interface PressOptions { 975 /** 976 * Time to wait between `keydown` and `keyup` in milliseconds. Defaults to 0. 977 */ 978 delay?: number; 979 /** 980 * If specified, generates an input event with this text. 981 */ 982 text?: string; 983} 984 985function computeQuadArea(quad: Array<{ x: number; y: number }>): number { 986 /* Compute sum of all directed areas of adjacent triangles 987 https://en.wikipedia.org/wiki/Polygon#Simple_polygons 988 */ 989 let area = 0; 990 for (let i = 0; i < quad.length; ++i) { 991 const p1 = quad[i]; 992 const p2 = quad[(i + 1) % quad.length]; 993 area += (p1.x * p2.y - p2.x * p1.y) / 2; 994 } 995 return Math.abs(area); 996} 997