chore: extract page bindings controller from `UtilityScript` (#35996)

This commit is contained in:
Dmitry Gozman 2025-05-20 12:43:46 +00:00 committed by GitHub
parent a88aa92d9d
commit d57f194693
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 139 additions and 77 deletions

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
import type { SerializedValue } from '@isomorphic/utilityScriptSerializers';
// This runtime guid is replaced by the actual guid at runtime in all generated sources.
const kRuntimeGuid = '$runtime_guid$';
// The name of the global playwright binding, referenced in Node.js.
const kPlaywrightBinding = `__playwright__binding__${kRuntimeGuid}`;
const kPlaywrightBindingController = `__playwright__binding__controller__${kRuntimeGuid}`;
export type BindingPayload = {
name: string;
seq: number;
serializedArgs?: SerializedValue[],
};
type BindingData = {
callbacks: Map<number, { resolve: (value: any) => void, reject: (error: Error) => void }>;
lastSeq: number;
handles: Map<number, any>;
};
class BindingsController {
// eslint-disable-next-line no-restricted-globals
private _global: typeof globalThis;
private _bindings = new Map<string, BindingData>();
// eslint-disable-next-line no-restricted-globals
constructor(global: typeof globalThis) {
this._global = global;
}
addBinding(bindingName: string, needsHandle: boolean) {
const data: BindingData = {
callbacks: new Map(),
lastSeq: 0,
handles: new Map(),
};
this._bindings.set(bindingName, data);
(this._global as any)[bindingName] = (...args: any[]) => {
if (needsHandle && args.slice(1).some(arg => arg !== undefined))
throw new Error(`exposeBindingHandle supports a single argument, ${args.length} received`);
const seq = ++data.lastSeq;
const promise = new Promise((resolve, reject) => data.callbacks.set(seq, { resolve, reject }));
let payload: BindingPayload;
if (needsHandle) {
data.handles.set(seq, args[0]);
payload = { name: bindingName, seq };
} else {
const serializedArgs = [];
for (let i = 0; i < args.length; i++) {
serializedArgs[i] = serializeAsCallArgument(args[i], v => {
return { fallThrough: v };
});
}
payload = { name: bindingName, seq, serializedArgs };
}
(this._global as any)[kPlaywrightBinding](JSON.stringify(payload));
return promise;
};
}
takeBindingHandle(arg: { name: string, seq: number }) {
const handles = this._bindings.get(arg.name)!.handles;
const handle = handles.get(arg.seq);
handles.delete(arg.seq);
return handle;
}
deliverBindingResult(arg: { name: string, seq: number, result?: any, error?: any }) {
const callbacks = this._bindings.get(arg.name)!.callbacks;
if ('error' in arg)
callbacks.get(arg.seq)!.reject(arg.error);
else
callbacks.get(arg.seq)!.resolve(arg.result);
callbacks.delete(arg.seq);
}
}
export function ensureBindingsController() {
// eslint-disable-next-line no-restricted-globals
const global = globalThis;
if (!(global as any)[kPlaywrightBindingController])
(global as any)[kPlaywrightBindingController] = new BindingsController(global);
}

View File

@ -16,8 +16,6 @@
import { parseEvaluationResultValue, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
import type { SerializedValue } from '@isomorphic/utilityScriptSerializers';
// --- This section should match javascript.ts and generated_injected_builtins.js ---
// This runtime guid is replaced by the actual guid at runtime in all generated sources.
@ -25,9 +23,6 @@ const kRuntimeGuid = '$runtime_guid$';
// This flag is replaced by true/false at runtime in all generated sources.
const kUtilityScriptIsUnderTest = false;
// The name of the global playwright binding, referenced in Node.js.
const kPlaywrightBinding = `__playwright__binding__${kRuntimeGuid}`;
// The name of the global property that stores the UtilityScript instance,
// referenced by generated_injected_builtins.js.
const kUtilityScriptGlobalProperty = `__playwright_utility_script__${kRuntimeGuid}`;
@ -56,26 +51,12 @@ export type Builtins = {
// --- End of the matching section ---
export type BindingPayload = {
name: string;
seq: number;
serializedArgs?: SerializedValue[],
};
type BindingData = {
callbacks: Map<number, { resolve: (value: any) => void, reject: (error: Error) => void }>;
lastSeq: number;
handles: Map<number, any>;
};
export class UtilityScript {
// eslint-disable-next-line no-restricted-globals
readonly global: typeof globalThis;
readonly builtins: Builtins;
readonly isUnderTest: boolean;
private _bindings = new Map<string, BindingData>();
// eslint-disable-next-line no-restricted-globals
constructor(global: typeof globalThis) {
this.global = global;
@ -134,52 +115,6 @@ export class UtilityScript {
return serializeAsCallArgument(value, (value: any) => ({ fallThrough: value }));
}
addBinding(bindingName: string, needsHandle: boolean) {
const data: BindingData = {
callbacks: new Map(),
lastSeq: 0,
handles: new Map(),
};
this._bindings.set(bindingName, data);
(this.global as any)[bindingName] = (...args: any[]) => {
if (needsHandle && args.slice(1).some(arg => arg !== undefined))
throw new Error(`exposeBindingHandle supports a single argument, ${args.length} received`);
const seq = ++data.lastSeq;
const promise = new Promise((resolve, reject) => data.callbacks.set(seq, { resolve, reject }));
let payload: BindingPayload;
if (needsHandle) {
data.handles.set(seq, args[0]);
payload = { name: bindingName, seq };
} else {
const serializedArgs = [];
for (let i = 0; i < args.length; i++) {
serializedArgs[i] = serializeAsCallArgument(args[i], v => {
return { fallThrough: v };
});
}
payload = { name: bindingName, seq, serializedArgs };
}
(this.global as any)[kPlaywrightBinding](JSON.stringify(payload));
return promise;
};
}
takeBindingHandle(arg: { name: string, seq: number }) {
const handles = this._bindings.get(arg.name)!.handles;
const handle = handles.get(arg.seq);
handles.delete(arg.seq);
return handle;
}
deliverBindingResult(arg: { name: string, seq: number, result?: any, error?: any }) {
const callbacks = this._bindings.get(arg.name)!.callbacks;
if ('error' in arg)
callbacks.get(arg.seq)!.reject(arg.error);
else
callbacks.get(arg.seq)!.resolve(arg.result);
callbacks.delete(arg.seq);
}
private _promiseAwareJsonValueNoThrow(value: any) {
const safeJson = (value: any) => {
try {

View File

@ -36,6 +36,7 @@ import { RecorderApp } from './recorder/recorderApp';
import { Tracing } from './trace/recorder/tracing';
import * as js from './javascript';
import * as rawStorageSource from '../generated/storageScriptSource';
import * as rawBindingsControllerSource from '../generated/bindingsControllerSource';
import type { Artifact } from './artifact';
import type { Browser, BrowserOptions } from './browser';
@ -90,6 +91,7 @@ export abstract class BrowserContext extends SdkObject {
private _customCloseHandler?: () => Promise<any>;
readonly _tempDirs: string[] = [];
private _settingStorageState = false;
bindingsInitScript?: InitScript;
initScripts: InitScript[] = [];
private _routesInFlight = new Set<network.Route>();
private _debugger!: Debugger;
@ -330,6 +332,17 @@ export abstract class BrowserContext extends SdkObject {
return;
this._playwrightBindingExposed = true;
await this.doExposePlaywrightBinding();
this.bindingsInitScript = new InitScript(`
(() => {
const module = {};
${js.prepareGeneratedScript(rawBindingsControllerSource.source)}
(module.exports.ensureBindingsController())();
})();
`, true /* internal */);
this.initScripts.push(this.bindingsInitScript);
await this.doAddInitScript(this.bindingsInitScript);
await this.safeNonStallingEvaluateInAllFrames(this.bindingsInitScript.source, 'main');
}
needsPlaywrightBinding() {
@ -347,8 +360,7 @@ export abstract class BrowserContext extends SdkObject {
const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding);
await this.doAddInitScript(binding.initScript);
const frames = this.pages().map(page => page.frames()).flat();
await Promise.all(frames.map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {})));
await this.safeNonStallingEvaluateInAllFrames(binding.initScript.source, 'main');
}
async _removeExposedBindings() {

View File

@ -380,6 +380,8 @@ export class FFBrowserContext extends BrowserContext {
private async _updateInitScripts() {
const bindingScripts = [...this._pageBindings.values()].map(binding => binding.initScript.source);
if (this.bindingsInitScript)
bindingScripts.unshift(this.bindingsInitScript.source);
const initScripts = this.initScripts.map(script => script.source);
await this._browser.session.send('Browser.setInitScripts', { browserContextId: this._browserContextId, scripts: [kUtilityInitScript.source, ...bindingScripts, ...initScripts].map(script => ({ script })) });
}

View File

@ -43,6 +43,11 @@ export function accessUtilityScript() {
// The name of the global playwright binding, accessed by UtilityScript.
export const kPlaywrightBinding = '__playwright__binding__' + runtimeGuid;
// Include this code in any evaluated source to get access to the BindingsController instance.
export function accessBindingsController() {
return `globalThis['__playwright__binding__controller__${runtimeGuid}']`;
}
// --- End of the matching section ---
interface TaggedAsJSHandle<T> {

View File

@ -47,7 +47,7 @@ import type * as types from './types';
import type { TimeoutOptions } from '../utils/isomorphic/types';
import type { ImageComparatorOptions } from './utils/comparators';
import type * as channels from '@protocol/channels';
import type { BindingPayload, UtilityScript } from '@injected/utilityScript';
import type { BindingPayload } from '@injected/bindingsController';
export interface PageDelegate {
readonly rawMouse: input.RawMouse;
@ -349,7 +349,7 @@ export class Page extends SdkObject {
const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding);
await this.delegate.addInitScript(binding.initScript);
await Promise.all(this.frames().map(frame => frame.evaluateExpression(binding.initScript.source).catch(e => {})));
await this.safeNonStallingEvaluateInAllFrames(binding.initScript.source, 'main');
}
private async _removeExposedBindings() {
@ -770,8 +770,10 @@ export class Page extends SdkObject {
}
allInitScripts() {
const bindings = [...this.browserContext._pageBindings.values(), ...this._pageBindings.values()];
return [kUtilityInitScript, ...bindings.map(binding => binding.initScript), ...this.browserContext.initScripts, ...this.initScripts];
const bindings = [...this.browserContext._pageBindings.values(), ...this._pageBindings.values()].map(binding => binding.initScript);
if (this.browserContext.bindingsInitScript)
bindings.unshift(this.browserContext.bindingsInitScript);
return [kUtilityInitScript, ...bindings, ...this.browserContext.initScripts, ...this.initScripts];
}
getBinding(name: string) {
@ -870,23 +872,21 @@ export class PageBinding {
constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) {
this.name = name;
this.playwrightFunction = playwrightFunction;
this.initScript = new InitScript(`${js.accessUtilityScript()}.addBinding(${JSON.stringify(name)}, ${needsHandle})`, true /* internal */);
this.initScript = new InitScript(`${js.accessBindingsController()}.addBinding(${JSON.stringify(name)}, ${needsHandle})`, true /* internal */);
this.needsHandle = needsHandle;
this.internal = name.startsWith('__pw');
}
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
const { name, seq, serializedArgs } = JSON.parse(payload) as BindingPayload;
let utilityScript: js.JSHandle<UtilityScript> | undefined;
try {
utilityScript = await context.utilityScript();
assert(context.world);
const binding = page.getBinding(name);
if (!binding)
throw new Error(`Function "${name}" is not exposed`);
let result: any;
if (binding.needsHandle) {
const handle = await utilityScript.evaluateHandle((utility, arg) => utility.takeBindingHandle(arg), { name, seq }).catch(e => null);
const handle = await context.evaluateExpressionHandle(`arg => ${js.accessBindingsController()}.takeBindingHandle(arg)`, { isFunction: true }, { name, seq }).catch(e => null);
result = await binding.playwrightFunction({ frame: context.frame, page, context: page.browserContext }, handle);
} else {
if (!Array.isArray(serializedArgs))
@ -894,9 +894,9 @@ export class PageBinding {
const args = serializedArgs!.map(a => parseEvaluationResultValue(a));
result = await binding.playwrightFunction({ frame: context.frame, page, context: page.browserContext }, ...args);
}
utilityScript.evaluate((utility, arg) => utility.deliverBindingResult(arg), { name, seq, result }).catch(e => debugLogger.log('error', e));
context.evaluateExpressionHandle(`arg => ${js.accessBindingsController()}.deliverBindingResult(arg)`, { isFunction: true }, { name, seq, result }).catch(e => debugLogger.log('error', e));
} catch (error) {
utilityScript?.evaluate((utility, arg) => utility.deliverBindingResult(arg), { name, seq, error }).catch(e => debugLogger.log('error', e));
context.evaluateExpressionHandle(`arg => ${js.accessBindingsController()}.deliverBindingResult(arg)`, { isFunction: true }, { name, seq, error }).catch(e => debugLogger.log('error', e));
}
}
}

View File

@ -56,6 +56,12 @@ const injectedScripts = [
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'injected', 'src', 'bindingsController.ts'),
path.join(ROOT, 'packages', 'injected', 'lib'),
path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'),
true,
],
[
path.join(ROOT, 'packages', 'injected', 'src', 'webSocketMock.ts'),
path.join(ROOT, 'packages', 'injected', 'lib'),