feat(JSHandles): introduce JSHandles (#943)

This patch:
- introduces ExecutionContext class that incapsulates javascript
  execution context. An examples of execution contexts are workers and
  frames
- introduces JSHandle that holds a references to the javascript
  object in ExecutionContext
- inherits ElementHandle from JSHandle

Fixes #382.
This commit is contained in:
Andrey Lushnikov
2017-10-06 15:35:02 -07:00
committed by GitHub
parent 59bcc2ee56
commit 0d0f9b7984
8 changed files with 654 additions and 112 deletions

View File

@@ -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;
}

192
lib/ExecutionContext.js Normal file
View File

@@ -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<!JSHandle>}
*/
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<?JSHandle>}
*/
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<Map<string, !ObjectHandle>>}
*/
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<?Object>}
*/
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};

View File

@@ -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<string, !Frame>} */
this._frames = new Map();
/** @type {!Map<string, !ExecutionContext>} */
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 = '<not-initialized>';
this._context = null;
/** @type {!Set<!WaitTask>} */
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<?ElementHandle>}
*/
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<!Array<!ElementHandle>>}
*/
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}
*/

View File

@@ -166,6 +166,16 @@ class Page extends EventEmitter {
return this.mainFrame().$(selector);
}
/**
* @param {string} selector
* @param {function()|string} pageFunction
* @param {!Array<*>} args
* @return {!Promise<!JSHandle>}
*/
async evaluateHandle(pageFunction, ...args) {
return this.mainFrame().executionContext().evaluateHandle(pageFunction, ...args);
}
/**
* @param {string} selector
* @param {function()|string} pageFunction

View File

@@ -64,7 +64,8 @@ class Helper {
* @param {!Object} remoteObject
* @return {!Promise<!Object>}
*/
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<!Object>}
*/
static async serializeRemoteObject(client, remoteObject) {
if (!remoteObject.objectId)
return remoteObject.value;
return Helper.valueFromRemoteObject(remoteObject);
if (remoteObject.subtype === 'promise')
return remoteObject.description;
try {