feat(popups): auto-attach to all pages in Chromium (#1226)

This commit is contained in:
Yury Semikhatsky 2020-03-05 10:45:32 -08:00 committed by GitHub
parent aabdac8380
commit 665888d579
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 128 additions and 80 deletions

View File

@ -43,10 +43,28 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
private _tracingPath: string | null = ''; private _tracingPath: string | null = '';
private _tracingClient: CRSession | undefined; private _tracingClient: CRSession | undefined;
static async connect(transport: ConnectionTransport, slowMo?: number): Promise<CRBrowser> { static async connect(transport: ConnectionTransport, isPersistent: boolean, slowMo?: number): Promise<CRBrowser> {
const connection = new CRConnection(SlowMoTransport.wrap(transport, slowMo)); const connection = new CRConnection(SlowMoTransport.wrap(transport, slowMo));
const browser = new CRBrowser(connection); 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<any>[] = [];
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; 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.targetCreated', this._targetCreated.bind(this));
this._client.on('Target.targetDestroyed', this._targetDestroyed.bind(this)); this._client.on('Target.targetDestroyed', this._targetDestroyed.bind(this));
this._client.on('Target.targetInfoChanged', this._targetInfoChanged.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<BrowserContext> { async newContext(options: BrowserContextOptions = {}): Promise<BrowserContext> {
@ -83,14 +102,20 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
return createPageInNewContext(this, options); return createPageInNewContext(this, options);
} }
async _targetCreated(event: Protocol.Target.targetCreatedPayload) { async _onAttachedToTarget(event: Protocol.Target.attachedToTargetPayload) {
const targetInfo = event.targetInfo; 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 {browserContextId} = targetInfo;
const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId)! : this._defaultContext; const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId)! : this._defaultContext;
const target = new CRTarget(this, targetInfo, context, () => this._connection.createSession(targetInfo)); const target = new CRTarget(this, targetInfo, context, () => this._connection.createSession(targetInfo));
assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); assert(!this._targets.has(targetInfo.targetId), 'Target should not exist before targetCreated');
this._targets.set(event.targetInfo.targetId, target); this._targets.set(targetInfo.targetId, target);
try { try {
switch (targetInfo.type) { switch (targetInfo.type) {
@ -120,7 +145,6 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
async _targetDestroyed(event: { targetId: string; }) { async _targetDestroyed(event: { targetId: string; }) {
const target = this._targets.get(event.targetId)!; const target = this._targets.get(event.targetId)!;
target._initializedCallback(false);
this._targets.delete(event.targetId); this._targets.delete(event.targetId);
target._didClose(); target._didClose();
} }
@ -136,7 +160,7 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
} }
_allTargets(): CRTarget[] { _allTargets(): CRTarget[] {
return Array.from(this._targets.values()).filter(target => target._isInitialized); return Array.from(this._targets.values());
} }
async close() { async close() {
@ -252,7 +276,6 @@ export class CRBrowserContext extends platform.EventEmitter implements BrowserCo
assertBrowserContextIsNotOwned(this); assertBrowserContextIsNotOwned(this);
const { targetId } = await this._browser._client.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId || undefined }); const { targetId } = await this._browser._client.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId || undefined });
const target = this._browser._targets.get(targetId)!; const target = this._browser._targets.get(targetId)!;
assert(await target._initializedPromise, 'Failed to create target for page');
const page = await target.page(); const page = await target.page();
return page!; return page!;
} }

View File

@ -68,33 +68,32 @@ export class CRPage implements PageDelegate {
} }
async initialize() { 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<any>[] = [ const promises: Promise<any>[] = [
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('Log.enable', {}),
this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), 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)); promises.push(this._initBinding(binding));
for (const source of this._browserContext._evaluateOnNewDocumentSources) for (const source of this._browserContext._evaluateOnNewDocumentSources)
promises.push(this.evaluateOnNewDocument(source)); promises.push(this.evaluateOnNewDocument(source));
promises.push(this._client.send('Runtime.runIfWaitingForDebugger'));
await Promise.all(promises); await Promise.all(promises);
} }

View File

@ -32,17 +32,20 @@ export class CRTarget {
private readonly _browserContext: CRBrowserContext; private readonly _browserContext: CRBrowserContext;
readonly _targetId: string; readonly _targetId: string;
readonly sessionFactory: () => Promise<CRSession>; readonly sessionFactory: () => Promise<CRSession>;
private _pagePromiseFulfill: ((page: Page) => void) | null = null;
private _pagePromiseReject: ((error: Error) => void) | null = null;
private _pagePromise: Promise<Page> | null = null; private _pagePromise: Promise<Page> | null = null;
_crPage: CRPage | null = null; _crPage: CRPage | null = null;
private _workerPromise: Promise<Worker> | null = null; private _workerPromise: Promise<Worker> | null = null;
readonly _initializedPromise: Promise<boolean>;
_initializedCallback: (success: boolean) => void = () => {};
_isInitialized: boolean;
static fromPage(page: Page): CRTarget { static fromPage(page: Page): CRTarget {
return (page as any)[targetSymbol]; return (page as any)[targetSymbol];
} }
static isPageType(type: string): boolean {
return type === 'page' || type === 'background_page';
}
constructor( constructor(
browser: CRBrowser, browser: CRBrowser,
targetInfo: Protocol.Target.TargetInfo, targetInfo: Protocol.Target.TargetInfo,
@ -53,22 +56,12 @@ export class CRTarget {
this._browserContext = browserContext; this._browserContext = browserContext;
this._targetId = targetInfo.targetId; this._targetId = targetInfo.targetId;
this.sessionFactory = sessionFactory; this.sessionFactory = sessionFactory;
this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => { if (CRTarget.isPageType(targetInfo.type)) {
if (!success) this._pagePromise = new Promise<Page>((fulfill, reject) => {
return false; this._pagePromiseFulfill = fulfill;
const opener = this.opener(); this._pagePromiseReject = reject;
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);
} }
_didClose() { _didClose() {
@ -77,19 +70,32 @@ export class CRTarget {
} }
async page(): Promise<Page | null> { async page(): Promise<Page | null> {
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; 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<Worker | null> { async serviceWorker(): Promise<Worker | null> {
if (this._targetInfo.type !== 'service_worker') if (this._targetInfo.type !== 'service_worker')
return null; return null;
@ -132,11 +138,5 @@ export class CRTarget {
_targetInfoChanged(targetInfo: Protocol.Target.TargetInfo) { _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo) {
this._targetInfo = targetInfo; this._targetInfo = targetInfo;
if (!this._isInitialized && (this._targetInfo.type !== 'page' || this._targetInfo.url !== '')) {
this._isInitialized = true;
this._initializedCallback(true);
return;
}
} }
} }

View File

@ -55,7 +55,7 @@ export class Chromium implements BrowserType {
if (options && (options as any).userDataDir) if (options && (options as any).userDataDir)
throw new Error('userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistent` instead'); throw new Error('userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistent` instead');
const { browserServer, transport } = await this._launchServer(options, 'local'); 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. // Hack: for typical launch scenario, ensure that close waits for actual process termination.
browser.close = () => browserServer.close(); browser.close = () => browserServer.close();
(browser as any)['__server__'] = browserServer; (browser as any)['__server__'] = browserServer;
@ -69,7 +69,7 @@ export class Chromium implements BrowserType {
async launchPersistent(userDataDir: string, options?: LaunchOptions): Promise<BrowserContext> { async launchPersistent(userDataDir: string, options?: LaunchOptions): Promise<BrowserContext> {
const { timeout = 30000 } = options || {}; const { timeout = 30000 } = options || {};
const { browserServer, transport } = await this._launchServer(options, 'persistent', userDataDir); 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)); const firstPage = new Promise(r => browser._defaultContext.once(Events.BrowserContext.Page, r));
await helper.waitWithTimeout(firstPage, 'first page', timeout); await helper.waitWithTimeout(firstPage, 'first page', timeout);
// Hack: for typical launch scenario, ensure that close waits for actual process termination. // 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<CRBrowser> { async connect(options: ConnectOptions): Promise<CRBrowser> {
return await platform.connectToWebsocket(options.wsEndpoint, transport => { return await platform.connectToWebsocket(options.wsEndpoint, transport => {
return CRBrowser.connect(transport, options.slowMo); return CRBrowser.connect(transport, false, options.slowMo);
}); });
} }

View File

@ -23,7 +23,7 @@ const connect = {
chromium: { chromium: {
connect: async (url: string) => { connect: async (url: string) => {
return await platform.connectToWebsocket(url, transport => { return await platform.connectToWebsocket(url, transport => {
return ChromiumBrowser.connect(transport); return ChromiumBrowser.connect(transport, false);
}); });
} }
}, },

View File

@ -2,6 +2,9 @@
<html> <html>
<head> <head>
<title>Popup</title> <title>Popup</title>
<script>
window.initialUserAgent = navigator.userAgent;
</script>
</head> </head>
<body> <body>
I am a popup I am a popup

View File

@ -19,8 +19,30 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE
const {it, fit, xit, dit} = testRunner; const {it, fit, xit, dit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = 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}) { 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('<a target=_blank rel=noopener href="/popup/popup.html">link</a>');
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({ const context = await browser.newContext({
userAgent: 'hey' userAgent: 'hey'
}); });
@ -36,7 +58,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE
expect(userAgent).toBe('hey'); expect(userAgent).toBe('hey');
expect(request.headers['user-agent']).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({ const context = await browser.newContext({
extraHTTPHeaders: { 'foo': 'bar' }, extraHTTPHeaders: { 'foo': 'bar' },
}); });
@ -60,7 +82,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE
await context.close(); await context.close();
expect(online).toBe(false); 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({ const context = await browser.newContext({
viewport: { width: 400, height: 500, isMobile: true } viewport: { width: 400, height: 500, isMobile: true }
}); });
@ -73,7 +95,7 @@ module.exports.describe = function({testRunner, expect, playwright, CHROMIUM, WE
await context.close(); await context.close();
expect(hasTouch).toBe(true); 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({ const context = await browser.newContext({
viewport: { width: 400, height: 500 } 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); expect(await popup.evaluate(() => !!window.opener)).toBe(true);
await context.close(); 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 context = await browser.newContext();
const page = await context.newPage(); const page = await context.newPage();
const [popup] = await Promise.all([ const [popup] = await Promise.all([