refactor: move context actions to the browser (#10621)

This commit is contained in:
Alex Rudenko 2023-07-25 13:30:57 +02:00 committed by GitHub
parent ede43ca2d3
commit 0e40f3e143
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 234 additions and 116 deletions

View File

@ -26,11 +26,16 @@ import {
} from '../../api/Browser.js'; } from '../../api/Browser.js';
import {BrowserContext as BrowserContextBase} from '../../api/BrowserContext.js'; import {BrowserContext as BrowserContextBase} from '../../api/BrowserContext.js';
import {Page} from '../../api/Page.js'; import {Page} from '../../api/Page.js';
import {Target} from '../../puppeteer-core.js'; import {Target} from '../../api/Target.js';
import {Viewport} from '../PuppeteerViewport.js'; import {Viewport} from '../PuppeteerViewport.js';
import {BrowserContext} from './BrowserContext.js'; import {BrowserContext} from './BrowserContext.js';
import {
BrowsingContext,
BrowsingContextEmittedEvents,
} from './BrowsingContext.js';
import {Connection} from './Connection.js'; import {Connection} from './Connection.js';
import {BiDiPageTarget, BiDiTarget} from './Target.js';
import {debugError} from './utils.js'; import {debugError} from './utils.js';
/** /**
@ -54,9 +59,6 @@ export class Browser extends BrowserBase {
'cdp.Debugger.scriptParsed', 'cdp.Debugger.scriptParsed',
]; ];
#browserName = '';
#browserVersion = '';
static async create(opts: Options): Promise<Browser> { static async create(opts: Options): Promise<Browser> {
let browserName = ''; let browserName = '';
let browserVersion = ''; let browserVersion = '';
@ -83,18 +85,25 @@ export class Browser extends BrowserBase {
: [...Browser.subscribeModules, ...Browser.subscribeCdpEvents], : [...Browser.subscribeModules, ...Browser.subscribeCdpEvents],
}); });
return new Browser({ const browser = new Browser({
...opts, ...opts,
browserName, browserName,
browserVersion, browserVersion,
}); });
await browser.#getTree();
return browser;
} }
#browserName = '';
#browserVersion = '';
#process?: ChildProcess; #process?: ChildProcess;
#closeCallback?: BrowserCloseCallback; #closeCallback?: BrowserCloseCallback;
#connection: Connection; #connection: Connection;
#defaultViewport: Viewport | null; #defaultViewport: Viewport | null;
#defaultContext: BrowserContext; #defaultContext: BrowserContext;
#targets = new Map<string, BiDiTarget>();
constructor( constructor(
opts: Options & { opts: Options & {
@ -118,8 +127,54 @@ export class Browser extends BrowserBase {
defaultViewport: this.#defaultViewport, defaultViewport: this.#defaultViewport,
isDefault: true, isDefault: true,
}); });
this.#connection.on(
'browsingContext.contextCreated',
this.#onContextCreated
);
this.#connection.on(
'browsingContext.contextDestroyed',
this.#onContextDestroyed
);
} }
#onContextCreated = (
event: Bidi.BrowsingContext.ContextCreatedEvent['params']
) => {
const context = new BrowsingContext(this.#connection, event);
this.#connection.registerBrowsingContexts(context);
// TODO: once more browsing context types are supported, this should be
// updated to support those. Currently, all top-level contexts are treated
// as pages.
const target = !context.parent
? new BiDiPageTarget(this.defaultBrowserContext(), context)
: new BiDiTarget(this.defaultBrowserContext(), context);
this.#targets.set(event.context, target);
if (context.parent) {
const topLevel = this.#connection.getTopLevelContext(context.parent);
topLevel.emit(BrowsingContextEmittedEvents.Created, context);
}
};
async #getTree(): Promise<void> {
const {result} = await this.#connection.send('browsingContext.getTree', {});
for (const context of result.contexts) {
this.#onContextCreated(context);
}
}
#onContextDestroyed = async (
event: Bidi.BrowsingContext.ContextDestroyedEvent['params']
) => {
const context = this.#connection.getBrowsingContext(event.context);
const topLevelContext = this.#connection.getTopLevelContext(event.context);
topLevelContext.emit(BrowsingContextEmittedEvents.Destroyed, context);
const target = this.#targets.get(event.context);
const page = await target?.page();
await page?.close().catch(debugError);
this.#targets.delete(event.context);
};
get connection(): Connection { get connection(): Connection {
return this.#connection; return this.#connection;
} }
@ -129,6 +184,14 @@ export class Browser extends BrowserBase {
} }
override async close(): Promise<void> { override async close(): Promise<void> {
this.#connection.off(
'browsingContext.contextDestroyed',
this.#onContextDestroyed
);
this.#connection.off(
'browsingContext.contextCreated',
this.#onContextCreated
);
if (this.#connection.closed) { if (this.#connection.closed) {
return; return;
} }
@ -181,9 +244,15 @@ export class Browser extends BrowserBase {
} }
override targets(): Target[] { override targets(): Target[] {
return this.browserContexts().flatMap(c => { return Array.from(this.#targets.values());
return c.targets(); }
});
_getTargetById(id: string): BiDiTarget {
const target = this.#targets.get(id);
if (!target) {
throw new Error('Target not found');
}
return target;
} }
} }

View File

@ -14,18 +14,14 @@
* limitations under the License. * limitations under the License.
*/ */
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {BrowserContext as BrowserContextBase} from '../../api/BrowserContext.js'; import {BrowserContext as BrowserContextBase} from '../../api/BrowserContext.js';
import {Page as PageBase} from '../../api/Page.js'; import {Page as PageBase} from '../../api/Page.js';
import {Target} from '../../api/Target.js'; import {Target} from '../../api/Target.js';
import {Deferred} from '../../util/Deferred.js';
import {Viewport} from '../PuppeteerViewport.js'; import {Viewport} from '../PuppeteerViewport.js';
import {Browser} from './Browser.js'; import {Browser} from './Browser.js';
import {Connection} from './Connection.js'; import {Connection} from './Connection.js';
import {Page} from './Page.js'; import {Page} from './Page.js';
import {BiDiTarget} from './Target.js';
import {debugError} from './utils.js'; import {debugError} from './utils.js';
interface BrowserContextOptions { interface BrowserContextOptions {
@ -40,9 +36,6 @@ export class BrowserContext extends BrowserContextBase {
#browser: Browser; #browser: Browser;
#connection: Connection; #connection: Connection;
#defaultViewport: Viewport | null; #defaultViewport: Viewport | null;
#targets = new Map<string, BiDiTarget>();
#onContextDestroyedBind = this.#onContextDestroyed.bind(this);
#init = Deferred.create<void>();
#isDefault = false; #isDefault = false;
constructor(browser: Browser, options: BrowserContextOptions) { constructor(browser: Browser, options: BrowserContextOptions) {
@ -50,12 +43,7 @@ export class BrowserContext extends BrowserContextBase {
this.#browser = browser; this.#browser = browser;
this.#connection = this.#browser.connection; this.#connection = this.#browser.connection;
this.#defaultViewport = options.defaultViewport; this.#defaultViewport = options.defaultViewport;
this.#connection.on(
'browsingContext.contextDestroyed',
this.#onContextDestroyedBind
);
this.#isDefault = options.isDefault; this.#isDefault = options.isDefault;
this.#getTree().catch(debugError);
} }
override targets(): Target[] { override targets(): Target[] {
@ -77,49 +65,23 @@ export class BrowserContext extends BrowserContextBase {
return this.#connection; return this.#connection;
} }
async #getTree(): Promise<void> {
if (!this.#isDefault) {
this.#init.resolve();
return;
}
try {
const {result} = await this.#connection.send(
'browsingContext.getTree',
{}
);
for (const context of result.contexts) {
const page = new Page(this, context);
const target = new BiDiTarget(page.mainFrame().context(), page);
this.#targets.set(context.context, target);
}
this.#init.resolve();
} catch (err) {
this.#init.reject(err as Error);
}
}
async #onContextDestroyed(
event: Bidi.BrowsingContext.ContextDestroyedEvent['params']
) {
const target = this.#targets.get(event.context);
const page = await target?.page();
await page?.close().catch(error => {
debugError(error);
});
this.#targets.delete(event.context);
}
override async newPage(): Promise<PageBase> { override async newPage(): Promise<PageBase> {
await this.#init.valueOrThrow();
const {result} = await this.#connection.send('browsingContext.create', { const {result} = await this.#connection.send('browsingContext.create', {
type: 'tab', type: 'tab',
}); });
const page = new Page(this, { const target = this.#browser._getTargetById(result.context);
context: result.context,
children: [], // TODO: once BiDi has some concept matching BrowserContext, the newly
}); // created contexts should get automatically assigned to the right
const target = new BiDiTarget(page.mainFrame().context(), page); // BrowserContext. For now, we assume that only explicitly created pages go
// to the current BrowserContext. Otherwise, the contexts get assigned to
// the default BrowserContext by the Browser.
target._setBrowserContext(this);
const page = await target.page();
if (!page) {
throw new Error('Page is not found');
}
if (this.#defaultViewport) { if (this.#defaultViewport) {
try { try {
await page.setViewport(this.#defaultViewport); await page.setViewport(this.#defaultViewport);
@ -128,25 +90,20 @@ export class BrowserContext extends BrowserContextBase {
} }
} }
this.#targets.set(result.context, target);
return page; return page;
} }
override async close(): Promise<void> { override async close(): Promise<void> {
await this.#init.valueOrThrow();
if (this.#isDefault) { if (this.#isDefault) {
throw new Error('Default context cannot be closed!'); throw new Error('Default context cannot be closed!');
} }
for (const target of this.#targets.values()) { for (const target of this.targets()) {
const page = await target?.page(); const page = await target?.page();
await page?.close().catch(error => { await page?.close().catch(error => {
debugError(error); debugError(error);
}); });
} }
this.#targets.clear();
} }
override browser(): Browser { override browser(): Browser {
@ -154,9 +111,8 @@ export class BrowserContext extends BrowserContextBase {
} }
override async pages(): Promise<PageBase[]> { override async pages(): Promise<PageBase[]> {
await this.#init.valueOrThrow();
const results = await Promise.all( const results = await Promise.all(
[...this.#targets.values()].map(t => { [...this.targets()].map(t => {
return t.page(); return t.page();
}) })
); );

View File

@ -106,6 +106,23 @@ export class CDPSessionWrapper extends EventEmitter implements CDPSession {
} }
} }
/**
* Internal events that the BrowsingContext class emits.
*
* @internal
*/
export const BrowsingContextEmittedEvents = {
/**
* Emitted on the top-level context, when a descendant context is created.
*/
Created: Symbol('BrowsingContext.created'),
/**
* Emitted on the top-level context, when a descendant context or the
* top-level context itself is destroyed.
*/
Destroyed: Symbol('BrowsingContext.destroyed'),
} as const;
/** /**
* @internal * @internal
*/ */
@ -113,12 +130,14 @@ export class BrowsingContext extends Realm {
#id: string; #id: string;
#url: string; #url: string;
#cdpSession: CDPSession; #cdpSession: CDPSession;
#parent?: string | null;
constructor(connection: Connection, info: Bidi.BrowsingContext.Info) { constructor(connection: Connection, info: Bidi.BrowsingContext.Info) {
super(connection, info.context); super(connection, info.context);
this.connection = connection; this.connection = connection;
this.#id = info.context; this.#id = info.context;
this.#url = info.url; this.#url = info.url;
this.#parent = info.parent;
this.#cdpSession = new CDPSessionWrapper(this); this.#cdpSession = new CDPSessionWrapper(this);
this.on( this.on(
@ -141,6 +160,10 @@ export class BrowsingContext extends Realm {
return this.#id; return this.#id;
} }
get parent(): string | undefined | null {
return this.#parent;
}
get cdpSession(): CDPSession { get cdpSession(): CDPSession {
return this.#cdpSession; return this.#cdpSession;
} }

View File

@ -246,6 +246,29 @@ export class Connection extends EventEmitter {
this.#browsingContexts.set(context.id, context); this.#browsingContexts.set(context.id, context);
} }
getBrowsingContext(contextId: string): BrowsingContext {
const currentContext = this.#browsingContexts.get(contextId);
if (!currentContext) {
throw new Error(`BrowsingContext ${contextId} does not exist.`);
}
return currentContext;
}
getTopLevelContext(contextId: string): BrowsingContext {
let currentContext = this.#browsingContexts.get(contextId);
if (!currentContext) {
throw new Error(`BrowsingContext ${contextId} does not exist.`);
}
while (currentContext.parent) {
contextId = currentContext.parent;
currentContext = this.#browsingContexts.get(contextId);
if (!currentContext) {
throw new Error(`BrowsingContext ${contextId} does not exist.`);
}
}
return currentContext;
}
unregisterBrowsingContexts(id: string): void { unregisterBrowsingContexts(id: string): void {
this.#browsingContexts.delete(id); this.#browsingContexts.delete(id);
} }

View File

@ -53,7 +53,11 @@ import {
import {Browser} from './Browser.js'; import {Browser} from './Browser.js';
import {BrowserContext} from './BrowserContext.js'; import {BrowserContext} from './BrowserContext.js';
import {BrowsingContext, CDPSessionWrapper} from './BrowsingContext.js'; import {
BrowsingContext,
BrowsingContextEmittedEvents,
CDPSessionWrapper,
} from './BrowsingContext.js';
import {Connection} from './Connection.js'; import {Connection} from './Connection.js';
import {Frame} from './Frame.js'; import {Frame} from './Frame.js';
import {HTTPRequest} from './HTTPRequest.js'; import {HTTPRequest} from './HTTPRequest.js';
@ -69,7 +73,6 @@ import {BidiSerializer} from './Serializer.js';
export class Page extends PageBase { export class Page extends PageBase {
#accessibility: Accessibility; #accessibility: Accessibility;
#timeoutSettings = new TimeoutSettings(); #timeoutSettings = new TimeoutSettings();
#browserContext: BrowserContext;
#connection: Connection; #connection: Connection;
#frameTree = new FrameTree<Frame>(); #frameTree = new FrameTree<Frame>();
#networkManager: NetworkManager; #networkManager: NetworkManager;
@ -82,8 +85,6 @@ export class Page extends PageBase {
'browsingContext.domContentLoaded', 'browsingContext.domContentLoaded',
this.#onFrameDOMContentLoaded.bind(this), this.#onFrameDOMContentLoaded.bind(this),
], ],
['browsingContext.contextCreated', this.#onFrameAttached.bind(this)],
['browsingContext.contextDestroyed', this.#onFrameDetached.bind(this)],
['browsingContext.fragmentNavigated', this.#onFrameNavigated.bind(this)], ['browsingContext.fragmentNavigated', this.#onFrameNavigated.bind(this)],
]) as Map<Bidi.Session.SubscriptionRequestEvent, Handler>; ]) as Map<Bidi.Session.SubscriptionRequestEvent, Handler>;
#networkManagerEvents = new Map<symbol, Handler<any>>([ #networkManagerEvents = new Map<symbol, Handler<any>>([
@ -108,29 +109,37 @@ export class Page extends PageBase {
this.emit.bind(this, PageEmittedEvents.Response), this.emit.bind(this, PageEmittedEvents.Response),
], ],
]); ]);
#browsingContextEvents = new Map<symbol, Handler<any>>([
[BrowsingContextEmittedEvents.Created, this.#onContextCreated.bind(this)],
[
BrowsingContextEmittedEvents.Destroyed,
this.#onContextDestroyed.bind(this),
],
]);
#tracing: Tracing; #tracing: Tracing;
#coverage: Coverage; #coverage: Coverage;
#emulationManager: EmulationManager; #emulationManager: EmulationManager;
#mouse: Mouse; #mouse: Mouse;
#touchscreen: Touchscreen; #touchscreen: Touchscreen;
#keyboard: Keyboard; #keyboard: Keyboard;
#browsingContext: BrowsingContext;
#browserContext: BrowserContext;
constructor( constructor(
browserContext: BrowserContext, browsingContext: BrowsingContext,
info: Omit<Bidi.BrowsingContext.Info, 'url'> & { browserContext: BrowserContext
url?: string;
}
) { ) {
super(); super();
this.#browsingContext = browsingContext;
this.#browserContext = browserContext; this.#browserContext = browserContext;
this.#connection = browserContext.connection; this.#connection = browsingContext.connection;
for (const [event, subscriber] of this.#browsingContextEvents) {
this.#browsingContext.on(event, subscriber);
}
this.#networkManager = new NetworkManager(this.#connection, this); this.#networkManager = new NetworkManager(this.#connection, this);
this.#onFrameAttached({
...info,
url: info.url ?? 'about:blank',
children: info.children ?? [],
});
for (const [event, subscriber] of this.#subscribedEvents) { for (const [event, subscriber] of this.#subscribedEvents) {
this.#connection.on(event, subscriber); this.#connection.on(event, subscriber);
@ -140,6 +149,15 @@ export class Page extends PageBase {
this.#networkManager.on(event, subscriber); this.#networkManager.on(event, subscriber);
} }
const frame = new Frame(
this,
this.#browsingContext,
this.#timeoutSettings,
this.#browsingContext.parent
);
this.#frameTree.addFrame(frame);
this.emit(PageEmittedEvents.FrameAttached, frame);
// TODO: https://github.com/w3c/webdriver-bidi/issues/443 // TODO: https://github.com/w3c/webdriver-bidi/issues/443
this.#accessibility = new Accessibility( this.#accessibility = new Accessibility(
this.mainFrame().context().cdpSession this.mainFrame().context().cdpSession
@ -154,6 +172,10 @@ export class Page extends PageBase {
this.#keyboard = new Keyboard(this.mainFrame().context()); this.#keyboard = new Keyboard(this.mainFrame().context());
} }
_setBrowserContext(browserContext: BrowserContext): void {
this.#browserContext = browserContext;
}
override get accessibility(): Accessibility { override get accessibility(): Accessibility {
return this.#accessibility; return this.#accessibility;
} }
@ -179,7 +201,7 @@ export class Page extends PageBase {
} }
override browser(): Browser { override browser(): Browser {
return this.#browserContext.browser(); return this.browserContext().browser();
} }
override browserContext(): BrowserContext { override browserContext(): BrowserContext {
@ -218,19 +240,16 @@ export class Page extends PageBase {
} }
} }
#onFrameAttached(info: Bidi.BrowsingContext.Info): void { #onContextCreated(context: BrowsingContext): void {
if ( if (
!this.frame(info.context) && !this.frame(context.id) &&
(this.frame(info.parent ?? '') || !this.#frameTree.getMainFrame()) (this.frame(context.parent ?? '') || !this.#frameTree.getMainFrame())
) { ) {
const context = new BrowsingContext(this.#connection, info);
this.#connection.registerBrowsingContexts(context);
const frame = new Frame( const frame = new Frame(
this, this,
context, context,
this.#timeoutSettings, this.#timeoutSettings,
info.parent context.parent
); );
this.#frameTree.addFrame(frame); this.#frameTree.addFrame(frame);
this.emit(PageEmittedEvents.FrameAttached, frame); this.emit(PageEmittedEvents.FrameAttached, frame);
@ -250,8 +269,8 @@ export class Page extends PageBase {
} }
} }
#onFrameDetached(info: Bidi.BrowsingContext.Info): void { #onContextDestroyed(context: BrowsingContext): void {
const frame = this.frame(info.context); const frame = this.frame(context.id);
if (frame) { if (frame) {
if (frame === this.mainFrame()) { if (frame === this.mainFrame()) {

View File

@ -24,40 +24,25 @@ import {BrowsingContext, CDPSessionWrapper} from './BrowsingContext.js';
import {Page} from './Page.js'; import {Page} from './Page.js';
export class BiDiTarget extends Target { export class BiDiTarget extends Target {
#browsingContext: BrowsingContext; protected _browserContext: BrowserContext;
#page: Page; protected _browsingContext: BrowsingContext;
constructor(browsingContext: BrowsingContext, page: Page) { constructor(
browserContext: BrowserContext,
browsingContext: BrowsingContext
) {
super(); super();
this.#browsingContext = browsingContext; this._browserContext = browserContext;
this.#page = page; this._browsingContext = browsingContext;
} }
override async worker(): Promise<WebWorker | null> { override async worker(): Promise<WebWorker | null> {
return null; return null;
} }
override async page(): Promise<Page | null> {
return this.#page;
}
override url(): string { override url(): string {
return this.#browsingContext.url; return this._browsingContext.url;
}
/**
* Creates a Chrome Devtools Protocol session attached to the target.
*/
override async createCDPSession(): Promise<CDPSession> {
const {sessionId} = await this.#browsingContext.cdpSession.send(
'Target.attachToTarget',
{
targetId: this.#page.mainFrame()._id,
flatten: true,
}
);
return new CDPSessionWrapper(this.#browsingContext, sessionId);
} }
/** /**
@ -82,7 +67,7 @@ export class BiDiTarget extends Target {
* Get the browser context the target belongs to. * Get the browser context the target belongs to.
*/ */
override browserContext(): BrowserContext { override browserContext(): BrowserContext {
throw new Error('Not implemented'); return this._browserContext;
} }
/** /**
@ -91,4 +76,47 @@ export class BiDiTarget extends Target {
override opener(): Target | undefined { override opener(): Target | undefined {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
_setBrowserContext(browserContext: BrowserContext): void {
this._browserContext = browserContext;
}
/**
* Creates a Chrome Devtools Protocol session attached to the target.
*/
override async createCDPSession(): Promise<CDPSession> {
const {sessionId} = await this._browsingContext.cdpSession.send(
'Target.attachToTarget',
{
targetId: this._browsingContext.id,
flatten: true,
}
);
return new CDPSessionWrapper(this._browsingContext, sessionId);
}
}
/**
* @internal
*/
export class BiDiPageTarget extends BiDiTarget {
#page: Page;
constructor(
browserContext: BrowserContext,
browsingContext: BrowsingContext
) {
super(browserContext, browsingContext);
this.#page = new Page(browsingContext, browserContext);
}
override async page(): Promise<Page | null> {
return this.#page;
}
override _setBrowserContext(browserContext: BrowserContext): void {
super._setBrowserContext(browserContext);
this.#page._setBrowserContext(browserContext);
}
} }