feat(bindings): best-effort serialize circular structures (#14008)

This commit is contained in:
Pavel Feldman 2022-05-09 14:07:04 -08:00 коммит произвёл GitHub
Родитель 1658172b2c
Коммит e55f7bd896
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 190 добавлений и 123 удалений

Просмотреть файл

@ -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]',
});
});