feat(bindings): best-effort serialize circular structures (#14008)
This commit is contained in:
parent
1658172b2c
commit
e55f7bd896
|
|
@ -23,129 +23,166 @@ export type SerializedValue =
|
||||||
{ o: { k: string, v: SerializedValue }[] } |
|
{ o: { k: string, v: SerializedValue }[] } |
|
||||||
{ h: number };
|
{ 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 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<any>): SerializedValue {
|
export function source(aliasComplexAndCircularObjects: boolean = false) {
|
||||||
const result = handleSerializer(value);
|
|
||||||
if ('fallThrough' in result)
|
|
||||||
value = result.fallThrough;
|
|
||||||
else
|
|
||||||
return result;
|
|
||||||
|
|
||||||
if (visited.has(value))
|
function isRegExp(obj: any): obj is RegExp {
|
||||||
throw new Error('Argument is a circular structure');
|
return obj instanceof RegExp || Object.prototype.toString.call(obj) === '[object RegExp]';
|
||||||
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') {
|
function isDate(obj: any): obj is Date {
|
||||||
const o: { k: string, v: SerializedValue }[] = [];
|
return obj instanceof Date || Object.prototype.toString.call(obj) === '[object Date]';
|
||||||
visited.add(value);
|
}
|
||||||
for (const name of Object.keys(value)) {
|
|
||||||
let item;
|
function isError(obj: any): obj is Error {
|
||||||
try {
|
return obj instanceof Error || (obj && obj.__proto__ && obj.__proto__.name === 'Error');
|
||||||
item = value[name];
|
}
|
||||||
} catch (e) {
|
|
||||||
continue; // native bindings will throw sometimes
|
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')
|
if ('d' in value)
|
||||||
o.push({ k: name, v: { o: [] } });
|
return new Date(value.d);
|
||||||
else
|
if ('r' in value)
|
||||||
o.push({ k: name, v: serialize(item, handleSerializer, visited) });
|
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 value;
|
||||||
return { o };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<any>): 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: <Window>';
|
||||||
|
if (globalThis.Document && value instanceof globalThis.Document)
|
||||||
|
return 'ref: <Document>';
|
||||||
|
if (globalThis.Node && value instanceof globalThis.Node)
|
||||||
|
return 'ref: <Node>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function innerSerialize(value: any, handleSerializer: (value: any) => HandleOrValue, visited: Set<any>): 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;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ import type { Artifact } from './artifact';
|
||||||
import type { TimeoutOptions } from '../common/types';
|
import type { TimeoutOptions } from '../common/types';
|
||||||
import type { ParsedSelector } from './isomorphic/selectorParser';
|
import type { ParsedSelector } from './isomorphic/selectorParser';
|
||||||
import { isInvalidSelectorError } from './isomorphic/selectorParser';
|
import { isInvalidSelectorError } from './isomorphic/selectorParser';
|
||||||
|
import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers';
|
||||||
|
import type { SerializedValue } from './isomorphic/utilityScriptSerializers';
|
||||||
|
|
||||||
export interface PageDelegate {
|
export interface PageDelegate {
|
||||||
readonly rawMouse: input.RawMouse;
|
readonly rawMouse: input.RawMouse;
|
||||||
|
|
@ -712,6 +714,12 @@ export class Worker extends SdkObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BindingPayload = {
|
||||||
|
name: string;
|
||||||
|
seq: number;
|
||||||
|
serializedArgs?: SerializedValue[],
|
||||||
|
};
|
||||||
|
|
||||||
export class PageBinding {
|
export class PageBinding {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly playwrightFunction: frames.FunctionWithSource;
|
readonly playwrightFunction: frames.FunctionWithSource;
|
||||||
|
|
@ -721,12 +729,12 @@ export class PageBinding {
|
||||||
constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) {
|
constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.playwrightFunction = playwrightFunction;
|
this.playwrightFunction = playwrightFunction;
|
||||||
this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle})`;
|
this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle}, (${source})(true))`;
|
||||||
this.needsHandle = needsHandle;
|
this.needsHandle = needsHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
|
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 {
|
try {
|
||||||
assert(context.world);
|
assert(context.world);
|
||||||
const binding = page.getBinding(name)!;
|
const binding = page.getBinding(name)!;
|
||||||
|
|
@ -735,6 +743,7 @@ export class PageBinding {
|
||||||
const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null);
|
const handle = await context.evaluateHandle(takeHandle, { name, seq }).catch(e => null);
|
||||||
result = await binding.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, handle);
|
result = await binding.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, handle);
|
||||||
} else {
|
} else {
|
||||||
|
const args = serializedArgs!.map(a => parseEvaluationResultValue(a, []));
|
||||||
result = await binding.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args);
|
result = await binding.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args);
|
||||||
}
|
}
|
||||||
context.evaluate(deliverResult, { name, seq, result }).catch(e => debugLogger.log('error', e));
|
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<typeof source>) {
|
||||||
const binding = (globalThis as any)[bindingName];
|
const binding = (globalThis as any)[bindingName];
|
||||||
if (binding.__installed)
|
if (binding.__installed)
|
||||||
return;
|
return;
|
||||||
|
|
@ -783,7 +792,7 @@ function addPageBinding(bindingName: string, needsHandle: boolean) {
|
||||||
callbacks = new Map();
|
callbacks = new Map();
|
||||||
me['callbacks'] = callbacks;
|
me['callbacks'] = callbacks;
|
||||||
}
|
}
|
||||||
const seq = (me['lastSeq'] || 0) + 1;
|
const seq: number = (me['lastSeq'] || 0) + 1;
|
||||||
me['lastSeq'] = seq;
|
me['lastSeq'] = seq;
|
||||||
let handles = me['handles'];
|
let handles = me['handles'];
|
||||||
if (!handles) {
|
if (!handles) {
|
||||||
|
|
@ -791,12 +800,17 @@ function addPageBinding(bindingName: string, needsHandle: boolean) {
|
||||||
me['handles'] = handles;
|
me['handles'] = handles;
|
||||||
}
|
}
|
||||||
const promise = new Promise((resolve, reject) => callbacks.set(seq, { resolve, reject }));
|
const promise = new Promise((resolve, reject) => callbacks.set(seq, { resolve, reject }));
|
||||||
|
let payload: BindingPayload;
|
||||||
if (needsHandle) {
|
if (needsHandle) {
|
||||||
handles.set(seq, args[0]);
|
handles.set(seq, args[0]);
|
||||||
binding(JSON.stringify({ name: bindingName, seq }));
|
payload = { name: bindingName, seq };
|
||||||
} else {
|
} 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;
|
return promise;
|
||||||
};
|
};
|
||||||
(globalThis as any)[bindingName].__installed = true;
|
(globalThis as any)[bindingName].__installed = true;
|
||||||
|
|
|
||||||
|
|
@ -284,3 +284,19 @@ it('should retain internal binding after reset', async ({ page }) => {
|
||||||
await (page as any)._removeExposedBindings();
|
await (page as any)._removeExposedBindings();
|
||||||
expect(await page.evaluate('__pw_add(5, 6)')).toBe(11);
|
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: <Window>', 'ref: <Document>', 'ref: <Node>']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue