/** * 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 { BrowserContext } from './browserContext'; import * as dom from './dom'; import { TimeoutError } from './errors'; import { prepareFilesForUpload } from './fileUploadUtils'; import { FrameSelectors } from './frameSelectors'; import { helper } from './helper'; import { SdkObject, serverSideCallMetadata } from './instrumentation'; import * as js from './javascript'; import * as network from './network'; import { Page } from './page'; import { ProgressController } from './progress'; import * as types from './types'; import { LongStandingScope, asLocator, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime } from '../utils'; import { isSessionClosedError } from './protocolError'; import { debugLogger } from './utils/debugLogger'; import { eventsHelper } from './utils/eventsHelper'; import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser'; import { ManualPromise } from '../utils/isomorphic/manualPromise'; import { compressCallLog } from './callLog'; import type { ConsoleMessage } from './console'; import type { Dialog } from './dialog'; import type { ElementStateWithoutStable, FrameExpectParams, InjectedScript } from '@injected/injectedScript'; import type { CallMetadata } from './instrumentation'; import type { Progress } from './progress'; import type { ScreenshotOptions } from './screenshotter'; import type { RegisteredListener } from './utils/eventsHelper'; import type { ParsedSelector } from '../utils/isomorphic/selectorParser'; import type * as channels from '@protocol/channels'; type ContextData = { contextPromise: ManualPromise; context: dom.FrameExecutionContext | null; }; type DocumentInfo = { // Unfortunately, we don't have documentId when we find out about // a pending navigation from things like frameScheduledNavigaiton. documentId: string | undefined, request: network.Request | undefined, }; export type GotoResult = { newDocumentId?: string, }; type ConsoleTagHandler = () => void; type RegularLifecycleEvent = Exclude; export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any; export type NavigationEvent = { // New frame url after navigation. url: string, // New frame name after navigation. name: string, // Information about the new document for cross-document navigations. // Undefined for same-document navigations. newDocument?: DocumentInfo, // Error for cross-document navigations if any. When error is present, // the navigation did not commit. error?: Error, // Whether this event should be visible to the clients via the public APIs. isPublic?: boolean; }; type ElementCallback = (injected: InjectedScript, element: Element, data: T) => R; export class NavigationAbortedError extends Error { readonly documentId?: string; constructor(documentId: string | undefined, message: string) { super(message); this.documentId = documentId; } } const kDummyFrameId = ''; export class FrameManager { private _page: Page; private _frames = new Map(); private _mainFrame: Frame; readonly _consoleMessageTags = new Map(); readonly _signalBarriers = new Set(); private _webSockets = new Map(); _openedDialogs: Set = new Set(); private _closeAllOpeningDialogs = false; constructor(page: Page) { this._page = page; this._mainFrame = undefined as any as Frame; } createDummyMainFrameIfNeeded() { if (!this._mainFrame) this.frameAttached(kDummyFrameId, null); } dispose() { for (const frame of this._frames.values()) { frame._stopNetworkIdleTimer(); frame._invalidateNonStallingEvaluations('Target crashed'); } } 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(Page.Events.FrameAttached, frame); return frame; } } async waitForSignalsCreatedBy(progress: Progress | null, waitAfter: boolean, action: () => Promise): Promise { if (!waitAfter) return action(); const barrier = new SignalBarrier(progress); this._signalBarriers.add(barrier); if (progress) progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier)); const result = await action(); await this._page._delegate.inputActionEpilogue(); await barrier.waitFor(); this._signalBarriers.delete(barrier); // Resolve in the next task, after all waitForNavigations. await new Promise(makeWaitForNextTask()); return result; } frameWillPotentiallyRequestNavigation() { for (const barrier of this._signalBarriers) barrier.retain(); } frameDidPotentiallyRequestNavigation() { for (const barrier of this._signalBarriers) barrier.release(); } frameRequestedNavigation(frameId: string, documentId?: string) { const frame = this._frames.get(frameId); if (!frame) return; for (const barrier of this._signalBarriers) barrier.addFrameNavigation(frame); if (frame.pendingDocument() && frame.pendingDocument()!.documentId === documentId) { // Do not override request with undefined. return; } const request = documentId ? Array.from(frame._inflightRequests).find(request => request._documentId === documentId) : undefined; frame.setPendingDocument({ documentId, request }); } frameCommittedNewDocumentNavigation(frameId: string, url: string, name: string, documentId: string, initial: boolean) { const frame = this._frames.get(frameId)!; this.removeChildFramesRecursively(frame); this.clearWebSockets(frame); frame._url = url; frame._name = name; let keepPending: DocumentInfo | undefined; const pendingDocument = frame.pendingDocument(); if (pendingDocument) { if (pendingDocument.documentId === undefined) { // Pending with unknown documentId - assume it is the one being committed. pendingDocument.documentId = documentId; } if (pendingDocument.documentId === documentId) { // Committing a pending document. frame._currentDocument = pendingDocument; } else { // Sometimes, we already have a new pending when the old one commits. // An example would be Chromium error page followed by a new navigation request, // where the error page commit arrives after Network.requestWillBeSent for the // new navigation. // We commit, but keep the pending request since it's not done yet. keepPending = pendingDocument; frame._currentDocument = { documentId, request: undefined }; } frame.setPendingDocument(undefined); } else { // No pending - just commit a new document. frame._currentDocument = { documentId, request: undefined }; } frame._onClearLifecycle(); const navigationEvent: NavigationEvent = { url, name, newDocument: frame._currentDocument, isPublic: true }; this._fireInternalFrameNavigation(frame, navigationEvent); if (!initial) { debugLogger.log('api', ` navigated to "${url}"`); this._page.frameNavigatedToNewDocument(frame); } // Restore pending if any - see comments above about keepPending. frame.setPendingDocument(keepPending); } frameCommittedSameDocumentNavigation(frameId: string, url: string) { const frame = this._frames.get(frameId); if (!frame) return; const pending = frame.pendingDocument(); if (pending && pending.documentId === undefined && pending.request === undefined) { // WebKit has notified about the same-document navigation being requested, so clear it. frame.setPendingDocument(undefined); } frame._url = url; const navigationEvent: NavigationEvent = { url, name: frame._name, isPublic: true }; this._fireInternalFrameNavigation(frame, navigationEvent); debugLogger.log('api', ` navigated to "${url}"`); } frameAbortedNavigation(frameId: string, errorText: string, documentId?: string) { const frame = this._frames.get(frameId); if (!frame || !frame.pendingDocument()) return; if (documentId !== undefined && frame.pendingDocument()!.documentId !== documentId) return; const navigationEvent: NavigationEvent = { url: frame._url, name: frame._name, newDocument: frame.pendingDocument(), error: new NavigationAbortedError(documentId, errorText), isPublic: !(documentId && frame._redirectedNavigations.has(documentId)), }; frame.setPendingDocument(undefined); this._fireInternalFrameNavigation(frame, navigationEvent); } frameDetached(frameId: string) { const frame = this._frames.get(frameId); if (frame) { this._removeFramesRecursively(frame); this._page.mainFrame()._recalculateNetworkIdle(); } } frameLifecycleEvent(frameId: string, event: RegularLifecycleEvent) { const frame = this._frames.get(frameId); if (frame) frame._onLifecycleEvent(event); } requestStarted(request: network.Request, route?: network.RouteDelegate) { const frame = request.frame()!; this._inflightRequestStarted(request); if (request._documentId) frame.setPendingDocument({ documentId: request._documentId, request }); if (request._isFavicon) { // Abort favicon requests to avoid network access in case of interception. route?.abort('aborted').catch(() => {}); return; } this._page.emitOnContext(BrowserContext.Events.Request, request); if (route) { const r = new network.Route(request, route); if (this._page._serverRequestInterceptor?.(r, request)) return; if (this._page._clientRequestInterceptor?.(r, request)) return; if (this._page._browserContext._requestInterceptor?.(r, request)) return; r.continue({ isFallback: true }).catch(() => {}); } } requestReceivedResponse(response: network.Response) { if (response.request()._isFavicon) return; this._page.emitOnContext(BrowserContext.Events.Response, response); } reportRequestFinished(request: network.Request, response: network.Response | null) { this._inflightRequestFinished(request); if (request._isFavicon) return; this._page.emitOnContext(BrowserContext.Events.RequestFinished, { request, response }); } requestFailed(request: network.Request, canceled: boolean) { const frame = request.frame()!; this._inflightRequestFinished(request); if (frame.pendingDocument() && frame.pendingDocument()!.request === request) { let errorText = request.failure()!.errorText; if (canceled) errorText += '; maybe frame was detached?'; this.frameAbortedNavigation(frame._id, errorText, frame.pendingDocument()!.documentId); } if (request._isFavicon) return; this._page.emitOnContext(BrowserContext.Events.RequestFailed, request); } dialogDidOpen(dialog: Dialog) { // Any ongoing evaluations will be stalled until the dialog is closed. for (const frame of this._frames.values()) frame._invalidateNonStallingEvaluations('JavaScript dialog interrupted evaluation'); if (this._closeAllOpeningDialogs) dialog.close().then(() => {}); else this._openedDialogs.add(dialog); } dialogWillClose(dialog: Dialog) { this._openedDialogs.delete(dialog); } async closeOpenDialogs() { await Promise.all([...this._openedDialogs].map(dialog => dialog.close())).catch(() => {}); this._openedDialogs.clear(); } setCloseAllOpeningDialogs(closeDialogs: boolean) { this._closeAllOpeningDialogs = closeDialogs; } removeChildFramesRecursively(frame: Frame) { for (const child of frame.childFrames()) this._removeFramesRecursively(child); } private _removeFramesRecursively(frame: Frame) { this.removeChildFramesRecursively(frame); frame._onDetached(); this._frames.delete(frame._id); if (!this._page.isClosed()) this._page.emit(Page.Events.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) frame._startNetworkIdleTimer(); } private _inflightRequestStarted(request: network.Request) { const frame = request.frame()!; if (request._isFavicon) return; frame._inflightRequests.add(request); if (frame._inflightRequests.size === 1) frame._stopNetworkIdleTimer(); } 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; } clearWebSockets(frame: Frame) { // TODO: attribute sockets to frames. if (frame.parentFrame()) return; this._webSockets.clear(); } onWebSocketCreated(requestId: string, url: string) { const ws = new network.WebSocket(this._page, url); this._webSockets.set(requestId, ws); } onWebSocketRequest(requestId: string) { const ws = this._webSockets.get(requestId); if (ws && ws.markAsNotified()) this._page.emit(Page.Events.WebSocket, ws); } onWebSocketResponse(requestId: string, status: number, statusText: string) { const ws = this._webSockets.get(requestId); if (status < 400) return; if (ws) ws.error(`${statusText}: ${status}`); } onWebSocketFrameSent(requestId: string, opcode: number, data: string) { const ws = this._webSockets.get(requestId); if (ws) ws.frameSent(opcode, data); } webSocketFrameReceived(requestId: string, opcode: number, data: string) { const ws = this._webSockets.get(requestId); if (ws) ws.frameReceived(opcode, data); } webSocketClosed(requestId: string) { const ws = this._webSockets.get(requestId); if (ws) ws.closed(); this._webSockets.delete(requestId); } webSocketError(requestId: string, errorMessage: string): void { const ws = this._webSockets.get(requestId); if (ws) ws.error(errorMessage); } private _fireInternalFrameNavigation(frame: Frame, event: NavigationEvent) { frame.emit(Frame.Events.InternalNavigation, event); } } export class Frame extends SdkObject { static Events = { InternalNavigation: 'internalnavigation', AddLifecycle: 'addlifecycle', RemoveLifecycle: 'removelifecycle', }; _id: string; _firedLifecycleEvents = new Set(); private _firedNetworkIdleSelf = false; _currentDocument: DocumentInfo; private _pendingDocument: DocumentInfo | undefined; readonly _page: Page; private _parentFrame: Frame | null; _url = ''; private _contextData = new Map(); private _childFrames = new Set(); _name = ''; _inflightRequests = new Set(); private _networkIdleTimer: NodeJS.Timeout | undefined; private _setContentCounter = 0; readonly _detachedScope = new LongStandingScope(); private _raceAgainstEvaluationStallingEventsPromises = new Set>(); readonly _redirectedNavigations = new Map }>(); // documentId -> data readonly selectors: FrameSelectors; constructor(page: Page, id: string, parentFrame: Frame | null) { super(page, 'frame'); this.attribution.frame = this; this._id = id; this._page = page; this._parentFrame = parentFrame; this._currentDocument = { documentId: undefined, request: undefined }; this.selectors = new FrameSelectors(this); this._contextData.set('main', { contextPromise: new ManualPromise(), context: null }); this._contextData.set('utility', { contextPromise: new ManualPromise(), context: null }); this._setContext('main', null); this._setContext('utility', null); if (this._parentFrame) this._parentFrame._childFrames.add(this); this._firedLifecycleEvents.add('commit'); if (id !== kDummyFrameId) this._startNetworkIdleTimer(); } isDetached(): boolean { return this._detachedScope.isClosed(); } _onLifecycleEvent(event: RegularLifecycleEvent) { if (this._firedLifecycleEvents.has(event)) return; this._firedLifecycleEvents.add(event); this.emit(Frame.Events.AddLifecycle, event); if (this === this._page.mainFrame() && this._url !== 'about:blank') debugLogger.log('api', ` "${event}" event fired`); this._page.mainFrame()._recalculateNetworkIdle(); } _onClearLifecycle() { for (const event of this._firedLifecycleEvents) this.emit(Frame.Events.RemoveLifecycle, event); this._firedLifecycleEvents.clear(); // Keep the current navigation request if any. this._inflightRequests = new Set(Array.from(this._inflightRequests).filter(request => request === this._currentDocument.request)); this._stopNetworkIdleTimer(); if (this._inflightRequests.size === 0) this._startNetworkIdleTimer(); this._page.mainFrame()._recalculateNetworkIdle(this); this._onLifecycleEvent('commit'); } setPendingDocument(documentInfo: DocumentInfo | undefined) { this._pendingDocument = documentInfo; if (documentInfo) this._invalidateNonStallingEvaluations('Navigation interrupted the evaluation'); } pendingDocument(): DocumentInfo | undefined { return this._pendingDocument; } _invalidateNonStallingEvaluations(message: string) { if (!this._raceAgainstEvaluationStallingEventsPromises.size) return; const error = new Error(message); for (const promise of this._raceAgainstEvaluationStallingEventsPromises) promise.reject(error); } async raceAgainstEvaluationStallingEvents(cb: () => Promise): Promise { if (this._pendingDocument) throw new Error('Frame is currently attempting a navigation'); if (this._page._frameManager._openedDialogs.size) throw new Error('Open JavaScript dialog prevents evaluation'); const promise = new ManualPromise(); this._raceAgainstEvaluationStallingEventsPromises.add(promise); try { return await Promise.race([ cb(), promise ]); } finally { this._raceAgainstEvaluationStallingEventsPromises.delete(promise); } } nonStallingRawEvaluateInExistingMainContext(expression: string): Promise { return this.raceAgainstEvaluationStallingEvents(() => { const context = this._existingMainContext(); if (!context) throw new Error('Frame does not yet have a main execution context'); return context.rawEvaluateJSON(expression); }); } nonStallingEvaluateInExistingContext(expression: string, world: types.World): Promise { return this.raceAgainstEvaluationStallingEvents(() => { const context = this._contextData.get(world)?.context; if (!context) throw new Error('Frame does not yet have the execution context'); return context.evaluateExpression(expression, { isFunction: false }); }); } _recalculateNetworkIdle(frameThatAllowsRemovingNetworkIdle?: Frame) { let isNetworkIdle = this._firedNetworkIdleSelf; for (const child of this._childFrames) { child._recalculateNetworkIdle(frameThatAllowsRemovingNetworkIdle); // We require networkidle event to be fired in the whole frame subtree, and then consider it done. if (!child._firedLifecycleEvents.has('networkidle')) isNetworkIdle = false; } if (isNetworkIdle && !this._firedLifecycleEvents.has('networkidle')) { this._firedLifecycleEvents.add('networkidle'); this.emit(Frame.Events.AddLifecycle, 'networkidle'); if (this === this._page.mainFrame() && this._url !== 'about:blank') debugLogger.log('api', ` "networkidle" event fired`); } if (frameThatAllowsRemovingNetworkIdle !== this && this._firedLifecycleEvents.has('networkidle') && !isNetworkIdle) { // Usually, networkidle is fired once and not removed after that. // However, when we clear them right before a new commit, this is allowed for a particular frame. this._firedLifecycleEvents.delete('networkidle'); this.emit(Frame.Events.RemoveLifecycle, 'networkidle'); } } async raceNavigationAction(progress: Progress, options: types.GotoOptions, action: () => Promise): Promise { return LongStandingScope.raceMultiple([ this._detachedScope, this._page.openScope, ], action().catch(e => { if (e instanceof NavigationAbortedError && e.documentId) { const data = this._redirectedNavigations.get(e.documentId); if (data) { progress.log(`waiting for redirected navigation to "${data.url}"`); return data.gotoPromise; } } throw e; })); } redirectNavigation(url: string, documentId: string, referer: string | undefined) { const controller = new ProgressController(serverSideCallMetadata(), this); const data = { url, gotoPromise: controller.run(progress => this._gotoAction(progress, url, { referer }), 0), }; this._redirectedNavigations.set(documentId, data); data.gotoPromise.finally(() => this._redirectedNavigations.delete(documentId)); } async goto(metadata: CallMetadata, url: string, options: types.GotoOptions = {}): Promise { const constructedNavigationURL = constructURLBasedOnBaseURL(this._page._browserContext._options.baseURL, url); const controller = new ProgressController(metadata, this); return controller.run(progress => this._goto(progress, constructedNavigationURL, options), this._page._timeoutSettings.navigationTimeout(options)); } private async _goto(progress: Progress, url: string, options: types.GotoOptions): Promise { return this.raceNavigationAction(progress, options, async () => this._gotoAction(progress, url, options)); } private async _gotoAction(progress: Progress, url: string, options: types.GotoOptions): Promise { const waitUntil = verifyLifecycle('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); progress.log(`navigating to "${url}", waiting until "${waitUntil}"`); const headers = this._page.extraHTTPHeaders() || []; const refererHeader = headers.find(h => h.name.toLowerCase() === 'referer'); let referer = refererHeader ? refererHeader.value : undefined; 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 navigationEvents: NavigationEvent[] = []; const collectNavigations = (arg: NavigationEvent) => navigationEvents.push(arg); this.on(Frame.Events.InternalNavigation, collectNavigations); const navigateResult = await this._page._delegate.navigateFrame(this, url, referer).finally( () => this.off(Frame.Events.InternalNavigation, collectNavigations)); let event: NavigationEvent; if (navigateResult.newDocumentId) { const predicate = (event: NavigationEvent) => { // We are interested either in this specific document, or any other document that // did commit and replaced the expected document. return event.newDocument && (event.newDocument.documentId === navigateResult.newDocumentId || !event.error); }; const events = navigationEvents.filter(predicate); if (events.length) event = events[0]; else event = await helper.waitForEvent(progress, this, Frame.Events.InternalNavigation, predicate).promise; if (event.newDocument!.documentId !== navigateResult.newDocumentId) { // This is just a sanity check. In practice, new navigation should // cancel the previous one and report "request cancelled"-like error. throw new NavigationAbortedError(navigateResult.newDocumentId, `Navigation to "${url}" is interrupted by another navigation to "${event.url}"`); } if (event.error) throw event.error; } else { // Wait for same document navigation. const predicate = (e: NavigationEvent) => !e.newDocument; const events = navigationEvents.filter(predicate); if (events.length) event = events[0]; else event = await helper.waitForEvent(progress, this, Frame.Events.InternalNavigation, predicate).promise; } if (!this._firedLifecycleEvents.has(waitUntil)) await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = event.newDocument ? event.newDocument.request : undefined; const response = request ? request._finalRequest().response() : null; return response; } async _waitForNavigation(progress: Progress, requiresNewDocument: boolean, options: types.NavigateOptions): Promise { const waitUntil = verifyLifecycle('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); progress.log(`waiting for navigation until "${waitUntil}"`); const navigationEvent: NavigationEvent = await helper.waitForEvent(progress, this, Frame.Events.InternalNavigation, (event: NavigationEvent) => { // Any failed navigation results in a rejection. if (event.error) return true; if (requiresNewDocument && !event.newDocument) return false; progress.log(` navigated to "${this._url}"`); return true; }).promise; if (navigationEvent.error) throw navigationEvent.error; if (!this._firedLifecycleEvents.has(waitUntil)) await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; const request = navigationEvent.newDocument ? navigationEvent.newDocument.request : undefined; return request ? request._finalRequest().response() : null; } async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise { const waitUntil = verifyLifecycle('state', state); if (!this._firedLifecycleEvents.has(waitUntil)) await helper.waitForEvent(progress, this, Frame.Events.AddLifecycle, (e: types.LifecycleEvent) => e === waitUntil).promise; } async frameElement(): Promise { return this._page._delegate.getFrameElement(this); } _context(world: types.World): Promise { return this._contextData.get(world)!.contextPromise.then(contextOrDestroyedReason => { if (contextOrDestroyedReason instanceof js.ExecutionContext) return contextOrDestroyedReason; throw new Error(contextOrDestroyedReason.destroyedReason); }); } _mainContext(): Promise { return this._context('main'); } private _existingMainContext(): dom.FrameExecutionContext | null { return this._contextData.get('main')?.context || null; } _utilityContext(): Promise { return this._context('utility'); } async evaluateExpression(expression: string, options: { isFunction?: boolean, world?: types.World } = {}, arg?: any): Promise { const context = await this._context(options.world ?? 'main'); const value = await context.evaluateExpression(expression, options, arg); return value; } async evaluateExpressionHandle(expression: string, options: { isFunction?: boolean, world?: types.World } = {}, arg?: any): Promise> { const context = await this._context(options.world ?? 'main'); const value = await context.evaluateExpressionHandle(expression, options, arg); return value; } async querySelector(selector: string, options: types.StrictOptions): Promise | null> { debugLogger.log('api', ` finding element using the selector "${selector}"`); return this.selectors.query(selector, options); } async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise | null> { const controller = new ProgressController(metadata, this); if ((options as any).visibility) throw new Error('options.visibility is not supported, did you mean options.state?'); if ((options as any).waitFor && (options as any).waitFor !== 'visible') throw new Error('options.waitFor is not supported, did you mean options.state?'); const { state = 'visible' } = options; if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) throw new Error(`state: expected one of (attached|detached|visible|hidden)`); return controller.run(async progress => { progress.log(`waiting for ${this._asLocator(selector)}${state === 'attached' ? '' : ' to be ' + state}`); return await this.waitForSelectorInternal(progress, selector, true, options, scope); }, this._page._timeoutSettings.timeout(options)); } async waitForSelectorInternal(progress: Progress, selector: string, performActionPreChecks: boolean, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise | null> { const { state = 'visible' } = options; const promise = this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { if (performActionPreChecks) await this._page.performActionPreChecks(progress); const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); progress.throwIfAborted(); if (!resolved) { if (state === 'hidden' || state === 'detached') return null; return continuePolling; } const result = await resolved.injected.evaluateHandle((injected, { info, root }) => { if (root && !root.isConnected) throw injected.createStacklessError('Element is not attached to the DOM'); const elements = injected.querySelectorAll(info.parsed, root || document); const element: Element | undefined = elements[0]; const visible = element ? injected.utils.isElementVisible(element) : false; let log = ''; if (elements.length > 1) { if (info.strict) throw injected.strictModeViolationError(info.parsed, elements); log = ` locator resolved to ${elements.length} elements. Proceeding with the first one: ${injected.previewNode(elements[0])}`; } else if (element) { log = ` locator resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`; } return { log, element, visible, attached: !!element }; }, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); const { log, visible, attached } = await result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached })); if (log) progress.log(log); const success = { attached, detached: !attached, visible, hidden: !visible }[state]; if (!success) { result.dispose(); return continuePolling; } if (options.omitReturnValue) { result.dispose(); return null; } const element = state === 'attached' || state === 'visible' ? await result.evaluateHandle(r => r.element) : null; result.dispose(); if (!element) return null; if ((options as any).__testHookBeforeAdoptNode) await (options as any).__testHookBeforeAdoptNode(); try { return await element._adoptTo(await resolved.frame._mainContext()); } catch (e) { return continuePolling; } }); return scope ? scope._context._raceAgainstContextDestroyed(promise) : promise; } async dispatchEvent(metadata: CallMetadata, selector: string, type: string, eventInit: Object = {}, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { await this._callOnElementOnceMatches(metadata, selector, (injectedScript, element, data) => { injectedScript.dispatchEvent(element, data.type, data.eventInit); }, { type, eventInit }, { mainWorld: true, ...options }, scope); } async evalOnSelector(selector: string, strict: boolean, expression: string, isFunction: boolean | undefined, arg: any, scope?: dom.ElementHandle): Promise { const handle = await this.selectors.query(selector, { strict }, scope); if (!handle) throw new Error(`Failed to find element matching selector "${selector}"`); const result = await handle.evaluateExpression(expression, { isFunction }, arg); handle.dispose(); return result; } async evalOnSelectorAll(selector: string, expression: string, isFunction: boolean | undefined, arg: any, scope?: dom.ElementHandle): Promise { const arrayHandle = await this.selectors.queryArrayInMainWorld(selector, scope); const result = await arrayHandle.evaluateExpression(expression, { isFunction }, arg); arrayHandle.dispose(); return result; } async maskSelectors(selectors: ParsedSelector[], color: string): Promise { const context = await this._utilityContext(); const injectedScript = await context.injectedScript(); await injectedScript.evaluate((injected, { parsed, color }) => { injected.maskSelectors(parsed, color); }, { parsed: selectors, color: color }); } async querySelectorAll(selector: string): Promise[]> { return this.selectors.queryAll(selector); } async queryCount(selector: string): Promise { return await this.selectors.queryCount(selector); } async content(): Promise { try { const context = await this._utilityContext(); return await context.evaluate(() => { let retVal = ''; if (document.doctype) retVal = new XMLSerializer().serializeToString(document.doctype); if (document.documentElement) retVal += document.documentElement.outerHTML; return retVal; }); } catch (e) { if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) throw e; throw new Error(`Unable to retrieve content because the page is navigating and changing the content.`); } } async setContent(metadata: CallMetadata, html: string, options: types.NavigateOptions = {}): Promise { const controller = new ProgressController(metadata, this); return controller.run(async progress => { await this.raceNavigationAction(progress, options, async () => { const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil; progress.log(`setting frame content, waiting until "${waitUntil}"`); 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._onClearLifecycle(); this._waitForLoadState(progress, waitUntil).then(resolve).catch(reject); }); }); const contentPromise = context.evaluate(({ html, tag }) => { document.open(); console.debug(tag); // eslint-disable-line no-console document.write(html); document.close(); }, { html, tag }); await Promise.all([contentPromise, lifecyclePromise]); return null; }); }, this._page._timeoutSettings.navigationTimeout(options)); } name(): string { return this._name || ''; } url(): string { return this._url; } origin(): string | undefined { if (!this._url.startsWith('http')) return; return network.parseURL(this._url)?.origin; } parentFrame(): Frame | null { return this._parentFrame; } childFrames(): Frame[] { return Array.from(this._childFrames); } async addScriptTag(params: { url?: string, content?: string, type?: string, }): Promise { const { url = null, content = null, type = '' } = params; if (!url && !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()!; const result = (await context.evaluateHandle(addScriptContent, { content: content!, type })).asElement()!; // Another round trip to the browser to ensure that we receive CSP error messages // (if any) logged asynchronously in a separate task on the content main thread. if (this._page._delegate.cspErrorsAsynchronousForInlineScripts) await context.evaluate(() => true); return result; }); async function addScriptUrl(params: { url: string, type: string }): Promise { const script = document.createElement('script'); script.src = params.url; if (params.type) script.type = params.type; const promise = new Promise((res, rej) => { script.onload = res; script.onerror = e => rej(typeof e === 'string' ? new Error(e) : new Error(`Failed to load script at ${script.src}`)); }); document.head.appendChild(script); await promise; return script; } function addScriptContent(params: { content: string, type: string }): HTMLElement { const script = document.createElement('script'); script.type = params.type || 'text/javascript'; script.text = params.content; let error = null; script.onerror = e => error = e; document.head.appendChild(script); if (error) throw error; return script; } } async addStyleTag(params: { url?: string, content?: string }): Promise { const { url = null, content = null } = params; if (!url && !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()!; 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 = func().then(r => result = r).catch(e => error = e); const errorPromise = new Promise(resolve => { listeners.push(eventsHelper.addEventListener(this._page._browserContext, BrowserContext.Events.Console, (message: ConsoleMessage) => { if (message.page() !== this._page || message.type() !== 'error') return; if (message.text().includes('Content-Security-Policy') || message.text().includes('Content Security Policy')) { cspMessage = message; resolve(); } })); }); await Promise.race([actionPromise, errorPromise]); eventsHelper.removeEventListeners(listeners); if (cspMessage) throw new Error(cspMessage.text()); if (error) throw error; return result!; } async retryWithProgressAndTimeouts(progress: Progress, timeouts: number[], action: (continuePolling: symbol) => Promise): Promise { const continuePolling = Symbol('continuePolling'); timeouts = [0, ...timeouts]; let timeoutIndex = 0; while (progress.isRunning()) { const timeout = timeouts[Math.min(timeoutIndex++, timeouts.length - 1)]; if (timeout) { // Make sure we react immediately upon page close or frame detach. // We need this to show expected/received values in time. const actionPromise = new Promise(f => setTimeout(f, timeout)); await LongStandingScope.raceMultiple([ this._page.openScope, this._detachedScope, ], actionPromise); } progress.throwIfAborted(); try { const result = await action(continuePolling); if (result === continuePolling) continue; return result as R; } catch (e) { if (this._isErrorThatCannotBeRetried(e)) throw e; continue; } } progress.throwIfAborted(); return undefined as any; } private _isErrorThatCannotBeRetried(e: Error) { // Always fail on JavaScript errors or when the main connection is closed. if (js.isJavaScriptErrorInEvaluate(e) || isSessionClosedError(e)) return true; // Certain errors opt-out of the retries, throw. if (dom.isNonRecoverableDOMError(e) || isInvalidSelectorError(e)) return true; // If the call is made on the detached frame - throw. if (this.isDetached()) return true; // Retry upon all other errors. return false; } private async _retryWithProgressIfNotConnected( progress: Progress, selector: string, strict: boolean | undefined, performActionPreChecks: boolean, action: (handle: dom.ElementHandle) => Promise): Promise { progress.log(`waiting for ${this._asLocator(selector)}`); return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { if (performActionPreChecks) await this._page.performActionPreChecks(progress); const resolved = await this.selectors.resolveInjectedForSelector(selector, { strict }); progress.throwIfAborted(); if (!resolved) return continuePolling; const result = await resolved.injected.evaluateHandle((injected, { info, callId }) => { const elements = injected.querySelectorAll(info.parsed, document); if (callId) injected.markTargetElements(new Set(elements), callId); const element = elements[0] as Element | undefined; let log = ''; if (elements.length > 1) { if (info.strict) throw injected.strictModeViolationError(info.parsed, elements); log = ` locator resolved to ${elements.length} elements. Proceeding with the first one: ${injected.previewNode(elements[0])}`; } else if (element) { log = ` locator resolved to ${injected.previewNode(element)}`; } return { log, success: !!element, element }; }, { info: resolved.info, callId: progress.metadata.id }); const { log, success } = await result.evaluate(r => ({ log: r.log, success: r.success })); if (log) progress.log(log); if (!success) { result.dispose(); return continuePolling; } const element = await result.evaluateHandle(r => r.element) as dom.ElementHandle; result.dispose(); try { const result = await action(element); if (result === 'error:notconnected') { progress.log('element was detached from the DOM, retrying'); return continuePolling; } return result; } finally { element?.dispose(); } }); } async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise { return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performActionPreChecks */, async handle => { await handle._frame.rafrafTimeout(timeout); return await this._page._screenshotter.screenshotElement(progress, handle, options); }); } async click(metadata: CallMetadata, selector: string, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions) { const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._click(progress, { ...options, waitAfter: !options.noWaitAfter }))); }, this._page._timeoutSettings.timeout(options)); } async dblclick(metadata: CallMetadata, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions = {}) { const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._dblclick(progress, options))); }, this._page._timeoutSettings.timeout(options)); } async dragAndDrop(metadata: CallMetadata, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions = {}) { const controller = new ProgressController(metadata, this); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, !options.force /* performActionPreChecks */, async handle => { return handle._retryPointerAction(progress, 'move and down', false, async point => { await this._page.mouse.move(point.x, point.y); await this._page.mouse.down(); }, { ...options, waitAfter: 'disabled', position: options.sourcePosition, timeout: progress.timeUntilDeadline(), }); })); // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. dom.assertDone(await this._retryWithProgressIfNotConnected(progress, target, options.strict, false /* performActionPreChecks */, async handle => { return handle._retryPointerAction(progress, 'move and up', false, async point => { await this._page.mouse.move(point.x, point.y); await this._page.mouse.up(); }, { ...options, waitAfter: 'disabled', position: options.targetPosition, timeout: progress.timeUntilDeadline(), }); })); }, this._page._timeoutSettings.timeout(options)); } async tap(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions) { if (!this._page._browserContext._options.hasTouch) throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.'); const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._tap(progress, options))); }, this._page._timeoutSettings.timeout(options)); } async fill(metadata: CallMetadata, selector: string, value: string, options: types.TimeoutOptions & types.StrictOptions & { force?: boolean }) { const controller = new ProgressController(metadata, this); return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performActionPreChecks */, handle => handle._fill(progress, value, options))); }, this._page._timeoutSettings.timeout(options)); } async focus(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) { const controller = new ProgressController(metadata, this); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._focus(progress))); }, this._page._timeoutSettings.timeout(options)); } async blur(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) { const controller = new ProgressController(metadata, this); await controller.run(async progress => { dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._blur(progress))); }, this._page._timeoutSettings.timeout(options)); } async textContent(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { return this._callOnElementOnceMatches(metadata, selector, (injected, element) => element.textContent, undefined, options, scope); } async innerText(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { return this._callOnElementOnceMatches(metadata, selector, (injectedScript, element) => { if (element.namespaceURI !== 'http://www.w3.org/1999/xhtml') throw injectedScript.createStacklessError('Node is not an HTMLElement'); return (element as HTMLElement).innerText; }, undefined, options, scope); } async innerHTML(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { return this._callOnElementOnceMatches(metadata, selector, (injected, element) => element.innerHTML, undefined, options, scope); } async getAttribute(metadata: CallMetadata, selector: string, name: string, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise { return this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => element.getAttribute(data.name), { name }, options, scope); } async inputValue(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { return this._callOnElementOnceMatches(metadata, selector, (injectedScript, node) => { const element = injectedScript.retarget(node, 'follow-label'); if (!element || (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')) throw injectedScript.createStacklessError('Node is not an ,