/** * Copyright 2017 Google Inc. All rights reserved. * Modifications 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 * as types from './types'; import * as js from './javascript'; import * as dom from './dom'; import * as network from './network'; import { helper, assert, RegisteredListener } from './helper'; import { TimeoutError } from './errors'; import { Events } from './events'; import { Page } from './page'; import { ConsoleMessage } from './console'; import * as platform from './platform'; type ContextType = 'main' | 'utility'; type ContextData = { contextPromise: Promise; contextResolveCallback: (c: dom.FrameExecutionContext) => void; context: dom.FrameExecutionContext | null; rerunnableTasks: Set; }; export type GotoOptions = types.NavigateOptions & { referer?: string, }; export type GotoResult = { newDocumentId?: string, }; type ConsoleTagHandler = () => void; export class FrameManager { private _page: Page; private _frames = new Map(); private _mainFrame: Frame; readonly _lifecycleWatchers = new Set<() => void>(); readonly _consoleMessageTags = new Map(); private _pendingNavigationBarriers = new Set(); constructor(page: Page) { this._page = page; this._mainFrame = undefined as any as Frame; } mainFrame(): Frame { return this._mainFrame; } frames() { const frames: Frame[] = []; collect(this._mainFrame); return frames; function collect(frame: Frame) { frames.push(frame); for (const subframe of frame.childFrames()) collect(subframe); } } frame(frameId: string): Frame | null { return this._frames.get(frameId) || null; } frameAttached(frameId: string, parentFrameId: string | null | undefined): Frame { const parentFrame = parentFrameId ? this._frames.get(parentFrameId)! : null; if (!parentFrame) { if (this._mainFrame) { // Update frame id to retain frame identity on cross-process navigation. this._frames.delete(this._mainFrame._id); this._mainFrame._id = frameId; } else { assert(!this._frames.has(frameId)); this._mainFrame = new Frame(this._page, frameId, parentFrame); } this._frames.set(frameId, this._mainFrame); return this._mainFrame; } else { assert(!this._frames.has(frameId)); const frame = new Frame(this._page, frameId, parentFrame); this._frames.set(frameId, frame); this._page.emit(Events.Page.FrameAttached, frame); return frame; } } async waitForNavigationsCreatedBy(action: () => Promise, options: types.NavigatingActionWaitOptions = {}, input?: boolean): Promise { if (options.waitUntil === 'nowait') return action(); const barrier = new PendingNavigationBarrier({ waitUntil: 'domcontentloaded', ...options }); this._pendingNavigationBarriers.add(barrier); try { const result = await action(); if (input) await this._page._delegate.inputActionEpilogue(); await barrier.waitFor(); // Resolve in the next task, after all waitForNavigations. await new Promise(platform.makeWaitForNextTask()); return result; } finally { this._pendingNavigationBarriers.delete(barrier); } } frameWillPotentiallyRequestNavigation() { for (const barrier of this._pendingNavigationBarriers) barrier.retain(); } frameDidPotentiallyRequestNavigation() { for (const barrier of this._pendingNavigationBarriers) barrier.release(); } frameRequestedNavigation(frameId: string) { const frame = this._frames.get(frameId); if (!frame) return; for (const barrier of this._pendingNavigationBarriers) barrier.addFrame(frame); } frameCommittedNewDocumentNavigation(frameId: string, url: string, name: string, documentId: string, initial: boolean) { const frame = this._frames.get(frameId)!; for (const child of frame.childFrames()) this._removeFramesRecursively(child); frame._url = url; frame._name = name; frame._lastDocumentId = documentId; for (const watcher of frame._documentWatchers) watcher(documentId); this.clearFrameLifecycle(frame); if (!initial) this._page.emit(Events.Page.FrameNavigated, frame); } frameCommittedSameDocumentNavigation(frameId: string, url: string) { const frame = this._frames.get(frameId); if (!frame) return; frame._url = url; for (const watcher of frame._sameDocumentNavigationWatchers) watcher(); this._page.emit(Events.Page.FrameNavigated, frame); } frameDetached(frameId: string) { const frame = this._frames.get(frameId); if (frame) this._removeFramesRecursively(frame); } frameStoppedLoading(frameId: string) { const frame = this._frames.get(frameId); if (!frame) return; const hasDOMContentLoaded = frame._firedLifecycleEvents.has('domcontentloaded'); const hasLoad = frame._firedLifecycleEvents.has('load'); frame._firedLifecycleEvents.add('domcontentloaded'); frame._firedLifecycleEvents.add('load'); for (const watcher of this._lifecycleWatchers) watcher(); if (frame === this.mainFrame() && !hasDOMContentLoaded) this._page.emit(Events.Page.DOMContentLoaded); if (frame === this.mainFrame() && !hasLoad) this._page.emit(Events.Page.Load); } frameLifecycleEvent(frameId: string, event: types.LifecycleEvent) { const frame = this._frames.get(frameId); if (!frame) return; frame._firedLifecycleEvents.add(event); for (const watcher of this._lifecycleWatchers) watcher(); if (frame === this._mainFrame && event === 'load') this._page.emit(Events.Page.Load); if (frame === this._mainFrame && event === 'domcontentloaded') this._page.emit(Events.Page.DOMContentLoaded); } clearFrameLifecycle(frame: Frame) { frame._firedLifecycleEvents.clear(); // Keep the current navigation request if any. frame._inflightRequests = new Set(Array.from(frame._inflightRequests).filter(request => request._documentId === frame._lastDocumentId)); this._stopNetworkIdleTimer(frame, 'networkidle0'); if (frame._inflightRequests.size === 0) this._startNetworkIdleTimer(frame, 'networkidle0'); this._stopNetworkIdleTimer(frame, 'networkidle2'); if (frame._inflightRequests.size <= 2) this._startNetworkIdleTimer(frame, 'networkidle2'); } requestStarted(request: network.Request) { this._inflightRequestStarted(request); for (const watcher of request.frame()._requestWatchers) watcher(request); if (!request._isFavicon) this._page._requestStarted(request); } requestReceivedResponse(response: network.Response) { if (!response.request()._isFavicon) this._page.emit(Events.Page.Response, response); } requestFinished(request: network.Request) { this._inflightRequestFinished(request); if (!request._isFavicon) this._page.emit(Events.Page.RequestFinished, request); } requestFailed(request: network.Request, canceled: boolean) { this._inflightRequestFinished(request); if (request._documentId) { const isCurrentDocument = request.frame()._lastDocumentId === request._documentId; if (!isCurrentDocument) { let errorText = request.failure()!.errorText; if (canceled) errorText += '; maybe frame was detached?'; for (const watcher of request.frame()._documentWatchers) watcher(request._documentId, new Error(errorText)); } } if (!request._isFavicon) this._page.emit(Events.Page.RequestFailed, request); } provisionalLoadFailed(frame: Frame, documentId: string, error: string) { for (const watcher of frame._documentWatchers) watcher(documentId, new Error(error)); } private _removeFramesRecursively(frame: Frame) { for (const child of frame.childFrames()) this._removeFramesRecursively(child); frame._onDetached(); this._frames.delete(frame._id); this._page.emit(Events.Page.FrameDetached, frame); } private _inflightRequestFinished(request: network.Request) { const frame = request.frame(); if (request._isFavicon) return; if (!frame._inflightRequests.has(request)) return; frame._inflightRequests.delete(request); if (frame._inflightRequests.size === 0) this._startNetworkIdleTimer(frame, 'networkidle0'); if (frame._inflightRequests.size === 2) this._startNetworkIdleTimer(frame, 'networkidle2'); } private _inflightRequestStarted(request: network.Request) { const frame = request.frame(); if (request._isFavicon) return; frame._inflightRequests.add(request); if (frame._inflightRequests.size === 1) this._stopNetworkIdleTimer(frame, 'networkidle0'); if (frame._inflightRequests.size === 3) this._stopNetworkIdleTimer(frame, 'networkidle2'); } private _startNetworkIdleTimer(frame: Frame, event: types.LifecycleEvent) { assert(!frame._networkIdleTimers.has(event)); if (frame._firedLifecycleEvents.has(event)) return; frame._networkIdleTimers.set(event, setTimeout(() => { this.frameLifecycleEvent(frame._id, event); }, 500)); } private _stopNetworkIdleTimer(frame: Frame, event: types.LifecycleEvent) { const timeoutId = frame._networkIdleTimers.get(event); if (timeoutId) clearTimeout(timeoutId); frame._networkIdleTimers.delete(event); } interceptConsoleMessage(message: ConsoleMessage): boolean { if (message.type() !== 'debug') return false; const tag = message.text(); const handler = this._consoleMessageTags.get(tag); if (!handler) return false; this._consoleMessageTags.delete(tag); handler(); return true; } } export class Frame { _id: string; readonly _firedLifecycleEvents: Set; _lastDocumentId = ''; _requestWatchers = new Set<(request: network.Request) => void>(); _documentWatchers = new Set<(documentId: string, error?: Error) => void>(); _sameDocumentNavigationWatchers = new Set<() => void>(); readonly _page: Page; private _parentFrame: Frame | null; _url = ''; private _detached = false; private _contextData = new Map(); private _childFrames = new Set(); _name = ''; _inflightRequests = new Set(); readonly _networkIdleTimers = new Map(); private _setContentCounter = 0; private _detachedPromise: Promise; private _detachedCallback = () => {}; constructor(page: Page, id: string, parentFrame: Frame | null) { this._id = id; this._firedLifecycleEvents = new Set(); this._page = page; this._parentFrame = parentFrame; this._detachedPromise = new Promise(x => this._detachedCallback = x); this._contextData.set('main', { contextPromise: new Promise(() => {}), contextResolveCallback: () => {}, context: null, rerunnableTasks: new Set() }); this._contextData.set('utility', { contextPromise: new Promise(() => {}), contextResolveCallback: () => {}, context: null, rerunnableTasks: new Set() }); this._setContext('main', null); this._setContext('utility', null); if (this._parentFrame) this._parentFrame._childFrames.add(this); } async goto(url: string, options: GotoOptions = {}): Promise { const headers = (this._page._state.extraHTTPHeaders || {}); let referer = headers['referer'] || headers['Referer']; if (options.referer !== undefined) { if (referer !== undefined && referer !== options.referer) throw new Error('"referer" is already specified as extra HTTP header'); referer = options.referer; } url = helper.completeUserURL(url); const { timeout = this._page._timeoutSettings.navigationTimeout() } = options; const disposer = new Disposer(); const timeoutPromise = disposer.add(createTimeoutPromise(timeout)); const frameDestroyedPromise = this._createFrameDestroyedPromise(); const sameDocumentPromise = disposer.add(this._waitForSameDocumentNavigation()); const requestWatcher = disposer.add(this._trackDocumentRequests()); let navigateResult: GotoResult; const navigate = async () => { try { navigateResult = await this._page._delegate.navigateFrame(this, url, referer); } catch (error) { return error; } }; throwIfError(await Promise.race([ navigate(), timeoutPromise, frameDestroyedPromise, ])); const promises: Promise[] = [timeoutPromise, frameDestroyedPromise]; if (navigateResult!.newDocumentId) promises.push(disposer.add(this._waitForSpecificDocument(navigateResult!.newDocumentId))); else promises.push(sameDocumentPromise); throwIfError(await Promise.race(promises)); const request = (navigateResult! && navigateResult!.newDocumentId) ? requestWatcher.get(navigateResult!.newDocumentId) : null; const waitForLifecyclePromise = disposer.add(this._waitForLifecycle(options.waitUntil)); throwIfError(await Promise.race([timeoutPromise, frameDestroyedPromise, waitForLifecyclePromise])); disposer.dispose(); return request ? request._finalRequest().response() : null; function throwIfError(error: Error|void): asserts error is void { if (!error) return; disposer.dispose(); const message = `While navigating to ${url}: ${error.message}`; if (error instanceof TimeoutError) throw new TimeoutError(message); throw new Error(message); } } async waitForNavigation(options: types.WaitForNavigationOptions = {}): Promise { const disposer = new Disposer(); const requestWatcher = disposer.add(this._trackDocumentRequests()); const {timeout = this._page._timeoutSettings.navigationTimeout()} = options; const failurePromise = Promise.race([ this._createFrameDestroyedPromise(), disposer.add(createTimeoutPromise(timeout)), ]); let documentId: string|null = null; let error: void|Error = await Promise.race([ failurePromise, disposer.add(this._waitForNewDocument(options.url)).then(result => { if (result.error) return result.error; documentId = result.documentId; }), disposer.add(this._waitForSameDocumentNavigation(options.url)), ]); const request = requestWatcher.get(documentId!); if (!error) { error = await Promise.race([ failurePromise, disposer.add(this._waitForLifecycle(options.waitUntil)), ]); } disposer.dispose(); if (error) throw error; return request ? request._finalRequest().response() : null; } async _waitForLoadState(options: types.NavigateOptions = {}): Promise { const {timeout = this._page._timeoutSettings.navigationTimeout()} = options; const disposer = new Disposer(); const error = await Promise.race([ this._createFrameDestroyedPromise(), disposer.add(createTimeoutPromise(timeout)), disposer.add(this._waitForLifecycle(options.waitUntil)), ]); disposer.dispose(); if (error) throw error; } _waitForSpecificDocument(expectedDocumentId: string): Disposable> { let resolve: (error: Error|void) => void; const promise = new Promise(x => resolve = x); const watch = (documentId: string, error?: Error) => { if (documentId === expectedDocumentId) resolve(error); else if (!error) resolve(new Error('Navigation interrupted by another one')); }; const dispose = () => this._documentWatchers.delete(watch); this._documentWatchers.add(watch); return {value: promise, dispose}; } _waitForNewDocument(url?: types.URLMatch): Disposable> { let resolve: (error: {error?: Error, documentId: string}) => void; const promise = new Promise<{error?: Error, documentId: string}>(x => resolve = x); const watch = (documentId: string, error?: Error) => { if (!error && !platform.urlMatches(this.url(), url)) return; resolve({error, documentId}); }; const dispose = () => this._documentWatchers.delete(watch); this._documentWatchers.add(watch); return {value: promise, dispose}; } _waitForSameDocumentNavigation(url?: types.URLMatch): Disposable> { let resolve: () => void; const promise = new Promise(x => resolve = x); const watch = () => { if (platform.urlMatches(this.url(), url)) resolve(); }; const dispose = () => this._sameDocumentNavigationWatchers.delete(watch); this._sameDocumentNavigationWatchers.add(watch); return {value: promise, dispose}; } _waitForLifecycle(waitUntil: types.LifecycleEvent = 'load'): Disposable> { let resolve: () => void; if (!types.kLifecycleEvents.has(waitUntil)) throw new Error(`Unsupported waitUntil option ${String(waitUntil)}`); const checkLifecycleComplete = () => { if (!checkLifecycleRecursively(this)) return; resolve(); }; const promise = new Promise(x => resolve = x); const dispose = () => this._page._frameManager._lifecycleWatchers.delete(checkLifecycleComplete); this._page._frameManager._lifecycleWatchers.add(checkLifecycleComplete); checkLifecycleComplete(); return {value: promise, dispose}; function checkLifecycleRecursively(frame: Frame): boolean { if (!frame._firedLifecycleEvents.has(waitUntil)) return false; for (const child of frame.childFrames()) { if (!checkLifecycleRecursively(child)) return false; } return true; } } _trackDocumentRequests(): Disposable> { const requestMap = new Map(); const dispose = () => { this._requestWatchers.delete(onRequest); }; const onRequest = (request: network.Request) => { if (!request._documentId || request.redirectedFrom()) return; requestMap.set(request._documentId, request); }; this._requestWatchers.add(onRequest); return {dispose, value: requestMap}; } _createFrameDestroyedPromise(): Promise { return Promise.race([ this._page._disconnectedPromise.then(() => new Error('Navigation failed because browser has disconnected!')), this._detachedPromise.then(() => new Error('Navigating frame was detached!')), ]); } async frameElement(): Promise { return this._page._delegate.getFrameElement(this); } _context(contextType: ContextType): Promise { if (this._detached) throw new Error(`Execution Context is not available in detached frame "${this.url()}" (are you trying to evaluate?)`); return this._contextData.get(contextType)!.contextPromise; } _mainContext(): Promise { return this._context('main'); } _utilityContext(): Promise { return this._context('utility'); } evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => { const context = await this._mainContext(); return context.evaluateHandle(pageFunction, ...args as any); } evaluate: types.Evaluate = async (pageFunction, ...args) => { const context = await this._mainContext(); return context.evaluate(pageFunction, ...args as any); } async $(selector: string): Promise | null> { const utilityContext = await this._utilityContext(); const mainContext = await this._mainContext(); const handle = await utilityContext._$(selector); if (handle && handle._context !== mainContext) { const adopted = this._page._delegate.adoptElementHandle(handle, mainContext); handle.dispose(); return adopted; } return handle; } async waitForSelector(selector: string, options?: types.WaitForElementOptions): Promise | null> { if (options && (options as any).visibility) throw new Error('options.visibility is not supported, did you mean options.waitFor?'); const { timeout = this._page._timeoutSettings.timeout(), waitFor = 'attached' } = (options || {}); if (!['attached', 'detached', 'visible', 'hidden'].includes(waitFor)) throw new Error(`Unsupported waitFor option "${waitFor}"`); const task = dom.waitForSelectorTask(selector, waitFor, timeout); const result = await this._scheduleRerunnableTask(task, 'utility', timeout, `selector "${selectorToString(selector, waitFor)}"`); if (!result.asElement()) { result.dispose(); return null; } const handle = result.asElement() as dom.ElementHandle; const mainContext = await this._mainContext(); if (handle && handle._context !== mainContext) { const adopted = await this._page._delegate.adoptElementHandle(handle, mainContext); handle.dispose(); return adopted; } return handle; } $eval: types.$Eval = async (selector, pageFunction, ...args) => { const context = await this._mainContext(); const elementHandle = await context._$(selector); if (!elementHandle) throw new Error(`Error: failed to find element matching selector "${selector}"`); const result = await elementHandle.evaluate(pageFunction, ...args as any); elementHandle.dispose(); return result; } $$eval: types.$$Eval = async (selector, pageFunction, ...args) => { const context = await this._mainContext(); const arrayHandle = await context._$array(selector); const result = await arrayHandle.evaluate(pageFunction, ...args as any); arrayHandle.dispose(); return result; } async $$(selector: string): Promise[]> { const context = await this._mainContext(); return context._$$(selector); } async content(): Promise { const context = await this._utilityContext(); return context.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?: types.NavigateOptions): Promise { const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`; const context = await this._utilityContext(); const lifecyclePromise = new Promise((resolve, reject) => { this._page._frameManager._consoleMessageTags.set(tag, () => { // Clear lifecycle right after document.open() - see 'tag' below. this._page._frameManager.clearFrameLifecycle(this); this._waitForLoadState(options).then(resolve).catch(reject); }); }); const contentPromise = context.evaluate((html, tag) => { window.stop(); document.open(); console.debug(tag); // eslint-disable-line no-console document.write(html); document.close(); }, html, tag); await Promise.all([contentPromise, lifecyclePromise]); } name(): string { return this._name || ''; } url(): string { return this._url; } parentFrame(): Frame | null { return this._parentFrame; } childFrames(): Frame[] { return Array.from(this._childFrames); } isDetached(): boolean { return this._detached; } async addScriptTag(options: { url?: string; path?: string; content?: string; type?: string; }): Promise { const { url = null, path = null, content = null, type = '' } = options; if (!url && !path && !content) throw new Error('Provide an object with a `url`, `path` or `content` property'); const context = await this._mainContext(); return this._raceWithCSPError(async () => { if (url !== null) return (await context.evaluateHandle(addScriptUrl, url, type)).asElement()!; if (path !== null) { let contents = await platform.readFileAsync(path, 'utf8'); contents += '//# sourceURL=' + path.replace(/\n/g, ''); return (await context.evaluateHandle(addScriptContent, contents, type)).asElement()!; } return (await context.evaluateHandle(addScriptContent, content!, type)).asElement()!; }); async function addScriptUrl(url: string, type: string): Promise { const script = document.createElement('script'); script.src = url; if (type) script.type = type; const promise = new Promise((res, rej) => { script.onload = res; script.onerror = rej; }); document.head.appendChild(script); await promise; return script; } function addScriptContent(content: string, type: string = 'text/javascript'): HTMLElement { const script = document.createElement('script'); script.type = type; script.text = content; let error = null; script.onerror = e => error = e; document.head.appendChild(script); if (error) throw error; return script; } } async addStyleTag(options: { url?: string; path?: string; content?: string; }): Promise { const { url = null, path = null, content = null } = options; if (!url && !path && !content) throw new Error('Provide an object with a `url`, `path` or `content` property'); const context = await this._mainContext(); return this._raceWithCSPError(async () => { if (url !== null) return (await context.evaluateHandle(addStyleUrl, url)).asElement()!; if (path !== null) { let contents = await platform.readFileAsync(path, 'utf8'); contents += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/'; return (await context.evaluateHandle(addStyleContent, contents)).asElement()!; } return (await context.evaluateHandle(addStyleContent, content!)).asElement()!; }); async function addStyleUrl(url: string): Promise { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; const promise = new Promise((res, rej) => { link.onload = res; link.onerror = rej; }); document.head.appendChild(link); await promise; return link; } async function addStyleContent(content: string): Promise { const style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(content)); const promise = new Promise((res, rej) => { style.onload = res; style.onerror = rej; }); document.head.appendChild(style); await promise; return style; } } private async _raceWithCSPError(func: () => Promise): Promise { const listeners: RegisteredListener[] = []; let result: dom.ElementHandle; let error: Error | undefined; let cspMessage: ConsoleMessage | undefined; const actionPromise = new Promise(async resolve => { try { result = await func(); } catch (e) { error = e; } resolve(); }); const errorPromise = new Promise(resolve => { listeners.push(helper.addEventListener(this._page, Events.Page.Console, (message: ConsoleMessage) => { if (message.type() === 'error' && message.text().includes('Content Security Policy')) { cspMessage = message; resolve(); } })); }); await Promise.race([actionPromise, errorPromise]); helper.removeEventListeners(listeners); if (cspMessage) throw new Error(cspMessage.text()); if (error) throw error; return result!; } async click(selector: string, options?: dom.ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.click(options); handle.dispose(); } async dblclick(selector: string, options?: dom.MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.dblclick(options); handle.dispose(); } async fill(selector: string, value: string, options?: types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.fill(value, options); handle.dispose(); } async focus(selector: string, options?: types.TimeoutOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.focus(); handle.dispose(); } async hover(selector: string, options?: dom.PointerActionOptions & types.PointerActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.hover(options); handle.dispose(); } async selectOption(selector: string, values: string | dom.ElementHandle | types.SelectOption | string[] | dom.ElementHandle[] | types.SelectOption[], options?: types.NavigatingActionWaitOptions): Promise { const handle = await this._waitForSelectorInUtilityContext(selector, options); const result = await handle.selectOption(values, options); handle.dispose(); return result; } async type(selector: string, text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.type(text, options); handle.dispose(); } async press(selector: string, key: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.press(key, options); handle.dispose(); } async check(selector: string, options?: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.check(options); handle.dispose(); } async uncheck(selector: string, options?: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { const handle = await this._waitForSelectorInUtilityContext(selector, options); await handle.uncheck(options); handle.dispose(); } async waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: types.WaitForFunctionOptions & types.WaitForElementOptions = {}, ...args: any[]): Promise { if (helper.isString(selectorOrFunctionOrTimeout)) return this.waitForSelector(selectorOrFunctionOrTimeout, options) as any; if (helper.isNumber(selectorOrFunctionOrTimeout)) return new Promise(fulfill => setTimeout(fulfill, selectorOrFunctionOrTimeout)); if (typeof selectorOrFunctionOrTimeout === 'function') return this.waitForFunction(selectorOrFunctionOrTimeout, options, ...args); return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout))); } private async _waitForSelectorInUtilityContext(selector: string, options?: types.WaitForElementOptions): Promise> { const { timeout = this._page._timeoutSettings.timeout(), waitFor = 'attached' } = (options || {}); const task = dom.waitForSelectorTask(selector, waitFor, timeout); const result = await this._scheduleRerunnableTask(task, 'utility', timeout, `selector "${selectorToString(selector, waitFor)}"`); return result.asElement() as dom.ElementHandle; } async waitForFunction(pageFunction: Function | string, options?: types.WaitForFunctionOptions, ...args: any[]): Promise { options = { timeout: this._page._timeoutSettings.timeout(), ...(options || {}) }; const task = dom.waitForFunctionTask(undefined, pageFunction, options, ...args); return this._scheduleRerunnableTask(task, 'main', options.timeout); } async title(): Promise { const context = await this._utilityContext(); return context.evaluate(() => document.title); } _onDetached() { this._detached = true; this._detachedCallback(); for (const data of this._contextData.values()) { for (const rerunnableTask of data.rerunnableTasks) rerunnableTask.terminate(new Error('waitForFunction failed: frame got detached.')); } if (this._parentFrame) this._parentFrame._childFrames.delete(this); this._parentFrame = null; } private _scheduleRerunnableTask(task: dom.Task, contextType: ContextType, timeout?: number, title?: string): Promise { const data = this._contextData.get(contextType)!; const rerunnableTask = new RerunnableTask(data, task, timeout, title); data.rerunnableTasks.add(rerunnableTask); if (data.context) rerunnableTask.rerun(data.context); return rerunnableTask.promise; } private _setContext(contextType: ContextType, context: dom.FrameExecutionContext | null) { const data = this._contextData.get(contextType)!; data.context = context; if (context) { data.contextResolveCallback.call(null, context); for (const rerunnableTask of data.rerunnableTasks) rerunnableTask.rerun(context); } else { data.contextPromise = new Promise(fulfill => { data.contextResolveCallback = fulfill; }); } } _contextCreated(contextType: ContextType, context: dom.FrameExecutionContext) { const data = this._contextData.get(contextType)!; // In case of multiple sessions to the same target, there's a race between // connections so we might end up creating multiple isolated worlds. // We can use either. if (data.context) this._setContext(contextType, null); this._setContext(contextType, context); } _contextDestroyed(context: dom.FrameExecutionContext) { for (const [contextType, data] of this._contextData) { if (data.context === context) this._setContext(contextType, null); } } } class RerunnableTask { readonly promise: Promise; private _contextData: ContextData; private _task: dom.Task; private _runCount: number; private _resolve: (result: js.JSHandle) => void = () => {}; private _reject: (reason: Error) => void = () => {}; private _timeoutTimer?: NodeJS.Timer; private _terminated = false; constructor(data: ContextData, task: dom.Task, timeout?: number, title?: string) { this._contextData = data; this._task = task; this._runCount = 0; this.promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); // Since page navigation requires us to re-install the pageScript, we should track // timeout on our end. if (timeout) { const timeoutError = new TimeoutError(`waiting for ${title || 'function'} failed: timeout ${timeout}ms exceeded`); this._timeoutTimer = setTimeout(() => this.terminate(timeoutError), timeout); } } terminate(error: Error) { this._terminated = true; this._reject(error); this._doCleanup(); } async rerun(context: dom.FrameExecutionContext) { const runCount = ++this._runCount; let success: js.JSHandle | null = null; let error = null; try { success = await this._task(context); } catch (e) { error = e; } if (this._terminated || runCount !== this._runCount) { if (success) success.dispose(); return; } // Ignore timeouts in pageScript - we track timeouts ourselves. // If execution context has been already destroyed, `context.evaluate` will // throw an error - ignore this predicate run altogether. if (!error && await context.evaluate(s => !s, success).catch(e => true)) { success!.dispose(); return; } // When the page is navigated, the promise is rejected. // We will try again in the new execution context. if (error && error.message.includes('Execution context was destroyed')) return; // We could have tried to evaluate in a context which was already // destroyed. if (error && error.message.includes('Cannot find context with specified id')) return; if (error) this._reject(error); else this._resolve(success!); this._doCleanup(); } _doCleanup() { if (this._timeoutTimer) clearTimeout(this._timeoutTimer); this._contextData.rerunnableTasks.delete(this); } } type Disposable = {value: T, dispose: () => void}; class Disposer { private _disposes: (() => void)[] = []; add({value, dispose}: Disposable) { this._disposes.push(dispose); return value; } dispose() { for (const dispose of this._disposes) dispose(); this._disposes = []; } } function createTimeoutPromise(timeout: number): Disposable> { if (!timeout) return { value: new Promise(() => {}), dispose: () => void 0 }; let timer: NodeJS.Timer; const errorMessage = 'Navigation timeout of ' + timeout + ' ms exceeded'; const promise = new Promise(fulfill => timer = setTimeout(fulfill, timeout)) .then(() => new TimeoutError(errorMessage)); const dispose = () => { clearTimeout(timer); }; return { value: promise, dispose }; } function selectorToString(selector: string, waitFor: 'attached' | 'detached' | 'visible' | 'hidden'): string { let label; switch (waitFor) { case 'visible': label = '[visible] '; break; case 'hidden': label = '[hidden] '; break; case 'attached': label = ''; break; case 'detached': label = '[detached]'; break; } return `${label}${selector}`; } class PendingNavigationBarrier { private _frameIds = new Map(); private _options: types.NavigatingActionWaitOptions | undefined; private _protectCount = 0; private _promise: Promise; private _promiseCallback = () => {}; constructor(options: types.NavigatingActionWaitOptions | undefined) { this._options = options; this._promise = new Promise(f => this._promiseCallback = f); this.retain(); } waitFor(): Promise { this.release(); return this._promise; } async addFrame(frame: Frame) { this.retain(); await frame.waitForNavigation(this._options as types.NavigateOptions).catch(e => {}); this.release(); } retain() { ++this._protectCount; } release() { --this._protectCount; this._maybeResolve(); } private async _maybeResolve() { if (!this._protectCount && !this._frameIds.size) this._promiseCallback(); } }