/** * Copyright 2019 Google Inc. All rights reserved. * * 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 {Protocol} from 'devtools-protocol'; import {JSHandle} from '../api/JSHandle.js'; import {assert} from '../util/assert.js'; import {createDeferredPromise} from '../util/DeferredPromise.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {CDPSession} from './Connection.js'; import {ExecutionContext} from './ExecutionContext.js'; import {Frame} from './Frame.js'; import {FrameManager} from './FrameManager.js'; import {MouseButton} from './Input.js'; import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js'; import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js'; import {TimeoutSettings} from './TimeoutSettings.js'; import { BindingPayload, EvaluateFunc, EvaluateFuncWith, HandleFor, InnerLazyParams, NodeFor, } from './types.js'; import {addPageBinding, createJSHandle, debugError} from './util.js'; import {TaskManager, WaitTask} from './WaitTask.js'; import type {ElementHandle} from '../api/ElementHandle.js'; import {Binding} from './Binding.js'; import {LazyArg} from './LazyArg.js'; /** * @public */ export interface WaitForSelectorOptions { /** * Wait for the selected element to be present in DOM and to be visible, i.e. * to not have `display: none` or `visibility: hidden` CSS properties. * * @defaultValue `false` */ visible?: boolean; /** * Wait for the selected element to not be found in the DOM or to be hidden, * i.e. have `display: none` or `visibility: hidden` CSS properties. * * @defaultValue `false` */ hidden?: boolean; /** * Maximum time to wait in milliseconds. Pass `0` to disable timeout. * * The default value can be changed by using {@link Page.setDefaultTimeout} * * @defaultValue `30000` (30 seconds) */ timeout?: number; } /** * @internal */ export interface PageBinding { name: string; pptrFunction: Function; } /** * @internal */ export interface IsolatedWorldChart { [key: string]: IsolatedWorld; [MAIN_WORLD]: IsolatedWorld; [PUPPETEER_WORLD]: IsolatedWorld; } /** * @internal */ export class IsolatedWorld { #frame: Frame; #document?: ElementHandle; #context = createDeferredPromise(); #detached = false; // Set of bindings that have been registered in the current context. #contextBindings = new Set(); // Contains mapping from functions that should be bound to Puppeteer functions. #bindings = new Map(); #taskManager = new TaskManager(); get taskManager(): TaskManager { return this.#taskManager; } get _bindings(): Map { return this.#bindings; } constructor(frame: Frame) { // Keep own reference to client because it might differ from the FrameManager's // client for OOP iframes. this.#frame = frame; this.#client.on('Runtime.bindingCalled', this.#onBindingCalled); } get #client(): CDPSession { return this.#frame._client(); } get #frameManager(): FrameManager { return this.#frame._frameManager; } get #timeoutSettings(): TimeoutSettings { return this.#frameManager.timeoutSettings; } frame(): Frame { return this.#frame; } clearContext(): void { this.#document = undefined; this.#context = createDeferredPromise(); } setContext(context: ExecutionContext): void { this.#contextBindings.clear(); this.#context.resolve(context); this.#taskManager.rerunAll(); } hasContext(): boolean { return this.#context.resolved(); } _detach(): void { this.#detached = true; this.#client.off('Runtime.bindingCalled', this.#onBindingCalled); this.#taskManager.terminateAll( new Error('waitForFunction failed: frame got detached.') ); } executionContext(): Promise { if (this.#detached) { throw new Error( `Execution context is not available in detached frame "${this.#frame.url()}" (are you trying to evaluate?)` ); } if (this.#context === null) { throw new Error(`Execution content promise is missing`); } return this.#context; } async evaluateHandle< Params extends unknown[], Func extends EvaluateFunc = EvaluateFunc >( pageFunction: Func | string, ...args: Params ): Promise>>> { const context = await this.executionContext(); return context.evaluateHandle(pageFunction, ...args); } async evaluate< Params extends unknown[], Func extends EvaluateFunc = EvaluateFunc >( pageFunction: Func | string, ...args: Params ): Promise>> { const context = await this.executionContext(); return context.evaluate(pageFunction, ...args); } async $( selector: Selector ): Promise> | null> { const document = await this.document(); return document.$(selector); } async $$( selector: Selector ): Promise>>> { const document = await this.document(); return document.$$(selector); } async document(): Promise> { if (this.#document) { return this.#document; } const context = await this.executionContext(); this.#document = await context.evaluateHandle(() => { return document; }); return this.#document; } async $x(expression: string): Promise>> { const document = await this.document(); return document.$x(expression); } async $eval< Selector extends string, Params extends unknown[], Func extends EvaluateFuncWith, Params> = EvaluateFuncWith< NodeFor, Params > >( selector: Selector, pageFunction: Func | string, ...args: Params ): Promise>> { const document = await this.document(); return document.$eval(selector, pageFunction, ...args); } async $$eval< Selector extends string, Params extends unknown[], Func extends EvaluateFuncWith< Array>, Params > = EvaluateFuncWith>, Params> >( selector: Selector, pageFunction: Func | string, ...args: Params ): Promise>> { const document = await this.document(); return document.$$eval(selector, pageFunction, ...args); } async content(): Promise { return await this.evaluate(() => { let retVal = ''; if (document.doctype) { retVal = new XMLSerializer().serializeToString(document.doctype); } if (document.documentElement) { retVal += document.documentElement.outerHTML; } return retVal; }); } async setContent( html: string, options: { timeout?: number; waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; } = {} ): Promise { const { waitUntil = ['load'], timeout = this.#timeoutSettings.navigationTimeout(), } = options; // We rely upon the fact that document.open() will reset frame lifecycle with "init" // lifecycle event. @see https://crrev.com/608658 await this.evaluate(html => { document.open(); document.write(html); document.close(); }, html); const watcher = new LifecycleWatcher( this.#frameManager, this.#frame, waitUntil, timeout ); const error = await Promise.race([ watcher.timeoutOrTerminationPromise(), watcher.lifecyclePromise(), ]); watcher.dispose(); if (error) { throw error; } } async click( selector: string, options: {delay?: number; button?: MouseButton; clickCount?: number} ): Promise { const handle = await this.$(selector); assert(handle, `No element found for selector: ${selector}`); await handle.click(options); await handle.dispose(); } async focus(selector: string): Promise { const handle = await this.$(selector); assert(handle, `No element found for selector: ${selector}`); await handle.focus(); await handle.dispose(); } async hover(selector: string): Promise { const handle = await this.$(selector); assert(handle, `No element found for selector: ${selector}`); await handle.hover(); await handle.dispose(); } async select(selector: string, ...values: string[]): Promise { const handle = await this.$(selector); assert(handle, `No element found for selector: ${selector}`); const result = await handle.select(...values); await handle.dispose(); return result; } async tap(selector: string): Promise { const handle = await this.$(selector); assert(handle, `No element found for selector: ${selector}`); await handle.tap(); await handle.dispose(); } async type( selector: string, text: string, options?: {delay: number} ): Promise { const handle = await this.$(selector); assert(handle, `No element found for selector: ${selector}`); await handle.type(text, options); await handle.dispose(); } // If multiple waitFor are set up asynchronously, we need to wait for the // first one to set up the binding in the page before running the others. #mutex = new Mutex(); async _addBindingToContext( context: ExecutionContext, name: string ): Promise { if (this.#contextBindings.has(name)) { return; } await this.#mutex.acquire(); try { await context._client.send('Runtime.addBinding', { name, executionContextName: context._contextName, }); await context.evaluate(addPageBinding, 'internal', name); this.#contextBindings.add(name); } catch (error) { // We could have tried to evaluate in a context which was already // destroyed. This happens, for example, if the page is navigated while // we are trying to add the binding if (error instanceof Error) { // Destroyed context. if (error.message.includes('Execution context was destroyed')) { return; } // Missing context. if (error.message.includes('Cannot find context with specified id')) { return; } } debugError(error); } finally { this.#mutex.release(); } } #onBindingCalled = async ( event: Protocol.Runtime.BindingCalledEvent ): Promise => { let payload: BindingPayload; try { payload = JSON.parse(event.payload); } catch { // The binding was either called by something in the page or it was // called before our wrapper was initialized. return; } const {type, name, seq, args, isTrivial} = payload; if (type !== 'internal') { return; } if (!this.#contextBindings.has(name)) { return; } const context = await this.#context; if (event.executionContextId !== context._contextId) { return; } const binding = this._bindings.get(name); await binding?.run(context, seq, args, isTrivial); }; async _waitForSelectorInPage( queryOne: Function, root: ElementHandle | undefined, selector: string, options: WaitForSelectorOptions, bindings = new Map unknown>() ): Promise | null> { const { visible: waitForVisible = false, hidden: waitForHidden = false, timeout = this.#timeoutSettings.timeout(), } = options; try { const handle = await this.waitForFunction( async (PuppeteerUtil, query, selector, root, visible) => { if (!PuppeteerUtil) { return; } const node = (await PuppeteerUtil.createFunction(query)( root ?? document, selector, PuppeteerUtil )) as Node | null; return PuppeteerUtil.checkVisibility(node, visible); }, { bindings, polling: waitForVisible || waitForHidden ? 'raf' : 'mutation', root, timeout, }, LazyArg.create(context => { return context.puppeteerUtil; }), queryOne.toString(), selector, root, waitForVisible ? true : waitForHidden ? false : undefined ); const elementHandle = handle.asElement(); if (!elementHandle) { await handle.dispose(); return null; } return elementHandle; } catch (error) { if (!isErrorLike(error)) { throw error; } error.message = `Waiting for selector \`${selector}\` failed: ${error.message}`; throw error; } } waitForFunction< Params extends unknown[], Func extends EvaluateFunc> = EvaluateFunc< InnerLazyParams > >( pageFunction: Func | string, options: { polling?: 'raf' | 'mutation' | number; timeout?: number; root?: ElementHandle; bindings?: Map unknown>; } = {}, ...args: Params ): Promise>>> { const { polling = 'raf', timeout = this.#timeoutSettings.timeout(), bindings, root, } = options; if (typeof polling === 'number' && polling < 0) { throw new Error('Cannot poll with non-positive interval'); } const waitTask = new WaitTask( this, { bindings, polling, root, timeout, }, pageFunction as unknown as | ((...args: unknown[]) => Promise>>) | string, ...args ); return waitTask.result; } async title(): Promise { return this.evaluate(() => { return document.title; }); } async adoptBackendNode( backendNodeId?: Protocol.DOM.BackendNodeId ): Promise> { const executionContext = await this.executionContext(); const {object} = await this.#client.send('DOM.resolveNode', { backendNodeId: backendNodeId, executionContextId: executionContext._contextId, }); return createJSHandle(executionContext, object) as JSHandle; } async adoptHandle>(handle: T): Promise { const context = await this.executionContext(); assert( handle.executionContext() !== context, 'Cannot adopt handle that already belongs to this execution context' ); const nodeInfo = await this.#client.send('DOM.describeNode', { objectId: handle.id, }); return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T; } async transferHandle>(handle: T): Promise { const context = await this.executionContext(); if (handle.executionContext() === context) { return handle; } const info = await this.#client.send('DOM.describeNode', { objectId: handle.remoteObject().objectId, }); const newHandle = (await this.adoptBackendNode( info.node.backendNodeId )) as T; await handle.dispose(); return newHandle; } } class Mutex { #locked = false; #acquirers: Array<() => void> = []; // This is FIFO. acquire(): Promise { if (!this.#locked) { this.#locked = true; return Promise.resolve(); } let resolve!: () => void; const promise = new Promise(res => { resolve = res; }); this.#acquirers.push(resolve); return promise; } release(): void { const resolve = this.#acquirers.shift(); if (!resolve) { this.#locked = false; return; } resolve(); } }