Skip to content

Commit

Permalink
Implement basic element handles (puppeteer#248)
Browse files Browse the repository at this point in the history
This patch implements basic element handles which a backed with remote objects.

Fixes puppeteer#111
  • Loading branch information
aslushnikov authored Aug 15, 2017
1 parent a424f56 commit af89e89
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 81 deletions.
102 changes: 81 additions & 21 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
3 changes: 3 additions & 0 deletions lib/Connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}

Expand Down
98 changes: 98 additions & 0 deletions lib/ElementHandle.js
Original file line number Diff line number Diff line change
@@ -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);
81 changes: 24 additions & 57 deletions lib/FrameManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
*/
Expand Down
Loading

0 comments on commit af89e89

Please sign in to comment.