From 665888d57913443515e6b455405c3e5bdc2b55a8 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 5 Mar 2020 10:45:32 -0800 Subject: [PATCH] feat(popups): auto-attach to all pages in Chromium (#1226) --- src/chromium/crBrowser.ts | 43 ++++++++++++++++------ src/chromium/crPage.ts | 52 +++++++++++++-------------- src/chromium/crTarget.ts | 70 ++++++++++++++++++------------------ src/server/chromium.ts | 6 ++-- src/web.ts | 2 +- test/assets/popup/popup.html | 3 ++ test/popup.spec.js | 32 ++++++++++++++--- 7 files changed, 128 insertions(+), 80 deletions(-) diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index a539157088..2b3f70eff7 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -43,10 +43,28 @@ export class CRBrowser extends platform.EventEmitter implements Browser { private _tracingPath: string | null = ''; private _tracingClient: CRSession | undefined; - static async connect(transport: ConnectionTransport, slowMo?: number): Promise { + static async connect(transport: ConnectionTransport, isPersistent: boolean, slowMo?: number): Promise { const connection = new CRConnection(SlowMoTransport.wrap(transport, slowMo)); const browser = new CRBrowser(connection); - await connection.rootSession.send('Target.setDiscoverTargets', { discover: true }); + const session = connection.rootSession; + const promises = [ + session.send('Target.setDiscoverTargets', { discover: true }), + session.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }), + ]; + const existingPageAttachPromises: Promise[] = []; + if (isPersistent) { + // First page and background pages in the persistent context are created automatically + // and may be initialized before we enable auto-attach. + function attachToExistingPage({targetInfo}: Protocol.Target.targetCreatedPayload) { + if (!CRTarget.isPageType(targetInfo.type)) + return; + existingPageAttachPromises.push(session.send('Target.attachToTarget', {targetId: targetInfo.targetId, flatten: true})); + } + session.on('Target.targetCreated', attachToExistingPage); + Promise.all(promises).then(() => session.off('Target.targetCreated', attachToExistingPage)).catch(debugError); + } + await Promise.all(promises); + await Promise.all(existingPageAttachPromises); return browser; } @@ -64,6 +82,7 @@ export class CRBrowser extends platform.EventEmitter implements Browser { this._client.on('Target.targetCreated', this._targetCreated.bind(this)); this._client.on('Target.targetDestroyed', this._targetDestroyed.bind(this)); this._client.on('Target.targetInfoChanged', this._targetInfoChanged.bind(this)); + this._client.on('Target.attachedToTarget', this._onAttachedToTarget.bind(this)); } async newContext(options: BrowserContextOptions = {}): Promise { @@ -83,14 +102,20 @@ export class CRBrowser extends platform.EventEmitter implements Browser { return createPageInNewContext(this, options); } - async _targetCreated(event: Protocol.Target.targetCreatedPayload) { - const targetInfo = event.targetInfo; + async _onAttachedToTarget(event: Protocol.Target.attachedToTargetPayload) { + if (!CRTarget.isPageType(event.targetInfo.type)) + return; + const target = this._targets.get(event.targetInfo.targetId); + const session = this._connection.session(event.sessionId)!; + await target!.initializePageSession(session).catch(debugError); + } + + async _targetCreated({targetInfo}: Protocol.Target.targetCreatedPayload) { const {browserContextId} = targetInfo; const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId)! : this._defaultContext; - const target = new CRTarget(this, targetInfo, context, () => this._connection.createSession(targetInfo)); - assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); - this._targets.set(event.targetInfo.targetId, target); + assert(!this._targets.has(targetInfo.targetId), 'Target should not exist before targetCreated'); + this._targets.set(targetInfo.targetId, target); try { switch (targetInfo.type) { @@ -120,7 +145,6 @@ export class CRBrowser extends platform.EventEmitter implements Browser { async _targetDestroyed(event: { targetId: string; }) { const target = this._targets.get(event.targetId)!; - target._initializedCallback(false); this._targets.delete(event.targetId); target._didClose(); } @@ -136,7 +160,7 @@ export class CRBrowser extends platform.EventEmitter implements Browser { } _allTargets(): CRTarget[] { - return Array.from(this._targets.values()).filter(target => target._isInitialized); + return Array.from(this._targets.values()); } async close() { @@ -252,7 +276,6 @@ export class CRBrowserContext extends platform.EventEmitter implements BrowserCo assertBrowserContextIsNotOwned(this); const { targetId } = await this._browser._client.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId || undefined }); const target = this._browser._targets.get(targetId)!; - assert(await target._initializedPromise, 'Failed to create target for page'); const page = await target.page(); return page!; } diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 57d9f64825..a01dd49045 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -68,33 +68,32 @@ export class CRPage implements PageDelegate { } async initialize() { - const [, { frameTree }] = await Promise.all([ - this._client.send('Page.enable'), - this._client.send('Page.getFrameTree'), - ] as const); - this._handleFrameTree(frameTree); - this._eventListeners = [ - helper.addEventListener(this._client, 'Inspector.targetCrashed', event => this._onTargetCrashed()), - helper.addEventListener(this._client, 'Log.entryAdded', event => this._onLogEntryAdded(event)), - helper.addEventListener(this._client, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)), - helper.addEventListener(this._client, 'Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)), - helper.addEventListener(this._client, 'Page.frameDetached', event => this._onFrameDetached(event.frameId)), - helper.addEventListener(this._client, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), - helper.addEventListener(this._client, 'Page.frameRequestedNavigation', event => this._onFrameRequestedNavigation(event)), - helper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), - helper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), - helper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)), - helper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), - helper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)), - helper.addEventListener(this._client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)), - helper.addEventListener(this._client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)), - helper.addEventListener(this._client, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)), - helper.addEventListener(this._client, 'Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId)), - helper.addEventListener(this._client, 'Runtime.executionContextsCleared', event => this._onExecutionContextsCleared()), - helper.addEventListener(this._client, 'Target.attachedToTarget', event => this._onAttachedToTarget(event)), - helper.addEventListener(this._client, 'Target.detachedFromTarget', event => this._onDetachedFromTarget(event)), - ]; const promises: Promise[] = [ + this._client.send('Page.enable'), + this._client.send('Page.getFrameTree').then(({frameTree}) => { + this._handleFrameTree(frameTree); + this._eventListeners = [ + helper.addEventListener(this._client, 'Inspector.targetCrashed', event => this._onTargetCrashed()), + helper.addEventListener(this._client, 'Log.entryAdded', event => this._onLogEntryAdded(event)), + helper.addEventListener(this._client, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event)), + helper.addEventListener(this._client, 'Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)), + helper.addEventListener(this._client, 'Page.frameDetached', event => this._onFrameDetached(event.frameId)), + helper.addEventListener(this._client, 'Page.frameNavigated', event => this._onFrameNavigated(event.frame, false)), + helper.addEventListener(this._client, 'Page.frameRequestedNavigation', event => this._onFrameRequestedNavigation(event)), + helper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), + helper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), + helper.addEventListener(this._client, 'Page.lifecycleEvent', event => this._onLifecycleEvent(event)), + helper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), + helper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)), + helper.addEventListener(this._client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)), + helper.addEventListener(this._client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)), + helper.addEventListener(this._client, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)), + helper.addEventListener(this._client, 'Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId)), + helper.addEventListener(this._client, 'Runtime.executionContextsCleared', event => this._onExecutionContextsCleared()), + helper.addEventListener(this._client, 'Target.attachedToTarget', event => this._onAttachedToTarget(event)), + helper.addEventListener(this._client, 'Target.detachedFromTarget', event => this._onDetachedFromTarget(event)), + ]; + }), this._client.send('Log.enable', {}), this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), @@ -126,6 +125,7 @@ export class CRPage implements PageDelegate { promises.push(this._initBinding(binding)); for (const source of this._browserContext._evaluateOnNewDocumentSources) promises.push(this.evaluateOnNewDocument(source)); + promises.push(this._client.send('Runtime.runIfWaitingForDebugger')); await Promise.all(promises); } diff --git a/src/chromium/crTarget.ts b/src/chromium/crTarget.ts index e7c150dd92..d17aa18eee 100644 --- a/src/chromium/crTarget.ts +++ b/src/chromium/crTarget.ts @@ -32,17 +32,20 @@ export class CRTarget { private readonly _browserContext: CRBrowserContext; readonly _targetId: string; readonly sessionFactory: () => Promise; + private _pagePromiseFulfill: ((page: Page) => void) | null = null; + private _pagePromiseReject: ((error: Error) => void) | null = null; private _pagePromise: Promise | null = null; _crPage: CRPage | null = null; private _workerPromise: Promise | null = null; - readonly _initializedPromise: Promise; - _initializedCallback: (success: boolean) => void = () => {}; - _isInitialized: boolean; static fromPage(page: Page): CRTarget { return (page as any)[targetSymbol]; } + static isPageType(type: string): boolean { + return type === 'page' || type === 'background_page'; + } + constructor( browser: CRBrowser, targetInfo: Protocol.Target.TargetInfo, @@ -53,22 +56,12 @@ export class CRTarget { this._browserContext = browserContext; this._targetId = targetInfo.targetId; this.sessionFactory = sessionFactory; - this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => { - if (!success) - return false; - const opener = this.opener(); - if (!opener || !opener._pagePromise || this.type() !== 'page') - return true; - const openerPage = await opener._pagePromise; - if (!openerPage.listenerCount(Events.Page.Popup)) - return true; - const popupPage = await this.page(); - openerPage.emit(Events.Page.Popup, popupPage); - return true; - }); - this._isInitialized = this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; - if (this._isInitialized) - this._initializedCallback(true); + if (CRTarget.isPageType(targetInfo.type)) { + this._pagePromise = new Promise((fulfill, reject) => { + this._pagePromiseFulfill = fulfill; + this._pagePromiseReject = reject; + }); + } } _didClose() { @@ -77,19 +70,32 @@ export class CRTarget { } async page(): Promise { - if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { - this._pagePromise = this.sessionFactory().then(async client => { - this._crPage = new CRPage(client, this._browser, this._browserContext); - const page = this._crPage.page(); - (page as any)[targetSymbol] = this; - client.once(CRSessionEvents.Disconnected, () => page._didDisconnect()); - await this._crPage.initialize(); - return page; - }); - } return this._pagePromise; } + async initializePageSession(session: CRSession) { + this._crPage = new CRPage(session, this._browser, this._browserContext); + const page = this._crPage.page(); + (page as any)[targetSymbol] = this; + session.once(CRSessionEvents.Disconnected, () => page._didDisconnect()); + try { + await this._crPage.initialize(); + this._pagePromiseFulfill!(page); + } catch (error) { + this._pagePromiseReject!(error); + } + + if (this.type() !== 'page') + return; + const opener = this.opener(); + if (!opener) + return; + const openerPage = await opener.page(); + if (!openerPage) + return; + openerPage.emit(Events.Page.Popup, page); + } + async serviceWorker(): Promise { if (this._targetInfo.type !== 'service_worker') return null; @@ -132,11 +138,5 @@ export class CRTarget { _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo) { this._targetInfo = targetInfo; - - if (!this._isInitialized && (this._targetInfo.type !== 'page' || this._targetInfo.url !== '')) { - this._isInitialized = true; - this._initializedCallback(true); - return; - } } } diff --git a/src/server/chromium.ts b/src/server/chromium.ts index fc01e53d59..7f6f0423bb 100644 --- a/src/server/chromium.ts +++ b/src/server/chromium.ts @@ -55,7 +55,7 @@ export class Chromium implements BrowserType { if (options && (options as any).userDataDir) throw new Error('userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistent` instead'); const { browserServer, transport } = await this._launchServer(options, 'local'); - const browser = await CRBrowser.connect(transport!, options && options.slowMo); + const browser = await CRBrowser.connect(transport!, false, options && options.slowMo); // Hack: for typical launch scenario, ensure that close waits for actual process termination. browser.close = () => browserServer.close(); (browser as any)['__server__'] = browserServer; @@ -69,7 +69,7 @@ export class Chromium implements BrowserType { async launchPersistent(userDataDir: string, options?: LaunchOptions): Promise { const { timeout = 30000 } = options || {}; const { browserServer, transport } = await this._launchServer(options, 'persistent', userDataDir); - const browser = await CRBrowser.connect(transport!); + const browser = await CRBrowser.connect(transport!, true); const firstPage = new Promise(r => browser._defaultContext.once(Events.BrowserContext.Page, r)); await helper.waitWithTimeout(firstPage, 'first page', timeout); // Hack: for typical launch scenario, ensure that close waits for actual process termination. @@ -155,7 +155,7 @@ export class Chromium implements BrowserType { async connect(options: ConnectOptions): Promise { return await platform.connectToWebsocket(options.wsEndpoint, transport => { - return CRBrowser.connect(transport, options.slowMo); + return CRBrowser.connect(transport, false, options.slowMo); }); } diff --git a/src/web.ts b/src/web.ts index b1b3fe242a..0eea8270e7 100644 --- a/src/web.ts +++ b/src/web.ts @@ -23,7 +23,7 @@ const connect = { chromium: { connect: async (url: string) => { return await platform.connectToWebsocket(url, transport => { - return ChromiumBrowser.connect(transport); + return ChromiumBrowser.connect(transport, false); }); } }, diff --git a/test/assets/popup/popup.html b/test/assets/popup/popup.html index b855162c25..76a33e88b0 100644 --- a/test/assets/popup/popup.html +++ b/test/assets/popup/popup.html @@ -2,6 +2,9 @@ Popup + I am a popup diff --git a/test/popup.spec.js b/test/popup.spec.js index cd6a5adc98..892bc1277d 100644 --- a/test/popup.spec.js +++ b/test/popup.spec.js @@ -19,8 +19,30 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE const {it, fit, xit, dit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; - describe('window.open', function() { + describe('Link navigation', function() { it.fail(CHROMIUM)('should inherit user agent from browser context', async function({browser, server}) { + const context = await browser.newContext({ + userAgent: 'hey' + }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.setContent('link'); + const requestPromise = server.waitForRequest('/popup/popup.html'); + const [popup] = await Promise.all([ + new Promise(fulfill => context.once('page', async pageEvent => fulfill(await pageEvent.page()))), + page.click('a'), + ]); + await popup.waitForLoadState(); + const userAgent = await popup.evaluate(() => window.initialUserAgent); + const request = await requestPromise; + await context.close(); + expect(userAgent).toBe('hey'); + expect(request.headers['user-agent']).toBe('hey'); + }); + }); + + describe('window.open', function() { + it('should inherit user agent from browser context', async function({browser, server}) { const context = await browser.newContext({ userAgent: 'hey' }); @@ -36,7 +58,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE expect(userAgent).toBe('hey'); expect(request.headers['user-agent']).toBe('hey'); }); - it.fail(CHROMIUM)('should inherit extra headers from browser context', async function({browser, server}) { + it('should inherit extra headers from browser context', async function({browser, server}) { const context = await browser.newContext({ extraHTTPHeaders: { 'foo': 'bar' }, }); @@ -60,7 +82,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE await context.close(); expect(online).toBe(false); }); - it.skip(FFOX).fail(CHROMIUM)('should inherit touch support from browser context', async function({browser, server}) { + it.skip(FFOX)('should inherit touch support from browser context', async function({browser, server}) { const context = await browser.newContext({ viewport: { width: 400, height: 500, isMobile: true } }); @@ -73,7 +95,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE await context.close(); expect(hasTouch).toBe(true); }); - it.fail(CHROMIUM)('should inherit viewport size from browser context', async function({browser, server}) { + it('should inherit viewport size from browser context', async function({browser, server}) { const context = await browser.newContext({ viewport: { width: 400, height: 500 } }); @@ -124,7 +146,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE expect(await popup.evaluate(() => !!window.opener)).toBe(true); await context.close(); }); - it.fail(CHROMIUM)('should work with empty url', async({browser}) => { + it('should work with empty url', async({browser}) => { const context = await browser.newContext(); const page = await context.newPage(); const [popup] = await Promise.all([