diff --git a/docs/api.md b/docs/api.md index ff79075e..af9567d0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -195,6 +195,7 @@ * [page.viewport()](#pageviewport) * [page.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]])](#pagewaitforselectororfunctionortimeout-options-args) * [page.waitForFileChooser([options])](#pagewaitforfilechooseroptions) + * [page.waitForFrame(urlOrPredicate[, options])](#pagewaitforframeurlorpredicate-options) * [page.waitForFunction(pageFunction[, options[, ...args]])](#pagewaitforfunctionpagefunction-options-args) * [page.waitForNavigation([options])](#pagewaitfornavigationoptions) * [page.waitForNetworkIdle([options])](#pagewaitfornetworkidleoptions) @@ -269,6 +270,7 @@ * [frame.goto(url[, options])](#framegotourl-options) * [frame.hover(selector)](#framehoverselector) * [frame.isDetached()](#frameisdetached) + * [frame.isOOPFrame()](#frameisoopframe) * [frame.name()](#framename) * [frame.parentFrame()](#frameparentframe) * [frame.select(selector, ...values)](#frameselectselector-values) @@ -385,6 +387,7 @@ - [class: CDPSession](#class-cdpsession) * [cdpSession.connection()](#cdpsessionconnection) * [cdpSession.detach()](#cdpsessiondetach) + * [cdpSession.id()](#cdpsessionid) * [cdpSession.send(method[, ...paramArgs])](#cdpsessionsendmethod-paramargs) - [class: Coverage](#class-coverage) * [coverage.startCSSCoverage([options])](#coveragestartcsscoverageoptions) @@ -2769,6 +2772,19 @@ await fileChooser.accept(['/tmp/myfile.pdf']); > **NOTE** “File picker” refers to the operating system’s file selection UI that lets you browse to a folder and select file(s) to be shared with the web app. It’s not the “Save file” dialog. +#### page.waitForFrame(urlOrPredicate[, options]) + +- `urlOrPredicate` <[string]|[Function]> A URL or predicate to wait for. +- `options` <[Object]> Optional waiting parameters + - `timeout` <[number]> Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method. +- returns: <[Promise]<[Frame]>> Promise which resolves to the matched frame. + +```js +const frame = await page.waitForFrame(async (frame) => { + return frame.name() === 'Test'; +}); +``` + #### page.waitForFunction(pageFunction[, options[, ...args]]) - `pageFunction` <[function]|[string]> Function to be evaluated in browser context @@ -3833,6 +3849,12 @@ If there's no element matching `selector`, the method throws an error. Returns `true` if the frame has been detached, or `false` otherwise. +#### frame.isOOPFrame() + +- returns: <[boolean]> + +Returns `true` if the frame is an OOP frame, or `false` otherwise. + #### frame.name() - returns: <[string]> @@ -5088,6 +5110,12 @@ Returns the underlying connection associated with the session. Can be used to ob Detaches the cdpSession from the target. Once detached, the cdpSession object won't emit any events and can't be used to send messages. +#### cdpSession.id() + +- returns: <[string]> + +Returns the session's id. + #### cdpSession.send(method[, ...paramArgs]) - `method` <[string]> protocol method name diff --git a/src/common/Connection.ts b/src/common/Connection.ts index f5471b7f..405432c1 100644 --- a/src/common/Connection.ts +++ b/src/common/Connection.ts @@ -350,6 +350,13 @@ export class CDPSession extends EventEmitter { this._connection = null; this.emit(CDPSessionEmittedEvents.Disconnected); } + + /** + * @internal + */ + id(): string { + return this._sessionId; + } } /** diff --git a/src/common/DOMWorld.ts b/src/common/DOMWorld.ts index 14d5b103..0ff005e1 100644 --- a/src/common/DOMWorld.ts +++ b/src/common/DOMWorld.ts @@ -37,6 +37,7 @@ import { } from './EvalTypes.js'; import { isNode } from '../environment.js'; import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; // predicateQueryHandler and checkWaitForOptions are declared here so that // TypeScript knows about them when used in the predicate function below. @@ -72,6 +73,7 @@ export interface PageBinding { */ export class DOMWorld { private _frameManager: FrameManager; + private _client: CDPSession; private _frame: Frame; private _timeoutSettings: TimeoutSettings; private _documentPromise?: Promise = null; @@ -96,15 +98,19 @@ export class DOMWorld { `${name}_${contextId}`; constructor( + client: CDPSession, frameManager: FrameManager, frame: Frame, timeoutSettings: TimeoutSettings ) { + // Keep own reference to client because it might differ from the FrameManager's + // client for OOP iframes. + this._client = client; this._frameManager = frameManager; this._frame = frame; this._timeoutSettings = timeoutSettings; this._setContext(null); - frameManager._client.on('Runtime.bindingCalled', (event) => + this._client.on('Runtime.bindingCalled', (event) => this._onBindingCalled(event) ); } diff --git a/src/common/FrameManager.ts b/src/common/FrameManager.ts index 8c96832d..f9fc0964 100644 --- a/src/common/FrameManager.ts +++ b/src/common/FrameManager.ts @@ -14,11 +14,9 @@ * limitations under the License. */ -import { debug } from '../common/Debug.js'; - import { EventEmitter } from './EventEmitter.js'; import { assert } from './assert.js'; -import { helper, debugError } from './helper.js'; +import { helper } from './helper.js'; import { ExecutionContext, EVALUATION_SCRIPT_URL } from './ExecutionContext.js'; import { LifecycleWatcher, @@ -27,7 +25,7 @@ import { import { DOMWorld, WaitForSelectorOptions } from './DOMWorld.js'; import { NetworkManager } from './NetworkManager.js'; import { TimeoutSettings } from './TimeoutSettings.js'; -import { CDPSession } from './Connection.js'; +import { Connection, CDPSession } from './Connection.js'; import { JSHandle, ElementHandle } from './JSHandle.js'; import { MouseButton } from './Input.js'; import { Page } from './Page.js'; @@ -72,9 +70,10 @@ export class FrameManager extends EventEmitter { private _networkManager: NetworkManager; _timeoutSettings: TimeoutSettings; private _frames = new Map(); - private _contextIdToContext = new Map(); + private _contextIdToContext = new Map(); private _isolatedWorlds = new Set(); private _mainFrame: Frame; + private _disconnectPromise?: Promise; constructor( client: CDPSession, @@ -87,53 +86,81 @@ export class FrameManager extends EventEmitter { this._page = page; this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); this._timeoutSettings = timeoutSettings; - this._client.on('Page.frameAttached', (event) => - this._onFrameAttached(event.frameId, event.parentFrameId) - ); - this._client.on('Page.frameNavigated', (event) => - this._onFrameNavigated(event.frame) - ); - this._client.on('Page.navigatedWithinDocument', (event) => - this._onFrameNavigatedWithinDocument(event.frameId, event.url) - ); - this._client.on('Page.frameDetached', (event) => - this._onFrameDetached(event.frameId) - ); - this._client.on('Page.frameStoppedLoading', (event) => - this._onFrameStoppedLoading(event.frameId) - ); - this._client.on('Runtime.executionContextCreated', (event) => - this._onExecutionContextCreated(event.context) - ); - this._client.on('Runtime.executionContextDestroyed', (event) => - this._onExecutionContextDestroyed(event.executionContextId) - ); - this._client.on('Runtime.executionContextsCleared', () => - this._onExecutionContextsCleared() - ); - this._client.on('Page.lifecycleEvent', (event) => - this._onLifecycleEvent(event) - ); - this._client.on('Target.attachedToTarget', async (event) => - this._onFrameMoved(event) - ); + this.setupEventListeners(this._client); } - async initialize(): Promise { - const result = await Promise.all([ - this._client.send('Page.enable'), - this._client.send('Page.getFrameTree'), - ]); + private setupEventListeners(session: CDPSession) { + session.on('Page.frameAttached', (event) => { + this._onFrameAttached(session, event.frameId, event.parentFrameId); + }); + session.on('Page.frameNavigated', (event) => { + this._onFrameNavigated(event.frame); + }); + session.on('Page.navigatedWithinDocument', (event) => { + this._onFrameNavigatedWithinDocument(event.frameId, event.url); + }); + session.on( + 'Page.frameDetached', + (event: Protocol.Page.FrameDetachedEvent) => { + this._onFrameDetached( + event.frameId, + event.reason as Protocol.Page.FrameDetachedEventReason + ); + } + ); + session.on('Page.frameStoppedLoading', (event) => { + this._onFrameStoppedLoading(event.frameId); + }); + session.on('Runtime.executionContextCreated', (event) => { + this._onExecutionContextCreated(event.context, session); + }); + session.on('Runtime.executionContextDestroyed', (event) => { + this._onExecutionContextDestroyed(event.executionContextId, session); + }); + session.on('Runtime.executionContextsCleared', () => { + this._onExecutionContextsCleared(session); + }); + session.on('Page.lifecycleEvent', (event) => { + this._onLifecycleEvent(event); + }); + session.on('Target.attachedToTarget', async (event) => { + this._onAttachedToTarget(event); + }); + session.on('Target.detachedFromTarget', async (event) => { + this._onDetachedFromTarget(event); + }); + } - const { frameTree } = result[1]; - this._handleFrameTree(frameTree); - await Promise.all([ - this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), - this._client - .send('Runtime.enable') - .then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), - this._networkManager.initialize(), - ]); + async initialize(client: CDPSession = this._client): Promise { + try { + const result = await Promise.all([ + client.send('Page.enable'), + client.send('Page.getFrameTree'), + ]); + + const { frameTree } = result[1]; + this._handleFrameTree(client, frameTree); + await Promise.all([ + client.send('Page.setLifecycleEventsEnabled', { enabled: true }), + client + .send('Runtime.enable') + .then(() => this._ensureIsolatedWorld(client, UTILITY_WORLD_NAME)), + // TODO: Network manager is not aware of OOP iframes yet. + client === this._client + ? this._networkManager.initialize() + : Promise.resolve(), + ]); + } catch (error) { + // The target might have been closed before the initialization finished. + if ( + error.message.includes('Target closed') || + error.message.includes('Session closed') + ) { + return; + } + + throw error; + } } networkManager(): NetworkManager { @@ -219,18 +246,31 @@ export class FrameManager extends EventEmitter { return watcher.navigationResponse(); } - private async _onFrameMoved(event: Protocol.Target.AttachedToTargetEvent) { + private async _onAttachedToTarget( + event: Protocol.Target.AttachedToTargetEvent + ) { if (event.targetInfo.type !== 'iframe') { return; } - // TODO(sadym): Remove debug message once proper OOPIF support is - // implemented: https://github.com/puppeteer/puppeteer/issues/2548 - debug('puppeteer:frame')( - `The frame '${event.targetInfo.targetId}' moved to another session. ` + - `Out-of-process iframes (OOPIF) are not supported by Puppeteer yet. ` + - `https://github.com/puppeteer/puppeteer/issues/2548` + const frame = this._frames.get(event.targetInfo.targetId); + const session = Connection.fromSession(this._client).session( + event.sessionId ); + frame._updateClient(session); + this.setupEventListeners(session); + await this.initialize(session); + } + + private async _onDetachedFromTarget( + event: Protocol.Target.DetachedFromTargetEvent + ) { + const frame = this._frames.get(event.targetId); + if (frame && frame.isOOPFrame()) { + // When an OOP iframe is removed from the page, it + // will only get a Target.detachedFromTarget event. + this._removeFramesRecursively(frame); + } } _onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { @@ -247,13 +287,23 @@ export class FrameManager extends EventEmitter { this.emit(FrameManagerEmittedEvents.LifecycleEvent, frame); } - _handleFrameTree(frameTree: Protocol.Page.FrameTree): void { - if (frameTree.frame.parentId) - this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); + _handleFrameTree( + session: CDPSession, + frameTree: Protocol.Page.FrameTree + ): void { + if (frameTree.frame.parentId) { + this._onFrameAttached( + session, + frameTree.frame.id, + frameTree.frame.parentId + ); + } this._onFrameNavigated(frameTree.frame); if (!frameTree.childFrames) return; - for (const child of frameTree.childFrames) this._handleFrameTree(child); + for (const child of frameTree.childFrames) { + this._handleFrameTree(session, child); + } } page(): Page { @@ -272,11 +322,24 @@ export class FrameManager extends EventEmitter { return this._frames.get(frameId) || null; } - _onFrameAttached(frameId: string, parentFrameId?: string): void { - if (this._frames.has(frameId)) return; + _onFrameAttached( + session: CDPSession, + frameId: string, + parentFrameId?: string + ): void { + if (this._frames.has(frameId)) { + const frame = this._frames.get(frameId); + if (session && frame.isOOPFrame()) { + // If an OOP iframes becomes a normal iframe again + // it is first attached to the parent page before + // the target is removed. + frame._updateClient(session); + } + return; + } assert(parentFrameId); const parentFrame = this._frames.get(parentFrameId); - const frame = new Frame(this, parentFrame, frameId); + const frame = new Frame(this, parentFrame, frameId, session); this._frames.set(frame._id, frame); this.emit(FrameManagerEmittedEvents.FrameAttached, frame); } @@ -305,7 +368,7 @@ export class FrameManager extends EventEmitter { frame._id = framePayload.id; } else { // Initial main frame navigation. - frame = new Frame(this, null, framePayload.id); + frame = new Frame(this, null, framePayload.id, this._client); } this._frames.set(framePayload.id, frame); this._mainFrame = frame; @@ -317,24 +380,26 @@ export class FrameManager extends EventEmitter { this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); } - async _ensureIsolatedWorld(name: string): Promise { - if (this._isolatedWorlds.has(name)) return; - this._isolatedWorlds.add(name); - await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + async _ensureIsolatedWorld(session: CDPSession, name: string): Promise { + const key = `${session.id()}:${name}`; + if (this._isolatedWorlds.has(key)) return; + this._isolatedWorlds.add(key); + + await session.send('Page.addScriptToEvaluateOnNewDocument', { source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`, worldName: name, }); // Frames might be removed before we send this. await Promise.all( - this.frames().map((frame) => - this._client - .send('Page.createIsolatedWorld', { + this.frames() + .filter((frame) => frame._client === session) + .map((frame) => + session.send('Page.createIsolatedWorld', { frameId: frame._id, worldName: name, grantUniveralAccess: true, }) - .catch(debugError) - ) + ) ); } @@ -346,19 +411,31 @@ export class FrameManager extends EventEmitter { this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); } - _onFrameDetached(frameId: string): void { + _onFrameDetached( + frameId: string, + reason: Protocol.Page.FrameDetachedEventReason + ): void { const frame = this._frames.get(frameId); - if (frame) this._removeFramesRecursively(frame); + if (reason === 'remove') { + // Only remove the frame if the reason for the detached event is + // an actual removement of the frame. + // For frames that become OOP iframes, the reason would be 'swap'. + if (frame) this._removeFramesRecursively(frame); + } } _onExecutionContextCreated( - contextPayload: Protocol.Runtime.ExecutionContextDescription + contextPayload: Protocol.Runtime.ExecutionContextDescription, + session: CDPSession ): void { const auxData = contextPayload.auxData as { frameId?: string }; const frameId = auxData ? auxData.frameId : null; const frame = this._frames.get(frameId) || null; let world = null; if (frame) { + // Only care about execution contexts created for the current session. + if (frame._client !== session) return; + if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) { world = frame._mainWorld; } else if ( @@ -371,27 +448,43 @@ export class FrameManager extends EventEmitter { world = frame._secondaryWorld; } } - const context = new ExecutionContext(this._client, contextPayload, world); + const context = new ExecutionContext( + frame._client || this._client, + contextPayload, + world + ); if (world) world._setContext(context); - this._contextIdToContext.set(contextPayload.id, context); + const key = `${session.id()}:${contextPayload.id}`; + this._contextIdToContext.set(key, context); } - private _onExecutionContextDestroyed(executionContextId: number): void { - const context = this._contextIdToContext.get(executionContextId); + private _onExecutionContextDestroyed( + executionContextId: number, + session: CDPSession + ): void { + const key = `${session.id()}:${executionContextId}`; + const context = this._contextIdToContext.get(key); if (!context) return; - this._contextIdToContext.delete(executionContextId); + this._contextIdToContext.delete(key); if (context._world) context._world._setContext(null); } - private _onExecutionContextsCleared(): void { - for (const context of this._contextIdToContext.values()) { + private _onExecutionContextsCleared(session: CDPSession): void { + for (const [key, context] of this._contextIdToContext.entries()) { + // Make sure to only clear execution contexts that belong + // to the current session. + if (context._client !== session) continue; if (context._world) context._world._setContext(null); + this._contextIdToContext.delete(key); } - this._contextIdToContext.clear(); } - executionContextById(contextId: number): ExecutionContext { - const context = this._contextIdToContext.get(contextId); + executionContextById( + contextId: number, + session: CDPSession = this._client + ): ExecutionContext { + const key = `${session.id()}:${contextId}`; + const context = this._contextIdToContext.get(key); assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); return context; } @@ -563,6 +656,10 @@ export class Frame { * @internal */ _childFrames: Set; + /** + * @internal + */ + _client: CDPSession; /** * @internal @@ -570,7 +667,8 @@ export class Frame { constructor( frameManager: FrameManager, parentFrame: Frame | null, - frameId: string + frameId: string, + client: CDPSession ) { this._frameManager = frameManager; this._parentFrame = parentFrame; @@ -579,19 +677,34 @@ export class Frame { this._detached = false; this._loaderId = ''; - this._mainWorld = new DOMWorld( - frameManager, - this, - frameManager._timeoutSettings - ); - this._secondaryWorld = new DOMWorld( - frameManager, - this, - frameManager._timeoutSettings - ); this._childFrames = new Set(); if (this._parentFrame) this._parentFrame._childFrames.add(this); + + this._updateClient(client); + } + + /** + * @internal + */ + _updateClient(client: CDPSession): void { + this._client = client; + this._mainWorld = new DOMWorld( + this._client, + this._frameManager, + this, + this._frameManager._timeoutSettings + ); + this._secondaryWorld = new DOMWorld( + this._client, + this._frameManager, + this, + this._frameManager._timeoutSettings + ); + } + + isOOPFrame(): boolean { + return this._client !== this._frameManager._client; } /** diff --git a/src/common/Page.ts b/src/common/Page.ts index 21f85c7d..1b2695b4 100644 --- a/src/common/Page.ts +++ b/src/common/Page.ts @@ -489,34 +489,41 @@ export class Page extends EventEmitter { this._screenshotTaskQueue = screenshotTaskQueue; this._viewport = null; - client.on('Target.attachedToTarget', (event) => { - if ( - event.targetInfo.type !== 'worker' && - event.targetInfo.type !== 'iframe' - ) { - // If we don't detach from service workers, they will never die. - // We still want to attach to workers for emitting events. - // We still want to attach to iframes so sessions may interact with them. - // We detach from all other types out of an abundance of caution. - // See https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypePage%5B%5D%22 - // for the complete list of available types. - client - .send('Target.detachFromTarget', { - sessionId: event.sessionId, - }) - .catch(debugError); - return; + client.on( + 'Target.attachedToTarget', + (event: Protocol.Target.AttachedToTargetEvent) => { + if ( + event.targetInfo.type !== 'worker' && + event.targetInfo.type !== 'iframe' + ) { + // If we don't detach from service workers, they will never die. + // We still want to attach to workers for emitting events. + // We still want to attach to iframes so sessions may interact with them. + // We detach from all other types out of an abundance of caution. + // See https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?ss=chromium&q=f:devtools%20-f:out%20%22::kTypePage%5B%5D%22 + // for the complete list of available types. + client + .send('Target.detachFromTarget', { + sessionId: event.sessionId, + }) + .catch(debugError); + return; + } + if (event.targetInfo.type === 'worker') { + const session = Connection.fromSession(client).session( + event.sessionId + ); + const worker = new WebWorker( + session, + event.targetInfo.url, + this._addConsoleMessage.bind(this), + this._handleException.bind(this) + ); + this._workers.set(event.sessionId, worker); + this.emit(PageEmittedEvents.WorkerCreated, worker); + } } - const session = Connection.fromSession(client).session(event.sessionId); - const worker = new WebWorker( - session, - event.targetInfo.url, - this._addConsoleMessage.bind(this), - this._handleException.bind(this) - ); - this._workers.set(event.sessionId, worker); - this.emit(PageEmittedEvents.WorkerCreated, worker); - }); + ); client.on('Target.detachedFromTarget', (event) => { const worker = this._workers.get(event.sessionId); if (!worker) return; @@ -1532,7 +1539,8 @@ export class Page extends EventEmitter { return; } const context = this._frameManager.executionContextById( - event.executionContextId + event.executionContextId, + this._client ); const values = event.args.map((arg) => createJSHandle(context, arg)); this._addConsoleMessage(event.type, values, event.stackTrace); @@ -2000,6 +2008,55 @@ export class Page extends EventEmitter { ); } + /** + * @param urlOrPredicate - A URL or predicate to wait for. + * @param options - Optional waiting parameters + * @returns Promise which resolves to the matched frame. + * @example + * ```js + * const frame = await page.waitForFrame(async (frame) => { + * return frame.name() === 'Test'; + * }); + * ``` + * @remarks + * Optional Parameter have: + * + * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, + * pass `0` to disable the timeout. The default value can be changed by using + * the {@link Page.setDefaultTimeout} method. + */ + async waitForFrame( + urlOrPredicate: string | ((frame: Frame) => boolean | Promise), + options: { timeout?: number } = {} + ): Promise { + const { timeout = this._timeoutSettings.timeout() } = options; + + async function predicate(frame: Frame) { + if (helper.isString(urlOrPredicate)) + return urlOrPredicate === frame.url(); + if (typeof urlOrPredicate === 'function') + return !!(await urlOrPredicate(frame)); + return false; + } + + return Promise.race([ + helper.waitForEvent( + this._frameManager, + FrameManagerEmittedEvents.FrameAttached, + predicate, + timeout, + this._sessionClosePromise() + ), + helper.waitForEvent( + this._frameManager, + FrameManagerEmittedEvents.FrameNavigated, + predicate, + timeout, + this._sessionClosePromise() + ), + ]); + } + /** * This method navigate to the previous page in history. * @param options - Navigation parameters diff --git a/test/assets/dynamic-oopif.html b/test/assets/dynamic-oopif.html index f00c741d..38614d02 100644 --- a/test/assets/dynamic-oopif.html +++ b/test/assets/dynamic-oopif.html @@ -3,7 +3,7 @@ window.addEventListener('DOMContentLoaded', () => { const iframe = document.createElement('iframe'); const url = new URL(location.href); url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost'; - url.pathname = '/grid.html'; + url.pathname = '/oopif.html'; iframe.src = url.toString(); document.body.appendChild(iframe); }, false); diff --git a/test/assets/oopif.html b/test/assets/oopif.html new file mode 100644 index 00000000..0761e8ab --- /dev/null +++ b/test/assets/oopif.html @@ -0,0 +1,2 @@ +Navigate within document + \ No newline at end of file diff --git a/test/oopif.spec.ts b/test/oopif.spec.ts index 845429a6..0880363e 100644 --- a/test/oopif.spec.ts +++ b/test/oopif.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import utils from './utils.js'; import expect from 'expect'; import { getTestState, describeChromeOnly } from './mocha-utils'; // eslint-disable-line import/extensions @@ -47,21 +48,226 @@ describeChromeOnly('OOPIF', function () { await browser.close(); browser = null; }); - xit('should report oopif frames', async () => { + it('should treat OOP iframes and normal iframes the same', async () => { const { server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => + frame.url().endsWith('/empty.html') + ); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame( + page, + 'frame2', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + await framePromise; + expect(page.mainFrame().childFrames()).toHaveLength(2); + }); + it('should track navigations within OOP iframes', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + await utils.attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + expect(frame.url()).toContain('/empty.html'); + await utils.navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/assets/frame.html' + ); + expect(frame.url()).toContain('/assets/frame.html'); + }); + it('should support OOP iframes becoming normal iframes again', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + + const frame = await framePromise; + expect(frame.isOOPFrame()).toBe(false); + await utils.navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + expect(frame.isOOPFrame()).toBe(true); + await utils.navigateFrame(page, 'frame1', server.EMPTY_PAGE); + expect(frame.isOOPFrame()).toBe(false); + expect(page.frames()).toHaveLength(2); + }); + it('should support frames within OOP frames', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame1Promise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + const frame2Promise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 2; + }); + await utils.attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/frames/one-frame.html' + ); + + const [frame1, frame2] = await Promise.all([frame1Promise, frame2Promise]); + + expect(await frame1.evaluate(() => document.location.href)).toMatch( + /one-frame\.html$/ + ); + expect(await frame2.evaluate(() => document.location.href)).toMatch( + /frames\/frame\.html$/ + ); + }); + it('should support OOP iframes getting detached', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + + const frame = await framePromise; + expect(frame.isOOPFrame()).toBe(false); + await utils.navigateFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + expect(frame.isOOPFrame()).toBe(true); + await utils.detachFrame(page, 'frame1'); + expect(page.frames()).toHaveLength(1); + }); + it('should keep track of a frames OOP state', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + await utils.attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + expect(frame.url()).toContain('/empty.html'); + await utils.navigateFrame(page, 'frame1', server.EMPTY_PAGE); + expect(frame.url()).toBe(server.EMPTY_PAGE); + }); + it('should support evaluating in oop iframes', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + await utils.attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + const frame = await framePromise; + await frame.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + _test = 'Test 123!'; + }); + const result = await frame.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return window._test; + }); + expect(result).toBe('Test 123!'); + }); + it('should provide access to elements', async () => { + const { server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const framePromise = page.waitForFrame((frame) => { + return page.frames().indexOf(frame) === 1; + }); + await utils.attachFrame( + page, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + + const frame = await framePromise; + await frame.evaluate(() => { + const button = document.createElement('button'); + button.id = 'test-button'; + document.body.appendChild(button); + }); + + await frame.click('#test-button'); + }); + it('should report oopif frames', async () => { + const { server } = getTestState(); + + const frame = page.waitForFrame((frame) => + frame.url().endsWith('/oopif.html') + ); await page.goto(server.PREFIX + '/dynamic-oopif.html'); + await frame; expect(oopifs(context).length).toBe(1); expect(page.frames().length).toBe(2); }); it('should load oopif iframes with subresources and request interception', async () => { const { server } = getTestState(); + const frame = page.waitForFrame((frame) => + frame.url().endsWith('/oopif.html') + ); await page.setRequestInterception(true); page.on('request', (request) => request.continue()); await page.goto(server.PREFIX + '/dynamic-oopif.html'); + await frame; expect(oopifs(context).length).toBe(1); }); + it('should support frames within OOP iframes', async () => { + const { server } = getTestState(); + + const oopIframePromise = page.waitForFrame((frame) => { + return frame.url().endsWith('/oopif.html'); + }); + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + const oopIframe = await oopIframePromise; + await utils.attachFrame( + oopIframe, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/empty.html' + ); + + const frame1 = oopIframe.childFrames()[0]; + expect(frame1.url()).toMatch(/empty.html$/); + await utils.navigateFrame( + oopIframe, + 'frame1', + server.CROSS_PROCESS_PREFIX + '/oopif.html' + ); + expect(frame1.url()).toMatch(/oopif.html$/); + await frame1.goto( + server.CROSS_PROCESS_PREFIX + '/oopif.html#navigate-within-document', + { waitUntil: 'load' } + ); + expect(frame1.url()).toMatch(/oopif.html#navigate-within-document$/); + await utils.detachFrame(oopIframe, 'frame1'); + expect(oopIframe.childFrames()).toHaveLength(0); + }); }); /**