diff --git a/docs/api/puppeteer.page.createcdpsession.md b/docs/api/puppeteer.page.createcdpsession.md new file mode 100644 index 00000000..e398ddfc --- /dev/null +++ b/docs/api/puppeteer.page.createcdpsession.md @@ -0,0 +1,19 @@ +--- +sidebar_label: Page.createCDPSession +--- + +# Page.createCDPSession() method + +Creates a Chrome Devtools Protocol session attached to the page. + +#### Signature: + +```typescript +class Page { + createCDPSession(): Promise; +} +``` + +**Returns:** + +Promise<[CDPSession](./puppeteer.cdpsession.md)> diff --git a/docs/api/puppeteer.page.md b/docs/api/puppeteer.page.md index cf3725e7..29549dc8 100644 --- a/docs/api/puppeteer.page.md +++ b/docs/api/puppeteer.page.md @@ -92,6 +92,7 @@ page.off('request', logRequest); | [close(options)](./puppeteer.page.close.md) | | | | [content()](./puppeteer.page.content.md) | | The full HTML contents of the page, including the DOCTYPE. | | [cookies(urls)](./puppeteer.page.cookies.md) | | If no URLs are specified, this method returns cookies for the current page URL. If URLs are specified, only cookies for those URLs are returned. | +| [createCDPSession()](./puppeteer.page.createcdpsession.md) | | Creates a Chrome Devtools Protocol session attached to the page. | | [createPDFStream(options)](./puppeteer.page.createpdfstream.md) | | Generates a PDF of the page with the print CSS media type. | | [deleteCookie(cookies)](./puppeteer.page.deletecookie.md) | | | | [emulate(device)](./puppeteer.page.emulate.md) | |

Emulates a given device's metrics and user agent.

To aid emulation, Puppeteer provides a list of known devices that can be via [KnownDevices](./puppeteer.knowndevices.md).

| diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index 961e35be..2b0fadeb 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -21,6 +21,7 @@ import {Protocol} from 'devtools-protocol'; import type {HTTPRequest} from '../api/HTTPRequest.js'; import type {HTTPResponse} from '../api/HTTPResponse.js'; import type {Accessibility} from '../common/Accessibility.js'; +import type {CDPSession} from '../common/Connection.js'; import type {ConsoleMessage} from '../common/ConsoleMessage.js'; import type {Coverage} from '../common/Coverage.js'; import {Device} from '../common/Device.js'; @@ -622,6 +623,13 @@ export class Page extends EventEmitter { throw new Error('Not implemented'); } + /** + * Creates a Chrome Devtools Protocol session attached to the page. + */ + createCDPSession(): Promise { + throw new Error('Not implemented'); + } + /** * {@inheritDoc Keyboard} */ diff --git a/packages/puppeteer-core/src/common/Page.ts b/packages/puppeteer-core/src/common/Page.ts index 37c3a3ea..5ca69d80 100644 --- a/packages/puppeteer-core/src/common/Page.ts +++ b/packages/puppeteer-core/src/common/Page.ts @@ -862,6 +862,10 @@ export class CDPPage extends Page { return result[0]; } + override async createCDPSession(): Promise { + return await this.target().createCDPSession(); + } + override async waitForRequest( urlOrPredicate: string | ((req: HTTPRequest) => boolean | Promise), options: {timeout?: number} = {} diff --git a/packages/puppeteer-core/src/common/bidi/Browser.ts b/packages/puppeteer-core/src/common/bidi/Browser.ts index 8bde7856..bf0e8cdd 100644 --- a/packages/puppeteer-core/src/common/bidi/Browser.ts +++ b/packages/puppeteer-core/src/common/bidi/Browser.ts @@ -48,6 +48,9 @@ export class Browser extends BrowserBase { 'cdp.Runtime.executionContextsCleared', // Tracing 'cdp.Tracing.tracingComplete', + // TODO: subscribe to all CDP events in the future. + 'cdp.Network.requestWillBeSent', + 'cdp.Debugger.scriptParsed', ]; #browserName = ''; diff --git a/packages/puppeteer-core/src/common/bidi/BrowsingContext.ts b/packages/puppeteer-core/src/common/bidi/BrowsingContext.ts index 50e68330..9882f185 100644 --- a/packages/puppeteer-core/src/common/bidi/BrowsingContext.ts +++ b/packages/puppeteer-core/src/common/bidi/BrowsingContext.ts @@ -5,7 +5,7 @@ import {WaitForOptions} from '../../api/Page.js'; import {assert} from '../../util/assert.js'; import {Deferred} from '../../util/Deferred.js'; import type {CDPSession, Connection as CDPConnection} from '../Connection.js'; -import {ProtocolError, TimeoutError} from '../Errors.js'; +import {ProtocolError, TargetCloseError, TimeoutError} from '../Errors.js'; import {EventEmitter} from '../EventEmitter.js'; import {PuppeteerLifeCycleEvent} from '../LifecycleWatcher.js'; import {TimeoutSettings} from '../TimeoutSettings.js'; @@ -13,6 +13,7 @@ import {getPageContent, setPageContent, waitWithTimeout} from '../util.js'; import {Connection} from './Connection.js'; import {Realm} from './Realm.js'; +import {debugError} from './utils.js'; /** * @internal @@ -36,35 +37,53 @@ const lifeCycleToReadinessState = new Map< ['domcontentloaded', 'interactive'], ]); +/** + * @internal + */ +export const cdpSessions = new Map(); + /** * @internal */ export class CDPSessionWrapper extends EventEmitter implements CDPSession { #context: BrowsingContext; #sessionId = Deferred.create(); + #detached = false; - constructor(context: BrowsingContext) { + constructor(context: BrowsingContext, sessionId?: string) { super(); this.#context = context; - context.connection - .send('cdp.getSession', { - context: context.id, - }) - .then(session => { - this.#sessionId.resolve(session.result.session!); - }) - .catch(err => { - this.#sessionId.reject(err); - }); + if (sessionId) { + this.#sessionId.resolve(sessionId); + cdpSessions.set(sessionId, this); + } else { + context.connection + .send('cdp.getSession', { + context: context.id, + }) + .then(session => { + this.#sessionId.resolve(session.result.session!); + cdpSessions.set(session.result.session!, this); + }) + .catch(err => { + this.#sessionId.reject(err); + }); + } } connection(): CDPConnection | undefined { return undefined; } + async send( method: T, ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] ): Promise { + if (this.#detached) { + throw new TargetCloseError( + `Protocol error (${method}): Session closed. Most likely the page has been closed.` + ); + } const session = await this.#sessionId.valueOrThrow(); const result = await this.#context.connection.send('cdp.sendCommand', { method: method, @@ -74,8 +93,12 @@ export class CDPSessionWrapper extends EventEmitter implements CDPSession { return result.result; } - detach(): Promise { - throw new Error('Method not implemented.'); + async detach(): Promise { + cdpSessions.delete(this.id()); + await this.#context.cdpSession.send('Target.detachFromTarget', { + sessionId: this.id(), + }); + this.#detached = true; } id(): string { @@ -244,6 +267,7 @@ export class BrowsingContext extends Realm { dispose(): void { this.removeAllListeners(); this.connection.unregisterBrowsingContexts(this.#id); + void this.#cdpSession.detach().catch(debugError); } } diff --git a/packages/puppeteer-core/src/common/bidi/Connection.ts b/packages/puppeteer-core/src/common/bidi/Connection.ts index 872271bd..00c1ae4f 100644 --- a/packages/puppeteer-core/src/common/bidi/Connection.ts +++ b/packages/puppeteer-core/src/common/bidi/Connection.ts @@ -21,7 +21,7 @@ import {ConnectionTransport} from '../ConnectionTransport.js'; import {debug} from '../Debug.js'; import {EventEmitter} from '../EventEmitter.js'; -import {BrowsingContext} from './BrowsingContext.js'; +import {BrowsingContext, cdpSessions} from './BrowsingContext.js'; const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); @@ -235,15 +235,9 @@ export class Connection extends EventEmitter { } else if ('source' in event.params && event.params.source.context) { context = this.#browsingContexts.get(event.params.source.context); } else if (isCDPEvent(event)) { - // TODO: this is not a good solution and we need to find a better one. - // Perhaps we need to have a dedicated CDP event emitter or emulate - // the CDPSession interface with BiDi?. - const cdpSessionId = event.params.session; - for (const context of this.#browsingContexts.values()) { - if (context.cdpSession?.id() === cdpSessionId) { - context.cdpSession!.emit(event.params.event, event.params.params); - } - } + cdpSessions + .get(event.params.session) + ?.emit(event.params.event, event.params.params); } context?.emit(event.method, event.params); } diff --git a/packages/puppeteer-core/src/common/bidi/Page.ts b/packages/puppeteer-core/src/common/bidi/Page.ts index a0779c06..8ee38e59 100644 --- a/packages/puppeteer-core/src/common/bidi/Page.ts +++ b/packages/puppeteer-core/src/common/bidi/Page.ts @@ -30,6 +30,7 @@ import { import {assert} from '../../util/assert.js'; import {Deferred} from '../../util/Deferred.js'; import {Accessibility} from '../Accessibility.js'; +import {CDPSession} from '../Connection.js'; import {ConsoleMessage, ConsoleMessageLocation} from '../ConsoleMessage.js'; import {Coverage} from '../Coverage.js'; import {EmulationManager} from '../EmulationManager.js'; @@ -52,7 +53,7 @@ import { import {Browser} from './Browser.js'; import {BrowserContext} from './BrowserContext.js'; -import {BrowsingContext} from './BrowsingContext.js'; +import {BrowsingContext, CDPSessionWrapper} from './BrowsingContext.js'; import {Connection} from './Connection.js'; import {Frame} from './Frame.js'; import {HTTPRequest} from './HTTPRequest.js'; @@ -632,6 +633,16 @@ export class Page extends PageBase { override title(): Promise { return this.mainFrame().title(); } + + override async createCDPSession(): Promise { + const {sessionId} = await this.mainFrame() + .context() + .cdpSession.send('Target.attachToTarget', { + targetId: this.mainFrame()._id, + flatten: true, + }); + return new CDPSessionWrapper(this.mainFrame().context(), sessionId); + } } function isConsoleLogEntry( diff --git a/test/TestExpectations.json b/test/TestExpectations.json index 5be27dde..0f1448b1 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -1667,18 +1667,48 @@ "parameters": ["cdp", "firefox"], "expectations": ["FAIL"] }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should be able to detach session", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, { "testIdPattern": "[CDPSession.spec] Target.createCDPSession should enable and disable domains independently", "platforms": ["darwin", "linux", "win32"], "parameters": ["cdp", "firefox"], "expectations": ["FAIL"] }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should enable and disable domains independently", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, { "testIdPattern": "[CDPSession.spec] Target.createCDPSession should send events", "platforms": ["darwin", "linux", "win32"], "parameters": ["cdp", "firefox"], "expectations": ["FAIL", "PASS"] }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should send events", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should throw nice errors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[CDPSession.spec] Target.createCDPSession should work", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["PASS"] + }, { "testIdPattern": "[chromiumonly.spec] Chromium-Specific Launcher tests Puppeteer.launch |browserURL| option should be able to connect using browserUrl, with and without trailing slash", "platforms": ["darwin", "linux", "win32"], diff --git a/test/src/CDPSession.spec.ts b/test/src/CDPSession.spec.ts index 115d7869..31b2c96c 100644 --- a/test/src/CDPSession.spec.ts +++ b/test/src/CDPSession.spec.ts @@ -26,7 +26,7 @@ describe('Target.createCDPSession', function () { it('should work', async () => { const {page} = await getTestState(); - const client = await page.target().createCDPSession(); + const client = await page.createCDPSession(); await Promise.all([ client.send('Runtime.enable'), @@ -56,7 +56,7 @@ describe('Target.createCDPSession', function () { it('should send events', async () => { const {page, server} = await getTestState(); - const client = await page.target().createCDPSession(); + const client = await page.createCDPSession(); await client.send('Network.enable'); const events: unknown[] = []; client.on('Network.requestWillBeSent', event => { @@ -71,7 +71,7 @@ describe('Target.createCDPSession', function () { it('should enable and disable domains independently', async () => { const {page} = await getTestState(); - const client = await page.target().createCDPSession(); + const client = await page.createCDPSession(); await client.send('Runtime.enable'); await client.send('Debugger.enable'); // JS coverage enables and then disables Debugger domain. @@ -88,7 +88,7 @@ describe('Target.createCDPSession', function () { it('should be able to detach session', async () => { const {page} = await getTestState(); - const client = await page.target().createCDPSession(); + const client = await page.createCDPSession(); await client.send('Runtime.enable'); const evalResponse = await client.send('Runtime.evaluate', { expression: '1 + 2', @@ -112,7 +112,7 @@ describe('Target.createCDPSession', function () { it('should throw nice errors', async () => { const {page} = await getTestState(); - const client = await page.target().createCDPSession(); + const client = await page.createCDPSession(); const error = await theSourceOfTheProblems().catch(error => { return error; }); @@ -130,7 +130,7 @@ describe('Target.createCDPSession', function () { it('should expose the underlying connection', async () => { const {page} = await getTestState(); - const client = await page.target().createCDPSession(); + const client = await page.createCDPSession(); expect(client.connection()).toBeTruthy(); }); });