ExecutionContext.ts (16542B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; 10 import type {ElementHandle} from '../api/ElementHandle.js'; 11 import type {JSHandle} from '../api/JSHandle.js'; 12 import {EventEmitter} from '../common/EventEmitter.js'; 13 import {LazyArg} from '../common/LazyArg.js'; 14 import {scriptInjector} from '../common/ScriptInjector.js'; 15 import type {BindingPayload, EvaluateFunc, HandleFor} from '../common/types.js'; 16 import { 17 PuppeteerURL, 18 SOURCE_URL_REGEX, 19 debugError, 20 getSourcePuppeteerURLIfAvailable, 21 getSourceUrlComment, 22 isString, 23 } from '../common/util.js'; 24 import type PuppeteerUtil from '../injected/injected.js'; 25 import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; 26 import {DisposableStack, disposeSymbol} from '../util/disposable.js'; 27 import {stringifyFunction} from '../util/Function.js'; 28 import {Mutex} from '../util/Mutex.js'; 29 30 import {ARIAQueryHandler} from './AriaQueryHandler.js'; 31 import {Binding} from './Binding.js'; 32 import {CdpElementHandle} from './ElementHandle.js'; 33 import type {IsolatedWorld} from './IsolatedWorld.js'; 34 import {CdpJSHandle} from './JSHandle.js'; 35 import { 36 addPageBinding, 37 CDP_BINDING_PREFIX, 38 createEvaluationError, 39 valueFromRemoteObject, 40 } from './utils.js'; 41 42 const ariaQuerySelectorBinding = new Binding( 43 '__ariaQuerySelector', 44 ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown, 45 '', // custom init 46 ); 47 48 const ariaQuerySelectorAllBinding = new Binding( 49 '__ariaQuerySelectorAll', 50 (async ( 51 element: ElementHandle<Node>, 52 selector: string, 53 ): Promise<JSHandle<Node[]>> => { 54 const results = ARIAQueryHandler.queryAll(element, selector); 55 return await element.realm.evaluateHandle( 56 (...elements) => { 57 return elements; 58 }, 59 ...(await AsyncIterableUtil.collect(results)), 60 ); 61 }) as (...args: unknown[]) => unknown, 62 '', // custom init 63 ); 64 65 /** 66 * @internal 67 */ 68 export class ExecutionContext 69 extends EventEmitter<{ 70 /** Emitted when this execution context is disposed. */ 71 disposed: undefined; 72 consoleapicalled: Protocol.Runtime.ConsoleAPICalledEvent; 73 /** Emitted when a binding that is not installed by the ExecutionContext is called. */ 74 bindingcalled: Protocol.Runtime.BindingCalledEvent; 75 }> 76 implements Disposable 77 { 78 #client: CDPSession; 79 #world: IsolatedWorld; 80 #id: number; 81 #name?: string; 82 83 readonly #disposables = new DisposableStack(); 84 85 constructor( 86 client: CDPSession, 87 contextPayload: Protocol.Runtime.ExecutionContextDescription, 88 world: IsolatedWorld, 89 ) { 90 super(); 91 this.#client = client; 92 this.#world = world; 93 this.#id = contextPayload.id; 94 if (contextPayload.name) { 95 this.#name = contextPayload.name; 96 } 97 const clientEmitter = this.#disposables.use(new EventEmitter(this.#client)); 98 clientEmitter.on('Runtime.bindingCalled', this.#onBindingCalled.bind(this)); 99 clientEmitter.on('Runtime.executionContextDestroyed', async event => { 100 if (event.executionContextId === this.#id) { 101 this[disposeSymbol](); 102 } 103 }); 104 clientEmitter.on('Runtime.executionContextsCleared', async () => { 105 this[disposeSymbol](); 106 }); 107 clientEmitter.on('Runtime.consoleAPICalled', this.#onConsoleAPI.bind(this)); 108 clientEmitter.on(CDPSessionEvent.Disconnected, () => { 109 this[disposeSymbol](); 110 }); 111 } 112 113 // Contains mapping from functions that should be bound to Puppeteer functions. 114 #bindings = new Map<string, Binding>(); 115 116 // If multiple waitFor are set up asynchronously, we need to wait for the 117 // first one to set up the binding in the page before running the others. 118 #mutex = new Mutex(); 119 async #addBinding(binding: Binding): Promise<void> { 120 if (this.#bindings.has(binding.name)) { 121 return; 122 } 123 124 using _ = await this.#mutex.acquire(); 125 try { 126 await this.#client.send( 127 'Runtime.addBinding', 128 this.#name 129 ? { 130 name: CDP_BINDING_PREFIX + binding.name, 131 executionContextName: this.#name, 132 } 133 : { 134 name: CDP_BINDING_PREFIX + binding.name, 135 executionContextId: this.#id, 136 }, 137 ); 138 139 await this.evaluate( 140 addPageBinding, 141 'internal', 142 binding.name, 143 CDP_BINDING_PREFIX, 144 ); 145 146 this.#bindings.set(binding.name, binding); 147 } catch (error) { 148 // We could have tried to evaluate in a context which was already 149 // destroyed. This happens, for example, if the page is navigated while 150 // we are trying to add the binding 151 if (error instanceof Error) { 152 // Destroyed context. 153 if (error.message.includes('Execution context was destroyed')) { 154 return; 155 } 156 // Missing context. 157 if (error.message.includes('Cannot find context with specified id')) { 158 return; 159 } 160 } 161 162 debugError(error); 163 } 164 } 165 166 async #onBindingCalled( 167 event: Protocol.Runtime.BindingCalledEvent, 168 ): Promise<void> { 169 if (event.executionContextId !== this.#id) { 170 return; 171 } 172 173 let payload: BindingPayload; 174 try { 175 payload = JSON.parse(event.payload); 176 } catch { 177 // The binding was either called by something in the page or it was 178 // called before our wrapper was initialized. 179 return; 180 } 181 const {type, name, seq, args, isTrivial} = payload; 182 if (type !== 'internal') { 183 this.emit('bindingcalled', event); 184 return; 185 } 186 if (!this.#bindings.has(name)) { 187 this.emit('bindingcalled', event); 188 return; 189 } 190 191 try { 192 const binding = this.#bindings.get(name); 193 await binding?.run(this, seq, args, isTrivial); 194 } catch (err) { 195 debugError(err); 196 } 197 } 198 199 get id(): number { 200 return this.#id; 201 } 202 203 #onConsoleAPI(event: Protocol.Runtime.ConsoleAPICalledEvent): void { 204 if (event.executionContextId !== this.#id) { 205 return; 206 } 207 this.emit('consoleapicalled', event); 208 } 209 210 #bindingsInstalled = false; 211 #puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>; 212 get puppeteerUtil(): Promise<JSHandle<PuppeteerUtil>> { 213 let promise = Promise.resolve() as Promise<unknown>; 214 if (!this.#bindingsInstalled) { 215 promise = Promise.all([ 216 this.#addBindingWithoutThrowing(ariaQuerySelectorBinding), 217 this.#addBindingWithoutThrowing(ariaQuerySelectorAllBinding), 218 ]); 219 this.#bindingsInstalled = true; 220 } 221 scriptInjector.inject(script => { 222 if (this.#puppeteerUtil) { 223 void this.#puppeteerUtil.then(handle => { 224 void handle.dispose(); 225 }); 226 } 227 this.#puppeteerUtil = promise.then(() => { 228 return this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>; 229 }); 230 }, !this.#puppeteerUtil); 231 return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>; 232 } 233 234 async #addBindingWithoutThrowing(binding: Binding) { 235 try { 236 await this.#addBinding(binding); 237 } catch (err) { 238 // If the binding cannot be added, the context is broken. We cannot 239 // recover so we ignore the error. 240 debugError(err); 241 } 242 } 243 244 /** 245 * Evaluates the given function. 246 * 247 * @example 248 * 249 * ```ts 250 * const executionContext = await page.mainFrame().executionContext(); 251 * const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ; 252 * console.log(result); // prints "56" 253 * ``` 254 * 255 * @example 256 * A string can also be passed in instead of a function: 257 * 258 * ```ts 259 * console.log(await executionContext.evaluate('1 + 2')); // prints "3" 260 * ``` 261 * 262 * @example 263 * Handles can also be passed as `args`. They resolve to their referenced object: 264 * 265 * ```ts 266 * const oneHandle = await executionContext.evaluateHandle(() => 1); 267 * const twoHandle = await executionContext.evaluateHandle(() => 2); 268 * const result = await executionContext.evaluate( 269 * (a, b) => a + b, 270 * oneHandle, 271 * twoHandle, 272 * ); 273 * await oneHandle.dispose(); 274 * await twoHandle.dispose(); 275 * console.log(result); // prints '3'. 276 * ``` 277 * 278 * @param pageFunction - The function to evaluate. 279 * @param args - Additional arguments to pass into the function. 280 * @returns The result of evaluating the function. If the result is an object, 281 * a vanilla object containing the serializable properties of the result is 282 * returned. 283 */ 284 async evaluate< 285 Params extends unknown[], 286 Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, 287 >( 288 pageFunction: Func | string, 289 ...args: Params 290 ): Promise<Awaited<ReturnType<Func>>> { 291 return await this.#evaluate(true, pageFunction, ...args); 292 } 293 294 /** 295 * Evaluates the given function. 296 * 297 * Unlike {@link ExecutionContext.evaluate | evaluate}, this method returns a 298 * handle to the result of the function. 299 * 300 * This method may be better suited if the object cannot be serialized (e.g. 301 * `Map`) and requires further manipulation. 302 * 303 * @example 304 * 305 * ```ts 306 * const context = await page.mainFrame().executionContext(); 307 * const handle: JSHandle<typeof globalThis> = await context.evaluateHandle( 308 * () => Promise.resolve(self), 309 * ); 310 * ``` 311 * 312 * @example 313 * A string can also be passed in instead of a function. 314 * 315 * ```ts 316 * const handle: JSHandle<number> = await context.evaluateHandle('1 + 2'); 317 * ``` 318 * 319 * @example 320 * Handles can also be passed as `args`. They resolve to their referenced object: 321 * 322 * ```ts 323 * const bodyHandle: ElementHandle<HTMLBodyElement> = 324 * await context.evaluateHandle(() => { 325 * return document.body; 326 * }); 327 * const stringHandle: JSHandle<string> = await context.evaluateHandle( 328 * body => body.innerHTML, 329 * body, 330 * ); 331 * console.log(await stringHandle.jsonValue()); // prints body's innerHTML 332 * // Always dispose your garbage! :) 333 * await bodyHandle.dispose(); 334 * await stringHandle.dispose(); 335 * ``` 336 * 337 * @param pageFunction - The function to evaluate. 338 * @param args - Additional arguments to pass into the function. 339 * @returns A {@link JSHandle | handle} to the result of evaluating the 340 * function. If the result is a `Node`, then this will return an 341 * {@link ElementHandle | element handle}. 342 */ 343 async evaluateHandle< 344 Params extends unknown[], 345 Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, 346 >( 347 pageFunction: Func | string, 348 ...args: Params 349 ): Promise<HandleFor<Awaited<ReturnType<Func>>>> { 350 return await this.#evaluate(false, pageFunction, ...args); 351 } 352 353 async #evaluate< 354 Params extends unknown[], 355 Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, 356 >( 357 returnByValue: true, 358 pageFunction: Func | string, 359 ...args: Params 360 ): Promise<Awaited<ReturnType<Func>>>; 361 async #evaluate< 362 Params extends unknown[], 363 Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, 364 >( 365 returnByValue: false, 366 pageFunction: Func | string, 367 ...args: Params 368 ): Promise<HandleFor<Awaited<ReturnType<Func>>>>; 369 async #evaluate< 370 Params extends unknown[], 371 Func extends EvaluateFunc<Params> = EvaluateFunc<Params>, 372 >( 373 returnByValue: boolean, 374 pageFunction: Func | string, 375 ...args: Params 376 ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> { 377 const sourceUrlComment = getSourceUrlComment( 378 getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ?? 379 PuppeteerURL.INTERNAL_URL, 380 ); 381 382 if (isString(pageFunction)) { 383 const contextId = this.#id; 384 const expression = pageFunction; 385 const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) 386 ? expression 387 : `${expression}\n${sourceUrlComment}\n`; 388 389 const {exceptionDetails, result: remoteObject} = await this.#client 390 .send('Runtime.evaluate', { 391 expression: expressionWithSourceUrl, 392 contextId, 393 returnByValue, 394 awaitPromise: true, 395 userGesture: true, 396 }) 397 .catch(rewriteError); 398 399 if (exceptionDetails) { 400 throw createEvaluationError(exceptionDetails); 401 } 402 403 if (returnByValue) { 404 return valueFromRemoteObject(remoteObject); 405 } 406 407 return this.#world.createCdpHandle(remoteObject) as HandleFor< 408 Awaited<ReturnType<Func>> 409 >; 410 } 411 412 const functionDeclaration = stringifyFunction(pageFunction); 413 const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test( 414 functionDeclaration, 415 ) 416 ? functionDeclaration 417 : `${functionDeclaration}\n${sourceUrlComment}\n`; 418 let callFunctionOnPromise; 419 try { 420 callFunctionOnPromise = this.#client.send('Runtime.callFunctionOn', { 421 functionDeclaration: functionDeclarationWithSourceUrl, 422 executionContextId: this.#id, 423 // LazyArgs are used only internally and should not affect the order 424 // evaluate calls for the public APIs. 425 arguments: args.some(arg => { 426 return arg instanceof LazyArg; 427 }) 428 ? await Promise.all( 429 args.map(arg => { 430 return convertArgumentAsync(this, arg); 431 }), 432 ) 433 : args.map(arg => { 434 return convertArgument(this, arg); 435 }), 436 returnByValue, 437 awaitPromise: true, 438 userGesture: true, 439 }); 440 } catch (error) { 441 if ( 442 error instanceof TypeError && 443 error.message.startsWith('Converting circular structure to JSON') 444 ) { 445 error.message += ' Recursive objects are not allowed.'; 446 } 447 throw error; 448 } 449 const {exceptionDetails, result: remoteObject} = 450 await callFunctionOnPromise.catch(rewriteError); 451 if (exceptionDetails) { 452 throw createEvaluationError(exceptionDetails); 453 } 454 455 if (returnByValue) { 456 return valueFromRemoteObject(remoteObject); 457 } 458 459 return this.#world.createCdpHandle(remoteObject) as HandleFor< 460 Awaited<ReturnType<Func>> 461 >; 462 463 async function convertArgumentAsync( 464 context: ExecutionContext, 465 arg: unknown, 466 ) { 467 if (arg instanceof LazyArg) { 468 arg = await arg.get(context); 469 } 470 return convertArgument(context, arg); 471 } 472 473 function convertArgument( 474 context: ExecutionContext, 475 arg: unknown, 476 ): Protocol.Runtime.CallArgument { 477 if (typeof arg === 'bigint') { 478 return {unserializableValue: `${arg.toString()}n`}; 479 } 480 if (Object.is(arg, -0)) { 481 return {unserializableValue: '-0'}; 482 } 483 if (Object.is(arg, Infinity)) { 484 return {unserializableValue: 'Infinity'}; 485 } 486 if (Object.is(arg, -Infinity)) { 487 return {unserializableValue: '-Infinity'}; 488 } 489 if (Object.is(arg, NaN)) { 490 return {unserializableValue: 'NaN'}; 491 } 492 const objectHandle = 493 arg && (arg instanceof CdpJSHandle || arg instanceof CdpElementHandle) 494 ? arg 495 : null; 496 if (objectHandle) { 497 if (objectHandle.realm !== context.#world) { 498 throw new Error( 499 'JSHandles can be evaluated only in the context they were created!', 500 ); 501 } 502 if (objectHandle.disposed) { 503 throw new Error('JSHandle is disposed!'); 504 } 505 if (objectHandle.remoteObject().unserializableValue) { 506 return { 507 unserializableValue: 508 objectHandle.remoteObject().unserializableValue, 509 }; 510 } 511 if (!objectHandle.remoteObject().objectId) { 512 return {value: objectHandle.remoteObject().value}; 513 } 514 return {objectId: objectHandle.remoteObject().objectId}; 515 } 516 return {value: arg}; 517 } 518 } 519 520 override [disposeSymbol](): void { 521 this.#disposables.dispose(); 522 this.emit('disposed', undefined); 523 } 524 } 525 526 const rewriteError = (error: Error): Protocol.Runtime.EvaluateResponse => { 527 if (error.message.includes('Object reference chain is too long')) { 528 return {result: {type: 'undefined'}}; 529 } 530 if (error.message.includes("Object couldn't be returned by value")) { 531 return {result: {type: 'undefined'}}; 532 } 533 534 if ( 535 error.message.endsWith('Cannot find context with specified id') || 536 error.message.endsWith('Inspected target navigated or closed') 537 ) { 538 throw new Error( 539 'Execution context was destroyed, most likely because of a navigation.', 540 ); 541 } 542 throw error; 543 };