diff --git a/src/chromium/crAccessibility.ts b/src/chromium/crAccessibility.ts index 52866fcb7c..f0012e64cb 100644 --- a/src/chromium/crAccessibility.ts +++ b/src/chromium/crAccessibility.ts @@ -95,8 +95,8 @@ class CRAXNode implements accessibility.AXNode { } async _findElement(element: dom.ElementHandle): Promise { - const remoteObject = element._remoteObject as Protocol.Runtime.RemoteObject; - const {node: {backendNodeId}} = await this._client.send('DOM.describeNode', {objectId: remoteObject.objectId}); + const objectId = element._objectId!; + const {node: {backendNodeId}} = await this._client.send('DOM.describeNode', { objectId }); const needle = this.find(node => node._payload.backendDOMNodeId === backendNodeId); return needle || null; } diff --git a/src/chromium/crExecutionContext.ts b/src/chromium/crExecutionContext.ts index 3bacb2a129..c0698c1d7d 100644 --- a/src/chromium/crExecutionContext.ts +++ b/src/chromium/crExecutionContext.ts @@ -17,10 +17,11 @@ import { CRSession } from './crConnection'; import { helper } from '../helper'; -import { valueFromRemoteObject, getExceptionMessage, releaseObject } from './crProtocolHelper'; +import { getExceptionMessage, releaseObject } from './crProtocolHelper'; import { Protocol } from './protocol'; import * as js from '../javascript'; import * as debugSupport from '../debug/debugSupport'; +import { RemoteObject, parseEvaluationResultValue } from '../remoteObject'; export class CRExecutionContext implements js.ExecutionContextDelegate { _client: CRSession; @@ -31,7 +32,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { this._contextId = contextPayload.id; } - async rawEvaluate(expression: string): Promise { + async rawEvaluate(expression: string): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression: debugSupport.ensureSourceUrl(expression), contextId: this._contextId, @@ -51,29 +52,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { if (typeof pageFunction !== 'function') throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); - - const { functionText, values, handles, dispose } = await js.prepareFunctionCall(pageFunction, context, args, (value: any) => { - if (typeof value === 'bigint') // eslint-disable-line valid-typeof - return { handle: { unserializableValue: `${value.toString()}n` } }; - if (Object.is(value, -0)) - return { handle: { unserializableValue: '-0' } }; - if (Object.is(value, Infinity)) - return { handle: { unserializableValue: 'Infinity' } }; - if (Object.is(value, -Infinity)) - return { handle: { unserializableValue: '-Infinity' } }; - if (Object.is(value, NaN)) - return { handle: { unserializableValue: 'NaN' } }; - if (value && (value instanceof js.JSHandle)) { - const remoteObject = toRemoteObject(value); - if (remoteObject.unserializableValue) - return { handle: { unserializableValue: remoteObject.unserializableValue } }; - if (!remoteObject.objectId) - return { handle: { value: remoteObject.value } }; - return { handle: { objectId: remoteObject.objectId } }; - } - return { value }; - }); - + const { functionText, values, handles, dispose } = await js.prepareFunctionCall(pageFunction, context, args); return this._callOnUtilityScript(context, 'callFunction', [ { value: functionText }, @@ -87,7 +66,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { const utilityScript = await context.utilityScript(); const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { functionDeclaration: `function (...args) { return this.${method}(...args) }` + debugSupport.generateSourceUrl(), - objectId: utilityScript._remoteObject.objectId, + objectId: utilityScript._objectId, arguments: [ { value: returnByValue }, ...args @@ -98,14 +77,14 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { }).catch(rewriteError); if (exceptionDetails) throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails)); - return returnByValue ? valueFromRemoteObject(remoteObject) : context.createHandle(remoteObject); + return returnByValue ? parseEvaluationResultValue(remoteObject.value) : context.createHandle(remoteObject); } finally { dispose(); } } async getProperties(handle: js.JSHandle): Promise> { - const objectId = toRemoteObject(handle).objectId; + const objectId = handle._objectId; if (!objectId) return new Map(); const response = await this._client.send('Runtime.getProperties', { @@ -114,7 +93,7 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { }); const result = new Map(); for (const property of response.result) { - if (!property.enumerable) + if (!property.enumerable || !property.value) continue; result.set(property.name, handle._context.createHandle(property.value)); } @@ -122,35 +101,20 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { } async releaseHandle(handle: js.JSHandle): Promise { - await releaseObject(this._client, toRemoteObject(handle)); + if (!handle._objectId) + return; + await releaseObject(this._client, handle._objectId); } async handleJSONValue(handle: js.JSHandle): Promise { - const remoteObject = toRemoteObject(handle); - if (remoteObject.objectId) { - const response = await this._client.send('Runtime.callFunctionOn', { - functionDeclaration: 'function() { return this; }' + debugSupport.generateSourceUrl(), - objectId: remoteObject.objectId, - returnByValue: true, - awaitPromise: true, - }); - return valueFromRemoteObject(response.result); + if (handle._objectId) { + return this._callOnUtilityScript(handle._context, + `jsonValue`, [ + { objectId: handle._objectId }, + ], true, () => {}); } - return valueFromRemoteObject(remoteObject); + return handle._value; } - - handleToString(handle: js.JSHandle, includeType: boolean): string { - const object = toRemoteObject(handle); - if (object.objectId) { - const type = object.subtype || object.type; - return 'JSHandle@' + type; - } - return (includeType ? 'JSHandle:' : '') + valueFromRemoteObject(object); - } -} - -function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject { - return handle._remoteObject as Protocol.Runtime.RemoteObject; } function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue { diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 280d354201..948ab3c20c 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -665,7 +665,7 @@ class FrameSession { _onLogEntryAdded(event: Protocol.Log.entryAddedPayload) { const {level, text, args, source, url, lineNumber} = event.entry; if (args) - args.map(arg => releaseObject(this._client, arg)); + args.map(arg => releaseObject(this._client, arg.objectId!)); if (source !== 'worker') this._page.emit(Events.Page.Console, new ConsoleMessage(level, text, [], {url, lineNumber})); } @@ -778,7 +778,7 @@ class FrameSession { async _getContentFrame(handle: dom.ElementHandle): Promise { const nodeInfo = await this._client.send('DOM.describeNode', { - objectId: handle._remoteObject.objectId + objectId: handle._objectId }); if (!nodeInfo || typeof nodeInfo.node.frameId !== 'string') return null; @@ -795,11 +795,10 @@ class FrameSession { }); if (!documentElement) return null; - const remoteObject = documentElement._remoteObject; - if (!remoteObject.objectId) + if (!documentElement._objectId) return null; const nodeInfo = await this._client.send('DOM.describeNode', { - objectId: remoteObject.objectId + objectId: documentElement._objectId }); const frameId = nodeInfo && typeof nodeInfo.node.frameId === 'string' ? nodeInfo.node.frameId : null; @@ -809,7 +808,7 @@ class FrameSession { async _getBoundingBox(handle: dom.ElementHandle): Promise { const result = await this._client.send('DOM.getBoxModel', { - objectId: handle._remoteObject.objectId + objectId: handle._objectId }).catch(logError(this._page)); if (!result) return null; @@ -823,7 +822,7 @@ class FrameSession { async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> { return await this._client.send('DOM.scrollIntoViewIfNeeded', { - objectId: handle._remoteObject.objectId, + objectId: handle._objectId, rect, }).then(() => 'success' as const).catch(e => { if (e instanceof Error && e.message.includes('Node does not have a layout object')) @@ -839,7 +838,7 @@ class FrameSession { async _getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._client.send('DOM.getContentQuads', { - objectId: handle._remoteObject.objectId + objectId: handle._objectId }).catch(logError(this._page)); if (!result) return null; @@ -853,7 +852,7 @@ class FrameSession { async _adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { const nodeInfo = await this._client.send('DOM.describeNode', { - objectId: handle._remoteObject.objectId, + objectId: handle._objectId, }); return this._adoptBackendNodeId(nodeInfo.node.backendNodeId, to) as Promise>; } diff --git a/src/chromium/crProtocolHelper.ts b/src/chromium/crProtocolHelper.ts index abc22b212d..8f566bf9b9 100644 --- a/src/chromium/crProtocolHelper.ts +++ b/src/chromium/crProtocolHelper.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { assert } from '../helper'; import { CRSession } from './crConnection'; import { Protocol } from './protocol'; import * as fs from 'fs'; @@ -35,31 +34,8 @@ export function getExceptionMessage(exceptionDetails: Protocol.Runtime.Exception return message; } -export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObject): any { - assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); - if (remoteObject.unserializableValue) { - if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') - return BigInt(remoteObject.unserializableValue.replace('n', '')); - switch (remoteObject.unserializableValue) { - case '-0': - return -0; - case 'NaN': - return NaN; - case 'Infinity': - return Infinity; - case '-Infinity': - return -Infinity; - default: - throw new Error('Unsupported unserializable value: ' + remoteObject.unserializableValue); - } - } - return remoteObject.value; -} - -export async function releaseObject(client: CRSession, remoteObject: Protocol.Runtime.RemoteObject) { - if (!remoteObject.objectId) - return; - await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => {}); +export async function releaseObject(client: CRSession, objectId: string) { + await client.send('Runtime.releaseObject', { objectId }).catch(error => {}); } export async function readProtocolStream(client: CRSession, handle: string, path: string | null): Promise { diff --git a/src/console.ts b/src/console.ts index 0e40887ed4..528146a75d 100644 --- a/src/console.ts +++ b/src/console.ts @@ -42,7 +42,7 @@ export class ConsoleMessage { text(): string { if (this._text === undefined) - this._text = this._args.map(arg => arg._context._delegate.handleToString(arg, false /* includeType */)).join(' '); + this._text = this._args.map(arg => arg._handleToString(false /* includeType */)).join(' '); return this._text; } diff --git a/src/firefox/ffAccessibility.ts b/src/firefox/ffAccessibility.ts index 3d7fcb0762..cd42ab6290 100644 --- a/src/firefox/ffAccessibility.ts +++ b/src/firefox/ffAccessibility.ts @@ -21,7 +21,7 @@ import { Protocol } from './protocol'; import * as dom from '../dom'; export async function getAccessibilityTree(session: FFSession, needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}> { - const objectId = needle ? needle._remoteObject.objectId : undefined; + const objectId = needle ? needle._objectId : undefined; const { tree } = await session.send('Accessibility.getFullAXTree', { objectId }); const axNode = new FFAXNode(tree); return { diff --git a/src/firefox/ffExecutionContext.ts b/src/firefox/ffExecutionContext.ts index 7fb7c98fe6..bc0e53579c 100644 --- a/src/firefox/ffExecutionContext.ts +++ b/src/firefox/ffExecutionContext.ts @@ -20,6 +20,7 @@ import * as js from '../javascript'; import { FFSession } from './ffConnection'; import { Protocol } from './protocol'; import * as debugSupport from '../debug/debugSupport'; +import { RemoteObject, parseEvaluationResultValue } from '../remoteObject'; export class FFExecutionContext implements js.ExecutionContextDelegate { _session: FFSession; @@ -30,7 +31,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { this._executionContextId = executionContextId; } - async rawEvaluate(expression: string): Promise { + async rawEvaluate(expression: string): Promise { const payload = await this._session.send('Runtime.evaluate', { expression: debugSupport.ensureSourceUrl(expression), returnByValue: false, @@ -50,25 +51,13 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { if (typeof pageFunction !== 'function') throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); - const { functionText, values, handles, dispose } = await js.prepareFunctionCall(pageFunction, context, args, (value: any) => { - if (Object.is(value, -0)) - return { handle: { unserializableValue: '-0' } }; - if (Object.is(value, Infinity)) - return { handle: { unserializableValue: 'Infinity' } }; - if (Object.is(value, -Infinity)) - return { handle: { unserializableValue: '-Infinity' } }; - if (Object.is(value, NaN)) - return { handle: { unserializableValue: 'NaN' } }; - if (value && (value instanceof js.JSHandle)) - return { handle: this._toCallArgument(value._remoteObject) }; - return { value }; - }); + const { functionText, values, handles, dispose } = await js.prepareFunctionCall(pageFunction, context, args); return this._callOnUtilityScript(context, `callFunction`, [ { value: functionText }, ...values.map(value => ({ value })), - ...handles, + ...handles.map(handle => ({ objectId: handle.objectId, value: handle.value })), ], returnByValue, dispose); } @@ -78,7 +67,7 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { const payload = await this._session.send('Runtime.callFunction', { functionDeclaration: `(utilityScript, ...args) => utilityScript.${method}(...args)` + debugSupport.generateSourceUrl(), args: [ - { objectId: utilityScript._remoteObject.objectId, value: undefined }, + { objectId: utilityScript._objectId, value: undefined }, { value: returnByValue }, ...args ], @@ -87,15 +76,15 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { }).catch(rewriteError); checkException(payload.exceptionDetails); if (returnByValue) - return deserializeValue(payload.result!); - return context.createHandle(payload.result); + return parseEvaluationResultValue(payload.result!.value); + return context.createHandle(payload.result!); } finally { dispose(); } } async getProperties(handle: js.JSHandle): Promise> { - const objectId = handle._remoteObject.objectId; + const objectId = handle._objectId; if (!objectId) return new Map(); const response = await this._session.send('Runtime.getObjectProperties', { @@ -109,36 +98,22 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { } async releaseHandle(handle: js.JSHandle): Promise { - if (!handle._remoteObject.objectId) + if (!handle._objectId) return; await this._session.send('Runtime.disposeObject', { executionContextId: this._executionContextId, - objectId: handle._remoteObject.objectId, + objectId: handle._objectId, }).catch(error => {}); } async handleJSONValue(handle: js.JSHandle): Promise { - const payload = handle._remoteObject; - if (!payload.objectId) - return deserializeValue(payload as Protocol.Runtime.RemoteObject); - const simpleValue = await this._session.send('Runtime.callFunction', { - executionContextId: this._executionContextId, - returnByValue: true, - functionDeclaration: ((e: any) => e).toString() + debugSupport.generateSourceUrl(), - args: [this._toCallArgument(payload)], - }); - return deserializeValue(simpleValue.result!); - } - - handleToString(handle: js.JSHandle, includeType: boolean): string { - const payload = handle._remoteObject; - if (payload.objectId) - return 'JSHandle@' + (payload.subtype || payload.type); - return (includeType ? 'JSHandle:' : '') + deserializeValue(payload as Protocol.Runtime.RemoteObject); - } - - private _toCallArgument(payload: any): any { - return { value: payload.value, unserializableValue: payload.unserializableValue, objectId: payload.objectId }; + if (handle._objectId) { + return await this._callOnUtilityScript(handle._context, + `jsonValue`, [ + { objectId: handle._objectId, value: undefined }, + ], true, () => {}); + } + return handle._value; } } @@ -151,18 +126,6 @@ function checkException(exceptionDetails?: Protocol.Runtime.ExceptionDetails) { throw new Error('Evaluation failed: ' + exceptionDetails.text + '\n' + exceptionDetails.stack); } -export function deserializeValue({unserializableValue, value}: Protocol.Runtime.RemoteObject) { - if (unserializableValue === 'Infinity') - return Infinity; - if (unserializableValue === '-Infinity') - return -Infinity; - if (unserializableValue === '-0') - return -0; - if (unserializableValue === 'NaN') - return NaN; - return value; -} - function rewriteError(error: Error): (Protocol.Runtime.evaluateReturnValue | Protocol.Runtime.callFunctionReturnValue) { if (error.message.includes('cyclic object value') || error.message.includes('Object is not serializable')) return {result: {type: 'undefined', value: undefined}}; diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index 95b4b3fb3c..426cde0c02 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -373,7 +373,7 @@ export class FFPage implements PageDelegate { async getContentFrame(handle: dom.ElementHandle): Promise { const { contentFrameId } = await this._session.send('Page.describeNode', { frameId: handle._context.frame._id, - objectId: handle._remoteObject.objectId!, + objectId: handle._objectId!, }); if (!contentFrameId) return null; @@ -383,7 +383,7 @@ export class FFPage implements PageDelegate { async getOwnerFrame(handle: dom.ElementHandle): Promise { const { ownerFrameId } = await this._session.send('Page.describeNode', { frameId: handle._context.frame._id, - objectId: handle._remoteObject.objectId!, + objectId: handle._objectId!, }); return ownerFrameId || null; } @@ -414,7 +414,7 @@ export class FFPage implements PageDelegate { async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> { return await this._session.send('Page.scrollIntoViewIfNeeded', { frameId: handle._context.frame._id, - objectId: handle._remoteObject.objectId!, + objectId: handle._objectId!, rect, }).then(() => 'success' as const).catch(e => { if (e instanceof Error && e.message.includes('Node is detached from document')) @@ -433,7 +433,7 @@ export class FFPage implements PageDelegate { async getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._session.send('Page.getContentQuads', { frameId: handle._context.frame._id, - objectId: handle._remoteObject.objectId!, + objectId: handle._objectId!, }).catch(logError(this._page)); if (!result) return null; @@ -452,7 +452,7 @@ export class FFPage implements PageDelegate { async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { const result = await this._session.send('Page.adoptNode', { frameId: handle._context.frame._id, - objectId: handle._remoteObject.objectId!, + objectId: handle._objectId!, executionContextId: (to._delegate as FFExecutionContext)._executionContextId }); if (!result.remoteObject) diff --git a/src/injected/utilityScript.ts b/src/injected/utilityScript.ts index d216abb856..8e75f09b80 100644 --- a/src/injected/utilityScript.ts +++ b/src/injected/utilityScript.ts @@ -14,44 +14,38 @@ * limitations under the License. */ +import { serializeAsCallArgument, parseEvaluationResultValue } from '../remoteObject'; + export default class UtilityScript { evaluate(returnByValue: boolean, expression: string) { const result = global.eval(expression); - return returnByValue ? this._serialize(result) : result; + return returnByValue ? this._promiseAwareJsonValueNoThrow(result) : result; } callFunction(returnByValue: boolean, functionText: string, ...args: any[]) { const argCount = args[0] as number; - const handleCount = args[argCount + 1] as number; - const handles = { __proto__: null } as any; - for (let i = 0; i < handleCount; i++) - handles[args[argCount + 2 + i]] = args[argCount + 2 + handleCount + i]; - const visit = (arg: any) => { - if ((typeof arg === 'string') && (arg in handles)) - return handles[arg]; - if (arg && (typeof arg === 'object')) { - for (const name of Object.keys(arg)) - arg[name] = visit(arg[name]); - } - return arg; - }; - const processedArgs = []; - for (let i = 0; i < argCount; i++) - processedArgs[i] = visit(args[i + 1]); + const handles = args.slice(argCount + 1); + const parameters = args.slice(1, argCount + 1).map(a => parseEvaluationResultValue(a, handles)); const func = global.eval('(' + functionText + ')'); - const result = func(...processedArgs); - return returnByValue ? this._serialize(result) : result; + const result = func(...parameters); + return returnByValue ? this._promiseAwareJsonValueNoThrow(result) : result; } - private _serialize(value: any): any { - if (value instanceof Error) { - const error = value; - if ('captureStackTrace' in global.Error) { - // v8 - return error.stack; + jsonValue(returnByValue: true, value: any) { + return serializeAsCallArgument(value, (value: any) => ({ fallThrough: value })); + } + + private _promiseAwareJsonValueNoThrow(value: any) { + const safeJson = (value: any) => { + try { + return this.jsonValue(true, value); + } catch (e) { + return undefined; } - return `${error.name}: ${error.message}\n${error.stack}`; - } - return value; + }; + + if (value && typeof value === 'object' && typeof value.then === 'function') + return value.then(safeJson); + return safeJson(value); } } diff --git a/src/javascript.ts b/src/javascript.ts index 1f87c22bb4..9b44093514 100644 --- a/src/javascript.ts +++ b/src/javascript.ts @@ -16,17 +16,16 @@ import * as types from './types'; import * as dom from './dom'; -import { helper } from './helper'; import * as utilityScriptSource from './generated/utilityScriptSource'; import { InnerLogger } from './logger'; import * as debugSupport from './debug/debugSupport'; +import { RemoteObject, serializeAsCallArgument } from './remoteObject'; export interface ExecutionContextDelegate { evaluate(context: ExecutionContext, returnByValue: boolean, pageFunction: string | Function, ...args: any[]): Promise; rawEvaluate(pageFunction: string): Promise; getProperties(handle: JSHandle): Promise>; releaseHandle(handle: JSHandle): Promise; - handleToString(handle: JSHandle, includeType: boolean): string; handleJSONValue(handle: JSHandle): Promise; } @@ -68,26 +67,26 @@ export class ExecutionContext { return this._utilityScriptPromise; } - createHandle(remoteObject: any): JSHandle { + createHandle(remoteObject: RemoteObject): JSHandle { return new JSHandle(this, remoteObject); } } -export type RemoteObject = { - type?: string, - subtype?: string, - objectId?: string, - value?: any -}; - export class JSHandle { readonly _context: ExecutionContext; - readonly _remoteObject: RemoteObject; _disposed = false; + readonly _objectId: string | undefined; + readonly _value: any; + private _type: string; constructor(context: ExecutionContext, remoteObject: RemoteObject) { this._context = context; - this._remoteObject = remoteObject; + this._objectId = remoteObject.objectId; + // Remote objects for primitive (or unserializable) objects carry value. + this._value = potentiallyUnserializableValue(remoteObject); + // WebKit does not have a 'promise' type. + const isPromise = remoteObject.className === 'Promise'; + this._type = isPromise ? 'promise' : remoteObject.subtype || remoteObject.type || 'object'; } async evaluate(pageFunction: types.FuncOn, arg: Arg): Promise; @@ -133,16 +132,26 @@ export class JSHandle { await this._context._delegate.releaseHandle(this); } + _handleToString(includeType: boolean): string { + if (this._objectId) + return 'JSHandle@' + this._type; + return (includeType ? 'JSHandle:' : '') + this._value; + } + toString(): string { - return this._context._delegate.handleToString(this, true /* includeType */); + return this._handleToString(true); } } -export async function prepareFunctionCall( +type CallArgument = { + value?: any, + objectId?: string +} + +export async function prepareFunctionCall( pageFunction: Function, context: ExecutionContext, - args: any[], - toCallArgumentIfNeeded: (value: any) => { handle?: T, value?: any }): Promise<{ functionText: string, values: any[], handles: T[], dispose: () => void }> { + args: any[]): Promise<{ functionText: string, values: any[], handles: CallArgument[], dispose: () => void }> { const originalText = pageFunction.toString(); let functionText = originalText; @@ -163,78 +172,55 @@ export async function prepareFunctionCall( } } - const guids: string[] = []; - const handles: (Promise)[] = []; + const handles: (Promise)[] = []; const toDispose: Promise[] = []; - const pushHandle = (handle: Promise): string => { - const guid = helper.guid(); - guids.push(guid); + const pushHandle = (handle: Promise): number => { handles.push(handle); - return guid; + return handles.length - 1; }; - const visited = new Set(); - let error: string | undefined; - const visit = (arg: any, depth: number): any => { - if (!depth) { - error = 'Argument nesting is too deep'; - return; - } - if (visited.has(arg)) { - error = 'Argument is a circular structure'; - return; - } - if (Array.isArray(arg)) { - visited.add(arg); - const result = []; - for (let i = 0; i < arg.length; ++i) - result.push(visit(arg[i], depth - 1)); - visited.delete(arg); - return result; - } - if (arg && (typeof arg === 'object') && !(arg instanceof JSHandle)) { - visited.add(arg); - const result: any = {}; - for (const name of Object.keys(arg)) - result[name] = visit(arg[name], depth - 1); - visited.delete(arg); - return result; - } - if (arg && (arg instanceof JSHandle)) { - if (arg._disposed) - throw new Error('JSHandle is disposed!'); - const adopted = context.adoptIfNeeded(arg); - if (adopted === null) - return pushHandle(Promise.resolve(arg)); - toDispose.push(adopted); - return pushHandle(adopted); - } - const { handle, value } = toCallArgumentIfNeeded(arg); - if (handle) - return pushHandle(Promise.resolve(handle)); - return value; - }; - - args = args.map(arg => visit(arg, 100)); - if (error) - throw new Error(error); - - const resolved = await Promise.all(handles); - const resultHandles: T[] = []; - for (let i = 0; i < resolved.length; i++) { - const handle = resolved[i]; + args = args.map(arg => serializeAsCallArgument(arg, (handle: any): { h?: number, fallThrough?: any } => { if (handle instanceof JSHandle) { - if (handle._context !== context) - throw new Error('JSHandles can be evaluated only in the context they were created!'); - resultHandles.push(toCallArgumentIfNeeded(handle).handle!); - } else { - resultHandles.push(handle); + if (!handle._objectId) + return { fallThrough: handle._value }; + if (handle._disposed) + throw new Error('JSHandle is disposed!'); + const adopted = context.adoptIfNeeded(handle); + if (adopted === null) + return { h: pushHandle(Promise.resolve(handle)) }; + toDispose.push(adopted); + return { h: pushHandle(adopted) }; } + return { fallThrough: handle }; + })); + const resultHandles: CallArgument[] = []; + for (const handle of await Promise.all(handles)) { + if (handle._context !== context) + throw new Error('JSHandles can be evaluated only in the context they were created!'); + resultHandles.push({ objectId: handle._objectId }); } const dispose = () => { toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose())); }; functionText += await debugSupport.generateSourceMapUrl(originalText, functionText); - return { functionText, values: [ args.length, ...args, guids.length, ...guids ], handles: resultHandles, dispose }; + return { functionText, values: [ args.length, ...args ], handles: resultHandles, dispose }; +} + +function potentiallyUnserializableValue(remoteObject: RemoteObject): any { + const value = remoteObject.value; + let unserializableValue = remoteObject.unserializableValue; + if (remoteObject.type === 'number' && value === null) + unserializableValue = remoteObject.description; + if (!unserializableValue) + return value; + if (unserializableValue === 'NaN') + return NaN; + if (unserializableValue === 'Infinity') + return Infinity; + if (unserializableValue === '-Infinity') + return -Infinity; + if (unserializableValue === '-0') + return -0; + return undefined; } diff --git a/src/remoteObject.ts b/src/remoteObject.ts new file mode 100644 index 0000000000..1b2da9b2dc --- /dev/null +++ b/src/remoteObject.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * 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. + */ + +// This file can't have dependencies, it is a part of the utility script. + +export type RemoteObject = { + type?: string, + subtype?: string, + className?: string, + objectId?: string, + value?: any, + unserializableValue?: string + description?: string +}; + +export function parseEvaluationResultValue(value: any, handles: any[] = []): any { + // { type: 'undefined' } does not even have value. + if (value === 'undefined') + return undefined; + if (typeof value === 'object') { + if (value.v === 'undefined') + return undefined; + if (value.v === null) + return null; + if (value.v === 'NaN') + return NaN; + if (value.v === 'Infinity') + return Infinity; + if (value.v === '-Infinity') + return -Infinity; + if (value.v === '-0') + return -0; + if (value.d) + return new Date(value.d); + if (value.r) + return new RegExp(value.r[0], value.r[1]); + if (value.a) + return value.a.map((a: any) => parseEvaluationResultValue(a, handles)); + if (value.o) { + for (const name of Object.keys(value.o)) + value.o[name] = parseEvaluationResultValue(value.o[name], handles); + return value.o; + } + if (typeof value.h === 'number') + return handles[value.h]; + } + return value; +} + +export function serializeAsCallArgument(value: any, jsHandleSerializer: (value: any) => { fallThrough?: any }): any { + return serialize(value, jsHandleSerializer, new Set()); +} + +function serialize(value: any, jsHandleSerializer: (value: any) => { fallThrough?: any }, visited: Set): any { + if (value && typeof value === 'object' && typeof value.then === 'function') + return value; + + const result = jsHandleSerializer(value); + if ('fallThrough' in result) + value = result.fallThrough; + else + return result; + + if (visited.has(value)) + throw new Error('Argument is a circular structure'); + if (typeof value === 'symbol') + return { v: 'undefined' }; + if (Object.is(value, undefined)) + return { v: 'undefined' }; + if (Object.is(value, null)) + return { v: null }; + if (Object.is(value, NaN)) + return { v: 'NaN' }; + if (Object.is(value, Infinity)) + return { v: 'Infinity' }; + if (Object.is(value, -Infinity)) + return { v: '-Infinity' }; + if (Object.is(value, -0)) + return { v: '-0' }; + if (isPrimitiveValue(value)) + return value; + + if (value instanceof Error) { + const error = value; + if ('captureStackTrace' in global.Error) { + // v8 + return error.stack; + } + return `${error.name}: ${error.message}\n${error.stack}`; + } + if (value instanceof Date) + return { d: value.toJSON() }; + if (value instanceof RegExp) + return { r: [ value.source, value.flags ] }; + + if (Array.isArray(value)) { + const result = []; + visited.add(value); + for (let i = 0; i < value.length; ++i) + result.push(serialize(value[i], jsHandleSerializer, visited)); + visited.delete(value); + return { a: result }; + } + + if (typeof value === 'object') { + const result: any = {}; + visited.add(value); + for (const name of Object.keys(value)) { + let item; + try { + item = value[name]; + } catch (e) { + continue; // native bindings will throw sometimes + } + if (name === 'toJSON' && typeof item === 'function') + result[name] = {}; + else + result[name] = serialize(item, jsHandleSerializer, visited); + } + visited.delete(value); + return { o: result }; + } +} + +export function isPrimitiveValue(value: any): boolean { + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + return true; + default: + return false; + } +} diff --git a/src/webkit/wkAccessibility.ts b/src/webkit/wkAccessibility.ts index 4c7e7e362d..5bcc413add 100644 --- a/src/webkit/wkAccessibility.ts +++ b/src/webkit/wkAccessibility.ts @@ -19,7 +19,7 @@ import { Protocol } from './protocol'; import * as dom from '../dom'; export async function getAccessibilityTree(session: WKSession, needle?: dom.ElementHandle) { - const objectId = needle ? needle._remoteObject.objectId : undefined; + const objectId = needle ? needle._objectId : undefined; const {axNode} = await session.send('Page.accessibilitySnapshot', { objectId }); const tree = new WKAXNode(axNode); return { diff --git a/src/webkit/wkExecutionContext.ts b/src/webkit/wkExecutionContext.ts index ab8eadb225..8715fd1460 100644 --- a/src/webkit/wkExecutionContext.ts +++ b/src/webkit/wkExecutionContext.ts @@ -17,12 +17,10 @@ import { WKSession, isSwappedOutError } from './wkConnection'; import { helper } from '../helper'; -import { valueFromRemoteObject, releaseObject } from './wkProtocolHelper'; import { Protocol } from './protocol'; import * as js from '../javascript'; import * as debugSupport from '../debug/debugSupport'; - -type MaybeCallArgument = Protocol.Runtime.CallArgument | { unserializable: any }; +import { RemoteObject, parseEvaluationResultValue } from '../remoteObject'; export class WKExecutionContext implements js.ExecutionContextDelegate { private readonly _session: WKSession; @@ -42,7 +40,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { this._contextDestroyedCallback(); } - async rawEvaluate(expression: string): Promise { + async rawEvaluate(expression: string): Promise { const contextId = this._contextId; const response = await this._session.send('Runtime.evaluate', { expression: debugSupport.ensureSourceUrl(expression), @@ -71,8 +69,8 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { if (!returnByValue) return context.createHandle(response.result); if (response.result.objectId) - return await this._returnObjectByValue(response.result.objectId); - return valueFromRemoteObject(response.result); + return await this._returnObjectByValue(context, response.result.objectId); + return parseEvaluationResultValue(response.result.value); } catch (error) { if (isSwappedOutError(error) || error.message.includes('Missing injected script for given')) throw new Error('Execution context was destroyed, most likely because of a navigation.'); @@ -86,8 +84,10 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { const functionDeclaration = `function (returnByValue, pageFunction) { return this.evaluate(returnByValue, pageFunction); }` + debugSupport.generateSourceUrl(); return await this._session.send('Runtime.callFunctionOn', { functionDeclaration, - objectId: utilityScript._remoteObject.objectId!, - arguments: [ { value: returnByValue }, { value: debugSupport.ensureSourceUrl(pageFunction) } ], + objectId: utilityScript._objectId!, + arguments: [ + { value: returnByValue }, + { value: debugSupport.ensureSourceUrl(pageFunction) } ], returnByValue: false, // We need to return real Promise if that is a promise. emulateUserGesture: true }); @@ -96,27 +96,19 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { if (typeof pageFunction !== 'function') throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); - const { functionText, values, handles, dispose } = await js.prepareFunctionCall(pageFunction, context, args, (value: any) => { - if (typeof value === 'bigint' || Object.is(value, -0) || Object.is(value, Infinity) || Object.is(value, -Infinity) || Object.is(value, NaN)) - return { handle: { unserializable: value } }; - if (value && (value instanceof js.JSHandle)) { - const remoteObject = value._remoteObject; - if (!remoteObject.objectId && !Object.is(valueFromRemoteObject(remoteObject), remoteObject.value)) - return { handle: { unserializable: value } }; - if (!remoteObject.objectId) - return { handle: { value: valueFromRemoteObject(remoteObject) } }; - return { handle: { objectId: remoteObject.objectId } }; - } - return { value }; - }); + const { functionText, values, handles, dispose } = await js.prepareFunctionCall(pageFunction, context, args); try { const utilityScript = await context.utilityScript(); - const callParams = this._serializeFunctionAndArguments(functionText, values, handles, returnByValue); return await this._session.send('Runtime.callFunctionOn', { - functionDeclaration: callParams.functionText + debugSupport.generateSourceUrl(), - objectId: utilityScript._remoteObject.objectId!, - arguments: callParams.callArguments, + functionDeclaration: `function (...args) { return this.callFunction(...args) }` + debugSupport.generateSourceUrl(), + objectId: utilityScript._objectId!, + arguments: [ + { value: returnByValue }, + { value: functionText }, + ...values.map(value => ({ value })), + ...handles, + ], returnByValue: false, // We need to return real Promise if that is a promise. emulateUserGesture: true }); @@ -125,56 +117,19 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { } } - private _serializeFunctionAndArguments(originalText: string, values: any[], handles: MaybeCallArgument[], returnByValue: boolean): { functionText: string, callArguments: Protocol.Runtime.CallArgument[]} { - const callArguments: Protocol.Runtime.CallArgument[] = values.map(value => ({ value })); - let functionText = `function (returnByValue, functionText, ...args) { return this.callFunction(returnByValue, functionText, ...args); }`; - if (handles.some(handle => 'unserializable' in handle)) { - const paramStrings = []; - for (let i = 0; i < callArguments.length; i++) - paramStrings.push('a[' + i + ']'); - for (const handle of handles) { - if ('unserializable' in handle) { - paramStrings.push(unserializableToString(handle.unserializable)); - } else { - paramStrings.push('a[' + callArguments.length + ']'); - callArguments.push(handle); - } - } - functionText = `function (returnByValue, functionText, ...a) { return this.callFunction(returnByValue, functionText, ${paramStrings.join(',')}); }`; - } else { - callArguments.push(...(handles as Protocol.Runtime.CallArgument[])); - } - return { functionText, callArguments: [ { value: returnByValue }, { value: originalText }, ...callArguments ] }; - - function unserializableToString(arg: any) { - if (Object.is(arg, -0)) - return '-0'; - if (Object.is(arg, Infinity)) - return 'Infinity'; - if (Object.is(arg, -Infinity)) - return '-Infinity'; - if (Object.is(arg, NaN)) - return 'NaN'; - if (arg instanceof js.JSHandle) { - const remoteObj = arg._remoteObject; - if (!remoteObj.objectId) - return valueFromRemoteObject(remoteObj); - } - throw new Error('Unsupported value: ' + arg + ' (' + (typeof arg) + ')'); - } - } - - private async _returnObjectByValue(objectId: Protocol.Runtime.RemoteObjectId): Promise { + private async _returnObjectByValue(context: js.ExecutionContext, objectId: Protocol.Runtime.RemoteObjectId): Promise { + // This is different from handleJSONValue in that it does not throw. try { + const utilityScript = await context.utilityScript(); const serializeResponse = await this._session.send('Runtime.callFunctionOn', { - // Serialize object using standard JSON implementation to correctly pass 'undefined'. - functionDeclaration: 'function(){return this}\n' + debugSupport.generateSourceUrl(), - objectId: objectId, + functionDeclaration: 'object => object' + debugSupport.generateSourceUrl(), + objectId: utilityScript._objectId!, + arguments: [ { objectId } ], returnByValue: true }); if (serializeResponse.wasThrown) return undefined; - return serializeResponse.result.value; + return parseEvaluationResultValue(serializeResponse.result.value); } catch (e) { if (isSwappedOutError(e)) return contextDestroyedResult; @@ -183,7 +138,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { } async getProperties(handle: js.JSHandle): Promise> { - const objectId = handle._remoteObject.objectId; + const objectId = handle._objectId; if (!objectId) return new Map(); const response = await this._session.send('Runtime.getProperties', { @@ -192,7 +147,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { }); const result = new Map(); for (const property of response.properties) { - if (!property.enumerable) + if (!property.enumerable || !property.value) continue; result.set(property.name, handle._context.createHandle(property.value)); } @@ -200,32 +155,25 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { } async releaseHandle(handle: js.JSHandle): Promise { - await releaseObject(this._session, handle._remoteObject); + if (!handle._objectId) + return; + await this._session.send('Runtime.releaseObject', {objectId: handle._objectId}).catch(error => {}); } async handleJSONValue(handle: js.JSHandle): Promise { - const remoteObject = handle._remoteObject; - if (remoteObject.objectId) { + if (handle._objectId) { + const utilityScript = await handle._context.utilityScript(); const response = await this._session.send('Runtime.callFunctionOn', { - functionDeclaration: 'function() { return this; }' + debugSupport.generateSourceUrl(), - objectId: remoteObject.objectId, + functionDeclaration: 'function (object) { return this.jsonValue(true, object); }' + debugSupport.generateSourceUrl(), + objectId: utilityScript._objectId!, + arguments: [ { objectId: handle._objectId } ], returnByValue: true }); - return valueFromRemoteObject(response.result); + if (response.wasThrown) + throw new Error('Evaluation failed: ' + response.result.description); + return parseEvaluationResultValue(response.result.value); } - return valueFromRemoteObject(remoteObject); - } - - handleToString(handle: js.JSHandle, includeType: boolean): string { - const object = handle._remoteObject as Protocol.Runtime.RemoteObject; - if (object.objectId) { - let type: string = object.subtype || object.type; - // FIXME: promise doesn't have special subtype in WebKit. - if (object.className === 'Promise') - type = 'promise'; - return 'JSHandle@' + type; - } - return (includeType ? 'JSHandle:' : '') + valueFromRemoteObject(object); + return handle._value; } } diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index e06ef0e4c7..7e91dc5cba 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -710,7 +710,7 @@ export class WKPage implements PageDelegate { async getContentFrame(handle: dom.ElementHandle): Promise { const nodeInfo = await this._session.send('DOM.describeNode', { - objectId: toRemoteObject(handle).objectId! + objectId: handle._objectId! }); if (!nodeInfo.contentFrameId) return null; @@ -718,11 +718,10 @@ export class WKPage implements PageDelegate { } async getOwnerFrame(handle: dom.ElementHandle): Promise { - const remoteObject = toRemoteObject(handle); - if (!remoteObject.objectId) + if (!handle._objectId) return null; const nodeInfo = await this._session.send('DOM.describeNode', { - objectId: remoteObject.objectId + objectId: handle._objectId }); return nodeInfo.ownerFrameId || null; } @@ -752,7 +751,7 @@ export class WKPage implements PageDelegate { async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> { return await this._session.send('DOM.scrollIntoViewIfNeeded', { - objectId: toRemoteObject(handle).objectId!, + objectId: handle._objectId!, rect, }).then(() => 'success' as const).catch(e => { if (e instanceof Error && e.message.includes('Node does not have a layout object')) @@ -772,7 +771,7 @@ export class WKPage implements PageDelegate { async getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._session.send('DOM.getContentQuads', { - objectId: toRemoteObject(handle).objectId! + objectId: handle._objectId! }).catch(logError(this._page)); if (!result) return null; @@ -789,13 +788,13 @@ export class WKPage implements PageDelegate { } async setInputFiles(handle: dom.ElementHandle, files: types.FilePayload[]): Promise { - const objectId = toRemoteObject(handle).objectId!; + const objectId = handle._objectId!; await this._session.send('DOM.setInputFiles', { objectId, files: dom.toFileTransferPayload(files) }); } async adoptElementHandle(handle: dom.ElementHandle, to: dom.FrameExecutionContext): Promise> { const result = await this._session.send('DOM.resolveNode', { - objectId: toRemoteObject(handle).objectId, + objectId: handle._objectId!, executionContextId: (to._delegate as WKExecutionContext)._contextId }).catch(logError(this._page)); if (!result || result.object.subtype === 'null') @@ -927,7 +926,3 @@ export class WKPage implements PageDelegate { await this._pageProxySession.send('Emulation.resetPermissions', {}); } } - -function toRemoteObject(handle: dom.ElementHandle): Protocol.Runtime.RemoteObject { - return handle._remoteObject as Protocol.Runtime.RemoteObject; -} diff --git a/src/webkit/wkProtocolHelper.ts b/src/webkit/wkProtocolHelper.ts deleted file mode 100644 index 8b61002b47..0000000000 --- a/src/webkit/wkProtocolHelper.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright 2017 Google Inc. All rights reserved. - * Modifications copyright (c) Microsoft Corporation. - * - * 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. - */ - -import { assert } from '../helper'; -import { WKSession } from './wkConnection'; -import { Protocol } from './protocol'; -import * as js from '../javascript'; - -export function valueFromRemoteObject(ro: js.RemoteObject): any { - const remoteObject = ro as Protocol.Runtime.RemoteObject; - assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); - if (remoteObject.type === 'number') { - if (remoteObject.value === null) { - switch (remoteObject.description) { - case 'NaN': - return NaN; - case 'Infinity': - return Infinity; - case '-Infinity': - return -Infinity; - default: - throw new Error('Unsupported unserializable value: ' + remoteObject.description); - } - } else if (remoteObject.value === 0) { - switch (remoteObject.description) { - case '-0': - return -0; - } - } - } - return remoteObject.value; -} - -export async function releaseObject(client: WKSession, remoteObject: js.RemoteObject) { - if (!remoteObject.objectId) - return; - await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => {}); -} - diff --git a/test/evaluation.spec.js b/test/evaluation.spec.js index 5b26e34784..7ef807f556 100644 --- a/test/evaluation.spec.js +++ b/test/evaluation.spec.js @@ -40,6 +40,44 @@ describe('Page.evaluate', function() { const result = await page.evaluate(a => a, -Infinity); expect(Object.is(result, -Infinity)).toBe(true); }); + it('should roundtrip unserializable values', async({page}) => { + const value = { + infinity: Infinity, + nInfinity: -Infinity, + nZero: -0, + nan: NaN, + }; + const result = await page.evaluate(value => value, value); + expect(result).toEqual(value); + }); + it('should roundtrip promise to value', async({page}) => { + { + const result = await page.evaluate(value => Promise.resolve(value), null); + expect(result === null).toBeTruthy(); + } + { + const result = await page.evaluate(value => Promise.resolve(value), Infinity); + expect(result === Infinity).toBeTruthy(); + } + { + const result = await page.evaluate(value => Promise.resolve(value), -0); + expect(result === -0).toBeTruthy(); + } + { + const result = await page.evaluate(value => Promise.resolve(value), undefined); + expect(result === undefined).toBeTruthy(); + } + }); + it('should roundtrip promise to unserializable values', async({page}) => { + const value = { + infinity: Infinity, + nInfinity: -Infinity, + nZero: -0, + nan: NaN, + }; + const result = await page.evaluate(value => Promise.resolve(value), value); + expect(result).toEqual(value); + }); it('should transfer arrays', async({page, server}) => { const result = await page.evaluate(a => a, [1, 2, 3]); expect(result).toEqual([1,2,3]); @@ -61,7 +99,7 @@ describe('Page.evaluate', function() { expect(await page.evaluate('globalVar')).toBe(123); }); it('should return undefined for objects with symbols', async({page, server}) => { - expect(await page.evaluate(() => [Symbol('foo4')])).toBe(undefined); + expect(await page.evaluate(() => [Symbol('foo4')])).toEqual([undefined]); expect(await page.evaluate(() => { const a = { }; a[Symbol('foo4')] = 42; @@ -69,7 +107,7 @@ describe('Page.evaluate', function() { })).toEqual({}); expect(await page.evaluate(() => { return { foo: [{ a: Symbol('foo4') }] }; - })).toBe(undefined); + })).toEqual({ foo: [ { a: undefined } ] }); }); it('should work with function shorthands', async({page, server}) => { const a = { @@ -287,7 +325,8 @@ describe('Page.evaluate', function() { const result = await page.evaluate(() => ({abc: 123})); expect(result).toEqual({abc: 123}); }); - it('should await promise from popup', async function({page, server}) { + it.fail(FFOX)('should await promise from popup', async function({page, server}) { + // Something is wrong about the way Firefox waits for the chained promise await page.goto(server.EMPTY_PAGE); const result = await page.evaluate(() => { const win = window.open('about:blank'); @@ -339,19 +378,29 @@ describe('Page.evaluate', function() { const error = await page.evaluate(`new Error('error message')`); expect(error).toContain('Error: error message'); }); - it('should evaluate date as {}', async({page}) => { - const result = await page.evaluate(() => ({ date: new Date() })); - expect(result).toEqual({ date: {} }); + it('should evaluate date', async({page}) => { + const result = await page.evaluate(() => ({ date: new Date('2020-05-27T01:31:38.506Z') })); + expect(result).toEqual({ date: new Date('2020-05-27T01:31:38.506Z') }); }); - it('should jsonValue() date as {}', async({page}) => { - const resultHandle = await page.evaluateHandle(() => ({ date: new Date() })); - expect(await resultHandle.jsonValue()).toEqual({ date: {} }); + it('should roundtrip date', async({page}) => { + const date = new Date('2020-05-27T01:31:38.506Z'); + const result = await page.evaluate(date => date, date); + expect(result.toUTCString()).toEqual(date.toUTCString()); }); - it.fail(FFOX)('should not use toJSON when evaluating', async({page, server}) => { + it('should roundtrip regex', async({page}) => { + const regex = /hello/im; + const result = await page.evaluate(regex => regex, regex); + expect(result.toString()).toEqual(regex.toString()); + }); + it('should jsonValue() date', async({page}) => { + const resultHandle = await page.evaluateHandle(() => ({ date: new Date('2020-05-27T01:31:38.506Z') })); + expect(await resultHandle.jsonValue()).toEqual({ date: new Date('2020-05-27T01:31:38.506Z') }); + }); + it('should not use toJSON when evaluating', async({page, server}) => { const result = await page.evaluate(() => ({ toJSON: () => 'string', data: 'data' })); expect(result).toEqual({ data: 'data', toJSON: {} }); }); - it.fail(FFOX)('should not use toJSON in jsonValue', async({page, server}) => { + it('should not use toJSON in jsonValue', async({page, server}) => { const resultHandle = await page.evaluateHandle(() => ({ toJSON: () => 'string', data: 'data' })); expect(await resultHandle.jsonValue()).toEqual({ data: 'data', toJSON: {} }); }); diff --git a/test/jshandle.spec.js b/test/jshandle.spec.js index 4ba304143b..4c0e3a2aff 100644 --- a/test/jshandle.spec.js +++ b/test/jshandle.spec.js @@ -58,16 +58,6 @@ describe('Page.evaluateHandle', function() { a2: { bar: 5, arr: [{ baz: ['baz'] }] } }); }); - it('should throw for deep objects', async({page, server}) => { - let a = { x: 1 }; - for (let i = 0; i < 98; i++) - a = { x: a }; - expect(await page.evaluate(x => x, a)).toEqual(a); - let error = await page.evaluate(x => x, {a}).catch(e => e); - expect(error.message).toBe('Argument nesting is too deep'); - error = await page.evaluate(x => x, [a]).catch(e => e); - expect(error.message).toBe('Argument nesting is too deep'); - }); it('should throw for circular objects', async({page, server}) => { const a = { x: 1 }; a.y = a; @@ -137,7 +127,23 @@ describe('JSHandle.getProperty', function() { expect(await nullHandle.jsonValue()).toEqual(null); const emptyhandle = await aHandle.getProperty('empty'); expect(String(await emptyhandle.jsonValue())).toEqual('undefined'); - }) + }); + it('should work with unserializable values', async({page, server}) => { + const aHandle = await page.evaluateHandle(() => ({ + infinity: Infinity, + nInfinity: -Infinity, + nan: NaN, + nzero: -0 + })); + const infinityHandle = await aHandle.getProperty('infinity'); + expect(await infinityHandle.jsonValue()).toEqual(Infinity); + const nInfinityHandle = await aHandle.getProperty('nInfinity'); + expect(await nInfinityHandle.jsonValue()).toEqual(-Infinity); + const nanHandle = await aHandle.getProperty('nan'); + expect(String(await nanHandle.jsonValue())).toEqual('NaN'); + const nzeroHandle = await aHandle.getProperty('nzero'); + expect(await nzeroHandle.jsonValue()).toEqual(-0); + }); }); describe('JSHandle.jsonValue', function() { @@ -155,12 +161,7 @@ describe('JSHandle.jsonValue', function() { const windowHandle = await page.evaluateHandle('window'); let error = null; await windowHandle.jsonValue().catch(e => error = e); - if (WEBKIT) - expect(error.message).toContain('Object has too long reference chain'); - else if (CHROMIUM) - expect(error.message).toContain('Object reference chain is too long'); - else if (FFOX) - expect(error.message).toContain('Object is not serializable'); + expect(error.message).toContain('Argument is a circular structure'); }); it('should work with tricky values', async({page, server}) => { const aHandle = await page.evaluateHandle(() => ({a: 1}));