Puppeteer 加载与测试 Chrome 扩展的完整指南:启用扩展、后台上下文、Popup 与内容脚本实战
本文是一份面向开发者的实战指南,围绕 Puppeteer 提供的 Chrome 扩展测试能力展开:如何通过 enableExtensions 启动带扩展的浏览器、如何在运行时动态安装与卸载扩展、如何定位 MV3 Service Worker / MV2 后台页、如何打开 Popup 与触发扩展动作,以及如何借助 extensionRealms 在内容脚本上下文中执行代码。读完本文,你将能够直接用 Puppeteer 写出可复用的 Chrome 扩展端到端测试,并在 docs/guides/chrome-extensions.md 之外,理解这些 API 在 packages/puppeteer-core/src/api/Browser.ts、ChromeLauncher.ts 等源码中的底层实现。
一、Puppeteer 扩展测试能力总览
Chrome 扩展由 Manifest 声明其能力,并由多种运行上下文组成:MV3(Manifest V3)以 Service Worker 作为后台、content_scripts 注入普通网页、action 点击后可能弹出 Popup 或打开页面。因此“测试扩展”远比“测试网页”复杂——既要控制浏览器加载扩展,又要在多个不同的 JS 执行上下文里注入断言。
Puppeteer 在官方文档中明确声明可用于测试 Chrome 扩展,其支持的完整操作集合为:
- 启动浏览器时直接加载扩展(
enableExtensions接收扩展路径数组),或在运行中按需安装(browser.installExtension); - 列出、读取与卸载已安装扩展(browser.extensions、browser.uninstallExtension);
- 获取扩展的 Service Worker(MV3)或后台页(MV2)句柄,并在其中执行代码;
- 打开并测试
action弹出的 Popup 页面; - 以编程方式触发扩展动作(page.triggerExtensionAction 与 extension.triggerAction);
- 定位扩展注入内容脚本后产生的 Realm,在内容脚本上下文中执行代码。
Extension 抽象类(源码见 packages/puppeteer-core/src/api/Extension.ts)即运行期扩展对象的公共模型:每个实例携带 id、name、version、path、enabled 只读属性,并声明了 workers()(当前活跃的 Service Worker 列表)、pages()(当前可见的扩展页面列表)与 triggerAction(page) 三个抽象方法。注意该类在文档中标注为 @experimental,扩展相关 API 属于实验性能力,使用前请留意你所依赖的 Puppeteer 版本。
二、扩展支持在底层是如何实现的
要正确使用扩展测试能力,有必要先理解 Puppeteer 对浏览器默认参数的处理逻辑,这部分证据集中在 packages/puppeteer-core/src/node/ChromeLauncher.ts。
Puppeteer 为浏览器进程拼装的默认启动参数中默认会携带 --disable-extensions,这正是扩展无法被加载的根本原因。其逻辑为:
const {enableExtensions = false, ...} = options;
// ...
if (!enableExtensions) {
chromeArguments.push('--disable-extensions');
}
(见 ChromeLauncher.ts。)换言之,只要 enableExtensions 为真值,Puppeteer 就不再注入这一禁用参数,从而为扩展打开大门。参数类型在 LaunchOptions.ts 中被声明为:
/**
* If `true`, avoids passing default arguments to the browser that would
* prevent extensions from being enabled. Passing a list of strings will
* load the provided paths as unpacked extensions.
*/
enableExtensions?: boolean | string[];
值得进一步说明的是 string[] 形态在底层并非“启动参数直接加载”,而是启动完成后逐个调用安装 API。在 BrowserLauncher.ts 的 launch() 收尾阶段可以看到:
if (Array.isArray(enableExtensions)) {
await Promise.all([
enableExtensions.map(path => {
return browser.installExtension(path, {
enabledInIncognito: extensionsEnabledInIncognito.includes(path),
});
}),
]);
}
这带来两个重要的工程含义:
- 传数组时 Puppeteer 会等待所有扩展安装完成后才继续返回
browser,因此首屏即能waitForTarget到后台上下文; - 每个扩展是否在隐身模式(Incognito / off-the-record)下启用,由配套的
extensionsEnabledInIncognito?: string[](见 LaunchOptions.ts)指定,命中的路径会以enabledInIncognito: true完成安装。
也就是说,enableExtensions 的三种取值分别对应三种典型诉求:true = 允许扩展但不预先安装;路径数组 = 启动时同步安装若干扩展;extensionsEnabledInIncognito 数组 = 同时声明哪些扩展要进入隐身上下文。无论哪种方式,扩展都必须以未打包(unpacked)的目录形式提供,目录内需含 manifest.json。
三、加载扩展的两种方式
3.1 通过 LaunchOptions 在启动时加载
最常见的做法是把扩展目录路径直接交给 launch(),Puppeteer 启动浏览器后会自动完成安装(其底层走的就是上一节的 browser.installExtension() 路径):
import puppeteer from 'puppeteer';
import path from 'path';
const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
enableExtensions: [pathToExtension],
});
3.2 在运行期动态安装
先以 enableExtensions: true 启动(仅保证不被 --disable-extensions 禁用),随后在任何时刻调用 browser.installExtension(path) 动态加载,并获得该扩展的 ID:
import puppeteer from 'puppeteer';
import path from 'path';
const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
enableExtensions: true,
});
const extensionId = await browser.installExtension(pathToExtension);
两种方式各有适用场景:静态方式适合“扩展固定不变、每次测试都从干净状态开始”的 CI 场景;动态方式适合“同一浏览器实例内串联测试多个扩展、按需注入”的场景。仓库测试 test/src/cdp/extensions.test.ts 正是以 setupSeparateTestBrowserHooks({enableExtensions: true}, {createContext: false}) 启动浏览器后,再逐条调用 browser.installExtension(extensionPath) 来验证 Service Worker、Popup、动作触发等行为的(其测试夹具位于 test/assets/simple-extension 与 test/assets/extension-with-page)。
四、列出、读取与卸载扩展
安装之后,用 browser.extensions() 可拿到以扩展 ID 为键的 Map<string, Extension>,进而读取 name、version 等元数据;browser.uninstallExtension(id) 则负责卸载:
const extensions = await browser.extensions();
const extension = extensions.get(extensionId);
console.log(extension?.name);
console.log(extension?.version);
await browser.uninstallExtension(extensionId);
在此基础上,Extension 抽象类(见 packages/puppeteer-core/src/api/Extension.ts)还进一步暴露了四个与运行实体相关的成员:
extension.id / name / version / path / enabled:标识与元数据,其中enabled表示扩展当前是否处于启用状态;extension.workers():返回该扩展当前活跃的 Service Worker(WebWorker[]),便于批量断言或终止;extension.pages():返回该扩展当前打开且可见的页面(Page[]),例如固定标签页(Tab)型扩展;extension.triggerAction(page):等价于在指定页面上触发扩展的默认动作(详见后文)。
在 test/src/cdp/extensions.test.ts 中可以看到配套的验证方式:安装后 waitForTarget 该扩展的 service_worker target,卸载后再次检查 browser.targets(),确认与该扩展关联的 Service Worker target 已不再出现。
五、访问后台上下文:MV3 Service Worker 与 MV2 后台页
扩展的逻辑中枢并不在可见页面里,而在后台上下文中。Puppeteer 通过 browser.waitForTarget() 结合 target.type() 来捕获它,拿到 Target 后再转换为可执行句柄。
5.1 MV3 Service Worker
MV3 扩展以 Service Worker 为后台。下面示例假设你的扩展只产生一个 URL 以 background.js 结尾的 Service Worker:
import puppeteer from 'puppeteer';
import path from 'path';
const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
enableExtensions: [pathToExtension],
});
const workerTarget = await browser.waitForTarget(
// Assumes that there is only one service worker created by the extension and its URL ends with background.js.
target =>
target.type() === 'service_worker' &&
target.url().endsWith('background.js'),
);
const worker = await workerTarget.worker();
// Test the service worker.
await browser.close();
拿到 WebWorker 后,可以:
- 用
worker.evaluate(fn, ...args)在扩展后台上下文中执行任意代码(读取全局状态、触发内部逻辑); - 配合 target 的生命周期进行“强制终止/重新唤醒”类测试——例如先断言扩展在空闲后被浏览器回收,再断言某个事件能把它重新唤醒。
仓库测试即演示了“在 Service Worker 中求值”的写法:安装 simple-extension 后 waitForTarget、再 target.worker() 并 evaluate(() => globalThis.MAGIC),断言结果为 42(见 test/src/cdp/extensions.test.ts)。
5.2 MV2 后台页
对于仍使用 MV2 的扩展,后台是一个真正的页面(background_page 类型 target),因此可以直接将其转为普通 Page 来操作,与测试普通页面完全一致:
import puppeteer from 'puppeteer';
import path from 'path';
const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
enableExtensions: [pathToExtension],
});
const backgroundPageTarget = await browser.waitForTarget(
target => target.type() === 'background_page',
);
const backgroundPage = await backgroundPageTarget.page();
// Test the background page as you would any other page.
await browser.close();
六、测试 Popup:由 Service Worker 打开并捕获目标页
许多扩展通过点击工具栏图标弹出 Popup。Popup 本质是一个普通页面 target,但它由后台 Service Worker 调用 chrome.action.openPopup() 打开,因此测试流程分两步:先进入后台上下文触发打开,再用 waitForTarget 捕获 URL 以 popup.html 结尾的页面 target,最后通过 target.asPage() 拿页面句柄:
await worker.evaluate('chrome.action.openPopup();');
const popupTarget = await browser.waitForTarget(
// Assumes that there is only one page with the URL ending with popup.html
// and that is the popup created by the extension.
target => target.type() === 'page' && target.url().endsWith('popup.html'),
);
const popupPage = await popupTarget.asPage();
// Test the popup page as you would any other page.
await browser.close();
worker 即上一节获取的 Service Worker 句柄(见 5.1)。捕获 Popup target 时的 url() 过滤通常以扩展 ID + popup.html 双条件为准,避免与普通页面混淆。
七、以编程方式触发扩展动作
真实用户是通过点击工具栏图标触发扩展 action 的。Puppeteer 提供两条等价的程序化触发入口,效果等同用户点击扩展按钮:
page.triggerExtensionAction(extension):在当前页面上触发指定扩展的默认动作(API 文档);extension.triggerAction(page):从扩展对象一侧向指定页面发起同样触发。
const extensions = await browser.extensions();
const extension = extensions.get(extensionId);
// You can trigger the action for a specific extension on a page.
await page.triggerExtensionAction(extension);
// Alternatively, you can trigger it from the extension object itself.
await extension.triggerAction(page);
// If the action opens a popup, you can then wait for the popup target.
const popupTarget = await browser.waitForTarget(
target =>
target.type() === 'page' &&
target.url().includes(extensionId) &&
target.url().endsWith('popup.html'),
);
当扩展的 action 被配置为“打开 Popup”时,触发后立即 waitForTarget 即可拿到弹出页;当 action 被配置为执行脚本或打开新标签页时,则可继续用 waitForTarget/target.asPage() 处理后续目标。仓库在 test/src/cdp/extensions.test.ts 中对“page.triggerExtensionAction(extension) 触发 → waitForTarget 捕获 popup.html”的完整链路有端到端覆盖。
八、测试内容脚本:通过扩展 Realm 注入断言
内容脚本(content script)会按 Manifest 声明注入到匹配的普通网页中,因此测试方式非常直观:browser.newPage() 打开一个满足 matches 条件的页面,内容脚本便会随页面加载而注入(见 docs/guides/chrome-extensions.md)。
难点在于:内容脚本运行在隔离世界(isolated world)中,与页面主世界互不可见,Puppeteer 常规的 page.evaluate() 进不去。解决方案是使用扩展 Realm:
page.extensionRealms()(Page API,其实现为mainFrame().extensionRealms()的快捷方式,见 Frame.ts)返回页面上与各扩展关联的Realm[];- 遍历 Realm 并调用
realm.extension()(Realm API)拿回其所属扩展,与目标extensionId比对; - 命中后调用
realm.evaluate(...)即可在内容脚本上下文内执行代码、访问其 DOM 与私有状态。
// Get the extension ID
const extensionId = await browser.installExtension(pathToExtension);
// Find the extension realm.
const realms = page.extensionRealms();
let extensionRealm;
for (const realm of realms) {
const extension = await realm.extension();
if (extension?.id === extensionId) {
extensionRealm = realm;
break;
}
}
if (!extensionRealm) {
throw new Error('Extension realm not found');
}
// Evaluate code in the content script context.
const result = await extensionRealm.evaluate(() => {
return document.title;
});
这段代码实际验证了内容脚本侧的真实 DOM(示例返回 document.title)。实际项目中,extensionRealm.evaluate 更适合断言内容脚本注入的元素、事件监听副作用,或调用内容脚本内部暴露的调试钩子——这些都是主世界 page.evaluate 无法触及的。realm.extension() 返回可能为 null(例如不属于任何扩展的 Realm),因此官方示例使用 extension?.id === extensionId 的可空安全写法。该 API 相关实现逻辑可在 Frame.ts、Realm 与 Page.extensionRealms 中找到,cdp/IsolatedWorld 与 cdp/Frame 也参与了该能力。
九、把零散能力组合成一条完整测试链路
将前文串联起来,一个典型 MV3 扩展的端到端测试通常呈现如下骨架:
- 启动:
puppeteer.launch({enableExtensions: [pathToExtension]})(或enableExtensions: true后运行期installExtension); - 取 ID:从
browser.installExtension()返回值或browser.extensions()拿到extensionId; - 后台:
waitForTarget捕获service_workertarget →target.worker()获得后台句柄,先做状态断言; - 动作与 Popup:
page.triggerExtensionAction(extension)(或extension.triggerAction(page))→waitForTarget捕获popup.html→asPage()后做 UI 断言; - 内容脚本:
page.extensionRealms()定位到目标扩展 Realm,在隔离世界中校验 DOM 副作用; - 清理:
browser.uninstallExtension(extensionId)或browser.close()。
仓库还内置了一个可运行的参考实现 examples/puppeteer-in-extension/,内含 manifest.json、background.js、iframe.html 与 playground.html,可直接作为“如何在扩展里跑 Puppeteer”或“如何组织扩展页面清单”的脚手架阅读;若要观察 Puppeteer 官方自测的扩展编排方式,可对照 test/src/cdp/extensions.test.ts 及其加载的 test/assets/simple-extension、test/assets/extension-with-page 两个夹具目录。
十、注意事项与已知边界
- 默认参数即开关:只要
enableExtensions未开启,Puppeteer 的默认参数就会写入--disable-extensions(ChromeLauncher.ts),因此所有扩展用法都以此开关为前提; - 扩展必须是未打包目录:
enableExtensions数组、installExtension参数均指向包含manifest.json的本地目录,不支持.crx打包文件; - target 过滤要写准:扩展运行时会同时出现
service_worker、background_page、普通page(Popup/选项页/固定标签页)等不同 target,务必组合target.type()与target.url()过滤,避免误捕获; - 上下文差异:Service Worker 用
worker.evaluate,Popup/后台页用page.evaluate,内容脚本用realm.evaluate,三者互不通用; - 实验性标记:
Extension及其相关 API 在类型注释中标注@experimental,接口可能在后续版本演进,升级 Puppeteer 时需留意 CHANGELOG.md 与 packages/puppeteer-core/CHANGELOG.md; - 查询优先级:本指南对应的官方原文 docs/guides/chrome-extensions.md 只描述 API 层面的使用方式;若你运行的浏览器/平台不在此 API 的支持范围,应以实测或该仓库的版本说明为准。各方法逐个的签名与类型定义可继续查阅 docs/api 目录下的
puppeteer.browser.installextension.md、puppeteer.browser.extensions.md、puppeteer.extension.*.md、puppeteer.page.triggerextensionaction.md、puppeteer.realm.extension.md等文档页。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00