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

This commit is contained in:
Pavel Feldman 2022-05-09 14:07:04 -08:00 committed by GitHub
parent 1658172b2c
commit e55f7bd896
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 190 additions and 123 deletions

View File

@ -23,19 +23,23 @@ export type SerializedValue =
{ o: { k: string, v: SerializedValue }[] } |
{ h: number };
function isRegExp(obj: any): obj is RegExp {
export type HandleOrValue = { h: number } | { fallThrough: any };
export function source(aliasComplexAndCircularObjects: boolean = false) {
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 {
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 {
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 {
function parseEvaluationResultValue(value: SerializedValue, handles: any[] = []): any {
if (Object.is(value, undefined))
return undefined;
if (typeof value === 'object' && value) {
@ -70,22 +74,48 @@ export function parseEvaluationResultValue(value: SerializedValue, handles: any[
return handles[value.h];
}
return value;
}
}
export type HandleOrValue = { h: number } | { fallThrough: any };
export function serializeAsCallArgument(value: any, handleSerializer: (value: any) => HandleOrValue): SerializedValue {
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 {
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 (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))
@ -148,4 +178,11 @@ function serialize(value: any, handleSerializer: (value: any) => HandleOrValue,
visited.delete(value);
return { o };
}
}
return { parseEvaluationResultValue, serializeAsCallArgument };
}
const result = source();
export const parseEvaluationResultValue = result.parseEvaluationResultValue;
export const serializeAsCallArgument = result.serializeAsCallArgument;

View File

@ -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;

View File

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