11. Chrome扩展与浏览器集成

11.Chrome扩展与浏览器集成

Puppeteer 与 Chrome 扩展的集成是一个双向的过程。一方面,我们可以用 Puppeteer 来加载和测试自己开发的扩展程序,模拟真实用户与扩展的交互;另一方面,我们还能在扩展程序内部使用 Puppeteer,通过 chrome.debugger API 直接控制浏览器标签页。这两个方向看似相似,实则应用场景和技术细节大不相同。本章将逐一拆解这些集成方式,帮助我们在不同的开发需求下做出正确选择。

加载与测试 Chrome 扩展

开发 Chrome 扩展时,手动测试每个功能点既繁琐又容易遗漏边界情况。Puppeteer 为此提供了完美的自动化测试方案,能够像测试普通网页一样测试扩展的各个组件。无论是 Manifest V2 的 Background Page,还是 Manifest V3 的 Service Worker,甚至是点击扩展图标弹出的 Popup 窗口,都可以被自动化脚本接管。

启动时加载扩展

最自然的测试方式是在浏览器启动时就加载扩展。这样做的好处是扩展会随着浏览器实例一起初始化,所有后台脚本和事件监听器都能按预期工作,最接近真实用户的使用场景。

import puppeteer from 'puppeteer';
import path from 'path';

const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
  pipe: true,
  enableExtensions: [pathToExtension],
});

这段代码做了几件关键的事情。首先,我们通过 path.join 和 process.cwd() 定位到扩展目录,确保路径在不同操作系统下都能正确解析。pipe: true 参数启用了管道通信模式,这比默认的 WebSocket 连接更稳定,特别是在扩展加载场景下。enableExtensions 数组则明确告诉浏览器要加载哪些扩展,这里传入的是扩展的本地路径。

需要注意的是,扩展目录必须是一个合法的 Chrome 扩展,包含 manifest.json 文件和必要的资源。如果路径错误或扩展格式不正确,浏览器会静默忽略加载请求,不会抛出明显的错误。因此,建议在测试脚本开始前先检查目录结构和 manifest 文件是否存在。

运行时动态加载

有时我们可能需要在测试过程中动态安装扩展,比如在某些测试用例中需要对比扩展安装前后的页面行为差异。Puppeteer 提供了 browser.installExtension() 方法来实现这一需求。

import puppeteer from 'puppeteer';
import path from 'path';

const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
  pipe: true,
  enableExtensions: true,
});

await browser.installExtension(pathToExtension);

这里的 enableExtensions: true 与之前的数组形式不同,它仅仅启用了扩展功能,但并不自动加载任何扩展。真正的加载动作由后续的 installExtension 方法完成。这种分离设计让我们能够精确控制扩展的安装时机。

动态加载特别适合需要测试扩展安装流程本身的场景,比如验证扩展首次安装时的欢迎页面是否正确弹出,或者检查扩展图标是否在安装后立即显示在工具栏上。不过要留意,频繁安装卸载扩展可能会影响测试性能,因为每次安装都会触发浏览器的扩展初始化流程。

扩展 Service Worker 通信

Manifest V3 是当前 Chrome 扩展的标准,它用 Service Worker 替代了传统的 Background Page。Service Worker 的生命周期更加动态,可能在闲置时被浏览器终止,在有事件触发时重新唤醒。这种特性给自动化测试带来了新的挑战。

获取 Service Worker 实例

要测试扩展的后台逻辑,首先需要获取到 Service Worker 的引用。Puppeteer 通过 Target API 提供了这一能力。

import puppeteer from 'puppeteer';
import path from 'path';

const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
  pipe: true,
  enableExtensions: [pathToExtension],
});

const workerTarget = await browser.waitForTarget(
  target =>
    target.type() === 'service_worker' &&
    target.url().endsWith('background.js'),
);

const worker = await workerTarget.worker();

browser.waitForTarget 是一个强大的等待机制,它会持续监听浏览器目标的变化,直到找到匹配条件的 target 为止。这里的判断条件有两个:目标类型必须是 service_worker,且 URL 以 background.js 结尾。这种双重验证确保了即使浏览器中运行着多个 Service Worker,我们也能准确找到属于自己的那个。

获取到 worker 对象后,就可以像操作普通页面一样执行 JavaScript 代码了。例如,可以调用 worker.evaluate() 来检查扩展的存储状态,或者验证 Service Worker 中的事件监听器是否正确注册。

处理 Background Page (MV2)

对于仍在维护的 Manifest V2 扩展,后台是一个持久化的页面,获取方式略有不同。

import puppeteer from 'puppeteer';
import path from 'path';

const pathToExtension = path.join(process.cwd(), 'my-extension');
const browser = await puppeteer.launch({
  pipe: true,
  enableExtensions: [pathToExtension],
});
const backgroundPageTarget = await browser.waitForTarget(
  target => target.type() === 'background_page',
);
const backgroundPage = await backgroundPageTarget.page();

MV2 的 Background Page 是一个真实的页面,因此调用 target.page() 就能拿到 Page 实例。这个页面虽然对用户不可见,但拥有完整的 DOM 结构和 JavaScript 执行环境。我们可以截图查看后台页面的状态,或者模拟发送消息到内容脚本。

需要特别注意的是,Chrome 正在逐步淘汰 MV2,新项目应优先选择 MV3。但对于企业内部分发或特定用户群体的扩展,可能还需要在一段时间内支持 MV2,因此了解两者的测试差异仍然很有必要。

测试 Popup 页面

Popup 是扩展与用户交互的重要界面,测试它的打开、关闭和内部功能同样重要。Puppeteer 可以模拟点击扩展图标打开 Popup,也可以直接通过 Chrome API 控制。

// 假设已经获取了 worker 实例
await worker.evaluate('chrome.action.openPopup();');

const popupTarget = await browser.waitForTarget(
  target => target.type() === 'page' && target.url().endsWith('popup.html'),
);

const popupPage = popupTarget.asPage();

chrome.action.openPopup() 是 Chrome 提供的专用 API,只能在扩展的 Service Worker 上下文中调用。通过 worker.evaluate 执行这行代码,相当于扩展自己触发了 Popup 的打开动作。

等待 Popup 页面出现需要一点技巧。Popup 本质上是一个普通页面,但它的生命周期很短,关闭后会立即销毁。因此 waitForTarget 的等待条件要足够精确,通常通过 URL 匹配是最可靠的方式。拿到 popupPage 后,就可以进行表单填写、按钮点击等常规操作了。

测试 Popup 时还要考虑一个边界情况:用户点击页面空白处或切换到其他标签时,Popup 会自动关闭。Puppeteer 可以模拟这种行为,验证扩展是否正确处理了清理逻辑,比如是否及时保存了用户未提交的数据。

内容脚本的测试局限

内容脚本注入到普通网页中运行,理论上应该可以通过 Puppeteer 的 Page API 直接测试。但这里存在一个技术限制:目前 Puppeteer 无法直接在内容脚本的 isolated world 中执行代码。

// 可以导航到内容脚本注入的页面
const page = await browser.newPage();
await page.goto('https://example.com');

// 但无法直接在内容脚本上下文中执行代码
// 以下代码无法访问内容脚本定义的变量和函数
// await page.evaluate('contentScriptVariable');

这意味着我们只能测试内容脚本对页面的最终影响,比如是否正确地修改了 DOM 结构,或者是否向页面注入了特定的样式。但无法直接调用内容脚本内部的函数,或读取它闭包中的变量。

变通的方法是在内容脚本中主动暴露一些测试钩子到页面的全局作用域,然后在 Puppeteer 中通过 page.evaluate 访问这些钩子。当然,这种做法需要在发布扩展时移除测试代码,增加了维护成本。

在 Chrome 扩展中运行 Puppeteer

前面讨论的是如何用 Puppeteer 测试扩展,现在我们把视角翻转过来:在扩展内部使用 Puppeteer 控制浏览器。这种需求常见于需要深度操作网页的扩展,比如批量下载工具、自动化表单填写助手等。

环境差异与限制

Chrome 扩展的运行环境与 Node.js 截然不同。扩展基于浏览器的事件驱动模型,没有文件系统访问权限,也不能像 Node.js 那样自由启动子进程。因此,Puppeteer 在扩展中运行时需要采用完全不同的架构。

扩展通过 chrome.debugger API 获得对浏览器的有限控制权。这个 API 提供了 CDP 的一个子集,且一次只能附加到一个标签页。这意味着 Puppeteer 在扩展中的视图被限制在单个页面内,无法像 Node.js 版本那样管理多个页面或创建新浏览器实例。

另一个重要限制是生命周期。扩展的 Service Worker 可能在几分钟不活动后被浏览器终止,这会导致 Puppeteer 连接断开。因此,所有自动化操作都需要在 Service Worker 存活期间快速完成,或者通过保持活跃状态来延长生命周期。

实现步骤与代码示例

要在扩展中使用 Puppeteer,首先需要构建一个浏览器兼容的版本。这通常借助打包工具完成,比如 Rollup 或 Webpack。

import {
  connect,
  ExtensionTransport,
} from 'puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js';

// 创建或找到一个要附加的标签页
const tab = await chrome.tabs.create({
  url: 'https://example.com',
});

// 使用 ExtensionTransport 建立连接
const browser = await connect({
  transport: await ExtensionTransport.connectTab(tab.id),
});

// 此时 browser 对象只包含一个页面,即我们连接的标签页
const [page] = await browser.pages();

// 可以执行常规的 Puppeteer 操作
console.log(await page.evaluate('document.title'));

// 操作完成后断开连接
browser.disconnect();

这段代码展示了核心流程。关键点在于从 puppeteer-core-browser.js 导入,这是专门为浏览器环境编译的入口文件。ExtensionTransport.connectTab 方法封装了 chrome.debugger.attach 的调用细节,建立了与指定标签页的 CDP 连接。

连接成功后,browser.pages() 返回的数组永远只有一个元素,就是我们附加的那个标签页。这个限制源于 chrome.debugger 的设计,它不允许一个调试连接同时控制多个目标。

如果需要控制多个标签页,必须为每个标签页创建独立的 Puppeteer 连接。这会带来一定的资源开销,但也是在扩展环境中唯一可行的方案。

打包配置详解

扩展的打包配置需要特别处理依赖关系。以下是一个 Rollup 配置示例:

import {nodeResolve} from '@rollup/plugin-node-resolve';

export default {
  input: 'main.mjs',
  output: {
    format: 'esm',
    dir: 'out',
  },
  // 排除 WebDriver BiDi 相关模块以减小体积
  external: ['chromium-bidi/lib/cjs/bidiMapper/BidiMapper.js'],
  plugins: [
    nodeResolve({
      // 目标环境设为浏览器
      browser: true,
      // 只解析 puppeteer-core 的依赖
      resolveOnly: ['puppeteer-core'],
    }),
  ],
};

nodeResolve 插件的 browser: true 选项告诉打包工具,在解析模块路径时要优先使用 package.json 中 browser 字段指定的浏览器版本。resolveOnly: ['puppeteer-core'] 则限制了依赖解析的范围,避免将 Node.js 特有的模块打包进扩展。

external 数组排除了 WebDriver BiDi 的实现模块。在扩展环境中,我们只能使用 CDP,BiDi 协议尚未支持,因此可以安全地排除这个体积较大的依赖,显著减小最终打包文件的大小。

实际项目中,我们可能还需要配置 commonjs 插件来处理一些遗留的 CommonJS 模块,以及 terser 插件来压缩代码。扩展的代码体积直接影响安装和更新速度,优化打包配置是值得投入精力的工作。

浏览器内 Puppeteer 环境

除了在扩展中运行,Puppeteer 还能在普通网页中执行。这听起来有些不可思议,但确实可行。网页中的 Puppeteer 无法启动本地浏览器,但可以连接到远程的、开启了调试端口的浏览器实例。

适用场景与能力边界

浏览器内 Puppeteer 的主要用途是构建基于 Web 的自动化控制面板。比如,我们可以开发一个内部工具网站,让测试人员通过浏览器界面控制实验室中的测试机器上的浏览器,执行自动化脚本并实时查看结果。

支持的功能包括:

  • 通过 WebSocket 连接远程浏览器
  • 在远程页面执行 JavaScript
  • 生成截图和 PDF
  • 管理 Cookie 和网络请求
  • 创建和关闭页面

不支持的功能也很明确:

  • 启动或下载浏览器(依赖 Node.js API)
  • 访问本地文件系统
  • 使用系统级功能

这种方案的优势在于部署简单,只需一个静态网页服务器即可。但安全性需要格外注意,因为 WebSocket 连接信息可能暴露在客户端代码中,必须配合身份验证和权限控制机制。

连接远程浏览器实例

实现浏览器内 Puppeteer 同样需要打包步骤,代码结构与扩展版本类似。

import puppeteer from 'puppeteer-core/lib/esm/puppeteer/puppeteer-core-browser.js';

const browser = await puppeteer.connect({
  browserWSEndpoint: wsUrl,
});

alert('远程浏览器有 ' + (await browser.pages()).length + ' 个页面');

browser.disconnect();

这里的 wsUrl 是远程浏览器的 WebSocket 调试地址,格式通常是 ws://hostname:9222/devtools/browser/<id>。获取这个地址需要先在远程机器上启动 Chrome 时添加 --remote-debugging-port=9222 参数。

与扩展版本不同,浏览器内 Puppeteer 使用的是标准的 WebSocketTransport,因此可以管理多个页面,功能更接近 Node.js 版本。但网络延迟和连接稳定性会成为新的考量因素,建议在正式环境中使用 wss 协议和心跳机制保持连接。

打包与部署注意事项

浏览器版本的打包配置与扩展版本几乎相同,因为它们都针对浏览器环境。主要区别在于,网页版不需要考虑扩展的权限模型和生命周期管理。

部署时,需要将打包后的 JavaScript 文件引入到 HTML 页面中:

<!DOCTYPE html>
<html>
<head>
  <title>Puppeteer Web Console</title>
</head>
<body>
  <div id="app"></div>
  <script src="out/bundle.js"></script>
</body>
</html>

由于 Puppeteer 的浏览器版本体积仍然较大(即使排除了 BiDi 模块),建议启用 gzip 压缩,并考虑使用 CDN 加速静态资源加载。对于移动端访问,还可以实现按需加载,只在用户需要特定功能时才加载对应的代码模块。

安全方面,绝对不要将浏览器的调试端口直接暴露到公网。正确的做法是搭建一个后端代理服务,由后端管理与浏览器的 CDP 连接,前端通过安全的 API 与后端通信。这样既能保护调试端口,也能实现更灵活的权限控制和操作审计。

总结

本章探讨了 Puppeteer 与 Chrome 扩展的两种集成方向。测试扩展时,Puppeteer 提供了加载扩展、获取后台上下文、操作 Popup 的完整能力,但内容脚本的测试仍存在局限。在扩展中运行 Puppeteer 则需要适应浏览器环境的限制,通过 chrome.debugger 实现单页面控制,并配合打包工具构建兼容版本。

浏览器内 Puppeteer 进一步拓展了应用场景,让自动化能力可以通过 Web 界面交付。这三种技术路线各有侧重,选择哪种取决于具体需求:是测试扩展、在扩展中自动化,还是构建 Web 化的控制面板。

下一章将讨论如何将 Puppeteer 项目容器化,解决扩展和浏览器内版本无法覆盖的服务端部署问题。Docker 化不仅能提供一致的运行环境,还能更好地管理浏览器依赖和系统配置,是生产环境部署的关键一步。