Skip to content

Commit

Permalink
feat: add support for manifest v3 extensions and background service w…
Browse files Browse the repository at this point in the history
…orkers (#81)
  • Loading branch information
scurker authored Mar 22, 2024
1 parent fc51a63 commit 0608018
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 24 deletions.
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 61 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,57 +19,90 @@ import {

const devtoolsUrl = 'devtools://'
const extensionUrl = 'chrome-extension://'
const types = {
page: 'page',
serviceWorker: 'service_worker'
}

const isDevtools = (target: Target) => {
return target.url().startsWith(devtoolsUrl)
}
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<Page> {
): Promise<Page | WebWorker> {
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<Page> {
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<Page> {
): Promise<Page | WebWorker> {
return getContext(page, isBackground, options)
}

Expand Down Expand Up @@ -134,6 +167,7 @@ async function getDevtoolsPanel(
{ timeout }
)

/* istanbul ignore next */
switch(strategy) {
case 'ui-viewmanager':
await devtools.evaluate(`UI.viewManager.showView('${extensionPanelView}')`)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/extension-manifest-v3/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
var foo
3 changes: 3 additions & 0 deletions test/extension-manifest-v3/content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
(() => {
window.extension_content_script = true;
})()
10 changes: 10 additions & 0 deletions test/extension-manifest-v3/devtools.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>devtools page</title>
<script src="devtools.js"></script>
</head>
<body>
</body>
</html>
2 changes: 2 additions & 0 deletions test/extension-manifest-v3/devtools.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
chrome.devtools.panels.create('test extension', '', 'panel.html', ()=> {})
25 changes: 25 additions & 0 deletions test/extension-manifest-v3/manifest.json
Original file line number Diff line number Diff line change
@@ -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": ["<all_urls>"]
}]
}
9 changes: 9 additions & 0 deletions test/extension-manifest-v3/page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>extension page</title>
</head>
<body>
</body>
</html>
14 changes: 14 additions & 0 deletions test/extension-manifest-v3/panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
background-color: #fff;
}
</style>
</head>
<body>
<div>devtools panel</div>
</body>
</html>
145 changes: 145 additions & 0 deletions test/test-manifest-v3.ts
Original file line number Diff line number Diff line change
@@ -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 })
)
})

})
1 change: 1 addition & 0 deletions test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ afterEach(async function() {
if (browser) {
await browser.close()
}
this.context = null
})

describe('puppeteer-devtools', () => {
Expand Down

0 comments on commit 0608018

Please sign in to comment.