返回知识中心
平台

在 Puppeteer 与 Playwright 中加载扩展

未打包扩展需要持久上下文和成对的两个 flag,但它仍可能加载失败。一次实测运行说明为何加载必须被验证,而不能假定。

BotBrowser Team

文档中心

想直接进入 平台 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

从自动化启动中加载未打包扩展,看起来只是两个 flag 的问题,实际却表现为启动条件的问题。BotBrowser 提供已声明的配置档案,使页面可读取的各项表面在多次启动之间保持一致,也包括 launch 级设置;它不能让一个 build 接受它拒绝加载的扩展,也不能保证 service worker 一直存活。加载成功的扩展需要哪些条件是可以逐条列出的,而一次实测运行显示这些条件似乎全部满足,扩展却完全没有出现。

摘要

  • 未打包扩展需要持久上下文,因为非持久上下文无法在页面之间保留扩展状态。
  • 两个 flag 应当一起传入:一个指定允许什么,另一个排除其余全部。
  • 只传加载 flag 会保留默认扩展集合,从而改变页面看到的内容。
  • MV3 的 service worker 不是常驻对象,所以 worker 缺失并不能证明扩展加载失败。
  • 在一次实测运行中,两个 flag 都在 headless=new 下传入,浏览器干净退出,配置档案里列出零个扩展。
  • 同一台主机上的旧 headless 模式和带界面模式结果相同,说明失败来自环境而非模式。
  • 有用的应对是在启动时断言扩展存在,不存在就让这次运行失败。
  • 在没有扩展的情况下静默运行的测试会因为错误的理由通过,这比失败的测试更糟。

目录

三个面板。第一个列出启动条件:持久上下文,以及一起传入的两个 flag。第二个显示一次实测运行,两个 flag 都已传入,配置档案却列出零个扩展。第三个区分被挂起的 service worker 与加载失败,并展示能分辨两者的断言。

启动条件,以及一次测量到的失败是什么样

三个条件决定未打包扩展是否有机会,它们彼此独立。第一个是上下文类型。扩展存放在浏览器配置档案里,因此结束时丢弃配置档案的上下文没有地方保留它。带显式 --user-data-dir 的持久上下文是唯一站得住的形式,而两种工具表达它的方式不同:Playwright 把这个目录作为 chromium.launchPersistentContext 的第一个参数,Puppeteer 则通过启动选项和命令行参数抵达同一个配置档案。启动参数本身在 Puppeteer 启动与配置档案的坑 中有说明,包括会盖过配置档案显示设置的 viewport 覆盖。

persistent context  -> profile on disk; extension state can persist
non-persistent      -> fresh profile per launch; nothing to hold the extension

第二个是 flag 组合。一个 flag 指定要加载的扩展目录,另一个指定唯一允许运行的扩展集合。只传加载 flag 会把扩展加到配置档案已有的内容之上,这很少是测试想要的;两个都传才能让被启动浏览器的扩展集合等于预期集合。

--disable-extensions-except=/path/to/ext
--load-extension=/path/to/ext

第三个条件是浏览器 build 接受该目录。这不是形式问题:限制了扩展加载的 build、被 build 判为无效的 manifest,以及浏览器无法读取的路径,三种情况产生的可观察结果相同,都是没有扩展。

上面三个条件很容易说出口,也很容易让人相信自己已经满足。为检验这一信念记录了一次运行,用的是带后台 service worker 的最小 MV3 扩展,两个 flag 一起传入。

persistent_headless_new: headless=true persistent=true extLoaded=false sw_count=0 id=(none)
persistent_headed:       headless=false persistent=true extLoaded=false sw_count=0 id=(none)

随后在浏览器退出后读取配置档案,这是一项不依赖运行中浏览器内部时序的检查:

--headless=new: exit=0 prefs_extension_ids=0
--headless:     exit=0 prefs_extension_ids=0
(headed):       exit=0 prefs_extension_ids=0

三种模式,三次干净退出,配置档案里零个扩展。这个结果值得仔细读,因为它说的比“扩展不能用”更窄。它说的是:在这台主机、这个 build、这个目录下,传入两个有文档的 flag 并没有安装扩展,而浏览器对此没有报告任何错误。

实际后果是:假定 flag 生效的测试会在没有扩展的情况下运行,并且仍然通过。extLoaded=false 是记录加载结果的字段,退出后读取配置档案会报出 prefs_extension_ids=0;worker 计数也是 sw_count=0,但这一个值单独无法区分加载失败与从未启动的 worker。扩展内部的每条断言都被跳过,被测页面看到的是默认环境,运行报告成功。这比可见的失败更糟,因为输出里没有任何东西促使人再看一眼。

修复很小而且机械,但两种工具给出的证据不同。Puppeteer 会枚举已安装的扩展,所以断言针对那个列表。Playwright 没有针对已安装集合的方法,因此同一事实要在浏览器退出后从配置档案里读,那里 extensions.settings 以扩展标识符为键。

puppeteer:  installed = await browser.extensions()        // Map: id -> {name, version}
playwright: JSON.parse(readFileSync(join(userDataDir, 'Default', 'Secure Preferences')))
              .extensions.settings                        // keys are extension ids
assert installed.size > 0, "no extension installed; check flags, manifest and build"

worker 检查回答的是另一个问题,不能取代这条断言。在 Playwright 中等待 context.serviceWorkers(),或在 Puppeteer 中用 service_worker 谓词等待 browser.waitForTarget,确认的是后台上下文已经启动;带 30_000 ms 预算的有界等待是合适的,只要它的失败消息说的是 worker 未启动,而不是扩展未加载。

service worker 不是常驻对象,以及检查清单

基于 worker 列表的检查可能误导,还有第二个原因,而这是扩展平台的性质,不是自动化工具的性质。MV3 的后台 service worker 在有需要时启动,在空闲时停止。缺失的 worker 可能是尚未启动的那个,也可能是已经被挂起的那个。

absent worker + extension installed   -> not started, or suspended
absent worker + extension not present -> load failed

单次轮询看来两者完全一样,含义却不同,所以加载检查应当针对已安装集合,而 worker 检查保留为另一条带自己消息的运行时断言。退出后读取配置档案,或向浏览器询问已安装的扩展,可以区分两者;对 worker 列表轮询固定秒数做不到。

生命周期也改变了测试等待 worker 的方式。对快机器够长的固定 sleep,在慢机器上会太短;等待 worker 出现并在固定间隔后放弃的测试,测的是机器而不是扩展。用 30_000 ms 这样的有界预算等待条件,并在预算耗尽时报告该预算,产生的失败会点明原因。

第四个条件值得单独一段,因为它是随工具而变、而不是随浏览器而变的那个。Puppeteer 与 Playwright 都暴露启动时的参数列表,也都接受同样两个 flag,但它们默认构建的上下文不同。Puppeteer 启动浏览器并在其默认上下文中创建页面;Playwright 启动浏览器并让调用方创建上下文。两种情况下扩展都位于浏览器层级,这意味着它从持久上下文可见,而从非持久路径创建的上下文不可见。

puppeteer.launch({ args })                        -> browser-level flags; page may still be transient
chromium.launchPersistentContext(dir)             -> profile on disk; extension state survives
browser.newContext()                              -> fresh profile; extension state absent

正是这一差别让两个团队用同样有文档的 flag 报出相反结果。一个把 flag 传给配置档案会保留的 launch,另一个把 flag 传给 launch,然后创建一个丢弃配置档案的上下文。两者对各自观察到的现象都没有说错,而仅凭 flag 并不能解释这个差别。

第二个混淆来源是 manifest 版本。MV2 扩展与 MV3 扩展的失败方式不同:MV2 有持久后台页面,扩展一加载就很明显;而 MV3 只在有东西请求时才启动 service worker。因此从旧扩展换到新扩展的人可能看到行为变化,那其实是 manifest 差异而不是加载失败,区分两者的检查必须看已安装集合,而不是正在运行的 worker。

MV2 -> persistent background page; visible immediately
MV3 -> service worker; starts on demand, suspends when idle

这两种差异都指向同一条纪律:验证不可能有歧义的东西。已安装扩展集合就是这种东西,因为它要么包含该扩展,要么不包含,与轮询那一刻是否有 worker 在运行无关。

把检查清单写成 fixture

一份落到实处的检查清单可以为这个话题收尾,因为把条件当作有序列表比当作散文更容易执行。每一步都对应一个它避免的失败,顺序也有关系:上下文类型决定其余步骤能否成立,所以它排在第一位。

1. launch a persistent context with an explicit user-data directory
2. pass --disable-extensions-except and --load-extension with the same path
3. validate the manifest before launch
4. read the installed extension set after launch
5. assert the set contains the extension, and fail with the four recorded fields

第一步最常被跳过,因为 chromium.launch() 调用看起来应该能用,而失败是静默的。把 flag 加到一个会创建新上下文的 launch 上,是在错误的层级做正确的事,可观察结果与完全不传 flag 相同。

第三步被跳过,是因为格式错误的 manifest.json 通常产生的消息指向浏览器而不是文件。启动前校验 JSON 会把它变成带行号的解析错误,于是修复只需五秒,而不是在启动参数里翻找十五分钟。

第四步和第五步把静默通过变成响亮失败。没有它们,依赖扩展的测试会把断言跑在扩展从未接触过的页面上,而报告会说断言通过。这是本主题中代价最高的结果,因为写测试的工作已经做完,结果却毫无意义。

清单足够短,适合放进 fixture 而不是放进文档,那也是它该在的位置。一个执行这五步一次并暴露所得上下文的 fixture,意味着套件里的每个测试都继承这条断言,而不必重复它;坏掉的环境会在 fixture 处失败,而不是在某个无关测试内部失败。

当 headless 不是变量时

还有一次测量值得记录,因为它排除了读者本来会形成的假设。上面的失败看上去可以合理归因于 headless 模式,因为 headless 浏览器最常被描述为不支持扩展。于是同一台主机上用同样的目录和同样的 flag 试了带界面模式。

persistent_headless_new: extLoaded=false
persistent_headed:       extLoaded=false

带界面模式以同样方式失败,这就把 --headless 从这台主机的原因中排除,并把线索指向 build 或目录。这是个有用的否定结果:它省下了读者花一个下午在 headless 模式之间切换的时间,而两者都不是变量。排除三个条件之一的否定结果,与确认另一个条件的肯定结果同样有价值。

这次读数也提醒这些结果应当如何记录。“扩展在 headless 下不能用”是测量并不支持的断言,而且是读者会据此行动的断言。“在这台主机、这个 build、这个目录下,两种 headless 模式和带界面模式都报告没有扩展”才是测量支持的断言,它指向另一个下一步。

同样的纪律适用于肯定情形。扩展成功加载的一次运行证明三个条件在该主机上对该 build 得到了满足,并不证明 flag 在一般情况下都正确。记录四个字段,才能让后来的读者分辨一次运行究竟确立了这两个断言中的哪一个。

loaded + recorded build  -> this build accepted this directory
loaded + no build        -> something worked, cause unknown

最后一点关于扩展与页面所报数值之间的关系,因为这正是本主题紧邻配置档案一致性、而不是单独放进工具指南的原因。扩展可以读取并修改它运行的文档,而这一切发生在配置档案所描述的那个浏览器内部。这意味着改动页面某个表面的扩展会成为页面所报内容的一部分,而没有把它计入的配置档案描述的并不是被测的那个浏览器。同样的推理适用于页面能观察到的任何覆盖,自动化信号与一致性 把它当作各表面是否吻合的问题,而不是一份改动清单。

profile without extension -> describes the browser alone
profile with extension    -> describes the browser plus what the extension changed

实际后果是:扩展应当与 build 和配置档案记在同一条记录里,理由与后两者相同。加上扩展后发生变化的数值,对两者都不是缺陷;那是把另一个参与者放进文档后的预期结果,记录必须说明这一点,否则后来的读者会把变化归到错误的原因上。

这也是为什么悄悄丢掉扩展的测试环境会产生一个看似干净、其实不干净的结果。页面报告的是没有扩展的浏览器的数值,依赖扩展的断言从未运行,而报告对一套从未使用的配置自洽。上面清单里的断言正是为了防止这种结果,也是让整篇其余部分可执行的那一行代码。

实践结论

按平台期望的顺序构建启动:带显式配置档案目录的持久上下文,两个 flag 一起传,并在启动后立即断言扩展确实存在。只有当 worker 列表非空时才把它当作证据,因为 worker 缺失是有歧义的,而安装缺失没有歧义。在启动前而不是启动后校验 manifest,这样无效文件会以针对该文件的消息失败,而不是以针对 worker 的超时失败。

断言失败时,记录传入了什么、浏览器报告了什么,因为上面三个条件就是全部搜索空间。只记录“扩展没有加载”的运行,会让下一位读者猜测三者中哪一个失败,而猜错的方向通常是 flag,原因却常在 build。

至于扩展一旦运行能看到什么,请读 浏览器扩展内容脚本隔离;至于装的是哪个 build 以及如何固定它,请读 Chrome for Testing 版本固定。

关于扩展放在哪里,这条说明属于条件的一部分,因为在机器之间搬测试的人常常在无意中也搬走了扩展。未打包扩展从文件系统路径加载,而该路径属于启动的一部分,不属于测试。把扩展挂载到别处的容器因此需要另一个 flag 值,而硬编码某个路径的缓存启动配置会在另一台机器上悄悄失败。

host A: --load-extension=/work/ext
host B: --load-extension=/opt/ci/ext      (same extension, different path)

对未打包扩展来说,扩展标识符由路径推导而来,所以目录一移动,标识符和 flag 值会同时不同。任何以标识符为键的东西,例如存储键或权限条目,也会在同一刻不再匹配,于是失败看起来像权限问题而不是路径问题。

这就是为什么四个记录字段里包含 flag 字符串,而不是 loadExtension=true 这样的布尔值。布尔值在机器迁移之后原样不变,把差异藏了起来;字符串则显示路径变了,一眼就能同时解释扩展缺失和标识符改变。

同样的推理适用于从商店安装的打包扩展。标识符稳定,路径不可见,所以打包扩展可以在机器之间移动而不改变读者记录的任何东西;这时的失败模式是版本不匹配而不是路径不匹配,要记录的字段是扩展版本而不是目录。

来源

#Puppeteer#Playwright#扩展#MV3#自动化

本文属于指南: Web 平台能力面:一致性复核地图

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。