[api] Teach page.evaluate to accept element handles as parameters (#725)

This patch:
- teaches `page.evaluate` to accept ElementHandles as parameters
- removes `ElementHandle.evaluate` method since it's not needed any
  more

References #382
This commit is contained in:
Andrey Lushnikov 2017-09-11 19:20:02 -07:00 committed by GitHub
parent 69e707a2b7
commit 9292a56eaf
5 changed files with 118 additions and 108 deletions

View File

@ -116,7 +116,6 @@
* [class: ElementHandle](#class-elementhandle) * [class: ElementHandle](#class-elementhandle)
+ [elementHandle.click([options])](#elementhandleclickoptions) + [elementHandle.click([options])](#elementhandleclickoptions)
+ [elementHandle.dispose()](#elementhandledispose) + [elementHandle.dispose()](#elementhandledispose)
+ [elementHandle.evaluate(pageFunction, ...args)](#elementhandleevaluatepagefunction-args)
+ [elementHandle.hover()](#elementhandlehover) + [elementHandle.hover()](#elementhandlehover)
+ [elementHandle.tap()](#elementhandletap) + [elementHandle.tap()](#elementhandletap)
+ [elementHandle.uploadFile(...filePaths)](#elementhandleuploadfilefilepaths) + [elementHandle.uploadFile(...filePaths)](#elementhandleuploadfilefilepaths)
@ -333,7 +332,7 @@ Shortcut for [page.mainFrame().$$(selector)](#frameselector-1).
#### page.$eval(selector, pageFunction[, ...args]) #### page.$eval(selector, pageFunction[, ...args])
- `selector` <[string]> A [selector] to query page for - `selector` <[string]> A [selector] to query page for
- `pageFunction` <[function]> Function to be evaluated in browser context - `pageFunction` <[function]> Function to be evaluated in browser context
- `...args` <...[Serializable]> Arguments to pass to `pageFunction` - `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `pageFunction`
- returns: <[Promise]<[Serializable]>> Promise which resolves to the return value of `pageFunction` - returns: <[Promise]<[Serializable]>> Promise which resolves to the return value of `pageFunction`
This method runs `document.querySelector` within the page and passes it as the first argument to `pageFunction`. If there's no element matching `selector`, the method throws an error. This method runs `document.querySelector` within the page and passes it as the first argument to `pageFunction`. If there's no element matching `selector`, the method throws an error.
@ -450,22 +449,16 @@ List of all available devices is available in the source code: [DeviceDescriptor
#### page.evaluate(pageFunction, ...args) #### page.evaluate(pageFunction, ...args)
- `pageFunction` <[function]|[string]> Function to be evaluated in the page context - `pageFunction` <[function]|[string]> Function to be evaluated in the page context
- `...args` <...[Serializable]> Arguments to pass to `pageFunction` - `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `pageFunction`
- returns: <[Promise]<[Serializable]>> Resolves to the return value of `pageFunction` - returns: <[Promise]<[Serializable]>> Resolves to the return value of `pageFunction`
If the function, passed to the `page.evaluate`, returns a [Promise], then `page.evaluate` would wait for the promise to resolve and return it's value. If the function, passed to the `page.evaluate`, returns a [Promise], then `page.evaluate` would wait for the promise to resolve and return it's value.
```js ```js
const puppeteer = require('puppeteer'); const result = await page.evaluate(() => {
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
const result = await page.evaluate(() => {
return Promise.resolve(8 * 7); return Promise.resolve(8 * 7);
});
console.log(result); // prints "56"
browser.close();
}); });
console.log(result); // prints "56"
``` ```
A string can also be passed in instead of a function. A string can also be passed in instead of a function.
@ -474,6 +467,13 @@ A string can also be passed in instead of a function.
console.log(await page.evaluate('1 + 2')); // prints "3" console.log(await page.evaluate('1 + 2')); // prints "3"
``` ```
[ElementHandle] instances could be passed as arguments to the `page.evaluate`:
```js
const bodyHandle = await page.$('body');
const html = await page.evaluate(body => body.innerHTML, bodyHandle);
await bodyHandle.dispose();
```
Shortcut for [page.mainFrame().evaluate(pageFunction, ...args)](#frameevaluatepagefunction-args). Shortcut for [page.mainFrame().evaluate(pageFunction, ...args)](#frameevaluatepagefunction-args).
#### page.evaluateOnNewDocument(pageFunction, ...args) #### page.evaluateOnNewDocument(pageFunction, ...args)
@ -1113,7 +1113,7 @@ The method runs `document.querySelectorAll` within the frame. If no elements mat
#### frame.$eval(selector, pageFunction[, ...args]) #### frame.$eval(selector, pageFunction[, ...args])
- `selector` <[string]> A [selector] to query frame for - `selector` <[string]> A [selector] to query frame for
- `pageFunction` <[function]> Function to be evaluated in browser context - `pageFunction` <[function]> Function to be evaluated in browser context
- `...args` <...[Serializable]> Arguments to pass to `pageFunction` - `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `pageFunction`
- returns: <[Promise]<[Serializable]>> Promise which resolves to the return value of `pageFunction` - returns: <[Promise]<[Serializable]>> Promise which resolves to the return value of `pageFunction`
This method runs `document.querySelector` within the frame and passes it as the first argument to `pageFunction`. If there's no element matching `selector`, the method throws an error. This method runs `document.querySelector` within the frame and passes it as the first argument to `pageFunction`. If there's no element matching `selector`, the method throws an error.
@ -1138,28 +1138,29 @@ Adds a `<script>` tag to the frame with the desired url. Alternatively, JavaScri
#### frame.evaluate(pageFunction, ...args) #### frame.evaluate(pageFunction, ...args)
- `pageFunction` <[function]|[string]> Function to be evaluated in browser context - `pageFunction` <[function]|[string]> Function to be evaluated in browser context
- `...args` <...[Serializable]> Arguments to pass to `pageFunction` - `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `pageFunction`
- returns: <[Promise]<[Serializable]>> Promise which resolves to function return value - returns: <[Promise]<[Serializable]>> Promise which resolves to function return value
If the function, passed to the `page.evaluate`, returns a [Promise], then `page.evaluate` would wait for the promise to resolve and return it's value. If the function, passed to the `frame.evaluate`, returns a [Promise], then `frame.evaluate` would wait for the promise to resolve and return it's value.
```js ```js
const puppeteer = require('puppeteer'); const result = await frame.evaluate(() => {
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
const result = await page.mainFrame().evaluate(() => {
return Promise.resolve(8 * 7); return Promise.resolve(8 * 7);
});
console.log(result); // prints "56"
browser.close();
}); });
console.log(result); // prints "56"
``` ```
A string can also be passed in instead of a function. A string can also be passed in instead of a function.
```js ```js
console.log(await page.mainFrame().evaluate('1 + 2')); // prints "3" console.log(await frame.evaluate('1 + 2')); // prints "3"
```
[ElementHandle] instances could be passed as arguments to the `frame.evaluate`:
```js
const bodyHandle = await frame.$('body');
const html = await frame.evaluate(body => body.innerHTML, bodyHandle);
await bodyHandle.dispose();
``` ```
#### frame.injectFile(filePath) #### frame.injectFile(filePath)
@ -1286,14 +1287,6 @@ If the element is detached from DOM, the method throws an error.
The `elementHandle.dispose` method stops referencing the element handle. The `elementHandle.dispose` method stops referencing the element handle.
#### elementHandle.evaluate(pageFunction, ...args)
- `pageFunction` <[function]> Function to be evaluated in browser context
- `...args` <...[Serializable]> Arguments to pass to `pageFunction`
- returns: <[Promise]<[Serializable]>> 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 element will be passed as the first argument to `pageFunction`, followed by any `args`.
#### elementHandle.hover() #### elementHandle.hover()
- returns: <[Promise]> Promise which resolves when the element is successfully hovered. - returns: <[Promise]> Promise which resolves when the element is successfully hovered.

View File

@ -18,12 +18,14 @@ const {helper} = require('./helper');
class ElementHandle { class ElementHandle {
/** /**
* @param {!Frame} frame
* @param {!Connection} client * @param {!Connection} client
* @param {!Object} remoteObject * @param {!Object} remoteObject
* @param {!Mouse} mouse * @param {!Mouse} mouse
* @param {!Touchscreen} touchscreen; * @param {!Touchscreen} touchscreen;
*/ */
constructor(client, remoteObject, mouse, touchscreen) { constructor(frame, client, remoteObject, mouse, touchscreen) {
this._frame = frame;
this._client = client; this._client = client;
this._remoteObject = remoteObject; this._remoteObject = remoteObject;
this._mouse = mouse; this._mouse = mouse;
@ -31,6 +33,13 @@ class ElementHandle {
this._disposed = false; this._disposed = false;
} }
/**
* @return {?string}
*/
_remoteObjectId() {
return this._disposed ? null : this._remoteObject.objectId;
}
async dispose() { async dispose() {
if (this._disposed) if (this._disposed)
return; return;
@ -38,30 +47,11 @@ class ElementHandle {
await helper.releaseObject(this._client, this._remoteObject); await helper.releaseObject(this._client, this._remoteObject);
} }
/**
* @param {function()} pageFunction
* @param {!Array<*>} args
* @return {!Promise<(!Object|undefined)>}
*/
async evaluate(pageFunction, ...args) {
console.assert(!this._disposed, 'ElementHandle is disposed!');
console.assert(typeof pageFunction === 'function', 'First argument to ElementHandle.evaluate must be a function!');
const stringifiedArgs = ['this'];
stringifiedArgs.push(...args.map(x => JSON.stringify(x)));
const functionDeclaration = `function() { return (${pageFunction})(${stringifiedArgs.join(',')}) }`;
const objectId = this._remoteObject.objectId;
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { objectId, functionDeclaration, returnByValue: false, awaitPromise: true});
if (exceptionDetails)
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
return await helper.serializeRemoteObject(this._client, remoteObject);
}
/** /**
* @return {!Promise<{x: number, y: number}>} * @return {!Promise<{x: number, y: number}>}
*/ */
async _visibleCenter() { async _visibleCenter() {
const center = await this.evaluate(element => { const center = await this._frame.evaluate(element => {
if (!element.ownerDocument.contains(element)) if (!element.ownerDocument.contains(element))
return null; return null;
element.scrollIntoViewIfNeeded(); element.scrollIntoViewIfNeeded();
@ -70,7 +60,7 @@ class ElementHandle {
x: (Math.max(rect.left, 0) + Math.min(rect.right, window.innerWidth)) / 2, 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 y: (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2
}; };
}); }, this);
if (!center) if (!center)
throw new Error('No node found for selector: ' + selector); throw new Error('No node found for selector: ' + selector);
return center; return center;

View File

@ -194,7 +194,7 @@ class Frame {
async $(selector) { async $(selector) {
const remoteObject = await this._rawEvaluate(selector => document.querySelector(selector), selector); const remoteObject = await this._rawEvaluate(selector => document.querySelector(selector), selector);
if (remoteObject.subtype === 'node') if (remoteObject.subtype === 'node')
return new ElementHandle(this._client, remoteObject, this._mouse, this._touchscreen); return new ElementHandle(this, this._client, remoteObject, this._mouse, this._touchscreen);
await helper.releaseObject(this._client, remoteObject); await helper.releaseObject(this._client, remoteObject);
return null; return null;
} }
@ -209,7 +209,8 @@ class Frame {
const elementHandle = await this.$(selector); const elementHandle = await this.$(selector);
if (!elementHandle) if (!elementHandle)
throw new Error(`Error: failed to find element matching selector "${selector}"`); throw new Error(`Error: failed to find element matching selector "${selector}"`);
const result = await elementHandle.evaluate(pageFunction, ...args); args = [elementHandle].concat(args);
const result = await this.evaluate(pageFunction, ...args);
await elementHandle.dispose(); await elementHandle.dispose();
return result; return result;
} }
@ -229,7 +230,7 @@ class Frame {
const releasePromises = [helper.releaseObject(this._client, remoteObject)]; const releasePromises = [helper.releaseObject(this._client, remoteObject)];
for (const property of properties) { for (const property of properties) {
if (property.enumerable && property.value.subtype === 'node') if (property.enumerable && property.value.subtype === 'node')
result.push(new ElementHandle(this._client, property.value, this._mouse, this._touchscreen)); result.push(new ElementHandle(this, this._client, property.value, this._mouse, this._touchscreen));
else else
releasePromises.push(helper.releaseObject(this._client, property.value)); releasePromises.push(helper.releaseObject(this._client, property.value));
} }
@ -243,14 +244,52 @@ class Frame {
* @return {!Promise<(!Object|undefined)>} * @return {!Promise<(!Object|undefined)>}
*/ */
async _rawEvaluate(pageFunction, ...args) { async _rawEvaluate(pageFunction, ...args) {
const expression = helper.evaluationString(pageFunction, ...args); if (helper.isString(pageFunction)) {
const contextId = this._defaultContextId; const contextId = this._defaultContextId;
const expression = pageFunction;
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId, returnByValue: false, awaitPromise: true}); const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId, returnByValue: false, awaitPromise: true});
if (exceptionDetails) if (exceptionDetails)
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
return remoteObject; return remoteObject;
} }
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: pageFunction.toString(),
executionContextId: this._defaultContextId,
arguments: args.map(convertArgument.bind(this)),
returnByValue: false,
awaitPromise: true
});
if (exceptionDetails)
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails));
return remoteObject;
/**
* @param {*} arg
* @return {*}
* @this {Frame}
*/
function convertArgument(arg) {
if (Object.is(arg, -0))
return { unserializableValue: '-0' };
if (Object.is(arg, Infinity))
return { unserializableValue: 'Infinity' };
if (Object.is(arg, -Infinity))
return { unserializableValue: '-Infinity' };
if (Object.is(arg, NaN))
return { unserializableValue: 'NaN' };
if (arg instanceof ElementHandle) {
if (arg._frame !== this)
throw new Error('ElementHandles passed as arguments should belong to the frame that does evaluation');
const objectId = arg._remoteObjectId();
if (!objectId)
throw new Error('ElementHandle is disposed!');
return { objectId };
}
return { value: arg };
}
}
/** /**
* @return {string} * @return {string}
*/ */

View File

@ -689,7 +689,7 @@ class Page extends EventEmitter {
async focus(selector) { async focus(selector) {
const handle = await this.$(selector); const handle = await this.$(selector);
console.assert(handle, 'No node found for selector: ' + selector); console.assert(handle, 'No node found for selector: ' + selector);
await handle.evaluate(element => element.focus()); await this.evaluate(element => element.focus(), handle);
await handle.dispose(); await handle.dispose();
} }

View File

@ -266,6 +266,30 @@ describe('Page', function() {
const result = await page.evaluate('2 + 5;\n// do some math!'); const result = await page.evaluate('2 + 5;\n// do some math!');
expect(result).toBe(7); expect(result).toBe(7);
})); }));
it('should accept element handle as an argument', SX(async function() {
await page.setContent('<section>42</section>');
const element = await page.$('section');
const text = await page.evaluate(e => e.textContent, element);
expect(text).toBe('42');
}));
it('should throw if underlying element was disposed', SX(async function() {
await page.setContent('<section>39</section>');
const element = await page.$('section');
expect(element).toBeTruthy();
await element.dispose();
let error = null;
await page.evaluate(e => e.textContent, element).catch(e => error = e);
expect(error.message).toContain('ElementHandle is disposed');
}));
it('should throw if elementHandles are from other frames', SX(async function() {
const FrameUtils = require('./frame-utils');
await FrameUtils.attachFrame(page, 'frame1', EMPTY_PAGE);
const bodyHandle = await page.frames()[1].$('body');
let error = null;
await page.evaluate(body => body.innerHTML, bodyHandle).catch(e => error = e);
expect(error).toBeTruthy();
expect(error.message).toContain('ElementHandles passed as arguments should belong');
}));
}); });
describe('Page.injectFile', function() { describe('Page.injectFile', function() {
@ -1151,6 +1175,12 @@ describe('Page', function() {
const text = await page.$eval('section', (e, suffix) => e.textContent + suffix, ' world!'); const text = await page.$eval('section', (e, suffix) => e.textContent + suffix, ' world!');
expect(text).toBe('hello world!'); expect(text).toBe('hello world!');
})); }));
it('should accept ElementHandles as arguments', SX(async function() {
await page.setContent('<section>hello</section><div> world</div>');
const divHandle = await page.$('div');
const text = await page.$eval('section', (e, div) => e.textContent + div.textContent, divHandle);
expect(text).toBe('hello world');
}));
it('should throw error if no element is found', SX(async function() { it('should throw error if no element is found', SX(async function() {
let error = null; let error = null;
await page.$eval('section', e => e.id).catch(e => error = e); await page.$eval('section', e => e.id).catch(e => error = e);
@ -1174,7 +1204,7 @@ describe('Page', function() {
await page.setContent('<div>A</div><br/><div>B</div>'); await page.setContent('<div>A</div><br/><div>B</div>');
const elements = await page.$$('div'); const elements = await page.$$('div');
expect(elements.length).toBe(2); expect(elements.length).toBe(2);
const promises = elements.map(element => element.evaluate(e => e.textContent)); const promises = elements.map(element => page.evaluate(e => e.textContent, element));
expect(await Promise.all(promises)).toEqual(['A', 'B']); expect(await Promise.all(promises)).toEqual(['A', 'B']);
})); }));
it('should return empty array if nothing is found', SX(async function() { it('should return empty array if nothing is found', SX(async function() {
@ -1184,48 +1214,6 @@ describe('Page', function() {
})); }));
}); });
describe('ElementHandle.evaluate', function() {
it('should work', SX(async function() {
await page.setContent('<section>42</section>');
const element = await page.$('section');
const 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>');
const element = await page.$('section');
const text = await element.evaluate(e => Promise.resolve(e.textContent));
expect(text).toBe('39');
}));
it('should throw if underlying page got closed', SX(async function() {
const otherPage = await browser.newPage();
await otherPage.setContent('<section>88</section>');
const 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 disposed', SX(async function() {
await page.setContent('<section>39</section>');
const element = await page.$('section');
expect(element).toBeTruthy();
await element.dispose();
let error = null;
try {
await element.evaluate(e => e.textContent);
} catch (e) {
error = e;
}
expect(error.message).toContain('ElementHandle is disposed');
}));
});
describe('ElementHandle.click', function() { describe('ElementHandle.click', function() {
it('should work', SX(async function() { it('should work', SX(async function() {
await page.goto(PREFIX + '/input/button.html'); await page.goto(PREFIX + '/input/button.html');
@ -1284,13 +1272,13 @@ describe('Page', function() {
const filePath = path.relative(process.cwd(), __dirname + '/assets/file-to-upload.txt'); const filePath = path.relative(process.cwd(), __dirname + '/assets/file-to-upload.txt');
const input = await page.$('input'); const input = await page.$('input');
await input.uploadFile(filePath); await input.uploadFile(filePath);
expect(await input.evaluate(e => e.files[0].name)).toBe('file-to-upload.txt'); expect(await page.evaluate(e => e.files[0].name, input)).toBe('file-to-upload.txt');
expect(await input.evaluate(e => { expect(await page.evaluate(e => {
const reader = new FileReader(); const reader = new FileReader();
const promise = new Promise(fulfill => reader.onload = fulfill); const promise = new Promise(fulfill => reader.onload = fulfill);
reader.readAsText(e.files[0]); reader.readAsText(e.files[0]);
return promise.then(() => reader.result); return promise.then(() => reader.result);
})).toBe('contents of the file'); }, input)).toBe('contents of the file');
})); }));
it('should move with the arrow keys', SX(async function(){ it('should move with the arrow keys', SX(async function(){
await page.goto(PREFIX + '/input/textarea.html'); await page.goto(PREFIX + '/input/textarea.html');