Skip to content

Commit

Permalink
feat(popups): auto-attach to all pages in Chromium (#1226)
Browse files Browse the repository at this point in the history
  • Loading branch information
yury-s authored Mar 5, 2020
1 parent aabdac8 commit 665888d
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 80 deletions.
43 changes: 33 additions & 10 deletions src/chromium/crBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CRBrowser> {
static async connect(transport: ConnectionTransport, isPersistent: boolean, slowMo?: number): Promise<CRBrowser> {
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<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;
}

Expand All @@ -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<BrowserContext> {
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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!;
}
Expand Down
52 changes: 26 additions & 26 deletions src/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)),
Expand Down Expand Up @@ -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);
}

Expand Down
70 changes: 35 additions & 35 deletions src/chromium/crTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,20 @@ export class CRTarget {
private readonly _browserContext: CRBrowserContext;
readonly _targetId: string;
readonly sessionFactory: () => Promise<CRSession>;
private _pagePromiseFulfill: ((page: Page) => void) | null = null;
private _pagePromiseReject: ((error: Error) => void) | null = null;
private _pagePromise: Promise<Page> | null = null;
_crPage: CRPage | null = null;
private _workerPromise: Promise<Worker> | null = null;
readonly _initializedPromise: Promise<boolean>;
_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,
Expand All @@ -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<Page>((fulfill, reject) => {
this._pagePromiseFulfill = fulfill;
this._pagePromiseReject = reject;
});
}
}

_didClose() {
Expand All @@ -77,19 +70,32 @@ export class CRTarget {
}

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;
}

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> {
if (this._targetInfo.type !== 'service_worker')
return null;
Expand Down Expand Up @@ -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;
}
}
}
6 changes: 3 additions & 3 deletions src/server/chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -69,7 +69,7 @@ export class Chromium implements BrowserType {
async launchPersistent(userDataDir: string, options?: LaunchOptions): Promise<BrowserContext> {
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.
Expand Down Expand Up @@ -155,7 +155,7 @@ export class Chromium implements BrowserType {

async connect(options: ConnectOptions): Promise<CRBrowser> {
return await platform.connectToWebsocket(options.wsEndpoint, transport => {
return CRBrowser.connect(transport, options.slowMo);
return CRBrowser.connect(transport, false, options.slowMo);
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
},
Expand Down
3 changes: 3 additions & 0 deletions test/assets/popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
<html>
<head>
<title>Popup</title>
<script>
window.initialUserAgent = navigator.userAgent;
</script>
</head>
<body>
I am a popup
Expand Down
32 changes: 27 additions & 5 deletions test/popup.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('<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({
userAgent: 'hey'
});
Expand All @@ -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' },
});
Expand All @@ -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 }
});
Expand All @@ -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 }
});
Expand Down Expand Up @@ -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([
Expand Down

0 comments on commit 665888d

Please sign in to comment.