diff --git a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts index e2327539c6..62fd973b95 100644 --- a/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts +++ b/packages/playwright-core/src/server/isomorphic/utilityScriptSerializers.ts @@ -23,129 +23,166 @@ export type SerializedValue = { o: { k: string, v: SerializedValue }[] } | { h: number }; -function isRegExp(obj: any): obj is RegExp { - return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; -} - -function isDate(obj: any): obj is Date { - return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]'; -} - -function isError(obj: any): obj is Error { - return obj instanceof Error || (obj && obj.__proto__ && obj.__proto__.name === 'Error'); -} - -export function parseEvaluationResultValue(value: SerializedValue, handles: any[] = []): any { - if (Object.is(value, undefined)) - return undefined; - if (typeof value === 'object' && value) { - if ('v' in value) { - 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; - return undefined; - } - if ('d' in value) - return new Date(value.d); - if ('r' in value) - return new RegExp(value.r.p, value.r.f); - if ('a' in value) - return value.a.map((a: any) => parseEvaluationResultValue(a, handles)); - if ('o' in value) { - const result: any = {}; - for (const { k, v } of value.o) - result[k] = parseEvaluationResultValue(v, handles); - return result; - } - if ('h' in value) - return handles[value.h]; - } - return value; -} - export type HandleOrValue = { h: number } | { fallThrough: any }; -export function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue { - return serialize(value, handleSerializer, new Set()); -} -function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visited: Set): SerializedValue { - const result = handleSerializer(value); - if ('fallThrough' in result) - value = result.fallThrough; - else - return result; +export function source(aliasComplexAndCircularObjects: boolean = false) { - 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 (typeof value === 'boolean') - return value; - if (typeof value === 'number') - return value; - if (typeof value === 'string') - return value; - - if (isError(value)) { - const error = value; - if ('captureStackTrace' in globalThis.Error) { - // v8 - return error.stack || ''; - } - return `${error.name}: ${error.message}\n${error.stack}`; - } - if (isDate(value)) - return { d: value.toJSON() }; - if (isRegExp(value)) - return { r: { p: value.source, f: value.flags } }; - - if (Array.isArray(value)) { - const a = []; - visited.add(value); - for (let i = 0; i < value.length; ++i) - a.push(serialize(value[i], handleSerializer, visited)); - visited.delete(value); - return { a }; + function isRegExp(obj: any): obj is RegExp { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]'; } - if (typeof value === 'object') { - const o: { k: string, v: SerializedValue }[] = []; - visited.add(value); - for (const name of Object.keys(value)) { - let item; - try { - item = value[name]; - } catch (e) { - continue; // native bindings will throw sometimes + function isDate(obj: any): obj is Date { + return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]'; + } + + function isError(obj: any): obj is Error { + return obj instanceof Error || (obj && obj.__proto__ && obj.__proto__.name === 'Error'); + } + + function parseEvaluationResultValue(value: SerializedValue, handles: any[] = []): any { + if (Object.is(value, undefined)) + return undefined; + if (typeof value === 'object' && value) { + if ('v' in value) { + 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; + return undefined; } - if (name === 'toJSON' && typeof item === 'function') - o.push({ k: name, v: { o: [] } }); - else - o.push({ k: name, v: serialize(item, handleSerializer, visited) }); + if ('d' in value) + return new Date(value.d); + if ('r' in value) + return new RegExp(value.r.p, value.r.f); + if ('a' in value) + return value.a.map((a: any) => parseEvaluationResultValue(a, handles)); + if ('o' in value) { + const result: any = {}; + for (const { k, v } of value.o) + result[k] = parseEvaluationResultValue(v, handles); + return result; + } + if ('h' in value) + return handles[value.h]; } - visited.delete(value); - return { o }; + return value; } + + function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue { + return serialize(value, handleSerializer, new Set()); + } + + function serialize(value: any, handleSerializer: (value: any) => HandleOrValue, visited: Set): SerializedValue { + if (!aliasComplexAndCircularObjects) + return innerSerialize(value, handleSerializer, visited); + try { + const alias = serializeComplexObjectAsAlias(value); + return alias || innerSerialize(value, handleSerializer, visited); + } catch (error) { + return error.stack; + } + } + + function serializeComplexObjectAsAlias(value: any): string | undefined { + if (value && typeof value === 'object') { + if (globalThis.Window && value instanceof globalThis.Window) + return 'ref: '; + if (globalThis.Document && value instanceof globalThis.Document) + return 'ref: '; + if (globalThis.Node && value instanceof globalThis.Node) + return 'ref: '; + } + } + + function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrValue, visited: Set): SerializedValue { + const result = handleSerializer(value); + if ('fallThrough' in result) + value = result.fallThrough; + else + return result; + + if (visited.has(value)) { + if (aliasComplexAndCircularObjects) { + const alias = serializeComplexObjectAsAlias(value); + return alias || '[Circular Ref]'; + } + 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 (typeof value === 'boolean') + return value; + if (typeof value === 'number') + return value; + if (typeof value === 'string') + return value; + + if (isError(value)) { + const error = value; + if ('captureStackTrace' in globalThis.Error) { + // v8 + return error.stack || ''; + } + return `${error.name}: ${error.message}\n${error.stack}`; + } + if (isDate(value)) + return { d: value.toJSON() }; + if (isRegExp(value)) + return { r: { p: value.source, f: value.flags } }; + + if (Array.isArray(value)) { + const a = []; + visited.add(value); + for (let i = 0; i < value.length; ++i) + a.push(serialize(value[i], handleSerializer, visited)); + visited.delete(value); + return { a }; + } + + if (typeof value === 'object') { + const o: { k: string, v: SerializedValue }[] = []; + 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') + o.push({ k: name, v: { o: [] } }); + else + o.push({ k: name, v: serialize(item, handleSerializer, visited) }); + } + visited.delete(value); + return { o }; + } + } + + return { parseEvaluationResultValue, serializeAsCallArgument }; } + +const result = source(); +export const parseEvaluationResultValue = result.parseEvaluationResultValue; +export const serializeAsCallArgument = result.serializeAsCallArgument; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 2317f55812..24c051ebab 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -42,6 +42,8 @@ import type { Artifact } from './artifact'; import type { TimeoutOptions } from '../common/types'; import type { ParsedSelector } from './isomorphic/selectorParser'; import { isInvalidSelectorError } from './isomorphic/selectorParser'; +import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers'; +import type { SerializedValue } from './isomorphic/utilityScriptSerializers'; export interface PageDelegate { readonly rawMouse: input.RawMouse; @@ -712,6 +714,12 @@ export class Worker extends SdkObject { } } +type BindingPayload = { + name: string; + seq: number; + serializedArgs?: SerializedValue[], +}; + export class PageBinding { readonly name: string; readonly playwrightFunction: frames.FunctionWithSource; @@ -721,12 +729,12 @@ export class PageBinding { constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) { this.name = name; this.playwrightFunction = playwrightFunction; - this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle})`; + this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle}, (${source})(true))`; this.needsHandle = needsHandle; } static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { - const { name, seq, args } = JSON.parse(payload); + const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload; try { assert(context.world); const binding = page.getBinding(name)!; @@ -735,6 +743,7 @@ export class PageBinding { const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null); result = await binding.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, handle); } else { + const args = serializedArgs!.map(a => parseEvaluationResultValue(a, [])); result = await binding.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args); } context.evaluate(deliverResult, { name, seq, result }).catch(e => debugLogger.log('error', e)); @@ -770,7 +779,7 @@ export class PageBinding { } } -function addPageBinding(bindingName: string, needsHandle: boolean) { +function addPageBinding(bindingName: string, needsHandle: boolean, utilityScriptSerializers: ReturnType) { const binding = (globalThis as any)[bindingName]; if (binding.__installed) return; @@ -783,7 +792,7 @@ function addPageBinding(bindingName: string, needsHandle: boolean) { callbacks = new Map(); me['callbacks'] = callbacks; } - const seq = (me['lastSeq'] || 0) + 1; + const seq: number = (me['lastSeq'] || 0) + 1; me['lastSeq'] = seq; let handles = me['handles']; if (!handles) { @@ -791,12 +800,17 @@ function addPageBinding(bindingName: string, needsHandle: boolean) { me['handles'] = handles; } const promise = new Promise((resolve, reject) => callbacks.set(seq, { resolve, reject })); + let payload: BindingPayload; if (needsHandle) { handles.set(seq, args[0]); - binding(JSON.stringify({ name: bindingName, seq })); + payload = { name: bindingName, seq }; } else { - binding(JSON.stringify({ name: bindingName, seq, args })); + const serializedArgs = args.map(a => utilityScriptSerializers.serializeAsCallArgument(a, v => { + return { fallThrough: v }; + })); + payload = { name: bindingName, seq, serializedArgs }; } + binding(JSON.stringify(payload)); return promise; }; (globalThis as any)[bindingName].__installed = true; diff --git a/tests/page/page-expose-function.spec.ts b/tests/page/page-expose-function.spec.ts index 449f9ef563..d622ef89e1 100644 --- a/tests/page/page-expose-function.spec.ts +++ b/tests/page/page-expose-function.spec.ts @@ -284,3 +284,19 @@ it('should retain internal binding after reset', async ({ page }) => { await (page as any)._removeExposedBindings(); expect(await page.evaluate('__pw_add(5, 6)')).toBe(11); }); + +it('should alias Window, Document and Node', async ({ page }) => { + let object: any; + await page.exposeBinding('log', (source, obj) => object = obj); + await page.evaluate('window.log([window, document, document.body])'); + expect(object).toEqual(['ref: ', 'ref: ', 'ref: ']); +}); + +it('should trim cycles', async ({ page }) => { + let object: any; + await page.exposeBinding('log', (source, obj) => object = obj); + await page.evaluate('const a = { a: 1 }; a.a = a; window.log(a)'); + expect(object).toEqual({ + a: '[Circular Ref]', + }); +});