diff --git a/docs/api.md b/docs/api.md index 87c7205060019..c7e80ab3ee1d8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -44,6 +44,7 @@ + [page.emulate(options)](#pageemulateoptions) + [page.emulateMedia(mediaType)](#pageemulatemediamediatype) + [page.evaluate(pageFunction, ...args)](#pageevaluatepagefunction-args) + + [page.evaluateHandle(pageFunction, ...args)](#pageevaluatehandlepagefunction-args) + [page.evaluateOnNewDocument(pageFunction, ...args)](#pageevaluateonnewdocumentpagefunction-args) + [page.exposeFunction(name, puppeteerFunction)](#pageexposefunctionname-puppeteerfunction) + [page.focus(selector)](#pagefocusselector) @@ -112,6 +113,7 @@ + [frame.addStyleTag(url)](#frameaddstyletagurl) + [frame.childFrames()](#framechildframes) + [frame.evaluate(pageFunction, ...args)](#frameevaluatepagefunction-args) + + [frame.executionContext()](#frameexecutioncontext) + [frame.injectFile(filePath)](#frameinjectfilefilepath) + [frame.isDetached()](#frameisdetached) + [frame.name()](#framename) @@ -121,11 +123,28 @@ + [frame.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]])](#framewaitforselectororfunctionortimeout-options-args) + [frame.waitForFunction(pageFunction[, options[, ...args]])](#framewaitforfunctionpagefunction-options-args) + [frame.waitForSelector(selector[, options])](#framewaitforselectorselector-options) + * [class: ExecutionContext](#class-executioncontext) + + [executionContext.evaluate(pageFunction, ...args)](#executioncontextevaluatepagefunction-args) + + [executionContext.evaluateHandle(pageFunction, ...args)](#executioncontextevaluatehandlepagefunction-args) + * [class: JSHandle](#class-jshandle) + + [jsHandle.asElement()](#jshandleaselement) + + [jsHandle.dispose()](#jshandledispose) + + [jsHandle.executionContext()](#jshandleexecutioncontext) + + [jsHandle.getProperties()](#jshandlegetproperties) + + [jsHandle.getProperty(propertyName)](#jshandlegetpropertypropertyname) + + [jsHandle.jsonValue()](#jshandlejsonvalue) + + [jsHandle.toString()](#jshandletostring) * [class: ElementHandle](#class-elementhandle) + + [elementHandle.asElement()](#elementhandleaselement) + [elementHandle.click([options])](#elementhandleclickoptions) + [elementHandle.dispose()](#elementhandledispose) + + [elementHandle.executionContext()](#elementhandleexecutioncontext) + + [elementHandle.getProperties()](#elementhandlegetproperties) + + [elementHandle.getProperty(propertyName)](#elementhandlegetpropertypropertyname) + [elementHandle.hover()](#elementhandlehover) + + [elementHandle.jsonValue()](#elementhandlejsonvalue) + [elementHandle.tap()](#elementhandletap) + + [elementHandle.toString()](#elementhandletostring) + [elementHandle.uploadFile(...filePaths)](#elementhandleuploadfilefilepaths) * [class: Request](#class-request) + [request.abort()](#requestabort) @@ -352,7 +371,7 @@ Shortcut for [page.mainFrame().$$(selector)](#frameselector-1). 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. -If `pageFunction` returns a [Promise], then `page.$eval` would wait for the promise to resolve and return it's value. +If `pageFunction` returns a [Promise], then `page.$eval` would wait for the promise to resolve and return its value. Examples: ```js @@ -475,7 +494,7 @@ List of all available devices is available in the source code: [DeviceDescriptor - `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `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 its value. ```js const result = await page.evaluate(() => { @@ -499,6 +518,35 @@ await bodyHandle.dispose(); Shortcut for [page.mainFrame().evaluate(pageFunction, ...args)](#frameevaluatepagefunction-args). +#### page.evaluateHandle(pageFunction, ...args) +- `pageFunction` <[function]|[string]> Function to be evaluated in the page context +- `...args` <...[Serializable]|[JSHandle]> Arguments to pass to `pageFunction` +- returns: <[Promise]<[JSHandle]>> Resolves to the return value of `pageFunction` + +If the function, passed to the `page.evaluateHandle`, returns a [Promise], then `page.evaluateHandle` would wait for the promise to resolve and return its value. + +```js +const aWindowHandle = await page.evaluateHandle(() => Promise.resolve(window)); +aWindowHandle; // Handle for the window object. +``` + +A string can also be passed in instead of a function. + +```js +const aHandle = await page.evaluateHandle('document'); // Handle for the 'document'. +``` + +[JSHandle] instances could be passed as arguments to the `page.evaluateHandle`: +```js +const aHandle = await page.evaluateHandle(() => document.body); +const resultHandle = await page.evaluateHandle(body => body.innerHTML, aHandle); +console.log(await resultHandle.jsonValue()); +await resultHandle.dispose(); +``` + +Shortcut for [page.mainFrame().executionContext().evaluateHandle(pageFunction, ...args)](#frameobjectpagefunction-args). + + #### page.evaluateOnNewDocument(pageFunction, ...args) - `pageFunction` <[function]|[string]> Function to be evaluated in browser context - `...args` <...[Serializable]> Arguments to pass to `pageFunction` @@ -1171,7 +1219,7 @@ The method runs `document.querySelectorAll` within the frame. If no elements mat 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. -If `pageFunction` returns a [Promise], then `frame.$eval` would wait for the promise to resolve and return it's value. +If `pageFunction` returns a [Promise], then `frame.$eval` would wait for the promise to resolve and return its value. Examples: ```js @@ -1200,7 +1248,7 @@ Adds a `` tag to the frame with the desired url. - `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `pageFunction` - returns: <[Promise]<[Serializable]>> Promise which resolves to function return 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. +If the function, passed to the `frame.evaluate`, returns a [Promise], then `frame.evaluate` would wait for the promise to resolve and return its value. ```js const result = await frame.evaluate(() => { @@ -1222,6 +1270,9 @@ const html = await frame.evaluate(body => body.innerHTML, bodyHandle); await bodyHandle.dispose(); ``` +#### frame.executionContext() +- returns: <[ExecutionContext]> Execution context associated with this frame. + #### 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. @@ -1314,8 +1365,129 @@ puppeteer.launch().then(async browser => { }); ``` +### class: ExecutionContext + +The class represents a context for JavaScript execution. Examples of JavaScript contexts are: +- each [frame](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe) has a separate execution context +- all kind of [workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) have their own contexts + +#### executionContext.evaluate(pageFunction, ...args) +- `pageFunction` <[function]|[string]> Function to be evaluated in browser context +- `...args` <...[Serializable]|[ElementHandle]> Arguments to pass to `pageFunction` +- returns: <[Promise]<[Serializable]>> Promise which resolves to function return value + +If the function, passed to the `executionContext.evaluate`, returns a [Promise], then `executionContext.evaluate` would wait for the promise to resolve and return its value. + +```js +const result = await executionContext.evaluate(() => Promise.resolve(8 * 7)); +console.log(result); // prints "56" +``` + +A string can also be passed in instead of a function. + +```js +console.log(await executionContext.evaluate('1 + 2')); // prints "3" +``` + +[JSHandle] instances can be passed as arguments to the `frame.evaluate`: +```js +const oneHandle = await executionContext.evaluateHandle(() => 1); +const twoHandle = await executionContext.evaluateHandle(() => 2); +const result = await executionContext.evaluate((a, b) => a + b, oneHandle, twoHandle); +await oneHandle.dispose(); +await twoHandle.dispose(); +console.log(result); // prints '3'. +``` + +#### executionContext.evaluateHandle(pageFunction, ...args) +- `pageFunction` <[function]|[string]> Function to be evaluated in the page context +- `...args` <...[Serializable]|[JSHandle]> Arguments to pass to `pageFunction` +- returns: <[Promise]<[JSHandle]>> Resolves to the return value of `pageFunction` + +If the function, passed to the `executionContext.evaluateHandle`, returns a [Promise], then `executionContext.evaluteHandle` would wait for the promise to resolve and return its value. + +```js +const aHandle = await context.evaluateHandle(() => Promise.resolve(self)); +aHandle; // Handle for the global object. +``` + +A string can also be passed in instead of a function. + +```js +const aHandle = await context.evaluateHandle('1 + 2'); // Handle for the '3' object. +``` + +[JSHandle] instances could be passed as arguments to the `executionContext.evaluateHandle`: +```js +const context = page.mainFrame().executionContext(); +const aHandle = await context.evaluateHandle(() => document.body); +const resultHandle = await context.evaluateHandle(body => body.innerHTML, aHandle); +console.log(await resultHandle.jsonValue()); // prints body's innerHTML +await aHandle.dispose(); +await resultHandle.dispose(); +``` + +### class: JSHandle + +JSHandle represents an in-page javascript object. JSHandles could be created with the [page.evaluateHandle](#pageobjectpagefunction-args) method. + +```js +await windowHandle = await page.evaluateHandle(() => window); +// ... +``` + +JSHandle prevents references javascript objects from garbage collection unless the handle is [disposed](#objecthandledispose). JSHandles are auto-disposed when their origin frame gets navigated or the parent context gets destroyed. + +JSHandle instances can be used as arguments in [`page.$eval()`](#pageevalselector-pagefunction-args), [`page.evaluate()`](#pageevaluatepagefunction-args) and [`page.evaluateHandle`](#pageobjectpagefunction-args) methods. + +#### jsHandle.asElement() +- returns: <[ElementHandle]> + +Returns either `null` or the object handle itself, if the object handle is an instance of [ElementHandle]. + +#### jsHandle.dispose() +- returns: <[Promise]> Promise which resolves when the object handle is successfully disposed. + +The `jsHandle.dispose` method stops referencing the element handle. + +#### jsHandle.executionContext() +- returns: [ExecutionContext] + +Returns execution context the handle belongs to. + +#### jsHandle.getProperties() +- returns: <[Promise]<[Map]<[string], [JSHandle]>>> + +The method returns a map with property names as keys and JSHandle instances for the property values. + +```js +const handle = await page.evaluateHandle(() => {window, document}); +const properties = await handle.getProperties(); +const windowHandle = properties.get('window'); +const documentHandle = properties.get('document'); +await handle.dispose(); +``` + +#### jsHandle.getProperty(propertyName) +- `propertyName` <[string]> property to get +- returns: <[Promise]<[JSHandle]>> + +Fetches a single property from the referenced object. + +#### jsHandle.jsonValue() +- returns: <[Promise]<[Object]>> + +Returns a JSON representation of the object. The JSON is generated by running [`JSON.stringify`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) on the object in page and consequent [`JSON.parse`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) in puppeteer. + +> **NOTE** The method will throw if the referenced object is not stringifiable. + +#### jsHandle.toString() +- returns: <[string]> + ### class: ElementHandle +> **NOTE** Class [ElementHandle] extends [JSHandle]. + ElementHandle represents an in-page DOM element. ElementHandles could be created with the [page.$](#pageselector) method. ```js @@ -1334,6 +1506,9 @@ ElementHandle prevents DOM element from garbage collection unless the handle is ElementHandle instances can be used as arguments in [`page.$eval()`](#pageevalselector-pagefunction-args) and [`page.evaluate()`](#pageevaluatepagefunction-args) methods. +#### elementHandle.asElement() +- returns: <[ElementHandle]> + #### elementHandle.click([options]) - `options` <[Object]> - `button` <[string]> `left`, `right`, or `middle`, defaults to `left`. @@ -1349,18 +1524,54 @@ If the element is detached from DOM, the method throws an error. The `elementHandle.dispose` method stops referencing the element handle. +#### elementHandle.executionContext() +- returns: [ExecutionContext] + +#### elementHandle.getProperties() +- returns: <[Promise]<[Map]<[string], [JSHandle]>>> + +The method returns a map with property names as keys and JSHandle instances for the property values. + +```js +const listHandle = await page.evaluateHandle(() => document.body.children); +const properties = await containerHandle.getProperties(); +const children = []; +for (const property of properties.values()) { + const element = property.asElement(); + if (element) + children.push(element); +} +children; // holds elementHandles to all children of document.body +``` + +#### elementHandle.getProperty(propertyName) +- `propertyName` <[string]> property to get +- returns: <[Promise]<[JSHandle]>> + +Fetches a single property from the objectHandle. + #### 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.jsonValue() +- returns: <[Promise]<[Object]>> + +Returns a JSON representation of the object. The JSON is generated by running [`JSON.stringify`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) on the object in page and consequent [`JSON.parse`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) in puppeteer. + +> **NOTE** The method will throw if the referenced object is not stringifiable. + #### elementHandle.tap() - returns: <[Promise]> Promise which resolves when the element is successfully tapped. Promise gets rejected if the element is detached from DOM. This method scrolls element into view if needed, and then uses [touchscreen.tap](#touchscreentapx-y) to tap in the center of the element. If the element is detached from DOM, the method throws an error. +#### elementHandle.toString() +- returns: <[string]> + #### elementHandle.uploadFile(...filePaths) - `...filePaths` <...[string]> Sets the value of the file input these paths. If some of the `filePaths` are relative paths, then they are resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). - returns: <[Promise]> @@ -1479,6 +1690,8 @@ Contains the URL of the response. [Element]: https://developer.mozilla.org/en-US/docs/Web/API/element "Element" [Keyboard]: #class-keyboard "Keyboard" [Dialog]: #class-dialog "Dialog" +[JSHandle]: #class-objecthandle "JSHandle" +[ExecutionContext]: #class-executioncontext "ExecutionContext" [Mouse]: #class-mouse "Mouse" [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" diff --git a/lib/ElementHandle.js b/lib/ElementHandle.js index ad4a5e4149514..4239e8abb482d 100644 --- a/lib/ElementHandle.js +++ b/lib/ElementHandle.js @@ -14,55 +14,50 @@ * limitations under the License. */ const path = require('path'); +const {JSHandle} = require('./ExecutionContext'); const {helper} = require('./helper'); -class ElementHandle { +class ElementHandle extends JSHandle { /** - * @param {!Frame} frame - * @param {!Connection} client + * @param {!ExecutionContext} context + * @param {!Session} client * @param {!Object} remoteObject * @param {!Mouse} mouse * @param {!Touchscreen} touchscreen; */ - constructor(frame, client, remoteObject, mouse, touchscreen) { - this._frame = frame; - this._client = client; - this._remoteObject = remoteObject; + constructor(context, client, remoteObject, mouse, touchscreen) { + super(context, client, remoteObject); this._mouse = mouse; this._touchscreen = touchscreen; - this._disposed = false; } /** - * @return {?string} + * @override + * @return {?ElementHandle} */ - _remoteObjectId() { - return this._disposed ? null : this._remoteObject.objectId; - } - - async dispose() { - if (this._disposed) - return; - this._disposed = true; - await helper.releaseObject(this._client, this._remoteObject); + asElement() { + return this; } /** * @return {!Promise<{x: number, y: number}>} */ async _visibleCenter() { - const center = await this._frame.evaluate(element => { + const {center, error} = await this.executionContext().evaluate(element => { if (!element.ownerDocument.contains(element)) - return null; + return {center: null, error: 'Node is detached from document'}; + if (element.nodeType !== HTMLElement.ELEMENT_NODE) + return {center: null, error: 'Node is not of type HTMLElement'}; element.scrollIntoViewIfNeeded(); const rect = element.getBoundingClientRect(); - return { + const center = { 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 }; + return {center, error: null}; }, this); - if (!center) - throw new Error('No node found for selector: ' + selector); + if (error) + throw new Error(error); return center; } diff --git a/lib/ExecutionContext.js b/lib/ExecutionContext.js new file mode 100644 index 0000000000000..6bcda5dfa2e0d --- /dev/null +++ b/lib/ExecutionContext.js @@ -0,0 +1,192 @@ +/** + * 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 ExecutionContext { + /** + * @param {!Session} client + * @param {string} contextId + * @param {function(*):!JSHandle} objectHandleFactory + */ + constructor(client, contextId, objectHandleFactory) { + this._client = client; + this._contextId = contextId; + this._objectHandleFactory = objectHandleFactory; + } + + /** + * @param {function()|string} pageFunction + * @param {!Array<*>} args + * @return {!Promise<(!Object|undefined)>} + */ + async evaluate(pageFunction, ...args) { + const handle = await this.evaluateHandle(pageFunction, ...args); + const result = await handle.jsonValue(); + await handle.dispose(); + return result; + } + + /** + * @param {function()|string} pageFunction + * @param {!Array<*>} args + * @return {!Promise} + */ + async evaluateHandle(pageFunction, ...args) { + if (helper.isString(pageFunction)) { + const contextId = this._contextId; + const expression = pageFunction; + const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId, returnByValue: false, awaitPromise: true}); + if (exceptionDetails) + throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); + return this._objectHandleFactory(remoteObject); + } + + const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { + functionDeclaration: pageFunction.toString(), + executionContextId: this._contextId, + arguments: args.map(convertArgument.bind(this)), + returnByValue: false, + awaitPromise: true + }); + if (exceptionDetails) + throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); + return this._objectHandleFactory(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' }; + const objectHandle = arg && (arg instanceof JSHandle) ? arg : null; + if (objectHandle) { + if (objectHandle._context !== this) + throw new Error('JSHandles can be evaluated only in the context they were created!'); + if (objectHandle._disposed) + throw new Error('JSHandle is disposed!'); + if (objectHandle._remoteObject.unserializableValue) + return { unserializableValue: objectHandle._remoteObject.unserializableValue }; + if (!objectHandle._remoteObject.objectId) + return { value: objectHandle._remoteObject.value }; + return { objectId: objectHandle._remoteObject.objectId }; + } + return { value: arg }; + } + } +} + +class JSHandle { + /** + * @param {!ExecutionContext} context + * @param {!Session} client + * @param {!Object} remoteObject + */ + constructor(context, client, remoteObject) { + this._context = context; + this._client = client; + this._remoteObject = remoteObject; + this._disposed = false; + } + + /** + * @return {!ExecutionContext} + */ + executionContext() { + return this._context; + } + + /** + * @param {string} propertyName + * @return {!Promise} + */ + async getProperty(propertyName) { + const objectHandle = await this._context.evaluateHandle((object, propertyName) => { + const result = {__proto__: null}; + result[propertyName] = object[propertyName]; + return result; + }, this, propertyName); + const properties = await objectHandle.getProperties(); + const result = properties.get(propertyName) || null; + await objectHandle.dispose(); + return result; + } + + /** + * @return {!Property>} + */ + async getProperties() { + const response = await this._client.send('Runtime.getProperties', { + objectId: this._remoteObject.objectId, + ownProperties: true + }); + const result = new Map(); + for (const property of response.result) { + if (!property.enumerable) + continue; + result.set(property.name, this._context._objectHandleFactory(property.value)); + } + return result; + } + + /** + * @return {!Promise} + */ + async jsonValue() { + if (this._remoteObject.objectId) { + const jsonString = await this._context.evaluate(object => JSON.stringify(object), this); + return JSON.parse(jsonString); + } + return helper.valueFromRemoteObject(this._remoteObject); + } + + /** + * @return {?ElementHandle} + */ + asElement() { + return null; + } + + async dispose() { + if (this._disposed) + return; + this._disposed = true; + await helper.releaseObject(this._client, this._remoteObject); + } + + /** + * @override + * @return {string} + */ + toString() { + if (this._remoteObject.objectId) { + const type = this._remoteObject.subtype || this._remoteObject.type; + return 'JSHandle@' + type; + } + return helper.valueFromRemoteObject(this._remoteObject) + ''; + } +} + +helper.tracePublicAPI(JSHandle); +module.exports = {ExecutionContext, JSHandle}; diff --git a/lib/FrameManager.js b/lib/FrameManager.js index 0ca1afb32edec..afbb940e7516a 100644 --- a/lib/FrameManager.js +++ b/lib/FrameManager.js @@ -17,6 +17,7 @@ const fs = require('fs'); const EventEmitter = require('events'); const {helper} = require('./helper'); +const {ExecutionContext, JSHandle} = require('./ExecutionContext'); const ElementHandle = require('./ElementHandle'); class FrameManager extends EventEmitter { @@ -33,6 +34,8 @@ class FrameManager extends EventEmitter { this._touchscreen = touchscreen; /** @type {!Map} */ this._frames = new Map(); + /** @type {!Map} */ + this._contextIdToContext = new Map(); this._client.on('Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)); this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame)); @@ -112,16 +115,36 @@ class FrameManager extends EventEmitter { this._removeFramesRecursively(frame); } - _onExecutionContextCreated(context) { - const frameId = context.auxData && context.auxData.isDefault ? context.auxData.frameId : null; + _onExecutionContextCreated(contextPayload) { + const context = new ExecutionContext(this._client, contextPayload.id, this.createJSHandle.bind(this, contextPayload.id)); + this._contextIdToContext.set(contextPayload.id, context); + + const frameId = contextPayload.auxData && contextPayload.auxData.isDefault ? contextPayload.auxData.frameId : null; const frame = this._frames.get(frameId); if (!frame) return; - frame._defaultContextId = context.id; + frame._context = context; for (const waitTask of frame._waitTasks) waitTask.rerun(); } + _onExecutionContextDestroyed(contextPayload) { + this._contextIdToContext.delete(contextPayload.id); + } + + /** + * @param {string} contextId + * @param {*} remoteObject + * @return {!JSHandle} + */ + createJSHandle(contextId, remoteObject) { + const context = this._contextIdToContext.get(contextId); + console.assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); + if (remoteObject.subtype === 'node') + return new ElementHandle(context, this._client, remoteObject, this._mouse, this._touchscreen); + return new JSHandle(context, this._client, remoteObject); + } + /** * @param {!Frame} frame */ @@ -167,7 +190,7 @@ class Frame { this._parentFrame = parentFrame; this._url = ''; this._id = frameId; - this._defaultContextId = ''; + this._context = null; /** @type {!Set} */ this._waitTasks = new Set(); @@ -177,14 +200,20 @@ class Frame { this._parentFrame._childFrames.add(this); } + /** + * @return {!ExecutionContext} + */ + executionContext() { + return this._context; + } + /** * @param {function()|string} pageFunction * @param {!Array<*>} args * @return {!Promise<(!Object|undefined)>} */ async evaluate(pageFunction, ...args) { - const remoteObject = await this._rawEvaluate(pageFunction, ...args); - return await helper.serializeRemoteObject(this._client, remoteObject); + return this._context.evaluate(pageFunction, ...args); } /** @@ -192,10 +221,11 @@ class Frame { * @return {!Promise} */ async $(selector) { - const remoteObject = await this._rawEvaluate(selector => document.querySelector(selector), selector); - if (remoteObject.subtype === 'node') - return new ElementHandle(this, this._client, remoteObject, this._mouse, this._touchscreen); - await helper.releaseObject(this._client, remoteObject); + const handle = await this._context.evaluateHandle(selector => document.querySelector(selector), selector); + const element = handle.asElement(); + if (element) + return element; + await handle.dispose(); return null; } @@ -220,76 +250,18 @@ class Frame { * @return {!Promise>} */ async $$(selector) { - const remoteObject = await this._rawEvaluate(selector => Array.from(document.querySelectorAll(selector)), selector); - const response = await this._client.send('Runtime.getProperties', { - objectId: remoteObject.objectId, - ownProperties: true - }); - const properties = response.result; + const arrayHandle = await this._context.evaluateHandle(selector => document.querySelectorAll(selector), selector); + const properties = await arrayHandle.getProperties(); + await arrayHandle.dispose(); const result = []; - const releasePromises = [helper.releaseObject(this._client, remoteObject)]; - for (const property of properties) { - if (property.enumerable && property.value.subtype === 'node') - result.push(new ElementHandle(this, this._client, property.value, this._mouse, this._touchscreen)); - else - releasePromises.push(helper.releaseObject(this._client, property.value)); + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) + result.push(elementHandle); } - await Promise.all(releasePromises); return result; } - /** - * @param {function()|string} pageFunction - * @param {!Array<*>} args - * @return {!Promise<(!Object|undefined)>} - */ - async _rawEvaluate(pageFunction, ...args) { - if (helper.isString(pageFunction)) { - const contextId = this._defaultContextId; - const expression = pageFunction; - const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId, returnByValue: false, awaitPromise: true}); - if (exceptionDetails) - throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); - 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} */ diff --git a/lib/Page.js b/lib/Page.js index 0cf232015cd88..dd0da5b3f8343 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -166,6 +166,16 @@ class Page extends EventEmitter { return this.mainFrame().$(selector); } + /** + * @param {string} selector + * @param {function()|string} pageFunction + * @param {!Array<*>} args + * @return {!Promise} + */ + async evaluateHandle(pageFunction, ...args) { + return this.mainFrame().executionContext().evaluateHandle(pageFunction, ...args); + } + /** * @param {string} selector * @param {function()|string} pageFunction diff --git a/lib/helper.js b/lib/helper.js index 18ef2b7b27de8..4056f055ee9cd 100644 --- a/lib/helper.js +++ b/lib/helper.js @@ -64,7 +64,8 @@ class Helper { * @param {!Object} remoteObject * @return {!Promise} */ - static async serializeRemoteObject(client, remoteObject) { + static valueFromRemoteObject(remoteObject) { + console.assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); if (remoteObject.unserializableValue) { switch (remoteObject.unserializableValue) { case '-0': @@ -79,8 +80,17 @@ class Helper { throw new Error('Unsupported unserializable value: ' + remoteObject.unserializableValue); } } + return remoteObject.value; + } + + /** + * @param {!Session} client + * @param {!Object} remoteObject + * @return {!Promise} + */ + static async serializeRemoteObject(client, remoteObject) { if (!remoteObject.objectId) - return remoteObject.value; + return Helper.valueFromRemoteObject(remoteObject); if (remoteObject.subtype === 'promise') return remoteObject.description; try { diff --git a/test/test.js b/test/test.js index ef7dbc638c562..0596552ef0241 100644 --- a/test/test.js +++ b/test/test.js @@ -265,9 +265,10 @@ describe('Page', function() { const result = await page.evaluate((a, b) => Object.is(a, undefined) && Object.is(b, 'foo'), undefined, 'foo'); expect(result).toBe(true); })); - it('should not fail for window object', SX(async function() { - const result = await page.evaluate(() => window); - expect(result).toBe('Window'); + it('should fail for window object', SX(async function() { + let error = null; + await page.evaluate(() => window).catch(e => error = e); + expect(error.message).toContain('Converting circular structure to JSON'); })); it('should accept a string', SX(async function() { const result = await page.evaluate('1 + 2'); @@ -294,7 +295,7 @@ describe('Page', function() { await element.dispose(); let error = null; await page.evaluate(e => e.textContent, element).catch(e => error = e); - expect(error.message).toContain('ElementHandle is disposed'); + expect(error.message).toContain('JSHandle is disposed'); })); it('should throw if elementHandles are from other frames', SX(async function() { const FrameUtils = require('./frame-utils'); @@ -303,7 +304,117 @@ describe('Page', function() { 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'); + expect(error.message).toContain('JSHandles can be evaluated only in the context they were created'); + })); + it('should accept object handle as an argument', SX(async function() { + const navigatorHandle = await page.evaluateHandle(() => navigator); + const text = await page.evaluate(e => e.userAgent, navigatorHandle); + expect(text).toContain('Mozilla'); + })); + it('should accept object handle to primitive types', SX(async function() { + const aHandle = await page.evaluateHandle(() => 5); + const isFive = await page.evaluate(e => Object.is(e, 5), aHandle); + expect(isFive).toBeTruthy(); + })); + }); + + describe('Page.evaluateHandle', function() { + it('should work', SX(async function() { + const windowHandle = await page.evaluateHandle(() => window); + expect(windowHandle).toBeTruthy(); + })); + }); + + describe('JSHandle.getProperty', function() { + it('should work', SX(async function() { + const aHandle = await page.evaluateHandle(() => ({ + one: 1, + two: 2, + three: 3 + })); + const twoHandle = await aHandle.getProperty('two'); + expect(await twoHandle.jsonValue()).toEqual(2); + })); + }); + + describe('JSHandle.jsonValue', function() { + it('should work', SX(async function() { + const aHandle = await page.evaluateHandle(() => ({foo: 'bar'})); + const json = await aHandle.jsonValue(); + expect(json).toEqual({foo: 'bar'}); + })); + it('should work with dates', SX(async function() { + const dateHandle = await page.evaluateHandle(() => new Date('2017-09-26T00:00:00.000Z')); + const json = await dateHandle.jsonValue(); + expect(json).toBe('2017-09-26T00:00:00.000Z'); + })); + it('should throw for circular objects', SX(async function() { + const windowHandle = await page.evaluateHandle('window'); + let error = null; + await windowHandle.jsonValue().catch(e => error = e); + expect(error.message).toContain('Converting circular structure to JSON'); + })); + }); + + describe('JSHandle.getProperties', function() { + it('should work', SX(async function() { + const aHandle = await page.evaluateHandle(() => ({ + foo: 'bar' + })); + const properties = await aHandle.getProperties(); + const foo = properties.get('foo'); + expect(foo).toBeTruthy(); + expect(await foo.jsonValue()).toBe('bar'); + })); + it('should return even non-own properties', SX(async function() { + const aHandle = await page.evaluateHandle(() => { + class A { + constructor() { + this.a = '1'; + } + } + class B extends A { + constructor() { + super(); + this.b = '2'; + } + } + return new B(); + }); + const properties = await aHandle.getProperties(); + expect(await properties.get('a').jsonValue()).toBe('1'); + expect(await properties.get('b').jsonValue()).toBe('2'); + })); + }); + + describe('JSHandle.asElement', function() { + it('should work', SX(async function() { + const aHandle = await page.evaluateHandle(() => document.body); + const element = aHandle.asElement(); + expect(element).toBeTruthy(); + })); + it('should return null for non-elements', SX(async function() { + const aHandle = await page.evaluateHandle(() => 2); + const element = aHandle.asElement(); + expect(element).toBeFalsy(); + })); + it('should return ElementHandle for TextNodes', SX(async function() { + await page.setContent('
ee!
'); + const aHandle = await page.evaluateHandle(() => document.querySelector('div').firstChild); + const element = aHandle.asElement(); + expect(element).toBeTruthy(); + expect(await page.evaluate(e => e.nodeType === HTMLElement.TEXT_NODE, element)); + })); + }); + + describe('JSHandle.toString', function() { + it('should work for primitives', SX(async function() { + const aHandle = await page.evaluateHandle(() => 2); + expect(aHandle.toString()).toBe('2'); + })); + it('should work for complicated objects', SX(async function() { + const aHandle = await page.evaluateHandle(() => window); + expect(aHandle.toString()).toBe('JSHandle@object'); })); }); @@ -322,6 +433,30 @@ describe('Page', function() { })); }); + describe('Frame.context', function() { + const FrameUtils = require('./frame-utils'); + it('should work', SX(async function() { + await page.goto(EMPTY_PAGE); + await FrameUtils.attachFrame(page, 'frame1', EMPTY_PAGE); + expect(page.frames().length).toBe(2); + const [frame1, frame2] = page.frames(); + expect(frame1.executionContext()).toBeTruthy(); + expect(frame2.executionContext()).toBeTruthy(); + expect(frame1.executionContext() !== frame2.executionContext()).toBeTruthy(); + + await Promise.all([ + frame1.executionContext().evaluate(() => window.a = 1), + frame2.executionContext().evaluate(() => window.a = 2) + ]); + const [a1, a2] = await Promise.all([ + frame1.executionContext().evaluate(() => window.a), + frame2.executionContext().evaluate(() => window.a) + ]); + expect(a1).toBe(1); + expect(a2).toBe(2); + })); + }); + describe('Frame.evaluate', function() { const FrameUtils = require('./frame-utils'); it('should have different execution contexts', SX(async function() { @@ -1241,9 +1376,24 @@ describe('Page', function() { it('should work', SX(async function() { await page.goto(PREFIX + '/input/button.html'); const button = await page.$('button'); - await button.click('button'); + await button.click(); expect(await page.evaluate(() => result)).toBe('Clicked'); })); + it('should work for TextNodes', SX(async function() { + await page.goto(PREFIX + '/input/button.html'); + const buttonTextNode = await page.evaluateHandle(() => document.querySelector('button').firstChild); + let error = null; + await buttonTextNode.click().catch(err => error = err); + expect(error.message).toBe('Node is not of type HTMLElement'); + })); + it('should throw for detached nodes', SX(async function() { + await page.goto(PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate(button => button.remove(), button); + let error = null; + await button.click().catch(err => error = err); + expect(error.message).toBe('Node is detached from document'); + })); }); describe('ElementHandle.hover', function() { diff --git a/utils/doclint/check_public_api/MDBuilder.js b/utils/doclint/check_public_api/MDBuilder.js index 4ad917b4ef2bf..b82516e314996 100644 --- a/utils/doclint/check_public_api/MDBuilder.js +++ b/utils/doclint/check_public_api/MDBuilder.js @@ -59,8 +59,8 @@ class MDOutline { let actualText = element.firstChild.textContent; let angleIndex = actualText.indexOf('<'); let spaceIndex = actualText.indexOf(' '); - angleIndex = angleIndex === -1 ? angleText.length : angleIndex; - spaceIndex = spaceIndex === -1 ? spaceIndex.length : spaceIndex + 1; + angleIndex = angleIndex === -1 ? actualText.length : angleIndex; + spaceIndex = spaceIndex === -1 ? actualText.length : spaceIndex + 1; actualText = actualText.substring(0, Math.min(angleIndex, spaceIndex)); if (actualText !== expectedText) errors.push(`${member.name} has mistyped 'return' type declaration: expected exactly '${expectedText}', found '${actualText}'.`);