From b013f856177241f97ea83e7a0ada7e41b981e363 Mon Sep 17 00:00:00 2001 From: Blake Byrnes Date: Mon, 27 Feb 2023 15:14:13 -0500 Subject: [PATCH] feat(agent): enable out of process iframes --- agent/main/lib/Browser.ts | 21 +- agent/main/lib/BrowserContext.ts | 28 +- agent/main/lib/DevtoolsSessionLogger.ts | 7 +- agent/main/lib/DomStorageTracker.ts | 3 +- agent/main/lib/Frame.ts | 70 ++++- agent/main/lib/FrameOutOfProcess.ts | 58 ++++ agent/main/lib/FramesManager.ts | 235 ++++++++++++----- agent/main/lib/InjectedScripts.ts | 4 + agent/main/lib/NetworkManager.ts | 3 +- agent/main/lib/Page.ts | 72 +++-- agent/main/lib/Plugins.ts | 5 + agent/main/test/Frames.oopif.test.ts | 248 ++++++++++++++++++ agent/main/test/Frames.test.ts | 5 +- agent/main/test/_pageTestUtils.ts | 85 +++++- agent/main/test/assets/dynamic-oopif.html | 15 ++ agent/main/test/assets/inner-frame1.html | 10 + agent/main/test/assets/inner-frame2.html | 1 + agent/main/test/assets/lazy-oopif-frame.html | 3 + agent/main/test/assets/main-frame.html | 10 + agent/main/test/server/index.ts | 2 +- plugins/default-browser-emulator/index.ts | 13 + .../lib/helpers/configureBrowserLaunchArgs.ts | 3 +- .../lib/helpers/setGeolocation.ts | 11 +- .../lib/setPageDomOverrides.ts | 8 +- .../agent/browser/IBrowserContext.ts | 5 +- specification/agent/browser/IFrame.ts | 13 +- specification/agent/browser/IPage.ts | 4 +- specification/agent/hooks/IBrowserHooks.ts | 2 + 28 files changed, 816 insertions(+), 128 deletions(-) create mode 100644 agent/main/lib/FrameOutOfProcess.ts create mode 100644 agent/main/test/Frames.oopif.test.ts create mode 100644 agent/main/test/assets/dynamic-oopif.html create mode 100644 agent/main/test/assets/inner-frame1.html create mode 100644 agent/main/test/assets/inner-frame2.html create mode 100644 agent/main/test/assets/lazy-oopif-frame.html create mode 100644 agent/main/test/assets/main-frame.html diff --git a/agent/main/lib/Browser.ts b/agent/main/lib/Browser.ts index 8e1a29764..e36d11699 100755 --- a/agent/main/lib/Browser.ts +++ b/agent/main/lib/Browser.ts @@ -18,9 +18,9 @@ import env from '../env'; import DevtoolsPreferences from './DevtoolsPreferences'; import Page, { IPageCreateOptions } from './Page'; import IConnectionTransport from '../interfaces/IConnectionTransport'; +import { IBrowserContextHooks } from '@ulixee/unblocked-specification/agent/hooks/IBrowserHooks'; import GetVersionResponse = Protocol.Browser.GetVersionResponse; import TargetInfo = Protocol.Target.TargetInfo; -import { IBrowserContextHooks } from '@ulixee/unblocked-specification/agent/hooks/IBrowserHooks'; const { log } = Log(module); @@ -293,6 +293,7 @@ export default class Browser extends TypedEventEmitter implement this.devtoolsSession.on('Target.attachedToTarget', this.onAttachedToTarget.bind(this)); this.devtoolsSession.on('Target.detachedFromTarget', this.onDetachedFromTarget.bind(this)); this.devtoolsSession.on('Target.targetCreated', this.onTargetCreated.bind(this)); + this.devtoolsSession.on('Target.targetInfoChanged', this.onTargetInfoChanged.bind(this)); this.devtoolsSession.on('Target.targetDestroyed', this.onTargetDestroyed.bind(this)); this.devtoolsSession.on('Target.targetCrashed', this.onTargetCrashed.bind(this)); } @@ -358,6 +359,10 @@ export default class Browser extends TypedEventEmitter implement assert(targetInfo.browserContextId, `targetInfo: ${JSON.stringify(targetInfo, null, 2)}`); + this.browserContextsById + .get(targetInfo.browserContextId) + ?.targetsById.set(targetInfo.targetId, targetInfo); + const isDevtoolsPanel = targetInfo.url.startsWith('devtools://devtools'); if ( event.targetInfo.type === 'page' && @@ -400,6 +405,8 @@ export default class Browser extends TypedEventEmitter implement const context = new BrowserContext(this, false); context.hooks = this.browserContextCreationHooks ?? {}; context.id = targetInfo.browserContextId; + context.targetsById.set(targetInfo.targetId, targetInfo); + if (this.connectOnlyToPageTargets) { context.addPageInitializationOptions(this.connectOnlyToPageTargets); } @@ -409,7 +416,7 @@ export default class Browser extends TypedEventEmitter implement if (targetInfo.type === 'page' && !isDevtoolsPanel) { const devtoolsSession = this.connection.getSession(sessionId); const context = this.getBrowserContext(targetInfo.browserContextId); - context?.onPageAttached(devtoolsSession, targetInfo).catch(() => null); + context?.onPageAttached(devtoolsSession, targetInfo).catch(console.error); return; } @@ -456,11 +463,21 @@ export default class Browser extends TypedEventEmitter implement } } + private onTargetInfoChanged(event: Protocol.Target.TargetInfoChangedEvent): void { + const { targetInfo } = event; + this.browserContextsById + .get(targetInfo.browserContextId) + ?.targetsById.set(targetInfo.targetId, targetInfo); + } + private async onTargetCreated(event: Protocol.Target.TargetCreatedEvent): Promise { const { targetInfo } = event; if (this.debugLog) { log.stats('onTargetCreated', { targetInfo, sessionId: null }); } + this.browserContextsById + .get(targetInfo.browserContextId) + ?.targetsById.set(targetInfo.targetId, targetInfo); if (targetInfo.type === 'page' && !targetInfo.attached) { const context = this.getBrowserContext(targetInfo.browserContextId); diff --git a/agent/main/lib/BrowserContext.ts b/agent/main/lib/BrowserContext.ts index 929529b8c..2929ad36d 100644 --- a/agent/main/lib/BrowserContext.ts +++ b/agent/main/lib/BrowserContext.ts @@ -10,7 +10,6 @@ import { TypedEventEmitter } from '@ulixee/commons/lib/eventUtils'; import { IBoundLog } from '@ulixee/commons/interfaces/ILog'; import { CanceledPromiseError } from '@ulixee/commons/interfaces/IPendingWaitEvent'; import ProtocolMapping from 'devtools-protocol/types/protocol-mapping'; -import { IPage } from '@ulixee/unblocked-specification/agent/browser/IPage'; import { IBrowserContextHooks, IInteractHooks, @@ -28,6 +27,7 @@ import Resources from './Resources'; import WebsocketMessages from './WebsocketMessages'; import { DefaultCommandMarker } from './DefaultCommandMarker'; import DevtoolsSessionLogger from './DevtoolsSessionLogger'; +import FrameOutOfProcess from './FrameOutOfProcess'; import CookieParam = Protocol.Network.CookieParam; import TargetInfo = Protocol.Target.TargetInfo; import CreateBrowserContextRequest = Protocol.Target.CreateBrowserContextRequest; @@ -54,6 +54,7 @@ export default class BrowserContext public workersById = new Map(); public pagesById = new Map(); public pagesByTabId = new Map(); + public targetsById = new Map(); public devtoolsSessionsById = new Map(); public devtoolsSessionLogger: DevtoolsSessionLogger; public proxy: IProxyConnectionOptions; @@ -126,8 +127,6 @@ export default class BrowserContext }); } - public defaultPageInitializationFn: (page: IPage) => Promise = () => Promise.resolve(); - async newPage(options?: IPageCreateOptions): Promise { const createTargetPromise = new Resolvable(); this.creatingTargetPromises.push(createTargetPromise.promise); @@ -178,11 +177,29 @@ export default class BrowserContext initializePage(page: Page): Promise { if (page.runPageScripts === false) return Promise.resolve(); - return Promise.all([this.defaultPageInitializationFn(page), this.hooks.onNewPage?.(page)]); + return this.hooks.onNewPage?.(page) ?? Promise.resolve(); + } + + initializeOutOfProcessIframe(frame: FrameOutOfProcess): Promise { + if (frame.page.runPageScripts === false) return Promise.resolve(); + + return this.hooks.onNewFrameProcess?.(frame.frame) ?? Promise.resolve(); + } + + onIframeAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo, pageId: string): void { + const page = this.pagesById.get(pageId); + if (!page) return; + + this.devtoolsSessionLogger.subscribeToDevtoolsMessages(devtoolsSession, { + sessionType: 'iframe', + pageTargetId: pageId, + iframeTargetId: targetInfo.targetId, + }); } async onPageAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo): Promise { this.attachedTargetIds.add(targetInfo.targetId); + this.targetsById.set(targetInfo.targetId, targetInfo); await Promise.all(this.creatingTargetPromises); if (this.pagesById.has(targetInfo.targetId)) return; @@ -219,6 +236,7 @@ export default class BrowserContext onTargetDetached(targetId: string): void { this.attachedTargetIds.delete(targetId); + this.targetsById.delete(targetId); const page = this.pagesById.get(targetId); if (page) { this.pagesById.delete(targetId); @@ -237,6 +255,7 @@ export default class BrowserContext } onDevtoolsPanelAttached(devtoolsSession: DevtoolsSession, targetInfo: TargetInfo): void { + this.targetsById.set(targetInfo.targetId, targetInfo); this.devtoolsSessionsById.set(targetInfo.targetId, devtoolsSession); this.hooks.onDevtoolsPanelAttached?.(devtoolsSession, targetInfo).catch(() => null); } @@ -434,7 +453,6 @@ export default class BrowserContext this.pagesById.clear(); this.pagesByTabId.clear(); this.devtoolsSessionsById.clear(); - this.defaultPageInitializationFn = null; this.waitForPageAttachedById = null; this.creatingTargetPromises.length = null; this.commandMarker = null; diff --git a/agent/main/lib/DevtoolsSessionLogger.ts b/agent/main/lib/DevtoolsSessionLogger.ts index 74dd484b6..ffc1594c7 100644 --- a/agent/main/lib/DevtoolsSessionLogger.ts +++ b/agent/main/lib/DevtoolsSessionLogger.ts @@ -11,6 +11,7 @@ interface IMessageDetails { sessionType: IDevtoolsLogEvents['devtools-message']['sessionType']; workerTargetId?: string; pageTargetId?: string; + iframeTargetId?: string; } export default class DevtoolsSessionLogger extends TypedEventEmitter { @@ -146,6 +147,10 @@ export default class DevtoolsSessionLogger extends TypedEventEmitter implements IFrame { // TODO: switch this to "id" and migrate "id" to "devtoolsId" public readonly frameId: number; - + public didSwapOutOfProcess: boolean = false; public get id(): string { return this.internalFrame.id; } @@ -85,11 +90,20 @@ export default class Frame extends TypedEventEmitter implements IF return this.navigationLoadersById[this.activeLoaderId]; } + public get childFrames(): Frame[] { + const list: Frame[] = []; + for (const value of this.#framesManager.activeFrames) { + if (value.parentId === this.id) list.push(value); + } + return list; + } + public get page(): Page { return this.#framesManager.page; } public interactor: Interactor; + public jsPath: JsPath; public activeLoaderId: string; public navigationLoadersById: { [loaderId: string]: NavigationLoader } = {}; @@ -97,20 +111,21 @@ export default class Frame extends TypedEventEmitter implements IF public get hooks(): IInteractHooks { return this.page.browserContext.hooks; } - public navigations: FrameNavigations; + public navigationsObserver: FrameNavigationsObserver; + public devtoolsSession: DevtoolsSession; + + private outOfProcess: FrameOutOfProcess; #framesManager: FramesManager; private waitTimeouts: { timeout: NodeJS.Timeout; reject: (reason?: any) => void }[] = []; private frameElementDevtoolsNodeId?: Promise; private readonly parentFrame: Frame | null; - private readonly devtoolsSession: DevtoolsSession; - private defaultLoaderId: string; private startedLoaderId: string; - + public readonly pendingNewDocumentScripts: { script: string; isolated: boolean }[] = []; private defaultContextId: number; private isolatedContextId: number; private readonly activeContextIds: Set; @@ -151,6 +166,25 @@ export default class Frame extends TypedEventEmitter implements IF this.onAttached(internalFrame); } + public async updateDevtoolsSession(devtoolsSession: DevtoolsSession): Promise { + if (this.devtoolsSession === devtoolsSession) return; + this.devtoolsSession = devtoolsSession; + if ( + devtoolsSession === this.#framesManager.devtoolsSession || + devtoolsSession === this.parentFrame?.devtoolsSession + ) { + this.outOfProcess = null; + return; + } + + this.outOfProcess = new FrameOutOfProcess(this.page, this); + await this.outOfProcess.initialize(); + } + + public isOopif(): boolean { + return !!this.outOfProcess; + } + public close(error?: Error): void { this.isClosing = true; const cancelMessage = 'Cancel Pending Promise. Frame closed.'; @@ -171,7 +205,10 @@ export default class Frame extends TypedEventEmitter implements IF if (this.activeLoaderId !== this.defaultLoaderId) return; if (this.parentId) return; - const newDocumentScripts = this.#framesManager.pendingNewDocumentScripts; + const newDocumentScripts = [ + ...this.#framesManager.pendingNewDocumentScripts, + ...this.pendingNewDocumentScripts, + ]; if (newDocumentScripts.length) { const scripts = [...newDocumentScripts]; this.#framesManager.pendingNewDocumentScripts.length = 0; @@ -309,6 +346,15 @@ export default class Frame extends TypedEventEmitter implements IF return navigation; } + public click( + jsPathOrSelector: IJsPath | string, + verification?: IElementInteractVerification, + ): Promise { + let jsPath = jsPathOrSelector; + if (typeof jsPath === 'string') jsPath = ['document', ['querySelector', jsPathOrSelector]]; + return this.interact([{ command: 'click', mousePosition: jsPath, verification }]); + } + public async interact(...interactionGroups: IInteractionGroups): Promise { const timeoutMs = 120e3; const interactionResolvable = new Resolvable(timeoutMs); @@ -363,7 +409,7 @@ export default class Frame extends TypedEventEmitter implements IF if (!this.parentId) return { x: 0, y: 0 }; const parentOffset = await this.parentFrame.getContainerOffset(); const frameElementNodeId = await this.getFrameElementDevtoolsNodeId(); - const thisOffset = await this.evaluateOnNode( + const thisOffset = await this.parentFrame.evaluateOnNode( frameElementNodeId, `(() => { const rect = this.getBoundingClientRect(); @@ -456,7 +502,7 @@ export default class Frame extends TypedEventEmitter implements IF if (!this.parentFrame || this.frameElementDevtoolsNodeId) return this.frameElementDevtoolsNodeId; - this.frameElementDevtoolsNodeId = this.devtoolsSession + this.frameElementDevtoolsNodeId = this.parentFrame.devtoolsSession .send('DOM.getFrameOwner', { frameId: this.id }, this) .then(owner => this.parentFrame.resolveDevtoolsNodeId(owner.backendNodeId, true)); @@ -658,12 +704,16 @@ export default class Frame extends TypedEventEmitter implements IF } public removeContextId(executionContextId: number): void { - if (this.defaultContextId === executionContextId) this.defaultContextId = null; + if (this.defaultContextId === executionContextId) { + this.defaultContextId = null; + this.defaultContextCreated = null; + } if (this.isolatedContextId === executionContextId) this.isolatedContextId = null; } public clearContextIds(): void { this.defaultContextId = null; + this.defaultContextCreated = null; this.isolatedContextId = null; } diff --git a/agent/main/lib/FrameOutOfProcess.ts b/agent/main/lib/FrameOutOfProcess.ts new file mode 100644 index 000000000..1973bdf07 --- /dev/null +++ b/agent/main/lib/FrameOutOfProcess.ts @@ -0,0 +1,58 @@ +import Frame from './Frame'; +import Page from './Page'; +import NetworkManager from './NetworkManager'; +import DevtoolsSession from './DevtoolsSession'; +import DomStorageTracker from './DomStorageTracker'; +import BrowserContext from './BrowserContext'; + +export default class FrameOutOfProcess { + public page: Page; + public frame: Frame; + public devtoolsSession: DevtoolsSession; + + private networkManager: NetworkManager; + private domStorageTracker: DomStorageTracker; + private get browserContext(): BrowserContext { + return this.page.browserContext; + } + + constructor(page: Page, frame: Frame) { + this.devtoolsSession = frame.devtoolsSession; + this.page = page; + this.frame = frame; + this.networkManager = new NetworkManager( + this.devtoolsSession, + frame.logger, + page.browserContext.proxy, + ); + this.domStorageTracker = new DomStorageTracker( + page, + page.browserContext.domStorage, + this.networkManager, + page.logger, + page.domStorageTracker.isEnabled, + ); + } + + public async initialize(): Promise { + this.page.bindSessionEvents(this.devtoolsSession); + const results = await Promise.all([ + this.networkManager.initializeFromParent(this.page.networkManager).catch(err => err), + this.page.framesManager.initialize(this.devtoolsSession).catch(err => err), + this.domStorageTracker.initialize().catch(err => err), + this.devtoolsSession + .send('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }) + .catch(err => err), + this.browserContext.initializeOutOfProcessIframe(this).catch(err => err), + this.devtoolsSession.send('Runtime.runIfWaitingForDebugger').catch(err => err), + ]); + + for (const error of results) { + if (error && error instanceof Error) throw error; + } + } +} diff --git a/agent/main/lib/FramesManager.ts b/agent/main/lib/FramesManager.ts index 316c07a44..baceac17e 100644 --- a/agent/main/lib/FramesManager.ts +++ b/agent/main/lib/FramesManager.ts @@ -26,6 +26,7 @@ import NavigatedWithinDocumentEvent = Protocol.Page.NavigatedWithinDocumentEvent import FrameStoppedLoadingEvent = Protocol.Page.FrameStoppedLoadingEvent; import LifecycleEventEvent = Protocol.Page.LifecycleEventEvent; import FrameRequestedNavigationEvent = Protocol.Page.FrameRequestedNavigationEvent; +import TargetInfo = Protocol.Target.TargetInfo; export const DEFAULT_PAGE = 'about:blank'; export const ISOLATED_WORLD = '__agent_world__'; @@ -46,6 +47,8 @@ export default class FramesManager extends TypedEventEmitter this.framesById.get(x)); } + public devtoolsSession: DevtoolsSession; + protected readonly logger: IBoundLog; private onFrameCreatedResourceEventsByFrameId: { @@ -60,9 +63,8 @@ export default class FramesManager extends TypedEventEmitter(); - private activeContextIds = new Set(); + private activeContextIdsBySessionId = new Map>(); private readonly events = new EventSubscriber(); - private readonly devtoolsSession: DevtoolsSession; private readonly networkManager: NetworkManager; private readonly domStorageTracker: DomStorageTracker; @@ -71,25 +73,13 @@ export default class FramesManager extends TypedEventEmitter { + public initialize(devtoolsSession: DevtoolsSession): Promise { + this.events.group( + devtoolsSession.id, + this.events.on( + devtoolsSession, + 'Page.frameNavigated', + this.onFrameNavigated.bind(this, devtoolsSession), + ), + this.events.on( + devtoolsSession, + 'Page.navigatedWithinDocument', + this.onFrameNavigatedWithinDocument, + ), + this.events.on( + devtoolsSession, + 'Page.frameRequestedNavigation', + this.onFrameRequestedNavigation, + ), + this.events.on( + devtoolsSession, + 'Page.frameDetached', + this.onFrameDetached.bind(this, devtoolsSession), + ), + this.events.on( + devtoolsSession, + 'Page.frameAttached', + this.onFrameAttached.bind(this, devtoolsSession), + ), + this.events.on(devtoolsSession, 'Page.frameStoppedLoading', this.onFrameStoppedLoading), + this.events.on( + devtoolsSession, + 'Page.lifecycleEvent', + this.onLifecycleEvent.bind(this, devtoolsSession), + ), + this.events.on( + devtoolsSession, + 'Runtime.executionContextsCleared', + this.onExecutionContextsCleared.bind(this, devtoolsSession), + ), + this.events.on( + devtoolsSession, + 'Runtime.executionContextDestroyed', + this.onExecutionContextDestroyed.bind(this, devtoolsSession), + ), + this.events.on( + devtoolsSession, + 'Runtime.executionContextCreated', + this.onExecutionContextCreated.bind(this, devtoolsSession), + ), + ); + const id = devtoolsSession.id; + this.events.once(devtoolsSession, 'disconnected', () => this.events.endGroup(id)); + this.isReady = new Promise(async (resolve, reject) => { try { const [framesResponse, , readyStateResult] = await Promise.all([ - this.devtoolsSession.send('Page.getFrameTree'), - this.devtoolsSession.send('Page.enable'), - this.devtoolsSession + devtoolsSession.send('Page.getFrameTree'), + devtoolsSession.send('Page.enable'), + devtoolsSession .send('Runtime.evaluate', { expression: 'document.readyState', }) @@ -114,11 +156,11 @@ export default class FramesManager extends TypedEventEmitter any, isolateFromWebPageEnvironment?: boolean, + devtoolsSession?: DevtoolsSession, ): Promise { const params: Protocol.Runtime.AddBindingRequest = { name, }; if (isolateFromWebPageEnvironment) { - (params as any).executionContextName = ISOLATED_WORLD; + params.executionContextName = ISOLATED_WORLD; } + devtoolsSession ??= this.devtoolsSession; // add binding to every new context automatically - await this.devtoolsSession.send('Runtime.addBinding', params); - return this.events.on(this.devtoolsSession, 'Runtime.bindingCalled', async event => { + await devtoolsSession.send('Runtime.addBinding', params); + return this.events.on(devtoolsSession, 'Runtime.bindingCalled', async event => { if (event.name === name) { await this.isReady; const frame = this.getFrameForExecutionContext(event.executionContextId); @@ -190,14 +234,13 @@ export default class FramesManager extends TypedEventEmitter { - const installedScript = await this.devtoolsSession.send( - 'Page.addScriptToEvaluateOnNewDocument', - { - source: script, - worldName: installInIsolatedScope ? ISOLATED_WORLD : undefined, - }, - ); + devtoolsSession ??= this.devtoolsSession; + const installedScript = await devtoolsSession.send('Page.addScriptToEvaluateOnNewDocument', { + source: script, + worldName: installInIsolatedScope ? ISOLATED_WORLD : undefined, + }); this.pendingNewDocumentScripts.push({ script, isolated: installInIsolatedScope }); // sometimes we get a new anchor link that already has an initiated frame. If that's the case, newDocumentScripts won't trigger. // NOTE: we DON'T want this to trigger for internal pages (':', 'about:blank') @@ -264,6 +307,18 @@ export default class FramesManager extends TypedEventEmitter { + await this.isReady; + + const frame = this.framesById.get(target.targetId); + if (frame) { + await frame.updateDevtoolsSession(devtoolsSession); + } + } + /////// EXECUTION CONTEXT //////////////////////////////////////////////////// public getSecurityOrigins(): { origin: string; frameId: string }[] { @@ -299,35 +354,47 @@ export default class FramesManager extends TypedEventEmitter { + private async onExecutionContextDestroyed( + devtoolsSession: DevtoolsSession, + event: ExecutionContextDestroyedEvent, + ): Promise { await this.isReady; - this.activeContextIds.delete(event.executionContextId); + this.activeContextIdsBySessionId.get(devtoolsSession.id)?.delete(event.executionContextId); for (const frame of this.framesById.values()) { - frame.removeContextId(event.executionContextId); + if (frame.devtoolsSession === devtoolsSession) + frame.removeContextId(event.executionContextId); } } - private async onExecutionContextsCleared(): Promise { + private async onExecutionContextsCleared(devtoolsSession: DevtoolsSession): Promise { await this.isReady; - this.activeContextIds.clear(); + this.activeContextIdsBySessionId.get(devtoolsSession.id)?.clear(); for (const frame of this.framesById.values()) { - frame.clearContextIds(); + if (frame.devtoolsSession === devtoolsSession) frame.clearContextIds(); } } - private async onExecutionContextCreated(event: ExecutionContextCreatedEvent): Promise { + private async onExecutionContextCreated( + devtoolsSession: DevtoolsSession, + event: ExecutionContextCreatedEvent, + ): Promise { await this.isReady; const { context } = event; const frameId = context.auxData.frameId as string; const type = context.auxData.type as string; - this.activeContextIds.add(context.id); + if (!this.activeContextIdsBySessionId.has(devtoolsSession.id)) { + this.activeContextIdsBySessionId.set(devtoolsSession.id, new Set()); + } + this.activeContextIdsBySessionId.get(devtoolsSession.id).add(context.id); + const frame = this.framesById.get(frameId); if (!frame) { this.logger.warn('No frame for active context!', { frameId, executionContextId: context.id, }); + return; } const isDefault = @@ -340,15 +407,19 @@ export default class FramesManager extends TypedEventEmitter { + private async onFrameNavigated( + devtoolsSession: DevtoolsSession, + navigatedEvent: FrameNavigatedEvent, + ): Promise { await this.isReady; - const frame = this.recordFrame(navigatedEvent.frame); + const frame = this.recordFrame(devtoolsSession, navigatedEvent.frame); // if main frame, clear out other frames if (!frame.parentId) { this.clearChildFrames(); } frame.onNavigated(navigatedEvent.frame); - if (!frame.isDefaultUrl && !frame.parentId) { + this.emit('frame-navigated', { frame, loaderId: navigatedEvent.frame.loaderId }); + if (!frame.isDefaultUrl && !frame.parentId && devtoolsSession === this.devtoolsSession) { this.pendingNewDocumentScripts.length = 0; } this.domStorageTracker.track(frame.securityOrigin); @@ -377,25 +448,56 @@ export default class FramesManager extends TypedEventEmitter { + private async onFrameDetached( + devtoolsSession: DevtoolsSession, + frameDetachedEvent: FrameDetachedEvent, + ): Promise { await this.isReady; - const { frameId } = frameDetachedEvent; - this.attachedFrameIds.delete(frameId); + const { frameId, reason } = frameDetachedEvent; + const parentId = this.framesById.get(frameId)?.parentId; + if ( + reason === 'remove' && + // This is a local -> remote frame transtion, where + // Page.frameDetached arrives after Target.attachedToTarget. + // We've already handled the new target and frame reattach - nothing to do here. + (devtoolsSession === this.devtoolsSession || + devtoolsSession === this.framesById.get(parentId)?.devtoolsSession) + ) { + this.attachedFrameIds.delete(frameId); + } else if (reason === 'swap') { + this.framesById.get(frameId).didSwapOutOfProcess = true; + this.framesById.get(frameId).activeLoader.setNavigationResult(); + } } - private async onFrameAttached(frameAttachedEvent: FrameAttachedEvent): Promise { + private async onFrameAttached( + devtoolsSession: DevtoolsSession, + frameAttachedEvent: FrameAttachedEvent, + ): Promise { await this.isReady; const { frameId, parentFrameId } = frameAttachedEvent; - - this.recordFrame({ id: frameId, parentId: parentFrameId } as any); + const frame = this.framesById.get(frameId); + if (frame) { + if (devtoolsSession && frame.isOopif()) { + // If an OOP iframes becomes a normal iframe again + // it is first attached to the parent page before + // the target is removed. + await frame.updateDevtoolsSession(devtoolsSession); + } + return; + } + this.recordFrame(devtoolsSession, { id: frameId, parentId: parentFrameId } as any); this.attachedFrameIds.add(frameId); } - private async onLifecycleEvent(event: LifecycleEventEvent): Promise { + private async onLifecycleEvent( + devtoolsSession: DevtoolsSession, + event: LifecycleEventEvent, + ): Promise { await this.isReady; const { frameId, name, loaderId, timestamp } = event; const eventTime = this.networkManager.monotonicTimeToUnix(timestamp); - const frame = this.recordFrame({ id: frameId, loaderId } as any); + const frame = this.recordFrame(devtoolsSession, { id: frameId, loaderId } as any); frame.onLifecycleEvent(name, eventTime, loaderId); this.domStorageTracker.track(frame.securityOrigin); } @@ -412,20 +514,30 @@ export default class FramesManager extends TypedEventEmitter this.attachedFrameIds.has(id), parentFrame, diff --git a/agent/main/lib/InjectedScripts.ts b/agent/main/lib/InjectedScripts.ts index f86239f8f..ae4704500 100644 --- a/agent/main/lib/InjectedScripts.ts +++ b/agent/main/lib/InjectedScripts.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import { stringifiedTypeSerializerClass } from '@ulixee/commons/lib/TypeSerializer'; import { IDomPaintEvent } from '@ulixee/unblocked-specification/agent/browser/Location'; import FramesManager from './FramesManager'; +import DevtoolsSession from './DevtoolsSession'; const pageScripts = { NodeTracker: fs.readFileSync(`${__dirname}/../injected-scripts/NodeTracker.js`, 'utf8'), @@ -33,6 +34,7 @@ export default class InjectedScripts { public static install( framesManager: FramesManager, + devtoolsSession: DevtoolsSession, onPaintEvent: ( frameId: number, event: { url: string; event: IDomPaintEvent; timestamp: number }, @@ -43,10 +45,12 @@ export default class InjectedScripts { pageEventsCallbackName, (payload, frame) => onPaintEvent(frame.frameId, JSON.parse(payload)), framesManager.page.installJsPathIntoIsolatedContext, + devtoolsSession, ), framesManager.addNewDocumentScript( injectedScript, framesManager.page.installJsPathIntoIsolatedContext, + devtoolsSession, ), ]); } diff --git a/agent/main/lib/NetworkManager.ts b/agent/main/lib/NetworkManager.ts index cf6fcf958..0f76a2c2b 100755 --- a/agent/main/lib/NetworkManager.ts +++ b/agent/main/lib/NetworkManager.ts @@ -124,6 +124,7 @@ export default class NetworkManager extends TypedEventEmitter err) : Promise.resolve(), + // this.devtools.send('Security.setIgnoreCertificateErrors', { ignore: true }), ]); for (const error of errors) { if (error && error instanceof Error) throw error; @@ -168,7 +169,7 @@ export default class NetworkManager extends TypedEventEmitter { this.parentManager = parentManager; - this.mockNetworkRequests = parentManager.mockNetworkRequests + this.mockNetworkRequests = parentManager.mockNetworkRequests; return this.initialize(); } diff --git a/agent/main/lib/Page.ts b/agent/main/lib/Page.ts index d077c17dc..10cbddf58 100755 --- a/agent/main/lib/Page.ts +++ b/agent/main/lib/Page.ts @@ -36,7 +36,7 @@ import { IJsPath } from '@ulixee/js-path'; import INavigation from '@ulixee/unblocked-specification/agent/browser/INavigation'; import IExecJsPathResult from '@ulixee/unblocked-specification/agent/browser/IExecJsPathResult'; import IDialog from '@ulixee/unblocked-specification/agent/browser/IDialog'; -import { IFrame } from '@ulixee/unblocked-specification/agent/browser/IFrame'; +import { IFrame, IFrameEvents } from '@ulixee/unblocked-specification/agent/browser/IFrame'; import DevtoolsSession from './DevtoolsSession'; import NetworkManager from './NetworkManager'; import { Keyboard } from './Keyboard'; @@ -175,7 +175,7 @@ export default class Page extends TypedEventEmitter implements 'worker', ]); - this.framesManager.addEventEmitter(this, ['frame-created']); + this.framesManager.addEventEmitter(this, ['frame-created', 'frame-navigated']); this.domStorageTracker.addEventEmitter(this, ['dom-storage-updated']); this.networkManager.addEventEmitter(this, [ 'navigation-response', @@ -189,24 +189,7 @@ export default class Page extends TypedEventEmitter implements const session = this.devtoolsSession; this.events.once(session, 'disconnected', this.emit.bind(this, 'close')); - this.events.on(session, 'Inspector.targetCrashed', this.onTargetCrashed.bind(this)); - this.events.on(session, 'Runtime.exceptionThrown', this.onRuntimeException.bind(this)); - this.events.on(session, 'Runtime.consoleAPICalled', this.onRuntimeConsole.bind(this)); - this.events.on(session, 'Target.attachedToTarget', this.onAttachedToTarget.bind(this)); - this.events.on( - session, - 'Page.javascriptDialogOpening', - this.onJavascriptDialogOpening.bind(this), - ); - this.events.on( - session, - 'Page.javascriptDialogClosed', - this.onJavascriptDialogClosed.bind(this), - ); - - this.events.on(session, 'Page.fileChooserOpened', this.onFileChooserOpened.bind(this)); - this.events.on(session, 'Page.windowOpen', this.onWindowOpen.bind(this)); - this.events.on(session, 'Page.screencastFrame', this.onScreencastFrame.bind(this)); + this.bindSessionEvents(session); const resources = this.browserContext.resources; // websocket events @@ -244,9 +227,7 @@ export default class Page extends TypedEventEmitter implements jsPathOrSelector: IJsPath | string, verification?: IElementInteractVerification, ): Promise { - let jsPath = jsPathOrSelector; - if (typeof jsPath === 'string') jsPath = ['document', ['querySelector', jsPathOrSelector]]; - return this.mainFrame.interact([{ command: 'click', mousePosition: jsPath, verification }]); + return this.mainFrame.click(jsPathOrSelector, verification); } type(text: string): Promise { @@ -256,18 +237,23 @@ export default class Page extends TypedEventEmitter implements addNewDocumentScript( script: string, isolatedEnvironment: boolean, + devtoolsSession?: DevtoolsSession, ): Promise<{ identifier: string }> { - return this.framesManager.addNewDocumentScript(script, isolatedEnvironment); + return this.framesManager.addNewDocumentScript(script, isolatedEnvironment, devtoolsSession); } - removeDocumentScript(identifier: string): Promise { - return this.devtoolsSession.send('Page.removeScriptToEvaluateOnNewDocument', { identifier }); + removeDocumentScript(identifier: string, devtoolsSession?: DevtoolsSession): Promise { + return (devtoolsSession ?? this.devtoolsSession).send( + 'Page.removeScriptToEvaluateOnNewDocument', + { identifier }, + ); } addPageCallback( name: string, onCallback?: (payload: string, frame: IFrame) => any, isolateFromWebPageEnvironment?: boolean, + devtoolsSession?: DevtoolsSession, ): Promise { return this.framesManager.addPageCallback( name, @@ -281,6 +267,7 @@ export default class Page extends TypedEventEmitter implements }); }, isolateFromWebPageEnvironment, + devtoolsSession, ); } @@ -586,6 +573,33 @@ export default class Page extends TypedEventEmitter implements } } + bindSessionEvents(session: DevtoolsSession): void { + const id = session.id; + this.events.once(session, 'disconnected', () => this.events.endGroup(id)); + + this.events.group( + id, + this.events.on(session, 'Inspector.targetCrashed', this.onTargetCrashed.bind(this)), + this.events.on(session, 'Runtime.exceptionThrown', this.onRuntimeException.bind(this)), + this.events.on(session, 'Runtime.consoleAPICalled', this.onRuntimeConsole.bind(this)), + this.events.on(session, 'Target.attachedToTarget', this.onAttachedToTarget.bind(this)), + this.events.on( + session, + 'Page.javascriptDialogOpening', + this.onJavascriptDialogOpening.bind(this), + ), + this.events.on( + session, + 'Page.javascriptDialogClosed', + this.onJavascriptDialogClosed.bind(this), + ), + + this.events.on(session, 'Page.fileChooserOpened', this.onFileChooserOpened.bind(this)), + this.events.on(session, 'Page.windowOpen', this.onWindowOpen.bind(this)), + this.events.on(session, 'Page.screencastFrame', this.onScreencastFrame.bind(this)), + ); + } + private cleanup(): void { this.waitTimeouts.length = 0; this.workersById.clear(); @@ -610,7 +624,7 @@ export default class Page extends TypedEventEmitter implements private async initialize(): Promise { const promises = [ this.networkManager.initialize().catch(err => err), - this.framesManager.initialize().catch(err => err), + this.framesManager.initialize(this.devtoolsSession).catch(err => err), this.domStorageTracker.initialize().catch(err => err), this.devtoolsSession .send('Target.setAutoAttach', { @@ -661,6 +675,10 @@ export default class Page extends TypedEventEmitter implements const { sessionId, targetInfo, waitingForDebugger } = event; const devtoolsSession = this.devtoolsSession.connection.getSession(sessionId); + if (targetInfo.type === 'iframe') { + this.browserContext.onIframeAttached(devtoolsSession, targetInfo, this.targetId); + return this.framesManager.onFrameTargetAttached(devtoolsSession, targetInfo); + } if ( targetInfo.type === 'service_worker' || targetInfo.type === 'shared_worker' || diff --git a/agent/main/lib/Plugins.ts b/agent/main/lib/Plugins.ts index 93beec9d9..aba018bca 100644 --- a/agent/main/lib/Plugins.ts +++ b/agent/main/lib/Plugins.ts @@ -52,6 +52,7 @@ export default class Plugins implements IUnblockedPlugins { onNewBrowserContext: [], onDevtoolsPanelAttached: [], onNewPage: [], + onNewFrameProcess: [], onNewWorker: [], onDevtoolsPanelDetached: [], onDnsConfiguration: [], @@ -170,6 +171,10 @@ export default class Plugins implements IUnblockedPlugins { await Promise.all(this.hooksByName.onNewPage.map(fn => fn(page))); } + public async onNewFrameProcess(frame: IFrame): Promise { + await Promise.all(this.hooksByName.onNewFrameProcess.map(fn => fn(frame))); + } + public async onNewWorker(worker: IWorker): Promise { await Promise.all(this.hooksByName.onNewWorker.map(fn => fn(worker))); } diff --git a/agent/main/test/Frames.oopif.test.ts b/agent/main/test/Frames.oopif.test.ts new file mode 100644 index 000000000..7141e4234 --- /dev/null +++ b/agent/main/test/Frames.oopif.test.ts @@ -0,0 +1,248 @@ +import { TestLogger } from '@ulixee/unblocked-agent-testing/index'; +import { browserEngineOptions } from '@ulixee/unblocked-agent-testing/browserUtils'; +import { TestServer } from './server'; +import { attachFrame, detachFrame, navigateFrame, waitForVisible } from './_pageTestUtils'; +import { Browser, BrowserContext, Page } from '../index'; +import IBrowser from '@ulixee/unblocked-specification/agent/browser/IBrowser'; + +describe('Frames Out of Process', () => { + let server: TestServer; + let page: Page; + let browser: Browser; + let context: BrowserContext; + + beforeAll(async () => { + server = await TestServer.create(0); + browser = new Browser(browserEngineOptions, { + onNewBrowser(browser: IBrowser) { + browser.engine.launchArguments.push('--site-per-process', '--host-rules=MAP * 127.0.0.1'); + }, + }); + await browser.launch(); + const logger = TestLogger.forTest(module); + context = await browser.newContext({ logger }); + }); + + afterEach(async () => { + await page.close().catch(() => null); + server.reset(); + }); + + beforeEach(async () => { + TestLogger.testNumber += 1; + page = await context.newPage(); + }); + + afterAll(async () => { + await server.stop(); + await context.close().catch(() => null); + await browser.close(); + }); + + it('should treat OOP iframes and normal iframes the same', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-navigated', ({ frame }) => { + return frame.url.endsWith('/empty.html'); + }); + await attachFrame(page, 'frame1', server.emptyPage); + await attachFrame(page, 'frame2', server.crossProcessBaseUrl + '/empty.html'); + await framePromise; + expect(page.frames.filter(x => x.parentId === page.mainFrame.id)).toHaveLength(2); + }); + + it('should track navigations within OOP iframes', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-navigated', ({ frame }) => { + return frame.url === server.crossProcessBaseUrl + '/empty.html'; + }); + await attachFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + const { frame } = await framePromise; + expect(frame.url).toContain('/empty.html'); + await navigateFrame(page, 'frame1', server.crossProcessBaseUrl + '/assets/frame.html'); + expect(frame.url).toContain('/assets/frame.html'); + }); + + it('should support OOP iframes becoming normal iframes again', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.emptyPage); + const { frame } = await framePromise; + expect(frame.isOopif()).toBe(false); + await navigateFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + expect(frame.isOopif()).toBe(true); + await navigateFrame(page, 'frame1', server.emptyPage); + expect(frame.isOopif()).toBe(false); + expect(page.frames).toHaveLength(2); + }); + + it('should support frames within OOP frames', async () => { + await page.goto(server.emptyPage); + const frame1Promise = page.waitOn('frame-navigated', ({ frame }) => { + return frame.url === server.crossProcessBaseUrl + '/frames/one-frame.html'; + }); + const frame2Promise = page.waitOn('frame-navigated', ({ frame }) => { + return frame.url === server.crossProcessBaseUrl + '/frames/frame.html'; + }); + await attachFrame(page, 'frame1', server.crossProcessBaseUrl + '/frames/one-frame.html'); + + const [{ frame: frame1 }, { frame: 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 () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.emptyPage); + + const { frame } = await framePromise; + expect(frame.isOopif()).toBe(false); + await navigateFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + expect(frame.isOopif()).toBe(true); + await detachFrame(page, 'frame1'); + if (browser.majorVersion >= 109) expect(page.frames).toHaveLength(1); + }); + + it('should support wait for navigation for transitions from local to OOPIF', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.emptyPage); + + const { frame } = await framePromise; + expect(frame.isOopif()).toBe(false); + const nav = frame.waitForLoad({ loadStatus: 'JavascriptReady' }); + await navigateFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + await nav; + expect(frame.isOopif()).toBe(true); + await detachFrame(page, 'frame1'); + if (browser.majorVersion >= 109) expect(page.frames).toHaveLength(1); + }); + + it('should keep track of a frames OOP state', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + const { frame } = await framePromise; + expect(frame.url).toContain('/empty.html'); + await navigateFrame(page, 'frame1', server.emptyPage); + expect(frame.url).toBe(server.emptyPage); + }); + + it('should support evaluating in oop iframes', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.crossProcessBaseUrl + '/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 () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + + const { frame } = await framePromise; + await frame.evaluate(`(() => { + const button = document.createElement('button'); + button.id = 'test-button'; + button.innerText = 'click'; + button.onclick = () => { + button.id = 'clicked'; + }; + document.body.appendChild(button); + })()`); + await page.evaluate(`(() => { + document.body.style.border = '150px solid black'; + document.body.style.margin = '250px'; + document.body.style.padding = '50px'; + })()`); + await waitForVisible(frame as any, '#test-button'); + await frame.click('#test-button'); + await waitForVisible(frame as any, '#clicked'); + }); + + it('should report oopif frames', async () => { + context.targetsById.clear(); + const frame = page.waitOn('frame-navigated', ({ frame }) => { + return frame.url.endsWith('/oopif.html'); + }); + await page.goto(server.baseUrl + '/dynamic-oopif.html'); + await frame; + expect([...context.targetsById.values()].filter(x => x.type === 'iframe')).toHaveLength(1); + expect(page.frames.length).toBe(2); + }); + + it('should wait for inner OOPIFs', async () => { + context.targetsById.clear(); + await page.goto(`http://mainframe:${server.port}/main-frame.html`); + const { frame: frame2 } = await page.waitOn('frame-navigated', x => { + return x.frame.url.endsWith('inner-frame2.html'); + }); + expect([...context.targetsById.values()].filter(x => x.type === 'iframe')).toHaveLength(2); + expect( + page.frames.filter(frame => { + return frame.isOopif(); + }).length, + ).toBe(2); + expect(await frame2.evaluate(`document.querySelectorAll('button').length`)).toStrictEqual(1); + }); + + it('should support frames within OOP iframes', async () => { + const oopIframePromise = page.waitOn('frame-navigated', ({ frame }) => { + return frame.url.endsWith('/oopif.html'); + }); + await page.goto(server.baseUrl + '/dynamic-oopif.html'); + const { frame: oopIframe } = await oopIframePromise; + await attachFrame(oopIframe as any, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + + const frame1 = oopIframe.childFrames[0]!; + expect(frame1.url).toMatch(/empty.html$/); + await navigateFrame(oopIframe as any, 'frame1', server.crossProcessBaseUrl + '/oopif.html'); + expect(frame1.url).toMatch(/oopif.html$/); + await frame1.evaluate( + `location.href= "${server.crossProcessBaseUrl}/oopif.html#navigate-within-document"`, + ); + await frame1.waitForLoad({ loadStatus: 'AllContentLoaded' }); + expect(frame1.url).toMatch(/oopif.html#navigate-within-document$/); + await detachFrame(oopIframe as any, 'frame1'); + await new Promise(setImmediate); + expect(oopIframe.childFrames).toHaveLength(0); + }); + + it('clickablePoint, boundingBox, boxModel should work for elements inside OOPIFs', async () => { + await page.goto(server.emptyPage); + const framePromise = page.waitOn('frame-created'); + await attachFrame(page, 'frame1', server.crossProcessBaseUrl + '/empty.html'); + const { frame } = await framePromise; + await page.evaluate(`(() => { + document.body.style.border = '50px solid black'; + document.body.style.margin = '50px'; + document.body.style.padding = '50px'; + })()`); + await frame.evaluate(`(() => { + const button = document.createElement('button'); + button.id = 'test-button'; + button.innerText = 'click'; + document.body.appendChild(button); + })()`); + const visibility = await waitForVisible(frame as any, '#test-button'); + const containerOffset = await frame.getContainerOffset(); + const result = visibility.boundingClientRect; + expect(result.x + containerOffset.x).toBeGreaterThan(150); // padding + margin + border left + expect(result.y + containerOffset.y).toBeGreaterThan(150); // padding + margin + border top + }); +}); diff --git a/agent/main/test/Frames.test.ts b/agent/main/test/Frames.test.ts index b3111c6c1..cc010efc2 100644 --- a/agent/main/test/Frames.test.ts +++ b/agent/main/test/Frames.test.ts @@ -36,8 +36,11 @@ describe('Frames', () => { }); function getContexts(contextPage: Page): number { + let count = 0; // @ts-expect-error - return contextPage.framesManager.activeContextIds.size; + const contexts = contextPage.framesManager.activeContextIdsBySessionId.values(); + for (const context of contexts) count += context.size; + return count; } describe('basic', () => { diff --git a/agent/main/test/_pageTestUtils.ts b/agent/main/test/_pageTestUtils.ts index da81931c7..57c98cea2 100644 --- a/agent/main/test/_pageTestUtils.ts +++ b/agent/main/test/_pageTestUtils.ts @@ -1,9 +1,19 @@ import { Page } from '../index'; import Frame from '../lib/Frame'; +import INodeVisibility from '@ulixee/js-path/interfaces/INodeVisibility'; +import ConsoleMessage from '../lib/ConsoleMessage'; -export async function attachFrame(page: Page, frameId: string, url: string): Promise { - const framePromise = page.waitOn('frame-created'); - await page.evaluate(` +export async function attachFrame( + pageOrFrame: Page | Frame, + frameId: string, + url: string, +): Promise { + const frameCreatedPromise = + pageOrFrame instanceof Page + ? pageOrFrame.waitOn('frame-created') + : pageOrFrame.page.waitOn('frame-created'); + + await pageOrFrame.evaluate(` (async () => { const frame = document.createElement('iframe'); frame.src = '${url}'; @@ -11,8 +21,9 @@ export async function attachFrame(page: Page, frameId: string, url: string): Pro document.body.appendChild(frame); await new Promise(x => (frame.onload = x)); })()`); - const { frame } = await framePromise; - return page.frames.find(x => x.id === frame.id); + const { frame } = await frameCreatedPromise; + if (pageOrFrame instanceof Page) return pageOrFrame.frames.find(x => x.id === frame.id); + return pageOrFrame.childFrames.find(x => x.id === frame.id); } export async function setContent(page: Page, content: string) { @@ -21,3 +32,67 @@ export async function setContent(page: Page, content: string) { frameId: page.mainFrame.id, }); } + +export async function detachFrame(pageOrFrame: Page | Frame, frameId: string): Promise { + await pageOrFrame.evaluate(`document.getElementById('${frameId}').remove()`); +} + +export async function navigateFrame( + pageOrFrame: Page | Frame, + frameId: string, + url: string, +): Promise { + const result = await pageOrFrame.devtoolsSession.send('Runtime.evaluate', { + expression: `(() => { + const frame = document.getElementById('${frameId}') + frame.src = '${url}'; + return new Promise(x => { + return (frame.onload = x); + }); + })()`, + awaitPromise: true, + }); + if (result.exceptionDetails) { + throw ConsoleMessage.exceptionToError(result.exceptionDetails); + } +} + +export async function waitForVisible(frame: Frame, selector: string): Promise { + let visibility: INodeVisibility; + await wait( + async () => { + visibility = await frame.jsPath.getNodeVisibility(['document', ['querySelector', selector]]); + if (visibility.isVisible) { + return true; + } + }, + { loopDelayMs: 100, timeoutMs: 10e3 }, + ); + return visibility; +} + +function wait( + callbackFn: () => Promise, + options: { timeoutMs?: number; loopDelayMs?: number } = {}, +): Promise { + options.timeoutMs ??= 30e3; + const end = Date.now() + options.timeoutMs; + + return new Promise(async (resolve, reject) => { + while (Date.now() <= end) { + const isComplete = await callbackFn().catch(reject); + if (isComplete) { + resolve(true); + return; + } + if (options.loopDelayMs) { + await delay(options.loopDelayMs); + } + } + resolve(false); + }); +} + +function delay(millis: number): Promise { + return new Promise(resolve => setTimeout(resolve, millis)); +} diff --git a/agent/main/test/assets/dynamic-oopif.html b/agent/main/test/assets/dynamic-oopif.html new file mode 100644 index 000000000..e7018667f --- /dev/null +++ b/agent/main/test/assets/dynamic-oopif.html @@ -0,0 +1,15 @@ + diff --git a/agent/main/test/assets/inner-frame1.html b/agent/main/test/assets/inner-frame1.html new file mode 100644 index 000000000..00f19ec16 --- /dev/null +++ b/agent/main/test/assets/inner-frame1.html @@ -0,0 +1,10 @@ + diff --git a/agent/main/test/assets/inner-frame2.html b/agent/main/test/assets/inner-frame2.html new file mode 100644 index 000000000..9a236cc48 --- /dev/null +++ b/agent/main/test/assets/inner-frame2.html @@ -0,0 +1 @@ + diff --git a/agent/main/test/assets/lazy-oopif-frame.html b/agent/main/test/assets/lazy-oopif-frame.html new file mode 100644 index 000000000..a0cbceaf9 --- /dev/null +++ b/agent/main/test/assets/lazy-oopif-frame.html @@ -0,0 +1,3 @@ + +
+ diff --git a/agent/main/test/assets/main-frame.html b/agent/main/test/assets/main-frame.html new file mode 100644 index 000000000..0c50feff8 --- /dev/null +++ b/agent/main/test/assets/main-frame.html @@ -0,0 +1,10 @@ + diff --git a/agent/main/test/server/index.ts b/agent/main/test/server/index.ts index 5873b5ffb..9121435f0 100755 --- a/agent/main/test/server/index.ts +++ b/agent/main/test/server/index.ts @@ -34,6 +34,7 @@ export class TestServer { public get emptyPage() { return this.url('empty.html'); } + public port: number; private readonly server: http.Server | https.Server; private readonly dirPath = Path.resolve(__dirname, '../assets'); @@ -46,7 +47,6 @@ export class TestServer { private gzipRoutes = new Set(); private requestSubscribers = new Map>(); private readonly protocol: string = 'http:'; - private port: number; constructor(sslOptions?: https.ServerOptions) { if (sslOptions) { diff --git a/plugins/default-browser-emulator/index.ts b/plugins/default-browser-emulator/index.ts index 0a0d2effb..57e840139 100644 --- a/plugins/default-browser-emulator/index.ts +++ b/plugins/default-browser-emulator/index.ts @@ -213,6 +213,19 @@ export default class DefaultBrowserEmulator implements IUn ]); } + public onNewFrameProcess(frame: IFrame): Promise { + // Don't await here! we want to queue all these up to run before the debugger resumes + const devtools = frame.devtoolsSession; + const emulationProfile = this.emulationProfile; + return Promise.all([ + setUserAgent(emulationProfile, devtools, this.userAgentData), + setTimezone(emulationProfile, devtools), + setLocale(emulationProfile, devtools), + setPageDomOverrides(this.domOverridesBuilder, this.data, frame.page, frame.devtoolsSession), + setGeolocation(emulationProfile, frame), + ]); + } + public onNewWorker(worker: IWorker): Promise { const devtools = worker.devtoolsSession; return Promise.all([ diff --git a/plugins/default-browser-emulator/lib/helpers/configureBrowserLaunchArgs.ts b/plugins/default-browser-emulator/lib/helpers/configureBrowserLaunchArgs.ts index 23e9740f7..63686312f 100644 --- a/plugins/default-browser-emulator/lib/helpers/configureBrowserLaunchArgs.ts +++ b/plugins/default-browser-emulator/lib/helpers/configureBrowserLaunchArgs.ts @@ -22,7 +22,6 @@ export function configureBrowserLaunchArgs( '--disable-default-apps', // Disable installation of default apps on first run '--disable-dev-shm-usage', // https://github.com/GoogleChrome/puppeteer/issues/1834 '--disable-extensions', // Disable all chrome extensions. - '--disable-site-isolation-trials', /** * --disable-features * site-per-process = Disables OOPIF @@ -30,7 +29,7 @@ export function configureBrowserLaunchArgs( * AvoidUnnecessaryBeforeUnloadCheckSync = allow about:blank nav * MediaRouter,DialMediaRouteProvider (don't lookup local area casting options) */ - '--disable-features=PaintHolding,LazyFrameLoading,DestroyProfileOnBrowserClose,AvoidUnnecessaryBeforeUnloadCheckSync,IsolateOrigins,site-per-process,OutOfBlinkCors,GlobalMediaControls,MediaRouter,DialMediaRouteProvider', + '--disable-features=PaintHolding,LazyFrameLoading,DestroyProfileOnBrowserClose,AvoidUnnecessaryBeforeUnloadCheckSync,OutOfBlinkCors,GlobalMediaControls,MediaRouter,DialMediaRouteProvider', '--disable-blink-features=AutomationControlled', '--disable-hang-monitor', '--disable-ipc-flooding-protection', // Some javascript functions can be used to flood the browser process with IPC. By default, protection is on to limit the number of IPC sent to 10 per second per frame. diff --git a/plugins/default-browser-emulator/lib/helpers/setGeolocation.ts b/plugins/default-browser-emulator/lib/helpers/setGeolocation.ts index b3de2fab1..7bce3d914 100644 --- a/plugins/default-browser-emulator/lib/helpers/setGeolocation.ts +++ b/plugins/default-browser-emulator/lib/helpers/setGeolocation.ts @@ -1,10 +1,11 @@ import { assert } from '@ulixee/commons/lib/utils'; import { IPage } from '@ulixee/unblocked-specification/agent/browser/IPage'; import IEmulationProfile from '@ulixee/unblocked-specification/plugin/IEmulationProfile'; +import { IFrame } from '@ulixee/unblocked-specification/agent/browser/IFrame'; export default function setActiveAndFocused( emulationProfile: IEmulationProfile, - page: IPage, + pageOrFrame: IPage | IFrame, ): Promise { const location = emulationProfile.geolocation; if (!location) return; @@ -15,13 +16,15 @@ export default function setActiveAndFocused( if (!location.accuracy) location.accuracy = 50 - Math.floor(Math.random() * 10); assert(location.accuracy >= 0, 'Accuracy must be a number greater than or equal to 0'); + const browserContext = + 'browserContext' in pageOrFrame ? pageOrFrame.browserContext : pageOrFrame.page.browserContext; return Promise.all([ - page.devtoolsSession.send('Emulation.setGeolocationOverride', { + pageOrFrame.devtoolsSession.send('Emulation.setGeolocationOverride', { ...location, }), - page.browserContext.browser.devtoolsSession.send('Browser.grantPermissions', { + browserContext.browser.devtoolsSession.send('Browser.grantPermissions', { permissions: ['geolocation'], - browserContextId: page.browserContext.id, + browserContextId: browserContext.id, }), ]); } diff --git a/plugins/default-browser-emulator/lib/setPageDomOverrides.ts b/plugins/default-browser-emulator/lib/setPageDomOverrides.ts index 21c2c3fbd..9c3075e5c 100644 --- a/plugins/default-browser-emulator/lib/setPageDomOverrides.ts +++ b/plugins/default-browser-emulator/lib/setPageDomOverrides.ts @@ -1,19 +1,21 @@ import { IPage } from '@ulixee/unblocked-specification/agent/browser/IPage'; +import IDevtoolsSession from '@ulixee/unblocked-specification/agent/browser/IDevtoolsSession'; import IBrowserData from '../interfaces/IBrowserData'; import DomOverridesBuilder from './DomOverridesBuilder'; export default async function setPageDomOverrides( domOverrides: DomOverridesBuilder, data: IBrowserData, - page: IPage, + pageOrFrame: IPage, + devtoolsSession?: IDevtoolsSession, ): Promise { const script = domOverrides.build('page'); const promises: Promise[] = []; for (const { name, fn } of script.callbacks) { - promises.push(page.addPageCallback(name, fn, false)); + promises.push(pageOrFrame.addPageCallback(name, fn, false, devtoolsSession)); } // overrides happen in main frame - promises.push(page.addNewDocumentScript(script.script, false)); + promises.push(pageOrFrame.addNewDocumentScript(script.script, false)); await Promise.all(promises); } diff --git a/specification/agent/browser/IBrowserContext.ts b/specification/agent/browser/IBrowserContext.ts index 3c25f89fa..8a992cf1e 100644 --- a/specification/agent/browser/IBrowserContext.ts +++ b/specification/agent/browser/IBrowserContext.ts @@ -5,7 +5,7 @@ import { IWorker } from './IWorker'; import IBrowser from './IBrowser'; import { IBrowserContextHooks } from '../hooks/IBrowserHooks'; import { ICookie } from '../net/ICookie'; -import IInteractHooks from "../hooks/IInteractHooks"; +import IInteractHooks from '../hooks/IInteractHooks'; export default interface IBrowserContext extends ITypedEventEmitter { id: string; @@ -14,8 +14,7 @@ export default interface IBrowserContext extends ITypedEventEmitter; workersById: Map; - defaultPageInitializationFn: (page: IPage) => Promise; - hooks: (IBrowserContextHooks & IInteractHooks); + hooks: IBrowserContextHooks & IInteractHooks; newPage(): Promise; close(): Promise; diff --git a/specification/agent/browser/IFrame.ts b/specification/agent/browser/IFrame.ts index 8fa33ccaf..6605491fb 100644 --- a/specification/agent/browser/IFrame.ts +++ b/specification/agent/browser/IFrame.ts @@ -1,17 +1,22 @@ import type ITypedEventEmitter from '@ulixee/commons/interfaces/ITypedEventEmitter'; +import type IRegisteredEventListener from '@ulixee/commons/interfaces/IRegisteredEventListener'; import IPoint from './IPoint'; +import { IJsPath } from '@ulixee/js-path'; import { NavigationReason } from './NavigationReason'; import IJsPathFunctions from './IJsPathFunctions'; import { IFrameNavigations } from './IFrameNavigations'; import { ILoadStatus } from './Location'; import INavigation from './INavigation'; -import { IInteractionGroups } from '../interact/IInteractions'; +import { IElementInteractVerification, IInteractionGroups } from '../interact/IInteractions'; import { IPage } from './IPage'; +import IDevtoolsSession from './IDevtoolsSession'; export interface IFrame extends ITypedEventEmitter { frameId: number; // assigned id unique to the browser context id: string; + childFrames: IFrame[]; + devtoolsSession: IDevtoolsSession; page?: IPage; parentId?: string; name?: string; @@ -26,8 +31,13 @@ export interface IFrame extends ITypedEventEmitter { navigations: IFrameNavigations; + isOopif(): boolean; close(): void; interact(...interactionGroups: IInteractionGroups): Promise; + click( + jsPathOrSelector: IJsPath | string, + verification?: IElementInteractVerification, + ): Promise; waitForLifecycleEvent( event: keyof ILifecycleEvents, loaderId?: string, @@ -81,6 +91,7 @@ export interface INavigationLoader { export interface IFrameManagerEvents { 'frame-created': { frame: IFrame; loaderId: string }; + 'frame-navigated': { frame: IFrame; navigatedInDocument?: boolean; loaderId: string }; } export interface IFrameEvents { diff --git a/specification/agent/browser/IPage.ts b/specification/agent/browser/IPage.ts index de71d2862..9580fc84d 100644 --- a/specification/agent/browser/IPage.ts +++ b/specification/agent/browser/IPage.ts @@ -43,12 +43,14 @@ export interface IPage extends ITypedEventEmitter { addNewDocumentScript( script: string, isolateFromWebPageEnvironment: boolean, + devtoolsSession?: IDevtoolsSession, ): Promise<{ identifier: string }>; - removeDocumentScript(identifier: string): Promise; + removeDocumentScript(identifier: string, devtoolsSession?: IDevtoolsSession): Promise; addPageCallback( name: string, onCallback?: (payload: string, frame: IFrame) => any, isolateFromWebPageEnvironment?: boolean, + devtoolsSession?: IDevtoolsSession, ): Promise; } diff --git a/specification/agent/hooks/IBrowserHooks.ts b/specification/agent/hooks/IBrowserHooks.ts index 096065f99..13ae0495c 100644 --- a/specification/agent/hooks/IBrowserHooks.ts +++ b/specification/agent/hooks/IBrowserHooks.ts @@ -6,9 +6,11 @@ import IBrowserLaunchArgs from '../browser/IBrowserLaunchArgs'; import IBrowserContext from '../browser/IBrowserContext'; import Protocol from 'devtools-protocol'; import TargetInfo = Protocol.Target.TargetInfo; +import { IFrame } from '../browser/IFrame'; export interface IBrowserContextHooks { onNewPage?(page: IPage): Promise; + onNewFrameProcess?(frame: IFrame): Promise; onNewWorker?(worker: IWorker): Promise; onDevtoolsPanelAttached?(devtoolsSession: IDevtoolsSession, targetInfo?: TargetInfo): Promise;