├── .gitignore ├── .mocharc.json ├── .prettierrc.js ├── README.md ├── bin └── resize-images.js ├── example.ts ├── extension ├── background.js ├── contentScript.js ├── images │ ├── icon128.png │ ├── icon16.png │ ├── icon32.png │ └── icon48.png ├── manifest.json ├── newtab │ ├── index.html │ └── index.js └── popup │ ├── left-arrow.svg │ ├── popup.css │ ├── popup.html │ ├── popup.js │ └── right-arrow.svg ├── logo.pxm ├── logo512.png ├── package.json ├── src └── index.ts ├── test ├── index.test.ts ├── puppeteer.ts └── server_root │ ├── console.js │ ├── dynamic.js │ ├── index.html │ ├── newtab.html │ └── two.html └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .DS_Store 4 | dist/ -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "test/**/*.test.ts", 4 | "require": "ts-node/register" 5 | } -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | trailingComma: 'all', 4 | tabWidth: 2, 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # puppeteer-extensionbridge 2 | 3 | This library provides a bridge from puppeteer to the chrome extension API so that previously-unavailable interfaces can be controlled programmatically from node & puppeteer. 4 | 5 | ## Caveat 6 | 7 | Extensions don't work in headless mode. This is GUI-only. 8 | 9 | ## Status : Experimental 10 | 11 | This is an early implementation of something that works in non-production analysis scripts. That said, this isn't relying on any experimental APIs so it should remain functional as long as manifest V2 extensions are supported in Chrome. 12 | 13 | ## Who is this for 14 | 15 | - Puppeteer/Devtools power users 16 | 17 | ## Installation 18 | 19 | ```shell 20 | $ npm install puppeteer-extensionbridge 21 | ``` 22 | 23 | ## Example 24 | 25 | ```typescript 26 | import puppeteer from 'puppeteer'; 27 | 28 | import { decorateBrowser, mergeLaunchOptions } from './src'; 29 | 30 | (async function main() { 31 | const launchOptions = mergeLaunchOptions({ headless: false }); 32 | const vanillaBrowser = await puppeteer.launch(launchOptions); 33 | const browser = await decorateBrowser(vanillaBrowser); 34 | 35 | await browser.extension.send("chrome.storage.sync.set", { myKey: "myValue" }); 36 | 37 | const { value: [items] } = await browser.extension.send("chrome.storage.sync.get", ["myKey"]); 38 | // items is { myKey: "myValue" } 39 | 40 | let callback = (...args: any[]) => { console.log(args) }; 41 | await browser.extension.addListener("chrome.storage.onChanged", callback); 42 | 43 | await browser.extension.send("chrome.storage.sync.set", { myKey: "changedValue" }); 44 | 45 | await browser.extension.removeListener("chrome.storage.onChanged", callback); 46 | 47 | await browser.close(); 48 | }()); 49 | ``` 50 | 51 | ## API 52 | 53 | ### send(method:string, payload:any): Promise 54 | 55 | Sends a message to the extension to run the `method` (e.g. `"chrome.storage.settings.set"`) with the payload as the arguments. The arguments will be JSON-ified and spread across the calling method. Don't pass callbacks as defined by the Chrome extension API. Those are handled by the bridge. 56 | 57 | A promise is returned that resolves to a `BridgeResponse` object that contains a `.value` property containing the arguments that were passed to the success callback or an `.error` object containing an error. 58 | 59 | ### addListener(event: string, callback: Function) 60 | 61 | Registers `callback` as a listener to the passed `event`. 62 | 63 | ### removeListener(event: string, callback: Function) 64 | 65 | Removes `callback` as a listener to the passed `event`. 66 | 67 | ### decorateBrowser(browser: Browser, config: PluginConfig): Browser & BrowserExtensionBridge 68 | 69 | Wires up all the magic to the `browser` object and adds the `.extension` property to `browser`. 70 | 71 | This mutates the passed `browser` object so you can ignore the return value in vanilla JS. The return value is typed to account for the added `.extension` for TypeScript. 72 | 73 | #### Configuration options 74 | 75 | ##### `newtab` 76 | 77 | Specify a URL here for a custom newtab. This is necessary for communicating with new tabs that have not yet navigated to a page due to Chrome's security controls. 78 | 79 | ### mergeLaunchOptions(options: LaunchOptions): LaunchOptions 80 | 81 | This takes in your default puppeteer launch options and adds the options necessary to work with this library. You can 82 | do this by hand but this makes it much easier. 83 | 84 | ### BridgeResponse 85 | 86 | ```typescript 87 | interface BridgeResponse { 88 | value: any[]; 89 | error?: Error; 90 | } 91 | ``` -------------------------------------------------------------------------------- /bin/resize-images.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const sharp = require('sharp'); 4 | 5 | const original = path.join(__dirname, '..', 'logo512.png'); 6 | const destDir = path.join(__dirname, '..', 'extension', 'images'); 7 | const imagePrefix = 'icon'; 8 | const imageExtension = '.png'; 9 | 10 | const sizes = [16, 32, 48, 128]; 11 | 12 | main(); 13 | 14 | async function main() { 15 | const promises = sizes.map(size => sharp(original).resize(size).toFile(path.join(destDir, `${imagePrefix}${size}${imageExtension}`))); 16 | 17 | await Promise.allSettled([ 18 | 19 | ]); 20 | console.log("Done"); 21 | } 22 | -------------------------------------------------------------------------------- /example.ts: -------------------------------------------------------------------------------- 1 | // Using a local file is probably temporary due to 2 | // puppeteer@4.0.0 issues: https://twitter.com/jsoverson/status/1273283398816186368 3 | import puppeteer from './test/puppeteer'; 4 | 5 | import { decorateBrowser, mergeLaunchOptions } from './src'; 6 | 7 | (async function main() { 8 | const launchOptions = mergeLaunchOptions({ headless: false }); 9 | const vanillaBrowser = await puppeteer.launch(launchOptions); 10 | const browser = await decorateBrowser(vanillaBrowser); 11 | 12 | await browser.extension.send("chrome.storage.sync.set", { myKey: "myValue" }); 13 | 14 | const { value: [items] } = await browser.extension.send("chrome.storage.sync.get", ["myKey"]); 15 | // items is { myKey: "myValue" } 16 | 17 | let callback = (...args: any[]) => { console.log(args) }; 18 | await browser.extension.addListener("chrome.storage.onChanged", callback); 19 | 20 | await browser.extension.send("chrome.storage.sync.set", { myKey: "changedValue" }); 21 | 22 | await browser.extension.removeListener("chrome.storage.onChanged", callback); 23 | 24 | await browser.close(); 25 | }()); -------------------------------------------------------------------------------- /extension/background.js: -------------------------------------------------------------------------------- 1 | 2 | function getDotProp(context, path) { 3 | if (path == null || !context) return context; 4 | let pathParts = Array.isArray(path) ? path : (path + '').split('.'); 5 | let object = context, pathPart; 6 | 7 | while ((pathPart = pathParts.shift()) != null) { 8 | if (!(pathPart in object)) return {}; 9 | if (pathParts.length === 0) return { object, property: pathPart }; 10 | else object = object[pathPart]; 11 | } 12 | return { object, property: pathPart }; 13 | }; 14 | 15 | const listenerMap = new Map(); 16 | 17 | chrome.runtime.onMessage.addListener((message, sender, respond) => { 18 | switch (message.method) { 19 | case 'getConfig': 20 | return respond(bridge.getConfig()); 21 | default: 22 | return respond({ error: 'Method not implemented' }); 23 | } 24 | }) 25 | 26 | const bridge = { 27 | _config: { 28 | 29 | }, 30 | getConfig() { 31 | return { value: [this._config] }; 32 | }, 33 | setConfig(obj) { 34 | bridge._config = obj; 35 | }, 36 | async handle(command, argArray) { 37 | chrome.runtime.sendMessage({ type: 'internal', dir: 'in', what: 'handle', msg: command }); 38 | const { object, property } = getDotProp({ chrome }, command); 39 | return new Promise((res, rej) => { 40 | const args = [...argArray, (...args) => { 41 | chrome.runtime.sendMessage({ type: 'internal', dir: 'out', what: 'handle:response', msg: command }); 42 | return res({ value: args }) 43 | }]; 44 | try { 45 | object[property](...args); 46 | } catch (error) { 47 | chrome.runtime.sendMessage({ type: 'internal', dir: 'out', what: 'handle:Error', msg: command }); 48 | rej(error) 49 | } 50 | }) 51 | }, 52 | async addListener(event, functionName) { 53 | chrome.runtime.sendMessage({ type: 'internal', dir: 'in', what: 'addListener', msg: `event: ${event}, handler: ${functionName}` }); 54 | const { object, property } = getDotProp({ chrome }, event); 55 | const cb = (...args) => { 56 | if (typeof window[functionName] === 'function') { 57 | chrome.runtime.sendMessage({ type: 'internal', dir: 'out', what: 'addListener:callback', msg: `event: ${event}, sending response` }); 58 | window[functionName](...args); 59 | } else { 60 | chrome.runtime.sendMessage({ type: 'internal', dir: 'out', what: 'addListener:Error', msg: `event: ${event}: could not find handler: ${functionName}` }); 61 | console.log(`Could not find global function ${functionName} to respond to ${event} event`); 62 | } 63 | } 64 | listenerMap.set(`${event}_${functionName}`, cb); 65 | object[property].addListener(cb); 66 | }, 67 | async removeListener(event, functionName) { 68 | chrome.runtime.sendMessage({ type: 'internal', dir: 'in', what: 'removeListener', msg: `event: ${event}, handler: ${functionName}` }); 69 | const { object, property } = getDotProp({ chrome }, event); 70 | const cb = listenerMap.get(`${event}_${functionName}`); 71 | if (cb) object[property].removeListener(cb); 72 | else console.log(`Could not find callback for "${functionName}" to remove from ${event} event`); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /extension/contentScript.js: -------------------------------------------------------------------------------- 1 | 2 | window.addEventListener('message', (evt) => { 3 | const data = evt.data; 4 | if (!data || data.type !== 'extensionbridge') return; 5 | switch (data.type) { 6 | } 7 | // chrome.runtime.sendMessage() 8 | }) -------------------------------------------------------------------------------- /extension/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/puppeteer-extensionbridge/efdc4c08f1e689e1b92b3747e5dbef7738fb32bd/extension/images/icon128.png -------------------------------------------------------------------------------- /extension/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/puppeteer-extensionbridge/efdc4c08f1e689e1b92b3747e5dbef7738fb32bd/extension/images/icon16.png -------------------------------------------------------------------------------- /extension/images/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/puppeteer-extensionbridge/efdc4c08f1e689e1b92b3747e5dbef7738fb32bd/extension/images/icon32.png -------------------------------------------------------------------------------- /extension/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/puppeteer-extensionbridge/efdc4c08f1e689e1b92b3747e5dbef7738fb32bd/extension/images/icon48.png -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Puppeteer Extension Controller", 3 | "version": "0.4", 4 | "description": "Communication bridge between Puppeteer and Chrome extension APIs", 5 | "key": "aaaabbbbccccddddeeeeffffgggghhhh", 6 | "browser_action": { 7 | "default_icon": "images/icon16.png", 8 | "default_popup": "popup/popup.html" 9 | }, 10 | "chrome_url_overrides": { 11 | "newtab": "newtab/index.html" 12 | }, 13 | "//content_scripts": [ 14 | { 15 | "matches": [ 16 | "" 17 | ], 18 | "js": [ 19 | "contentScript.js" 20 | ] 21 | } 22 | ], 23 | "icons": { 24 | "16": "images/icon16.png", 25 | "32": "images/icon32.png", 26 | "48": "images/icon48.png", 27 | "128": "images/icon128.png" 28 | }, 29 | "background": { 30 | "scripts": [ 31 | "background.js" 32 | ] 33 | }, 34 | "permissions": [ 35 | "", 36 | "activeTab", 37 | "alarms", 38 | "background", 39 | "bookmarks", 40 | "browsingData", 41 | "certificateProvider", 42 | "clipboardRead", 43 | "clipboardWrite", 44 | "contentSettings", 45 | "contextMenus", 46 | "cookies", 47 | "debugger", 48 | "declarativeContent", 49 | "declarativeNetRequest", 50 | "declarativeWebRequest", 51 | "desktopCapture", 52 | "displaySource", 53 | "dns", 54 | "documentScan", 55 | "downloads", 56 | "enterprise.deviceAttributes", 57 | "enterprise.hardwarePlatform", 58 | "enterprise.platformKeys", 59 | "experimental", 60 | "fileBrowserHandler", 61 | "fileSystemProvider", 62 | "fontSettings", 63 | "gcm", 64 | "geolocation", 65 | "history", 66 | "identity", 67 | "idle", 68 | "idltest", 69 | "management", 70 | "nativeMessaging", 71 | "networking.config", 72 | "notifications", 73 | "pageCapture", 74 | "platformKeys", 75 | "power", 76 | "printerProvider", 77 | "privacy", 78 | "processes", 79 | "proxy", 80 | "sessions", 81 | "signedInDevices", 82 | "storage", 83 | "system.cpu", 84 | "system.display", 85 | "system.memory", 86 | "system.storage", 87 | "tabCapture", 88 | "tabs", 89 | "topSites", 90 | "tts", 91 | "ttsEngine", 92 | "unlimitedStorage", 93 | "vpnProvider", 94 | "wallpaper", 95 | "webNavigation", 96 | "webRequest", 97 | "webRequestBlocking" 98 | ], 99 | "manifest_version": 2 100 | } -------------------------------------------------------------------------------- /extension/newtab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Extension Bridge new tab 9 | 10 | 11 | -------------------------------------------------------------------------------- /extension/newtab/index.js: -------------------------------------------------------------------------------- 1 | 2 | chrome.runtime.sendMessage({ method: 'getConfig' }, function (response) { 3 | const config = response.value[0]; 4 | if (config && config.newtab) { 5 | window.location = config.newtab; 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /extension/popup/left-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /extension/popup/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 5px 10px 10px; 3 | padding: 4px; 4 | } 5 | 6 | html { 7 | width: 400px; 8 | } 9 | 10 | h1 { 11 | text-align: center; 12 | } 13 | 14 | .event-dir img { 15 | width: 10px; 16 | } 17 | 18 | .event-dir { 19 | margin-right: 1em; 20 | margin-left: .5em 21 | } 22 | 23 | .event-what { 24 | margin-right: 1em; 25 | } 26 | 27 | ol { 28 | margin-left: 1em; 29 | padding: 0; 30 | } -------------------------------------------------------------------------------- /extension/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Puppeteer Extension Bridge 6 | 7 | 8 | 9 | 10 | 11 |

Puppeteer Extension Bridge

12 |

This extension provides hooks that can be accessed by puppeteer or other browser controllers.

13 | 18 |

Messages

19 |
    20 | 21 | 22 | -------------------------------------------------------------------------------- /extension/popup/popup.js: -------------------------------------------------------------------------------- 1 | 2 | window.addEventListener('load', () => { 3 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 4 | const eventsEl = document.querySelector('#events'); 5 | console.log({ request, sender }); 6 | if (request.type === 'internal') { 7 | const listItem = document.createElement('li'); 8 | listItem.innerHTML = `${request.dir === 'in' ? '' : ''}${request.what}${request.msg}` 9 | eventsEl.prepend(listItem); 10 | } 11 | }) 12 | }) -------------------------------------------------------------------------------- /extension/popup/right-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logo.pxm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/puppeteer-extensionbridge/efdc4c08f1e689e1b92b3747e5dbef7738fb32bd/logo.pxm -------------------------------------------------------------------------------- /logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsoverson/puppeteer-extensionbridge/efdc4c08f1e689e1b92b3747e5dbef7738fb32bd/logo512.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "puppeteer-extensionbridge", 3 | "version": "1.1.0", 4 | "description": "Bridge that exposes the Chrome extension API to puppeteer", 5 | "main": "dist/src/index.js", 6 | "types": "dist/src/index.d.ts", 7 | "scripts": { 8 | "build": "tsc --declaration", 9 | "compile": "npm run clean && npm run build", 10 | "clean": "rm -rf dist", 11 | "prepublishOnly": "npm run compile", 12 | "format": "prettier --write 'src/**/*.ts' 'test/**/*.ts'", 13 | "watch": "tsc -w", 14 | "test": "mocha" 15 | }, 16 | "author": { 17 | "name": "Jarrod Overson", 18 | "email": "jsoverson@gmail.com", 19 | "url": "https://jarrodoverson.com" 20 | }, 21 | "files": [ 22 | "README.md", 23 | "dist/**/*", 24 | "extension/**/*", 25 | "example.ts" 26 | ], 27 | "keywords": [ 28 | "puppeteer", 29 | "extension", 30 | "chrome extension", 31 | "chrome.runtime", 32 | "chrome" 33 | ], 34 | "license": "ISC", 35 | "dependencies": { 36 | "@jsoverson/test-server": "^1.1.1", 37 | "debug": "^4.1.1", 38 | "find-root": "^1.1.0" 39 | }, 40 | "devDependencies": { 41 | "@types/debug": "^4.1.5", 42 | "@types/find-root": "^1.1.1", 43 | "@types/mocha": "^7.0.2", 44 | "@types/serve-handler": "^6.1.0", 45 | "mocha": "^7.1.1", 46 | "prettier": "^2.0.5", 47 | "puppeteer": "5.2.1", 48 | "serve-handler": "^6.1.2", 49 | "sharp": "^0.25.2", 50 | "ts-node": "^8.9.0", 51 | "typescript": "^3.8.3" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer/lib/cjs/puppeteer/common/Page'; 2 | import { ConsoleMessage } from 'puppeteer/lib/cjs/puppeteer/common/ConsoleMessage'; 3 | import path from 'path'; 4 | import findRoot from 'find-root'; 5 | import DEBUG from 'debug'; 6 | import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser'; 7 | import { CDPSession, Connection } from 'puppeteer/lib/cjs/puppeteer/common/Connection'; 8 | import { Target } from 'puppeteer/lib/cjs/puppeteer/common/Target'; 9 | import { BrowserOptions, LaunchOptions, ChromeArgOptions } from 'puppeteer/lib/cjs/puppeteer/node/LaunchOptions'; 10 | 11 | type PuppeteerLaunchOptions = LaunchOptions & BrowserOptions & ChromeArgOptions; 12 | 13 | const debug = DEBUG('puppeteer:extensionbridge'); 14 | 15 | export interface BrowserExtensionBridge { 16 | extension: ExtensionBridge; 17 | } 18 | 19 | export interface PluginConfig { 20 | newtab?: string; 21 | } 22 | 23 | export interface BridgeResponse { 24 | value: any[]; 25 | error?: Error; 26 | } 27 | 28 | const extensionId = require(path.join(findRoot(__dirname), 'extension', 'manifest.json')).key; 29 | 30 | export class ExtensionBridge { 31 | page?: Page; 32 | private exposedFunctionIndex = 0; 33 | private exposedFunctionMap = new WeakMap(); 34 | private exposedFunctionPrefix = 'extensionBridge_'; 35 | 36 | constructor(page?: Page) { 37 | if (!page) debug('ExtensionBridge instantiated with invalid page object'); 38 | else { 39 | this.page = page; 40 | page.on('console', async (consoleMessage: ConsoleMessage) => { 41 | debug(consoleMessage.args()); 42 | }); 43 | } 44 | } 45 | 46 | private async sendMessage(expression: string): Promise { 47 | if (!this.page) throw new Error('puppeteer-extensionbridge does not have access to a valid Page object'); 48 | const session = await this.page.target().createCDPSession(); 49 | const context = await this.page.mainFrame().executionContext(); 50 | try { 51 | const message = { 52 | expression: expression, 53 | // @ts-ignore I effing hate private fields. 54 | contextId: context._contextId, 55 | returnByValue: true, 56 | userGesture: true, 57 | awaitPromise: true, 58 | matchAboutBlank: true, 59 | }; 60 | debug('sending message to extension %o', message); 61 | const rv = (await session.send('Runtime.evaluate', message)) as { result: { value: any } }; 62 | return rv.result.value as any; 63 | } catch (e) { 64 | debug('ExtensionBridge: send failed %o', e.message); 65 | throw e; 66 | } 67 | } 68 | 69 | getConfig() { 70 | debug(`extensionBridge.getConfig()`); 71 | return this.sendMessage(`bridge.getConfig()`).then((response: BridgeResponse) => response.value[0]); 72 | } 73 | setConfig(obj: any) { 74 | debug(`extensionBridge.setConfig({...})`); 75 | let json = ''; 76 | try { 77 | json = JSON.stringify(obj); 78 | } catch (e) { 79 | console.log(`puppeteer-extensionbridge could not stringify payload for ${obj}.`); 80 | throw e; 81 | } 82 | return this.sendMessage(`bridge.setConfig(${json})`); 83 | } 84 | send(endpoint: string, ...payload: any): Promise { 85 | debug(`extensionBridge.send(${endpoint}, ...)`); 86 | let json = ''; 87 | try { 88 | json = JSON.stringify(payload); 89 | } catch (e) { 90 | console.log(`puppeteer-extensionbridge could not stringify payload ${payload}.`); 91 | throw e; 92 | } 93 | return this.sendMessage(`bridge.handle("${endpoint}", ${json})`); 94 | } 95 | async addListener(event: string, cb: (...args: any[]) => any) { 96 | debug(`extensionBridge.addListener(${event}, ...)`); 97 | 98 | const fnName = this.exposedFunctionPrefix + this.exposedFunctionIndex++; 99 | this.exposedFunctionMap.set(cb, fnName); 100 | if (!this.page) throw new Error('puppeteer-extensionbridge does not have access to a valid Page object'); 101 | await this.page.exposeFunction(fnName, cb); 102 | 103 | return this.sendMessage(`bridge.addListener("${event}", "${fnName}")`); 104 | } 105 | async removeListener(event: string, cb: (...args: any[]) => any) { 106 | debug(`extensionBridge.addListener(${event}, ...)`); 107 | const fnName = this.exposedFunctionMap.get(cb); 108 | return this.sendMessage(`bridge.removeListener("${event}", "${fnName}")`); 109 | } 110 | } 111 | 112 | export class NullExtensionBridge extends ExtensionBridge { 113 | async getConfig(): Promise {} 114 | async setConfig() { 115 | return { value: [] }; 116 | } 117 | async send() { 118 | return { value: [] }; 119 | } 120 | async addListener() { 121 | return { value: [] }; 122 | } 123 | async removeListener() { 124 | return { value: [] }; 125 | } 126 | } 127 | 128 | export function mergeLaunchOptions(options: PuppeteerLaunchOptions) { 129 | const extensionPath = path.join(findRoot(__dirname), 'extension'); 130 | if (!('headless' in options) || options.headless) { 131 | // Throw on this, adding it magically causes confusion. 132 | throw new Error( 133 | "puppeteer-extensionbridge has to run in GUI (non-headless) mode. Add `headless:false` puppeteer's launch options", 134 | ); 135 | } 136 | if (options.ignoreDefaultArgs) { 137 | if (Array.isArray(options.ignoreDefaultArgs)) { 138 | const ignoreArg_disableExtensions = options.ignoreDefaultArgs.includes('--disable-extensions'); 139 | if (!ignoreArg_disableExtensions) { 140 | debug('Adding --disable-extensions to ignoreDefaultArgs'); 141 | options.ignoreDefaultArgs.push('--disable-extensions'); 142 | } 143 | } 144 | } else { 145 | debug('Setting ignoreDefaultArgs to ["--disable-extensions"]'); 146 | options.ignoreDefaultArgs = [`--disable-extensions`]; 147 | } 148 | 149 | if (options.args) { 150 | const loadExtensionIndex = options.args.findIndex((a: string) => a.startsWith('--load-extension')); 151 | if (loadExtensionIndex > -1) { 152 | debug(`Appending ${extensionPath} to --load-extension arg`); 153 | options.args[loadExtensionIndex] += `,${extensionPath}`; 154 | } else { 155 | debug(`Adding arg '--load-extension=${extensionPath}`); 156 | options.args.push(`--load-extension=${extensionPath}`); 157 | } 158 | const whitelistExtensionIndex = options.args.findIndex((a: string) => a.startsWith('--whitelisted-extension-id')); 159 | if (whitelistExtensionIndex > -1) { 160 | debug(`Appending extensionbridge id (${extensionId}) to --whitelisted-extension-id`); 161 | options.args[whitelistExtensionIndex] += `,${extensionId}`; 162 | } else { 163 | debug(`Adding arg --whitelisted-extension-id=${extensionId}`); 164 | options.args.push(`--whitelisted-extension-id=${extensionId}`); 165 | } 166 | } else { 167 | debug(`Adding args --whitelisted-extension-id=${extensionId} and --load-extension=${extensionPath}`); 168 | options.args = [`--load-extension=${extensionPath}`, `--whitelisted-extension-id=${extensionId}`]; 169 | } 170 | return options; 171 | } 172 | 173 | export async function decorateBrowser( 174 | browser: Browser, 175 | config?: PluginConfig, 176 | ): Promise { 177 | debug(`waiting for extension's background page`); 178 | const extTarget = await browser.waitForTarget((t) => { 179 | // @ts-ignore 180 | return t.type() === 'background_page' && t._targetInfo.title === 'Puppeteer Extension Controller'; 181 | }); 182 | debug(`background page found, id: ${extTarget._targetId}`); 183 | const extPage = await extTarget.page(); 184 | if (!extPage) 185 | throw new Error( 186 | `puppeteer-extensionbridge failed to find the extension's background page. If this happened during normal use, it is a bug and should be reported.`, 187 | ); 188 | const bridge = new ExtensionBridge(extPage); 189 | debug(`passed config: %o`, config); 190 | if (config) { 191 | await bridge.setConfig(config); 192 | } 193 | return Object.assign(browser, { extension: bridge }); 194 | } 195 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import puppeteer from './puppeteer'; 4 | 5 | import { decorateBrowser, mergeLaunchOptions, BrowserExtensionBridge } from '../src'; 6 | import { start, TestServer } from '@jsoverson/test-server'; 7 | import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser'; 8 | 9 | describe('Extension Bridge', function () { 10 | let browser: Browser & BrowserExtensionBridge; 11 | let server: TestServer; 12 | 13 | before(async () => { 14 | server = await start(__dirname, 'server_root'); 15 | }); 16 | 17 | after(async () => { 18 | await server.stop(); 19 | }); 20 | 21 | beforeEach(async () => { 22 | const vanillaBrowser = await puppeteer.launch(mergeLaunchOptions({ headless: false })); 23 | browser = await decorateBrowser(vanillaBrowser, { newtab: server.url(`newtab.html`) }); 24 | }); 25 | 26 | afterEach(async () => { 27 | await browser.close(); 28 | }); 29 | 30 | it('should execute arbitary commands', async function () { 31 | await browser.extension.send('chrome.storage.sync.set', { myKey: 'myValue' }); 32 | const { 33 | value: [items], 34 | } = await browser.extension.send('chrome.storage.sync.get', ['myKey']); 35 | assert.equal(items.myKey, 'myValue'); 36 | }); 37 | it('should pass arbitary number of arguments', async function () { 38 | const [page] = await browser.pages(); 39 | await page.goto(server.url('index.html'), {}); 40 | const response = await browser.extension.send('chrome.tabs.query', { active: true }); 41 | const [results] = response.value; 42 | const activeTab = results[0]; 43 | const tabId = activeTab.id; 44 | 45 | const details = { 46 | code: `(function(){return "inpage" + "-result" }())`, 47 | matchAboutBlank: true, 48 | }; 49 | const executeResponse = await browser.extension.send('chrome.tabs.executeScript', tabId, details); 50 | const [result] = executeResponse.value; 51 | assert.equal(result, 'inpage-result'); 52 | }); 53 | it('should receive arbitrary events', async function () { 54 | let receivedChange = false; 55 | await browser.extension.send('chrome.storage.sync.set', { myKey: 'myValue' }); 56 | await browser.extension.addListener('chrome.storage.onChanged', (changes: object, areaName: string) => { 57 | receivedChange = true; 58 | }); 59 | await browser.extension.send('chrome.storage.sync.set', { myKey: 'changedValue' }); 60 | assert(receivedChange); 61 | }); 62 | it('should set and receive configuration', async function () { 63 | await browser.extension.setConfig({ myKey: 'myVal' }); 64 | const get = await browser.extension.getConfig(); 65 | assert.equal(get.myKey, 'myVal'); 66 | }); 67 | it('should set and receive configuration', async function () { 68 | await browser.extension.setConfig({ myKey: 'myVal' }); 69 | const get = await browser.extension.getConfig(); 70 | assert.equal(get.myKey, 'myVal'); 71 | }); 72 | it('should remove event listeners', async function () { 73 | let eventFired = false; 74 | let cb = (changes: object, areaName: string) => { 75 | eventFired = true; 76 | }; 77 | await browser.extension.addListener('chrome.storage.onChanged', cb); 78 | await browser.extension.removeListener('chrome.storage.onChanged', cb); 79 | await browser.extension.send('chrome.storage.sync.set', { myKey: 'changedValue' }); 80 | assert(eventFired === false); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import { Puppeteer } from 'puppeteer/lib/cjs/puppeteer/common/Puppeteer'; 2 | import findRoot from 'find-root'; 3 | import path from 'path'; 4 | 5 | import { initializePuppeteer } from 'puppeteer/lib/cjs/puppeteer/initialize'; 6 | 7 | const puppeteer = initializePuppeteer('puppeteer'); 8 | 9 | export default puppeteer as Puppeteer; 10 | -------------------------------------------------------------------------------- /test/server_root/console.js: -------------------------------------------------------------------------------- 1 | console.log('hi'); 2 | 3 | function localFunction(a, b, c) { 4 | return a + b + c; 5 | } -------------------------------------------------------------------------------- /test/server_root/dynamic.js: -------------------------------------------------------------------------------- 1 | 2 | document.querySelector('#dynamic').innerHTML = "Dynamic header"; -------------------------------------------------------------------------------- /test/server_root/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test page 5 | 6 | 7 | 8 | 9 |

    Test header

    10 |

    Unmodified header

    11 | 12 | 0 13 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/server_root/newtab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | New tab 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/server_root/two.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Page two 5 | 6 | 7 | 8 |

    Page two

    9 | 10 | 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*", 4 | "test/**/*" 5 | ], 6 | "exclude": [ 7 | "node_modules" 8 | ], 9 | "compilerOptions": { 10 | /* Basic Options */ 11 | // "incremental": true, /* Enable incremental compilation */ 12 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 13 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 14 | // "lib": ["ES2020"], /* Specify library files to be included in the compilation. */ 15 | // "allowJs": true, /* Allow javascript files to be compiled. */ 16 | // "checkJs": true, /* Report errors in .js files. */ 17 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 18 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 19 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 20 | "sourceMap": true, /* Generates corresponding '.map' file. */ 21 | // "outFile": "./", /* Concatenate and emit output to single file. */ 22 | "outDir": "./dist", /* Redirect output structure to the directory. */ 23 | "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 24 | // "composite": true, /* Enable project compilation */ 25 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 26 | // "removeComments": true, /* Do not emit comments to output. */ 27 | // "noEmit": true, /* Do not emit outputs. */ 28 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 30 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 31 | /* Strict Type-Checking Options */ 32 | "strict": true, /* Enable all strict type-checking options. */ 33 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 34 | "strictNullChecks": true, /* Enable strict null checks. */ 35 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 36 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 37 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 38 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 39 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 40 | /* Additional Checks */ 41 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 45 | /* Module Resolution Options */ 46 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | /* Source Map Options */ 57 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | /* Advanced Options */ 65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 66 | } 67 | } --------------------------------------------------------------------------------