feat(bindings): best-effort serialize circular structures (#14008)
This commit is contained in:
Родитель
1658172b2c
Коммит
e55f7bd896
|
@ -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<any>): 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<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 { 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<typeof source>) {
|
||||
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;
|
||||
|
|
|
@ -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: <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]',
|
||||
});
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче