feat(rpc): support more chromium-specific apis (#2883)

This includes page CDPSession, backgroundPages() and serviceWorkers().

This has also revealed an issue with closing order between the context
and the service worker.
This commit is contained in:
Dmitry Gozman 2020-07-08 21:36:03 -07:00 committed by GitHub
parent b3ca4afd40
commit 8fe29feb21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 124 additions and 26 deletions

View file

@ -26,6 +26,7 @@ import { ProxySettings } from './types';
import { LoggerSink } from './loggerSink';
export type BrowserOptions = {
name: string,
loggers: Loggers,
downloadsPath?: string,
headful?: boolean,

View file

@ -62,7 +62,7 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
readonly _options: BrowserContextOptions;
_routes: { url: types.URLMatch, handler: network.RouteHandler }[] = [];
private _isPersistentContext: boolean;
private _startedClosing = false;
private _closedStatus: 'open' | 'closing' | 'closed' = 'open';
readonly _closePromise: Promise<Error>;
private _closePromiseFulfill: ((error: Error) => void) | undefined;
readonly _permissions = new Map<string, string[]>();
@ -109,6 +109,12 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
}
private _didCloseInternal() {
if (this._closedStatus === 'closed') {
// We can come here twice if we close browser context and browser
// at the same time.
return;
}
this._closedStatus = 'closed';
this._downloads.clear();
this._closePromiseFulfill!(new Error('Context closed'));
this.emit(Events.BrowserContext.Close);
@ -235,8 +241,8 @@ export abstract class BrowserContextBase extends EventEmitter implements Browser
await this._browserBase.close();
return;
}
if (!this._startedClosing) {
this._startedClosing = true;
if (this._closedStatus === 'open') {
this._closedStatus = 'closing';
await this._doClose();
await Promise.all([...this._downloads].map(d => d.delete()));
this._didCloseInternal();

View file

@ -425,6 +425,16 @@ export class CRBrowserContext extends BrowserContextBase {
assert(this._browserContextId);
await this._browser._session.send('Target.disposeBrowserContext', { browserContextId: this._browserContextId });
this._browser._contexts.delete(this._browserContextId);
for (const [targetId, serviceWorker] of this._browser._serviceWorkers) {
if (serviceWorker._browserContext !== this)
continue;
// When closing a browser context, service workers are shutdown
// asynchronously and we get detached from them later.
// To avoid the wrong order of notifications, we manually fire
// "close" event here and forget about the serivce worker.
serviceWorker.emit(CommonEvents.Worker.Close);
this._browser._serviceWorkers.delete(targetId);
}
}
backgroundPages(): Page[] {

View file

@ -63,8 +63,7 @@ export interface BrowserChannel extends Channel {
close(): Promise<void>;
newContext(params: types.BrowserContextOptions): Promise<BrowserContextChannel>;
// Chromium-specific.
newBrowserCDPSession(): Promise<CDPSessionChannel>;
crNewBrowserCDPSession(): Promise<CDPSessionChannel>;
}
export type BrowserInitializer = {};
@ -92,9 +91,15 @@ export interface BrowserContextChannel extends Channel {
setNetworkInterceptionEnabled(params: { enabled: boolean }): Promise<void>;
setOffline(params: { offline: boolean }): Promise<void>;
waitForEvent(params: { event: string }): Promise<any>;
on(event: 'crBackgroundPage', callback: (params: PageChannel) => void): this;
on(event: 'crServiceWorker', callback: (params: WorkerChannel) => void): this;
crNewCDPSession(params: { page: PageChannel }): Promise<CDPSessionChannel>;
}
export type BrowserContextInitializer = {
pages: PageChannel[]
pages: PageChannel[],
crBackgroundPages: PageChannel[],
crServiceWorkers: WorkerChannel[],
};

View file

@ -79,8 +79,7 @@ export class Browser extends ChannelOwner<BrowserChannel, BrowserInitializer> {
await this._channel.close();
}
// Chromium-specific.
async newBrowserCDPSession(): Promise<CDPSession> {
return CDPSession.from(await this._channel.newBrowserCDPSession());
return CDPSession.from(await this._channel.crNewBrowserCDPSession());
}
}

View file

@ -26,9 +26,14 @@ import { Browser } from './browser';
import { ConnectionScope } from './connection';
import { Events } from '../../events';
import { TimeoutSettings } from '../../timeoutSettings';
import { CDPSession } from './cdpSession';
import { Events as ChromiumEvents } from '../../chromium/events';
import { Worker } from './worker';
export class BrowserContext extends ChannelOwner<BrowserContextChannel, BrowserContextInitializer> {
_pages = new Set<Page>();
_crBackgroundPages = new Set<Page>();
_crServiceWorkers = new Set<Worker>();
private _routes: { url: types.URLMatch, handler: network.RouteHandler }[] = [];
_browser: Browser | undefined;
readonly _bindings = new Map<string, frames.FunctionWithSource>();
@ -46,7 +51,7 @@ export class BrowserContext extends ChannelOwner<BrowserContextChannel, BrowserC
constructor(scope: ConnectionScope, guid: string, initializer: BrowserContextInitializer) {
super(scope, guid, initializer, true);
initializer.pages.map(p => {
initializer.pages.forEach(p => {
const page = Page.from(p);
this._pages.add(page);
page._setBrowserContext(this);
@ -55,6 +60,29 @@ export class BrowserContext extends ChannelOwner<BrowserContextChannel, BrowserC
this._channel.on('close', () => this._onClose());
this._channel.on('page', page => this._onPage(Page.from(page)));
this._channel.on('route', ({ route, request }) => this._onRoute(network.Route.from(route), network.Request.from(request)));
initializer.crBackgroundPages.forEach(p => {
const page = Page.from(p);
this._crBackgroundPages.add(page);
page._setBrowserContext(this);
});
this._channel.on('crBackgroundPage', pageChannel => {
const page = Page.from(pageChannel);
page._setBrowserContext(this);
this._crBackgroundPages.add(page);
this.emit(ChromiumEvents.CRBrowserContext.BackgroundPage, page);
});
initializer.crServiceWorkers.forEach(w => {
const worker = Worker.from(w);
worker._context = this;
this._crServiceWorkers.add(worker);
});
this._channel.on('crServiceWorker', serviceWorkerChannel => {
const worker = Worker.from(serviceWorkerChannel);
worker._context = this;
this._crServiceWorkers.add(worker);
this.emit(ChromiumEvents.CRBrowserContext.ServiceWorker, worker);
});
}
private _onPage(page: Page): void {
@ -199,4 +227,16 @@ export class BrowserContext extends ChannelOwner<BrowserContextChannel, BrowserC
async close(): Promise<void> {
await this._channel.close();
}
async newCDPSession(page: Page): Promise<CDPSession> {
return CDPSession.from(await this._channel.crNewCDPSession({ page: page._channel }));
}
backgroundPages(): Page[] {
return [...this._crBackgroundPages];
}
serviceWorkers(): Worker[] {
return [...this._crServiceWorkers];
}
}

View file

@ -21,9 +21,11 @@ import { ConnectionScope } from './connection';
import { ChannelOwner } from './channelOwner';
import { Func1, JSHandle, parseResult, serializeArgument, SmartHandle } from './jsHandle';
import { Page } from './page';
import { BrowserContext } from './browserContext';
export class Worker extends ChannelOwner<WorkerChannel, WorkerInitializer> {
_page: Page | undefined;
_page: Page | undefined; // Set for web workers.
_context: BrowserContext | undefined; // Set for service workers.
static from(worker: WorkerChannel): Worker {
return (worker as any)._object;
@ -32,7 +34,10 @@ export class Worker extends ChannelOwner<WorkerChannel, WorkerInitializer> {
constructor(scope: ConnectionScope, guid: string, initializer: WorkerInitializer) {
super(scope, guid, initializer);
this._channel.on('close', () => {
this._page!._workers.delete(this);
if (this._page)
this._page._workers.delete(this);
if (this._context)
this._context._crServiceWorkers.delete(this);
this.emit(Events.Worker.Close, this);
});
}

View file

@ -18,17 +18,31 @@ import * as types from '../../types';
import { BrowserContextBase, BrowserContext } from '../../browserContext';
import { Events } from '../../events';
import { Dispatcher, DispatcherScope, lookupNullableDispatcher, lookupDispatcher } from './dispatcher';
import { PageDispatcher, BindingCallDispatcher } from './pageDispatcher';
import { PageChannel, BrowserContextChannel, BrowserContextInitializer } from '../channels';
import { PageDispatcher, BindingCallDispatcher, WorkerDispatcher } from './pageDispatcher';
import { PageChannel, BrowserContextChannel, BrowserContextInitializer, CDPSessionChannel } from '../channels';
import { RouteDispatcher, RequestDispatcher } from './networkDispatchers';
import { Page } from '../../page';
import { CRBrowserContext } from '../../chromium/crBrowser';
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
import { Events as ChromiumEvents } from '../../chromium/events';
export class BrowserContextDispatcher extends Dispatcher<BrowserContext, BrowserContextInitializer> implements BrowserContextChannel {
private _context: BrowserContextBase;
constructor(scope: DispatcherScope, context: BrowserContextBase) {
let crBackgroundPages: PageDispatcher[] = [];
let crServiceWorkers: WorkerDispatcher[] = [];
if (context._browserBase._options.name === 'chromium') {
crBackgroundPages = (context as CRBrowserContext).backgroundPages().map(p => new PageDispatcher(scope, p));
context.on(ChromiumEvents.CRBrowserContext.BackgroundPage, page => this._dispatchEvent('crBackgroundPage', new PageDispatcher(this._scope, page)));
crServiceWorkers = (context as CRBrowserContext).serviceWorkers().map(w => new WorkerDispatcher(scope, w));
context.on(ChromiumEvents.CRBrowserContext.ServiceWorker, serviceWorker => this._dispatchEvent('crServiceWorker', new WorkerDispatcher(this._scope, serviceWorker)));
}
super(scope, context, 'context', {
pages: context.pages().map(p => new PageDispatcher(scope, p))
pages: context.pages().map(p => new PageDispatcher(scope, p)),
crBackgroundPages,
crServiceWorkers,
}, true);
this._context = context;
context.on(Events.BrowserContext.Page, page => this._dispatchEvent('page', new PageDispatcher(this._scope, page)));
@ -118,4 +132,9 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, Browser
async close(): Promise<void> {
await this._context.close();
}
async crNewCDPSession(params: { page: PageDispatcher }): Promise<CDPSessionChannel> {
const crBrowserContext = this._object as CRBrowserContext;
return new CDPSessionDispatcher(this._scope, await crBrowserContext.newCDPSession(params.page._object));
}
}

View file

@ -41,8 +41,7 @@ export class BrowserDispatcher extends Dispatcher<Browser, BrowserInitializer> i
await this._object.close();
}
// Chromium-specific.
async newBrowserCDPSession(): Promise<CDPSessionChannel> {
async crNewBrowserCDPSession(): Promise<CDPSessionChannel> {
const crBrowser = this._object as CRBrowser;
return new CDPSessionDispatcher(this._scope, await crBrowser.newBrowserCDPSession());
}

View file

@ -106,6 +106,7 @@ export abstract class BrowserTypeBase implements BrowserType {
if ((options as any).__testHookBeforeCreateBrowser)
await (options as any).__testHookBeforeCreateBrowser();
const browserOptions: BrowserOptions = {
name: this._name,
slowMo: options.slowMo,
persistent,
headful: !options.headless,
@ -142,7 +143,7 @@ export abstract class BrowserTypeBase implements BrowserType {
progress.cleanupWhenAborted(() => transport.closeAndWait());
if ((options as any).__testHookBeforeCreateBrowser)
await (options as any).__testHookBeforeCreateBrowser();
const browser = await this._connectToTransport(transport, { slowMo: options.slowMo, loggers });
const browser = await this._connectToTransport(transport, { name: this._name, slowMo: options.slowMo, loggers });
return browser;
}, loggers.browser, TimeoutSettings.timeout(options), 'browserType.connect');
}

View file

@ -202,7 +202,7 @@ export class Electron {
const chromeMatch = await waitForLine(progress, launchedProcess, launchedProcess.stderr, /^DevTools listening on (ws:\/\/.*)$/);
const chromeTransport = await WebSocketTransport.connect(progress, chromeMatch[1]);
const browserServer = new BrowserServer(launchedProcess, gracefullyClose, kill);
const browser = await CRBrowser.connect(chromeTransport, { headful: true, loggers, persistent: { viewport: null }, ownedServer: browserServer });
const browser = await CRBrowser.connect(chromeTransport, { name: 'electron', headful: true, loggers, persistent: { viewport: null }, ownedServer: browserServer });
app = new ElectronApplication(loggers, browser, nodeConnection);
await app._init();
return app;

View file

@ -16,7 +16,7 @@
const {FFOX, CHROMIUM, WEBKIT, CHANNEL} = require('../utils').testOptions(browserType);
describe.skip(CHANNEL)('ChromiumBrowserContext', function() {
describe('ChromiumBrowserContext', function() {
it('should create a worker from a service worker', async({browser, page, server, context}) => {
const [worker] = await Promise.all([
context.waitForEvent('serviceworker'),
@ -50,6 +50,19 @@ describe.skip(CHANNEL)('ChromiumBrowserContext', function() {
});
expect(serviceWorkerCreated).not.toBeTruthy();
});
it('should close service worker together with the context', async({browser, server}) => {
const context = await browser.newContext();
const page = await context.newPage();
const [worker] = await Promise.all([
context.waitForEvent('serviceworker'),
page.goto(server.PREFIX + '/serviceworkers/empty/sw.html')
]);
const messages = [];
context.on('close', () => messages.push('context'));
worker.on('close', () => messages.push('worker'));
await context.close();
expect(messages.join('|')).toBe('worker|context');
});
});
describe('Chromium-Specific Page Tests', function() {

View file

@ -19,7 +19,7 @@ const utils = require('../utils');
const {makeUserDataDir, removeUserDataDir} = utils;
const {FFOX, CHROMIUM, WEBKIT, WIN, CHANNEL} = utils.testOptions(browserType);
describe.skip(CHANNEL)('launcher', function() {
describe('launcher', function() {
it('should throw with remote-debugging-pipe argument', async({browserType, defaultBrowserOptions}) => {
const options = Object.assign({}, defaultBrowserOptions);
options.args = ['--remote-debugging-pipe'].concat(options.args || []);
@ -49,7 +49,7 @@ describe.skip(CHANNEL)('launcher', function() {
});
});
describe.skip(CHANNEL)('extensions', () => {
describe('extensions', () => {
it('should return background pages', async({browserType, defaultBrowserOptions}) => {
const userDataDir = await makeUserDataDir();
const extensionPath = path.join(__dirname, '..', 'assets', 'simple-extension');
@ -73,7 +73,7 @@ describe.skip(CHANNEL)('extensions', () => {
});
});
describe.skip(CHANNEL)('BrowserContext', function() {
describe('BrowserContext', function() {
it('should not create pages automatically', async ({browserType, defaultBrowserOptions}) => {
const browser = await browserType.launch(defaultBrowserOptions);
const browserSession = await browser.newBrowserCDPSession();

View file

@ -16,7 +16,7 @@
const {FFOX, CHROMIUM, WEBKIT, CHANNEL} = require('../utils').testOptions(browserType);
describe.skip(CHANNEL)('OOPIF', function() {
describe('OOPIF', function() {
beforeAll(async function(state) {
state.browser = await state.browserType.launch(Object.assign({}, state.defaultBrowserOptions, {
args: (state.defaultBrowserOptions.args || []).concat(['--site-per-process']),

View file

@ -16,7 +16,7 @@
const {FFOX, CHROMIUM, WEBKIT, CHANNEL} = require('../utils').testOptions(browserType);
describe.skip(CHANNEL)('ChromiumBrowserContext.createSession', function() {
describe('ChromiumBrowserContext.createSession', function() {
it('should work', async function({page, browser, server}) {
const client = await page.context().newCDPSession(page);
@ -35,7 +35,7 @@ describe.skip(CHANNEL)('ChromiumBrowserContext.createSession', function() {
await page.goto(server.EMPTY_PAGE);
expect(events.length).toBe(1);
});
it('should enable and disable domains independently', async function({page, browser, server}) {
it.skip(CHANNEL)('should enable and disable domains independently', async function({page, browser, server}) {
const client = await page.context().newCDPSession(page);
await client.send('Runtime.enable');
await client.send('Debugger.enable');
@ -64,7 +64,7 @@ describe.skip(CHANNEL)('ChromiumBrowserContext.createSession', function() {
} catch (e) {
error = e;
}
expect(error.message).toContain('Session closed.');
expect(error.message).toContain(CHANNEL ? 'Target browser or context has been closed' : 'Session closed.');
});
it('should throw nice errors', async function({page, browser}) {
const client = await page.context().newCDPSession(page);