From 06080187a19c13bc664eeff71074ae2084cc673c Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 22 Mar 2024 14:57:28 -0500 Subject: [PATCH] feat: add support for manifest v3 extensions and background service workers (#81) --- .circleci/config.yml | 2 +- src/index.ts | 84 +++++++++---- test/extension-manifest-v3/background.js | 2 + test/extension-manifest-v3/content.js | 3 + test/extension-manifest-v3/devtools.html | 10 ++ test/extension-manifest-v3/devtools.js | 2 + test/extension-manifest-v3/manifest.json | 25 ++++ test/extension-manifest-v3/page.html | 9 ++ test/extension-manifest-v3/panel.html | 14 +++ test/test-manifest-v3.ts | 145 +++++++++++++++++++++++ test/test.ts | 1 + 11 files changed, 273 insertions(+), 24 deletions(-) create mode 100644 test/extension-manifest-v3/background.js create mode 100644 test/extension-manifest-v3/content.js create mode 100644 test/extension-manifest-v3/devtools.html create mode 100644 test/extension-manifest-v3/devtools.js create mode 100644 test/extension-manifest-v3/manifest.json create mode 100644 test/extension-manifest-v3/page.html create mode 100644 test/extension-manifest-v3/panel.html create mode 100644 test/test-manifest-v3.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index cce3103..82577c1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -91,7 +91,7 @@ workflows: name: "test-chrome-<< matrix.chrome-version >>" matrix: parameters: - chrome-version: ["latest", "110.0.5481.77"] + chrome-version: ["latest", "111.0.5563.146"] requires: - dependencies - lint: diff --git a/src/index.ts b/src/index.ts index b5236cc..1e360b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ * distribute or in any file that contains substantial portions of this source * code. */ -import { Page, Frame, Target, errors } from 'puppeteer' +import { Page, WebWorker, Frame, Target, errors } from 'puppeteer' import { DOMWorld, ExecutionContext, @@ -19,6 +19,10 @@ import { const devtoolsUrl = 'devtools://' const extensionUrl = 'chrome-extension://' +const types = { + page: 'page', + serviceWorker: 'service_worker' +} const isDevtools = (target: Target) => { return target.url().startsWith(devtoolsUrl) @@ -26,50 +30,79 @@ const isDevtools = (target: Target) => { const isBackground = (target: Target) => { const url = target.url() return ( - url.startsWith(extensionUrl) && url.includes('generated_background_page') + url.startsWith(extensionUrl) && (url.includes('generated_background_page') || target.type() === types.serviceWorker) ) } +const isPage = (target: Page | WebWorker): target is Page => { + // When $ exists, it's most likely a page... + return '$' in target && typeof target.$ === 'function' +} async function getContext( page: Page, isTarget: (t: Target) => boolean, options?: { timeout?: number } -): Promise { +): Promise { const browser = page.browser() const { timeout } = options || {} const target = await browser.waitForTarget(isTarget, { timeout }) + const type = target.type() + const url = target.url() - // Hack to get puppeteer to allow us to access the page context - ;(target as any)._targetInfo.type = 'page' + if (type === types.serviceWorker) { + const worker = await target.worker() + if (!worker) { + /* istanbul ignore next */ + throw new Error(`Could not convert "${url}" target to a worker.`) + } - const contextPage = await target.page() + return worker + } else { + let contextPage: Page | null - if (!contextPage) { /* istanbul ignore next */ - throw new Error(`Could not convert "${extensionUrl}" target to a page.`) - } + if ('asPage' in target && typeof target.asPage === 'function') { + contextPage = await target.asPage() + } else { + // Hack to get puppeteer to allow us to access the page context + ;(target as any)._targetInfo.type = types.page + contextPage = await target.page() + } - await contextPage.waitForFunction( - /* istanbul ignore next */ - () => document.readyState === 'complete', - { timeout } - ) + if (!contextPage) { + /* istanbul ignore next */ + throw new Error(`Could not convert "${url}" target to a page.`) + } - return contextPage + await contextPage.waitForFunction( + /* istanbul ignore next */ + () => document.readyState === 'complete', + { timeout } + ) + + return contextPage + } } async function getDevtools( page: Page, options?: { timeout?: number } ): Promise { - return getContext(page, isDevtools, options) + const context = await getContext(page, isDevtools, options) + + if (!isPage(context)) { + /* istanbul ignore next */ + throw new Error(`Devtools target "${page.url()}" is not of type page.`) + } + + return context } async function getBackground( page: Page, options?: { timeout?: number } -): Promise { +): Promise { return getContext(page, isBackground, options) } @@ -134,6 +167,7 @@ async function getDevtoolsPanel( { timeout } ) + /* istanbul ignore next */ switch(strategy) { case 'ui-viewmanager': await devtools.evaluate(`UI.viewManager.showView('${extensionPanelView}')`) @@ -167,15 +201,19 @@ async function getDevtoolsPanel( throw err } - // Hack to get puppeteer to allow us to access the page context - ;(extensionPanelTarget as any)._targetInfo.type = 'page' - - // Get the targeted target's page and frame - const panel = await extensionPanelTarget.page() + let panel: Page | null + /* istanbul ignore next */ + if ('asPage' in extensionPanelTarget && typeof extensionPanelTarget.asPage === 'function') { + panel = await extensionPanelTarget.asPage() + } else { + // Hack to get puppeteer to allow us to access the page context + ;(extensionPanelTarget as any)._targetInfo.type = types.page + panel = await extensionPanelTarget.page() + } if (!panel) { /* istanbul ignore next */ - throw new Error(`Could not convert "${extensionUrl}" target to a page.`) + throw new Error(`Could not convert "${extensionPanelTarget.url()}" target to a page.`) } // The extension panel should be the first embedded frame of the targeted page diff --git a/test/extension-manifest-v3/background.js b/test/extension-manifest-v3/background.js new file mode 100644 index 0000000..f376600 --- /dev/null +++ b/test/extension-manifest-v3/background.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +var foo diff --git a/test/extension-manifest-v3/content.js b/test/extension-manifest-v3/content.js new file mode 100644 index 0000000..0c70f8b --- /dev/null +++ b/test/extension-manifest-v3/content.js @@ -0,0 +1,3 @@ +(() => { + window.extension_content_script = true; +})() \ No newline at end of file diff --git a/test/extension-manifest-v3/devtools.html b/test/extension-manifest-v3/devtools.html new file mode 100644 index 0000000..5f6f312 --- /dev/null +++ b/test/extension-manifest-v3/devtools.html @@ -0,0 +1,10 @@ + + + + + devtools page + + + + + \ No newline at end of file diff --git a/test/extension-manifest-v3/devtools.js b/test/extension-manifest-v3/devtools.js new file mode 100644 index 0000000..8f66531 --- /dev/null +++ b/test/extension-manifest-v3/devtools.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-function +chrome.devtools.panels.create('test extension', '', 'panel.html', ()=> {}) \ No newline at end of file diff --git a/test/extension-manifest-v3/manifest.json b/test/extension-manifest-v3/manifest.json new file mode 100644 index 0000000..99c9f8c --- /dev/null +++ b/test/extension-manifest-v3/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Test Chrome Extension Manifest V3", + "description": "Test Chrome Extension Manifest V3", + "version": "1.2.3", + "devtools_page": "devtools.html", + "content_scripts": [ + { + "matches": ["*://testpage.test/", "*://testpage.test/frame"], + "js": ["content.js"], + "all_frames": true, + "run_at": "document_start" + } + ], + "background": { + "service_worker": "background.js" + }, + "content_security_policy": { + "extension_pages": "script-src 'self' ; object-src 'self'" + }, + "web_accessible_resources": [{ + "resources": ["page.html"], + "matches": [""] + }] +} diff --git a/test/extension-manifest-v3/page.html b/test/extension-manifest-v3/page.html new file mode 100644 index 0000000..c9cecae --- /dev/null +++ b/test/extension-manifest-v3/page.html @@ -0,0 +1,9 @@ + + + + + extension page + + + + \ No newline at end of file diff --git a/test/extension-manifest-v3/panel.html b/test/extension-manifest-v3/panel.html new file mode 100644 index 0000000..dd5c47f --- /dev/null +++ b/test/extension-manifest-v3/panel.html @@ -0,0 +1,14 @@ + + + + + + + +
devtools panel
+ + \ No newline at end of file diff --git a/test/test-manifest-v3.ts b/test/test-manifest-v3.ts new file mode 100644 index 0000000..599879e --- /dev/null +++ b/test/test-manifest-v3.ts @@ -0,0 +1,145 @@ +import assert from 'assert' +import puppeteer from 'puppeteer' +import path from 'path' +import fs from 'fs' +import { + setCaptureContentScriptExecutionContexts, + getContentScriptExcecutionContext, + getDevtools, + getDevtoolsPanel, + getBackground +} from '../src' + +beforeEach(async function () { + try { + const pathToExtension = path.resolve(__dirname, 'extension-manifest-v3') + + const browser = await puppeteer.launch({ + args: [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}` + ], + defaultViewport: null, + devtools: true, + headless: false + }) + + const [page] = await browser.pages() + + // Respond to http://testpage urls with a set fixture page + await page.setRequestInterception(true) + page.on('request', async request => { + if (request.url() === 'http://testpage.test/frame') { + const body = fs.readFileSync( + path.resolve(__dirname, 'fixtures/frame.html') + ) + return request.respond({ + body, + contentType: 'text/html', + status: 200 + }) + } + if (request.url().startsWith('http://testpage')) { + const body = fs.readFileSync( + path.resolve(__dirname, 'fixtures/index.html') + ) + return request.respond({ + body, + contentType: 'text/html', + status: 200 + }) + } + return request.continue() + }) + + this.manifestV3context = { + browser, + page + } + } catch (ex) { + console.log(`Did not launch browser: ${(ex as Error).message}`) + } +}) + +afterEach(async function() { + const { browser } = this.manifestV3context + if (browser) { + await browser.close() + } + this.manifestV3context = null +}) + +describe('puppeteer-devtools (manifest v3)', () => { + + it('should return devtools page', async function() { + const { page } = this.manifestV3context + const devtools = await getDevtools(page) + assert.match(await devtools.url(), /^devtools:\/\//) + }) + + it('should return background page', async function() { + const { page } = this.manifestV3context + const background = await getBackground(page) + assert.match(await background?.url(), /background.js$/) + }) + + it('should return devtools panel', async function() { + const { page } = this.manifestV3context + const devtools = await getDevtoolsPanel(page) + const body = await devtools.$('body') + const textContent = await devtools.evaluate(el => el?.textContent, body) + assert.equal(textContent?.trim(), 'devtools panel') + }) + + it('should throw with no matching strategies for showing devtools panel', async function() { + const { page } = this.manifestV3context + const devtools = await getDevtools(page) + // remove known chrome public apis to force errors + await devtools.evaluate(` + delete window.UI + delete window.InspectorFrontendAPI + `) + await assert.rejects(getDevtoolsPanel(page, { timeout: 100 }), (err: Error) => { + assert.match(err.message, /Unable to find view manager for browser executable/) + return true + }) + }) + + it('should return extension content script execution context', async function() { + const { page } = this.manifestV3context + await setCaptureContentScriptExecutionContexts(page) + await page.goto('http://testpage.test', { waitUntil: 'networkidle2' }) + const contentExecutionContext = await getContentScriptExcecutionContext(page) + const mainFrameContext = await page.evaluate( + () => (window as any).extension_content_script + ) + const contentContext = await contentExecutionContext.evaluate( + () => (window as any).extension_content_script + ) + assert.equal(typeof mainFrameContext, 'undefined') + assert(contentContext) + }) + + it('should throw error when unable to find content script execution context', async function() { + const { page } = this.manifestV3context + await page.goto('http://testpage.test', { waitUntil: 'networkidle2' }) + assert.rejects(async () => await getContentScriptExcecutionContext(page)) + }) + + it('should throw error when unable to find content script execution context on page without permissions', async function() { + const { page } = this.manifestV3context + await setCaptureContentScriptExecutionContexts(page) + await page.goto('http://testpage.test/that/does/not/have/permission', { + waitUntil: 'networkidle2' + }) + assert.rejects(async () => await getContentScriptExcecutionContext(page)) + }) + + it('should throw error when unable to find devtools panel', async function() { + const { page } = this.manifestV3context + assert.rejects(async () => + getDevtoolsPanel(page, { panelName: 'foo.html', timeout: 500 }) + ) + }) + +}) \ No newline at end of file diff --git a/test/test.ts b/test/test.ts index fbdcf94..2bb8763 100644 --- a/test/test.ts +++ b/test/test.ts @@ -66,6 +66,7 @@ afterEach(async function() { if (browser) { await browser.close() } + this.context = null }) describe('puppeteer-devtools', () => {