Implement basic element handles (#248)

This patch implements basic element handles which a backed with remote objects.

Fixes #111
This commit is contained in:
Andrey Lushnikov 2017-08-15 14:54:02 -07:00 committed by GitHub
parent a424f5613a
commit af89e893e7
7 changed files with 299 additions and 81 deletions

View File

@ -26,6 +26,7 @@
+ [event: 'requestfailed'](#event-requestfailed)
+ [event: 'requestfinished'](#event-requestfinished)
+ [event: 'response'](#event-response)
+ [page.$(selector)](#pageselector)
+ [page.addBinding(name, puppeteerFunction)](#pageaddbindingname-puppeteerfunction)
+ [page.addScriptTag(url)](#pageaddscripttagurl)
+ [page.click(selector[, options])](#pageclickselector-options)
@ -82,12 +83,10 @@
+ [dialog.message()](#dialogmessage)
+ [dialog.type](#dialogtype)
* [class: Frame](#class-frame)
+ [frame.$(selector)](#frameselector)
+ [frame.addScriptTag(url)](#frameaddscripttagurl)
+ [frame.childFrames()](#framechildframes)
+ [frame.click(selector[, options])](#frameclickselector-options)
+ [frame.evaluate(pageFunction, ...args)](#frameevaluatepagefunction-args)
+ [frame.focus(selector)](#framefocusselector)
+ [frame.hover(selector)](#framehoverselector)
+ [frame.injectFile(filePath)](#frameinjectfilefilepath)
+ [frame.isDetached()](#frameisdetached)
+ [frame.name()](#framename)
@ -98,6 +97,11 @@
+ [frame.waitFor(selectorOrFunctionOrTimeout[, options])](#framewaitforselectororfunctionortimeout-options)
+ [frame.waitForFunction(pageFunction[, options, ...args])](#framewaitforfunctionpagefunction-options-args)
+ [frame.waitForSelector(selector[, options])](#framewaitforselectorselector-options)
* [class: ElementHandle](#class-elementhandle)
+ [elementHandle.click([options])](#elementhandleclickoptions)
+ [elementHandle.evaluate(pageFunction, ...args)](#elementhandleevaluatepagefunction-args)
+ [elementHandle.hover()](#elementhandlehover)
+ [elementHandle.release()](#elementhandlerelease)
* [class: Request](#class-request)
+ [request.abort()](#requestabort)
+ [request.continue([overrides])](#requestcontinueoverrides)
@ -283,6 +287,13 @@ Emitted when a request is successfully finished.
Emitted when a [response] is received.
#### page.$(selector)
- `selector` <[string]> Selector to query page for
- returns: <[Promise]<[ElementHandle]>> Promise which resolves to ElementHandle pointing to the page element.
The method queries page for the selector. If there's no such element on the page, the method will resolve to `null`.
Shortcut for [page.mainFrame().$(selector)](#frameselector).
#### page.addBinding(name, puppeteerFunction)
- `name` <[string]> Name of the binding on window object
@ -345,7 +356,6 @@ puppeteer.launch().then(async browser => {
```
#### page.addScriptTag(url)
- `url` <[string]> Url of a script to be added
- returns: <[Promise]> Promise which resolves as the script gets added and loads.
@ -932,6 +942,12 @@ puppeteer.launch().then(async browser => {
});
```
#### frame.$(selector)
- `selector` <[string]> Selector to query page for
- returns: <[Promise]<[ElementHandle]>> Promise which resolves to ElementHandle pointing to the page element.
The method queries page for the selector. If there's no such element on the page, the method will resolve to `null`.
#### frame.addScriptTag(url)
- `url` <[string]> Url of a script to be added
@ -942,14 +958,6 @@ Adds a `<script>` tag to the frame with the desired url. Alternatively, JavaScri
#### frame.childFrames()
- returns: <[Array]<[Frame]>>
#### frame.click(selector[, options])
- `selector` <[string]> A query [selector] to search for element to click. If there are multiple elements satisfying the selector, the first will be clicked.
- `options` <[Object]>
- `button` <[string]> `left`, `right`, or `middle`, defaults to `left`.
- `clickCount` <[number]> defaults to 1
- `delay` <[number]> Time to wait between `mousedown` and `mouseup` in milliseconds. Defaults to 0.
- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully clicked. Promise gets rejected if there's no element matching `selector`.
#### frame.evaluate(pageFunction, ...args)
- `pageFunction` <[function]|[string]> Function to be evaluated in browser context
- `...args` <...[string]> Arguments to pass to `pageFunction`
@ -976,15 +984,6 @@ A string can also be passed in instead of a function.
console.log(await page.evaluate('1 + 2')); // prints "3"
```
#### frame.focus(selector)
- `selector` <[string]> A query [selector] of element to focus. If there are multiple elements satisfying the selector, the first will be focused.
- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully focused. Promise gets rejected if there's no element matching `selector`.
#### frame.hover(selector)
- `selector` <[string]> A query [selector] to search for element to hover. If there are multiple elements satisfying the selector, the first will be hovered.
- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully hovered. Promise gets rejected if there's no element matching `selector`.
#### frame.injectFile(filePath)
- `filePath` <[string]> Path to the JavaScript file to be injected into frame. If `filePath` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd).
- returns: <[Promise]> Promise which resolves when file gets successfully evaluated in frame.
@ -1079,6 +1078,66 @@ puppeteer.launch().then(async browser => {
});
```
### class: ElementHandle
ElementHandle represents an in-page DOM element. ElementHandles could be created with the [page.$](#pageselector) method.
```js
const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
let page = await browser.newPage();
await page.goto('https://google.com');
let inputElement = await page.$('input[type=submit]');
await inputElement.click();
...
});
```
ElementHandle prevents DOM element from garbage collection unless the handle is [released](#elementhandlerelease). ElementHandles are auto-released when their origin frame gets navigated.
#### elementHandle.click([options])
- `options` <[Object]>
- `button` <[string]> `left`, `right`, or `middle`, defaults to `left`.
- `clickCount` <[number]> defaults to 1
- `delay` <[number]> Time to wait between `mousedown` and `mouseup` in milliseconds. Defaults to 0.
- returns: <[Promise]> Promise which resolves when the element is successfully clicked. Promise gets rejected if the element is detached from DOM.
This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to click in the center of the element.
If the element is detached from DOM, the method throws an error.
#### elementHandle.evaluate(pageFunction, ...args)
- `pageFunction` <[function]> Function to be evaluated in browser context
- `...args` <...[string]> Arguments to pass to `pageFunction`
- returns: <[Promise]<[Object]>> Promise which resolves to function return value
If the function, passed to the `elementHandle.evaluate`, returns a [Promise], then `elementHandle.evaluate` would wait for the promise to resolve and return it's value.
The function will be passed in the element ifself as a first argument.
#### elementHandle.hover()
- returns: <[Promise]> Promise which resolves when the element is successfully hovered.
This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to hover over the center of the element.
If the element is detached from DOM, the method throws an error.
#### elementHandle.release()
- returns: <[Promise]> Promise which resolves when the element handle is successfully released.
The `elementHandle.release` method stops referencing the element handle.
```js
const {Browser} = require('puppeteer');
const browser = new Browser();
browser.newPage().then(async page =>
await page.setContent('<div>hello</div>');
let element = await page.$('div');
let text = element.evaluate((e, suffix) => e.textContent + ' ' + suffix, 'world!');
console.log(text); // "hello world!"
browser.close();
});
```
### class: Request
Whenever the page sends a request, the following events are emitted by puppeteer's page:
@ -1189,3 +1248,4 @@ Contains the URL of the response.
[Map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map "Map"
[selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors "selector"
[Tracing]: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-tracing "Tracing"
[ElementHandle]: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-element "ElementHandle"

View File

@ -166,6 +166,8 @@ class Session extends EventEmitter {
* @return {!Promise<?Object>}
*/
send(method, params = {}) {
if (!this._connection)
return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the page has been closed.`));
let id = ++this._lastId;
let message = JSON.stringify({id, method, params});
debugSession('SEND ► ' + message);
@ -212,6 +214,7 @@ class Session extends EventEmitter {
for (let callback of this._callbacks.values())
callback.reject(new Error(`Protocol error (${callback.method}): Target closed.`));
this._callbacks.clear();
this._connection = null;
}
}

98
lib/ElementHandle.js Normal file
View File

@ -0,0 +1,98 @@
/**
* Copyright 2017 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.
*/
const helper = require('./helper');
class ElementHandle {
/**
* @param {!Connection} client
* @param {!Object} remoteObject
* @param {!Mouse} mouse
*/
constructor(client, remoteObject, mouse) {
this._client = client;
this._remoteObject = remoteObject;
this._mouse = mouse;
this._released = false;
}
/**
* @return {!Promise}
*/
async release() {
if (this._released)
return;
this._released = true;
await helper.releaseObject(this._client, this._remoteObject);
}
/**
* @param {function()} pageFunction
* @param {!Array<*>} args
* @return {!Promise<(!Object|undefined)>}
*/
async evaluate(pageFunction, ...args) {
console.assert(!this._released, 'ElementHandle is released!');
console.assert(typeof pageFunction === 'function', 'First argument to ElementHandle.evaluate must be a function!');
let stringifiedArgs = ['this'];
stringifiedArgs.push(...args.map(x => JSON.stringify(x)));
let functionDeclaration = `function() { return (${pageFunction})(${stringifiedArgs.join(',')}) }`;
const objectId = this._remoteObject.objectId;
let { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { objectId, functionDeclaration, returnByValue: false});
if (exceptionDetails)
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
return await helper.serializeRemoteObject(this._client, remoteObject);
}
/**
* @return {!Promise<{x: number, y: number}>}
*/
async _visibleCenter() {
let center = await this.evaluate(element => {
if (!element.ownerDocument.contains(element))
return null;
element.scrollIntoViewIfNeeded();
let rect = element.getBoundingClientRect();
return {
x: (Math.max(rect.left, 0) + Math.min(rect.right, window.innerWidth)) / 2,
y: (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2
};
});
if (!center)
throw new Error('No node found for selector: ' + selector);
return center;
}
/**
* @return {!Promise}
*/
async hover() {
let {x, y} = await this._visibleCenter();
await this._mouse.move(x, y);
}
/**
* @param {!Object=} options
* @return {!Promise}
*/
async click(options) {
let {x, y} = await this._visibleCenter();
await this._mouse.click(x, y, options);
}
}
module.exports = ElementHandle;
helper.tracePublicAPI(ElementHandle);

View File

@ -18,6 +18,7 @@ const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const helper = require('./helper');
const ElementHandle = require('./ElementHandle');
class FrameManager extends EventEmitter {
/**
@ -171,12 +172,34 @@ class Frame {
* @return {!Promise<(!Object|undefined)>}
*/
async evaluate(pageFunction, ...args) {
let remoteObject = await this._rawEvaluate(pageFunction, ...args);
return await helper.serializeRemoteObject(this._client, remoteObject);
}
/**
* @param {string} selector
* @return {!Promise<?ElementHandle>}
*/
async $(selector) {
let remoteObject = await this._rawEvaluate(selector => document.querySelector(selector), selector);
if (remoteObject.subtype === 'node')
return new ElementHandle(this._client, remoteObject, this._mouse);
helper.releaseObject(this._client, remoteObject);
return null;
}
/**
* @param {function()|string} pageFunction
* @param {!Array<*>} args
* @return {!Promise<(!Object|undefined)>}
*/
async _rawEvaluate(pageFunction, ...args) {
let expression = helper.evaluationString(pageFunction, ...args);
const contextId = this._defaultContextId;
let { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId, returnByValue: false});
if (exceptionDetails)
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
return await helper.serializeRemoteObject(this._client, remoteObject);
return remoteObject;
}
/**
@ -324,62 +347,6 @@ class Frame {
return this.evaluate(() => document.title);
}
/**
* @param {string} selector
* @return {!Promise<{x: number, y: number}>}
*/
async _centerOfElement(selector) {
let center = await this.evaluate(selector => {
let element = document.querySelector(selector);
if (!element)
return null;
element.scrollIntoViewIfNeeded();
let rect = element.getBoundingClientRect();
return {
x: (Math.max(rect.left, 0) + Math.min(rect.right, window.innerWidth)) / 2,
y: (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2
};
}, selector);
if (!center)
throw new Error('No node found for selector: ' + selector);
return center;
}
/**
* @param {string} selector
* @return {!Promise}
*/
async hover(selector) {
let {x, y} = await this._centerOfElement(selector);
await this._mouse.move(x, y);
}
/**
* @param {string} selector
* @param {!Object=} options
* @return {!Promise}
*/
async click(selector, options) {
let {x, y} = await this._centerOfElement(selector);
await this._mouse.click(x, y, options);
}
/**
* @param {string} selector
* @return {!Promise}
*/
async focus(selector) {
let success = await this.evaluate(selector => {
let node = document.querySelector(selector);
if (!node)
return false;
node.focus();
return true;
}, selector);
if (!success)
throw new Error('No node found for selector: ' + selector);
}
/**
* @param {!Object} framePayload
*/

View File

@ -138,6 +138,14 @@ class Page extends EventEmitter {
});
}
/**
* @param {string} selector
* @return {!Promise<?ElementHandle>}
*/
async $(selector) {
return this.mainFrame().$(selector);
}
/**
* @param {string} url
* @return {!Promise}
@ -555,7 +563,10 @@ class Page extends EventEmitter {
* @return {!Promise}
*/
async click(selector, options) {
await this.mainFrame().click(selector, options);
let handle = await this.$(selector);
console.assert(handle, 'No node found for selector: ' + selector);
await handle.click(options);
await handle.release();
}
/**
@ -563,7 +574,10 @@ class Page extends EventEmitter {
* @param {!Promise}
*/
async hover(selector) {
await this.mainFrame().hover(selector);
let handle = await this.$(selector);
console.assert(handle, 'No node found for selector: ' + selector);
await handle.hover();
await handle.release();
}
/**
@ -571,7 +585,10 @@ class Page extends EventEmitter {
* @return {!Promise}
*/
async focus(selector) {
return this.mainFrame().focus(selector);
let handle = await this.$(selector);
console.assert(handle, 'No node found for selector: ' + selector);
await handle.evaluate(element => element.focus());
await handle.release();
}
/**

View File

@ -1059,6 +1059,78 @@ describe('Page', function() {
}));
});
describe('Page.$', function() {
it('should query existing element', SX(async function() {
await page.setContent('<section>test</section>');
let element = await page.$('section');
expect(element).toBeTruthy();
}));
it('should return null for non-existing element', SX(async function() {
let element = await page.$('non-existing-element');
expect(element).toBe(null);
}));
});
describe('ElementHandle.evaluate', function() {
it('should work', SX(async function() {
await page.setContent('<section>42</section>');
let element = await page.$('section');
let text = await element.evaluate(e => e.textContent);
expect(text).toBe('42');
}));
it('should await promise if any', SX(async function() {
await page.setContent('<section>39</section>');
let element = await page.$('section');
let text = await element.evaluate(e => Promise.resolve(e.textContent));
expect(text).toBe('39');
}));
it('should throw if underlying page got closed', SX(async function() {
let otherPage = await browser.newPage();
await otherPage.setContent('<section>88</section>');
let element = await otherPage.$('section');
expect(element).toBeTruthy();
await otherPage.close();
let error = null;
try {
await element.evaluate(e => e.textContent);
} catch (e) {
error = e;
}
expect(error.message).toContain('Session closed');
}));
it('should throw if underlying element was released', SX(async function() {
await page.setContent('<section>39</section>');
let element = await page.$('section');
expect(element).toBeTruthy();
await element.release();
let error = null;
try {
await element.evaluate(e => e.textContent);
} catch (e) {
error = e;
}
expect(error.message).toContain('ElementHandle is released');
}));
});
describe('ElementHandle.click', function() {
it('should work', SX(async function() {
await page.goto(PREFIX + '/input/button.html');
let button = await page.$('button');
await button.click('button');
expect(await page.evaluate(() => result)).toBe('Clicked');
}));
});
describe('ElementHandle.hover', function() {
it('should work', SX(async function() {
await page.goto(PREFIX + '/input/scrollable.html');
let button = await page.$('#button-6');
await button.hover();
expect(await page.evaluate(() => document.querySelector('button:hover').id)).toBe('button-6');
}));
});
describe('input', function() {
it('should click the button', SX(async function() {
await page.goto(PREFIX + '/input/button.html');

View File

@ -38,6 +38,7 @@ const EXCLUDE_METHODS = new Set([
'Body.constructor',
'Browser.constructor',
'Dialog.constructor',
'ElementHandle.constructor',
'Frame.constructor',
'Headers.constructor',
'Headers.fromPayload',