From 5772210af301b97e156ca5f38d4dbfec97a701e1 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Fri, 3 May 2024 20:21:08 +0200 Subject: [PATCH] chore: add an ExtensionTransport (#12382) --- package-lock.json | 32 +++ packages/puppeteer-core/package.json | 1 + .../src/cdp/ExtensionTransport.test.ts | 183 ++++++++++++++++++ .../src/cdp/ExtensionTransport.ts | 182 +++++++++++++++++ packages/puppeteer-core/src/cdp/cdp.ts | 1 + 5 files changed, 399 insertions(+) create mode 100644 packages/puppeteer-core/src/cdp/ExtensionTransport.test.ts create mode 100644 packages/puppeteer-core/src/cdp/ExtensionTransport.ts diff --git a/package-lock.json b/package-lock.json index 226c0fb6b30..5391616e1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12266,6 +12266,7 @@ "ws": "8.17.0" }, "devDependencies": { + "@types/chrome": "0.0.267", "@types/debug": "4.1.12", "@types/node": "18.17.15", "@types/ws": "8.5.10", @@ -12277,6 +12278,37 @@ "node": ">=18" } }, + "packages/puppeteer-core/node_modules/@types/chrome": { + "version": "0.0.267", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.267.tgz", + "integrity": "sha512-vnCWPpYjazSPRMNmybRH+0q4f738F+Pbbls4ZPFsPr9/4TTNJyK1OLZDpSnghnEWb4stfmIUtq/GegnlfD4sPA==", + "dev": true, + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "packages/puppeteer-core/node_modules/@types/chrome/node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "dependencies": { + "@types/filewriter": "*" + } + }, + "packages/puppeteer-core/node_modules/@types/chrome/node_modules/@types/filesystem/node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true + }, + "packages/puppeteer-core/node_modules/@types/chrome/node_modules/@types/har-format": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.15.tgz", + "integrity": "sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==", + "dev": true + }, "packages/puppeteer-core/node_modules/@types/node": { "version": "18.17.15", "dev": true, diff --git a/packages/puppeteer-core/package.json b/packages/puppeteer-core/package.json index 92a8e4b33f3..ef3b49b018f 100644 --- a/packages/puppeteer-core/package.json +++ b/packages/puppeteer-core/package.json @@ -128,6 +128,7 @@ "devDependencies": { "@types/debug": "4.1.12", "@types/node": "18.17.15", + "@types/chrome": "0.0.267", "@types/ws": "8.5.10", "mitt": "3.0.1", "parsel-js": "1.1.2", diff --git a/packages/puppeteer-core/src/cdp/ExtensionTransport.test.ts b/packages/puppeteer-core/src/cdp/ExtensionTransport.test.ts new file mode 100644 index 00000000000..f265fd31bb3 --- /dev/null +++ b/packages/puppeteer-core/src/cdp/ExtensionTransport.test.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {afterEach, describe, it} from 'node:test'; + +import expect from 'expect'; +import sinon from 'sinon'; + +import {ExtensionTransport} from './ExtensionTransport.js'; + +type EventListenerFunction = ( + source: chrome.debugger.Debuggee, + method: string, + params?: object | undefined +) => void; + +describe('ExtensionTransport', function () { + afterEach(() => { + sinon.restore(); + }); + + function mockChrome() { + const fakeAttach = sinon.fake.resolves< + [chrome.debugger.Debuggee, string], + Promise + >(undefined); + const fakeDetach = sinon.fake.resolves< + [chrome.debugger.Debuggee], + Promise + >(undefined); + const fakeSendCommand = sinon.fake.resolves< + [chrome.debugger.Debuggee, string, object | undefined], + Promise + >({}); + const fakeGetTargets = sinon.fake.resolves< + [], + Promise + >([]); + const onEvent: EventListenerFunction[] = []; + sinon.define(globalThis, 'chrome', { + debugger: { + attach: fakeAttach, + detach: fakeDetach, + sendCommand: fakeSendCommand, + getTargets: fakeGetTargets, + onEvent: { + addListener: (cb: EventListenerFunction) => { + onEvent.push(cb); + }, + removeListener: (cb: EventListenerFunction) => { + const idx = onEvent.indexOf(cb); + if (idx !== -1) { + onEvent.splice(idx, 1); + } + }, + }, + }, + }); + return { + fakeAttach, + fakeDetach, + fakeSendCommand, + fakeGetTargets, + onEvent, + }; + } + + describe('connectTab', function () { + it('should attach using tabId', async () => { + const {fakeAttach, onEvent} = mockChrome(); + const transport = await ExtensionTransport.connectTab(1); + expect(transport).toBeInstanceOf(ExtensionTransport); + expect(fakeAttach.calledOnceWith({tabId: 1}, '1.3')).toBeTruthy(); + expect(onEvent).toHaveLength(1); + }); + + it('should detach', async () => { + const {onEvent} = mockChrome(); + const transport = await ExtensionTransport.connectTab(1); + transport.close(); + expect(onEvent).toHaveLength(0); + }); + }); + + describe('send', function () { + async function testTranportResponse(command: object): Promise { + const {fakeSendCommand} = mockChrome(); + const transport = await ExtensionTransport.connectTab(1); + const onmessageFake = sinon.fake(); + transport.onmessage = onmessageFake; + transport.send(JSON.stringify(command)); + expect(fakeSendCommand.notCalled).toBeTruthy(); + return onmessageFake.getCalls().map(call => { + return call.args[0]; + }); + } + + it('provides a dummy response to Browser.getVersion', async () => { + expect( + await testTranportResponse({ + id: 1, + method: 'Browser.getVersion', + }) + ).toStrictEqual([ + '{"id":1,"method":"Browser.getVersion","result":{"protocolVersion":"1.3","product":"chrome","revision":"unknown","userAgent":"chrome","jsVersion":"unknown"}}', + ]); + }); + + it('provides a dummy response to Target.getBrowserContexts', async () => { + expect( + await testTranportResponse({ + id: 1, + method: 'Target.getBrowserContexts', + }) + ).toStrictEqual([ + '{"id":1,"method":"Target.getBrowserContexts","result":{"browserContextIds":[]}}', + ]); + }); + + it('provides a dummy response and events to Target.setDiscoverTargets', async () => { + expect( + await testTranportResponse({ + id: 1, + method: 'Target.setDiscoverTargets', + }) + ).toStrictEqual([ + '{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"tabTargetId","type":"tab","title":"tab","url":"about:blank","attached":false,"canAccessOpener":false}}}', + '{"method":"Target.targetCreated","params":{"targetInfo":{"targetId":"pageTargetId","type":"page","title":"page","url":"about:blank","attached":false,"canAccessOpener":false}}}', + '{"id":1,"method":"Target.setDiscoverTargets","result":{}}', + ]); + }); + + it('attaches to a dummy tab target on Target.setAutoAttach', async () => { + expect( + await testTranportResponse({ + id: 1, + method: 'Target.setAutoAttach', + }) + ).toStrictEqual([ + '{"method":"Target.attachedToTarget","params":{"targetInfo":{"targetId":"tabTargetId","type":"tab","title":"tab","url":"about:blank","attached":false,"canAccessOpener":false},"sessionId":"tabTargetSessionId"}}', + '{"id":1,"method":"Target.setAutoAttach","result":{}}', + ]); + }); + + it('attaches to a dummy page target on Target.setAutoAttach', async () => { + expect( + await testTranportResponse({ + id: 1, + method: 'Target.setAutoAttach', + sessionId: 'tabTargetSessionId', + }) + ).toStrictEqual([ + '{"method":"Target.attachedToTarget","params":{"targetInfo":{"targetId":"pageTargetId","type":"page","title":"page","url":"about:blank","attached":false,"canAccessOpener":false},"sessionId":"pageTargetSessionId"}}', + '{"id":1,"sessionId":"tabTargetSessionId","method":"Target.setAutoAttach","result":{}}', + ]); + }); + + it('rewrites session id for pageTargetSessionId commands', async () => { + const {fakeSendCommand} = mockChrome(); + const transport = await ExtensionTransport.connectTab(1); + transport.send( + JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: {}, + sessionId: 'pageTargetSessionId', + }) + ); + expect(fakeSendCommand.calledOnce).toBeTruthy(); + expect(fakeSendCommand.lastCall.args).toStrictEqual([ + { + tabId: 1, + sessionId: undefined, + }, + 'Runtime.evaluate', + {}, + ]); + }); + }); +}); diff --git a/packages/puppeteer-core/src/cdp/ExtensionTransport.ts b/packages/puppeteer-core/src/cdp/ExtensionTransport.ts new file mode 100644 index 00000000000..53c4c607790 --- /dev/null +++ b/packages/puppeteer-core/src/cdp/ExtensionTransport.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2024 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; + +const tabTargetInfo = { + targetId: 'tabTargetId', + type: 'tab', + title: 'tab', + url: 'about:blank', + attached: false, + canAccessOpener: false, +}; + +const pageTargetInfo = { + targetId: 'pageTargetId', + type: 'page', + title: 'page', + url: 'about:blank', + attached: false, + canAccessOpener: false, +}; + +/** + * Experimental ExtensionTransport allows establishing a connection via + * chrome.debugger API if Puppeteer runs in an extension. Since Chrome + * DevTools Protocol is restricted for extensions, the transport + * implements missing commands and events. + * + * @experimental + * @internal + */ +export class ExtensionTransport implements ConnectionTransport { + static async connectTab(tabId: number): Promise { + await chrome.debugger.attach({tabId}, '1.3'); + return new ExtensionTransport(tabId); + } + + onmessage?: (message: string) => void; + onclose?: () => void; + + #tabId: number; + + /** + * @internal + */ + constructor(tabId: number) { + this.#tabId = tabId; + chrome.debugger.onEvent.addListener(this.#debuggerEventHandler); + } + + #debuggerEventHandler = ( + source: chrome.debugger.Debuggee, + method: string, + params?: object | undefined + ): void => { + if (source.tabId !== this.#tabId) { + return; + } + this.#dispatchResponse({ + // @ts-expect-error sessionId is not in stable yet. + sessionId: source.sessionId ?? 'pageTargetSessionId', + method: method, + params: params, + }); + }; + + #dispatchResponse(message: object): void { + this.onmessage?.(JSON.stringify(message)); + } + + send(message: string): void { + const parsed = JSON.parse(message); + switch (parsed.method) { + case 'Browser.getVersion': { + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: { + protocolVersion: '1.3', + product: 'chrome', + revision: 'unknown', + userAgent: 'chrome', + jsVersion: 'unknown', + }, + }); + return; + } + case 'Target.getBrowserContexts': { + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: { + browserContextIds: [], + }, + }); + return; + } + case 'Target.setDiscoverTargets': { + this.#dispatchResponse({ + method: 'Target.targetCreated', + params: { + targetInfo: tabTargetInfo, + }, + }); + this.#dispatchResponse({ + method: 'Target.targetCreated', + params: { + targetInfo: pageTargetInfo, + }, + }); + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: {}, + }); + return; + } + case 'Target.setAutoAttach': { + if (parsed.sessionId === 'tabTargetSessionId') { + this.#dispatchResponse({ + method: 'Target.attachedToTarget', + params: { + targetInfo: pageTargetInfo, + sessionId: 'pageTargetSessionId', + }, + }); + } else if (!parsed.sessionId) { + this.#dispatchResponse({ + method: 'Target.attachedToTarget', + params: { + targetInfo: tabTargetInfo, + sessionId: 'tabTargetSessionId', + }, + }); + } + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId, + method: parsed.method, + result: {}, + }); + return; + } + } + if (parsed.sessionId === 'pageTargetSessionId') { + delete parsed.sessionId; + } + chrome.debugger + .sendCommand( + // @ts-expect-error sessionId is not in stable yet. + {tabId: this.#tabId, sessionId: parsed.sessionId}, + parsed.method, + parsed.params + ) + .then(response => { + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId ?? 'pageTargetSessionId', + method: parsed.method, + result: response, + }); + }) + .catch(err => { + this.#dispatchResponse({ + id: parsed.id, + sessionId: parsed.sessionId ?? 'pageTargetSessionId', + method: parsed.method, + error: err, + }); + }); + } + + close(): void { + chrome.debugger.onEvent.removeListener(this.#debuggerEventHandler); + } +} diff --git a/packages/puppeteer-core/src/cdp/cdp.ts b/packages/puppeteer-core/src/cdp/cdp.ts index 1533d63f355..bd9c13686a0 100644 --- a/packages/puppeteer-core/src/cdp/cdp.ts +++ b/packages/puppeteer-core/src/cdp/cdp.ts @@ -19,6 +19,7 @@ export * from './Dialog.js'; export * from './ElementHandle.js'; export * from './EmulationManager.js'; export * from './ExecutionContext.js'; +export * from './ExtensionTransport.js'; export * from './FirefoxTargetManager.js'; export * from './Frame.js'; export * from './FrameManager.js';