返回知识中心
入门

Puppeteer BrowserContext、页面所有权与资源清理

了解 Puppeteer 浏览器、上下文、页面、弹窗、超时与清理由谁负责,让授权测试正常结束且不泄漏资源。

文档中心

想直接进入 入门 文档吗?

这篇文章属于博客内容库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

一个浏览器进程,其中包含一个隔离的上下文、其页面和明确的清理边界

Browser、BrowserContext 和 Page 的所有者各不相同

简要来说,Puppeteer 的 Browser 拥有与浏览器进程的连接,BrowserContext 拥有一组隔离的浏览器 target 和浏览器管理的数据,而 Page 表示上下文中的一个标签页或页面 target。测试代码拥有自己创建的 Puppeteer 句柄。除非更高层 fixture 明确负责清理,否则代码必须在获取这些句柄的同一作用域中关闭它们。这种所有权模型使故障归属清晰:页面故障属于一条工作流,上下文故障影响对应的隔离会话,浏览器故障则影响使用同一进程的所有上下文。

Puppeteer 的 Browser.createBrowserContext() 会创建一个不与其他浏览器上下文共享 cookie 或缓存的上下文。context.newPage() 在该上下文中创建页面。相比之下,browser.newPage() 在默认浏览器上下文中创建页面。两者都返回 Page,因此很容易忽略这一差异,但只有前者在代码中明确体现了上下文所有者。需要按场景隔离的测试应主动创建非默认上下文,并从该上下文创建页面。

默认上下文有特殊的生命周期。browser.defaultBrowserContext() 会返回它,但 Puppeteer 文档明确说明默认上下文无法关闭;它会随浏览器一起结束。createBrowserContext() 返回的上下文则可以独立关闭,关闭它还会关闭所有关联页面。因此,默认上下文适合短脚本,却不适合作为共享测试进程中的隐式 fixture 边界。单个测试无法在不影响其他无关测试的情况下可靠地销毁它。

这种关系是资源树,而不只是对象图。一个浏览器可以包含多个上下文,一个上下文可以包含多个页面和其他 target,包括 worker。browser.pages() 会跨所有上下文返回页面,而 context.pages() 只列出一个上下文中的页面。因此,在浏览器层面进行宽泛查找可能会选中另一个测试创建的页面。辅助函数应明确接收 Page 或 BrowserContext,而不是在整个浏览器中搜索当前 URL 恰好匹配的页面。

隔离也有明确的边界。独立上下文可防止意外共享由浏览器管理的 cookie 和缓存,但它们不是独立的操作系统沙箱,也不会创建独立的服务账户。页面消失后,应用仍可能已写入外部数据库、发送邮件、创建支付服务商记录,或保留服务器会话。浏览器上下文拥有客户端浏览环境;应用和服务仍负责其支持的登出、回滚和数据保留行为。

解释这一生命周期时,无需虚构一个通用的“存储状态”对象。Puppeteer 提供的是上下文 cookie、页面、权限和 target 等具体操作。其他存储各有自己的 origin 和 API 行为。导出 cookie 并不能完整捕获 local storage、IndexedDB、Cache Storage、service worker、内存中的 JavaScript 或远程会话状态。关于这些边界,请参阅浏览器存储模型,并明确每种存储的所有者。

在同一作用域中创建并清理上下文

最可靠的 fixture 结构是在获取资源后立即进入 try 块,并提供一个始终执行的清理路径。不要在一个模块中创建上下文,只把页面传过多个层级,然后寄希望于全局关闭钩子稍后发现缺失的所有者。创建上下文的代码应保留上下文句柄,即使场景通常只操作页面。

以下示例为一个授权场景分配一个上下文和一个页面。它还会同时保留工作流错误和清理错误:前者仍是测试失败的原因,后者也保持可见,不会悄然取代或掩盖前者。

import type { Browser } from 'puppeteer';

async function runCheckoutCheck(browser: Browser) {
  const context = await browser.createBrowserContext();
  let workflowFailed = false;
  let workflowError: unknown;

  try {
    const page = await context.newPage();
    page.setDefaultTimeout(10_000);
    page.setDefaultNavigationTimeout(20_000);

    await page.goto('https://example.test/checkout', {
      waitUntil: 'domcontentloaded',
    });
    await page.locator('[data-test="cart-total"]').wait();
  } catch (error) {
    workflowFailed = true;
    workflowError = error;
  }

  let cleanupFailed = false;
  let cleanupError: unknown;
  try {
    await context.close();
  } catch (error) {
    cleanupFailed = true;
    cleanupError = error;
  }

  if (workflowFailed && cleanupFailed) {
    throw new AggregateError([workflowError, cleanupError], '工作流和 BrowserContext 清理均失败');
  }
  if (workflowFailed) throw workflowError;
  if (cleanupFailed) throw cleanupError;
}

这里可以在进入主工作流之前创建上下文,因为 createBrowserContext() promise 被拒绝时,不会返回任何需要关闭的上下文句柄。如果设置过程包含多个资源获取步骤,应在每个资源成功获取后立即登记它。页面、下载流、临时目录或录制任务都可能在上下文已经存在、但场景尚未开始时失败。清理逻辑必须适应不完整的设置过程,而不能假设每个变量都已初始化。

通常无需在关闭上下文之前逐个关闭其中的页面。BrowserContext.close() 会关闭上下文及其所有关联页面。当页面的生命周期短于上下文、测试需要断言弹窗已经关闭,或较长场景需要提前释放页面时,显式调用 page.close() 很有用。但它不应取代对拥有这组页面的上下文进行关闭。

如果应用契约要求执行应用清理,应先于浏览器清理。例如,合成结账流程可能需要调用受支持的取消接口,测试账户也可能需要执行应用的正常登出操作。应在页面和上下文仍可用时完成该操作,再关闭浏览器资源。如果应用清理失败,仍要尝试清理上下文,并报告两种结果。绝不能仅仅因为浏览器未能顺利关闭,就删除共享文件或无关的服务记录。

fixture 的输出应比它管理的资源更精简。一个有用的结果会记录场景名称、浏览器版本、上下文创建结果、页面结果和清理结果;无需记录 cookie 值、授权标头、完整 HTML 或个人数据。截图和 trace 也可能包含凭据或用户内容,因此只应为获准的测试收集,并按团队通常的制品策略保留。

将页面和弹窗视为上下文拥有的资源

Page 始终属于某个浏览器上下文。代码可以通过 page.browserContext() 验证这种关系,但直接传入正确的所有者要好于在故障后才查明它。负责打开报告的辅助函数应接收场景的页面或上下文。除非确实有意将新页面放入默认上下文,否则它不应调用 browser.newPage(),否则辅助函数可能在没有明显错误的情况下跨越隔离边界。

弹窗带来了第二个所有权问题:新页面由浏览器行为创建,而不是由 context.newPage() 直接创建。弹窗仍属于某个上下文,但测试必须在用户操作可能创建它之前开始观察。点击后才等待会产生竞态:弹窗可能很快出现,早于监听器或 target 谓词的注册,导致测试等待一个已经发生的事件。

Puppeteer 提供页面的 popup 事件和上下文级 target 查找。共享浏览器时,在上下文范围内等待 target 很有用,因为它不会误选其他上下文中的 target。先注册 promise,再触发操作,最后等待这个已经注册的 promise。

const popupTargetPromise = context.waitForTarget(target => target.opener() === page.target(), { timeout: 10_000 });

const [popupTarget] = await Promise.all([popupTargetPromise, page.locator('[data-test="open-receipt"]').click()]);
const popup = await popupTarget.page();
if (!popup) {
  throw new Error('打开的 target 不是页面');
}

await popup.locator('[data-test="receipt-number"]').wait();
await popup.close();

opener 谓词很重要。在繁忙的上下文中,仅等待下一个类型为 page 的 target 并不够,因为无关的后台操作可能先创建另一个页面。target.opener() === page.target() 会将结果与执行操作的页面绑定。如果应用有意复用现有标签页而不是打开弹窗,应使用相应的导航或内容条件,不要强行把工作流套入弹窗假设。

导航也遵循同样的顺序规则。如果点击预期会触发导航,应在点击之前创建 page.waitForNavigation() promise,并同时等待两个操作。如果点击只更新页面而不导航,则等待特定的可见条件或响应条件。通用延迟无法证明预期转换已经发生;对于持续发出分析、流式传输或后台请求的页面,networkidle 也不是通用的应用就绪信号。

弹窗清理遵循上下文资源树。关闭弹窗会释放该页面,同时保留父页面和上下文;关闭上下文则会同时释放两者。只关闭 opener 并不能保证它打开的每个页面都已结束,因此完整场景结束时,清理应依赖上下文边界。在场景中途执行断言前,context.pages() 可提供有界清单,用于计数或所有权检查,而无需搜索其他上下文。

worker 和下载也需要同样的纪律,尽管它们并不全都表示为 Page 对象。页面转换后,worker 仍可能继续执行应用活动;下载的生命周期也可能超过触发它的点击。等待必须完成且属于测试的工作;如果取消是场景的一部分,则通过受支持的 API 取消;然后关闭所属上下文。关闭上下文会释放浏览器资源,但无法撤销 worker 已提交的远程变更,也无法删除测试 runner 已移到其他位置的文件。

让超时描述未满足的预期,而不是清理

超时是观察边界。它说明测试愿意为某个条件等待多久,但不能证明浏览器已经停止底层应用工作。当 waitForSelector、locator 等待、导航或 waitForTarget 超时时,上下文可能仍然打开,页面也可能仍在执行。因此,超时处理后与断言失败后一样,都必须进行清理。

Puppeteer 分别设置一般操作和导航操作的默认值。page.setDefaultTimeout(ms) 为使用页面超时设置的方法提供默认最大值。page.setDefaultNavigationTimeout(ms) 控制导航方法,并且对这些操作具有更高优先级。当某个操作确实需要不同期限时,显式传入方法选项最为清晰。超时值应根据测试环境和预期的用户可见转换来确定,而不是假定每个网站都能在同一个统一时限内完成。

超时应明确指出它保护的条件。弹窗 target 等待应说明预期 opener 的弹窗没有出现;选择器等待应说明哪个应用状态未变为可见;导航超时应区分没有发生导航和 HTTP 响应已到达但状态异常。用一个外层测试超时替代所有这些条件,只会产生一个含糊的故障,并让清理与同一个已经耗尽的期限竞争。

在测试 runner 层面为清理预留单独的时间预算。Puppeteer 的 context.close() 和 browser.close() 不接受与页面等待相同的单操作超时选项。runner 可以施加外层期限,但 Promise.race() 只会停止等待;它不会取消失败竞速的关闭 promise,也不能证明浏览器资源已经消失。如果测试框架报告关闭超时,它必须将清理标记为未完成,并把进程终止交给真正拥有浏览器进程的组件。

不要吞掉 Puppeteer 的 TimeoutError 后继续操作状态未知的页面。超时的操作可能已部分成功。在同一页面上重试购买、表单提交或会修改服务状态的操作,可能造成重复效果。首先判断失败的条件是否只读且可重试。对于可重试的基础设施故障,应关闭旧上下文,使用相同的授权合成输入创建新上下文,再执行一次全新尝试。对于应用侧的不确定状态,应先使用应用支持的幂等或状态查询契约,再决定是否重试。

清理错误也需要独立分类。页面关闭错误、上下文关闭错误和浏览器断连都不等同于应用断言失败。应先报告原始工作流错误,再附加清理错误,就像 fixture 示例使用 AggregateError 所做的那样。这样既保留了选择器超时的证据,也能显示清理未完成。若 finally 块抛出新的关闭错误却不保留原始异常,会让测试更难诊断。

不要把无界的进程退出钩子当作主要清理机制。退出钩子适合作为最后一道诊断边界,但异步工作不一定能在每种终止模式下完成。只要事件循环和连接仍然健康,按测试、按 fixture 执行清理即可提供确定的所有权。所有上下文所有者结束后,测试套件级所有者再根据自身有界的关闭策略关闭共享浏览器。

根据进程所有权选择 close 或 disconnect

page.close()、context.close()、browser.close() 和 browser.disconnect() 的效果有意设计为不同。如何选择不是代码风格问题,而是进程所有权问题。

操作结束的内容保留的内容
page.close()一个页面所属上下文、同级页面和浏览器
context.close()一个非默认上下文及其所有关联页面其他上下文和浏览器
browser.close()浏览器及其所有关联页面Node.js 测试进程及其非浏览器资源
browser.disconnect()Puppeteer 与浏览器的连接浏览器进程及其页面继续运行

Page.close() 默认不会运行 beforeunload 处理程序。使用 runBeforeUnload: true 时,它会运行这些处理程序,但 Puppeteer 不会等待页面实际关闭。只有当该处理程序属于受测场景时才使用此行为。清理不应依赖应用的 unload 处理程序完成远程回滚,该模式下成功解析的关闭调用也不能证明本地或远程清理已经完成。

Browser.close() 会关闭浏览器及其所有关联页面。当 fixture 拥有自己启动的浏览器时,这通常是正确的最终操作。上下文所有者应先关闭各自上下文,以便把故障归因于正确的场景;随后,测试套件级的浏览器所有者再关闭进程级资源。仅在最后调用 browser.close() 虽然也可能释放资源,却会掩盖运行期间究竟是哪个测试泄漏了上下文或页面。

Browser.disconnect() 会断开 Puppeteer,同时让浏览器进程继续运行。当另一个组件拥有长期运行的浏览器、当前客户端只拥有连接时,这种做法才合适。它并不是对页面、上下文、cookie、下载或服务器会话的清理。断开后,该客户端无法再通过已断连的 Browser 句柄管理这些对象。进程所有者必须保留独立的控制路径和明确的关闭策略。

代码使用 puppeteer.launch() 还是 puppeteer.connect(),可作为判断所有权的线索,但最终应以部署契约为准。由 worker 启动的进程通常归该 worker 所有;通过浏览器 WebSocket endpoint 连接的进程通常归某项服务所有。代码不能仅因 API 允许就关闭共享服务,也不能与自己拥有的浏览器断连后声称进程已经释放。

关闭不会抹除外部影响。context.close() 会移除仍在运行的上下文及其页面,但不会撤销已经复制到其他位置的 token、取消订单、删除测试账户,或清理测试 runner 下载的文件。如果某条工作流需要在保持上下文存活的同时清除网站数据,应使用准确的浏览器或应用机制,并验证其文档化作用范围。网站数据清理指南解释了为什么删除客户端数据与清理账户是两项不同的主张。

用可观察条件验证清理

请使用实际采用的浏览器版本和 fixture 形态执行小范围检查。以下观察来自标准 Chrome 154 与 Puppeteer 24.40.0 中一个已激活的本地页面;它们为测试提供验收条件,而不是通用的时序保证或对 BotBrowser 的验证。

检查项最小操作继续前要观察的结果不应推断
默认页面关闭添加 beforeunload 处理程序,然后不带 runBeforeUnload 调用 page.close()未观察到对话框,且 page.isClosed() 为 true所有关闭路径都会显示 unload 对话框
启用 unload 的关闭激活本地页面,调用 page.close({ runBeforeUnload: true }),然后接受对话框关闭 Promise 解析时 page.isClosed() 仍可能为 false;接受对话框后应等待关闭条件固定延迟可以保证关闭或远程清理
自有上下文隔离在同一 origin 的两个上下文中设置不同的合成 cookie 和 local storage 值每个上下文只读取自己的值独立上下文会隔离服务器账户或操作系统资源
上下文关闭和断连关闭自有上下文,然后断开使用浏览器 endpoint 的客户端关闭上下文后其页面已关闭而浏览器仍保持连接;断连后,应通过 endpoint 重新连接再使用浏览器句柄disconnect() 会终止浏览器进程或清理远程状态

在升级浏览器或 Puppeteer 后应重复这些检查,并等待可观察到的关闭状态,而不是依赖定时器。它们描述的是一个有界本地案例,不代表外部网站结果、配置文件测试或应用清理保证。

将该模型应用于 BotBrowser,但不扩大结论

当 Puppeteer 驱动 BotBrowser Chromium 进程时,Puppeteer 标准的 Browser、BrowserContext、Target 和 Page API 仍然定义自动化资源树。已经验证的 BotBrowser 特有适配范围更窄:其多账户隔离文档描述了独立的上下文存储与会话,以及适用于文档所述授权方案的上下文专属配置控制。由于 renderer 会在启动时读取上下文配置,这些控制项应在创建页面之前分配。BotBrowser 不会取代 context.close()、页面清理、应用清理、服务器会话失效处理或秘密信息管理。它无法让 browser.disconnect() 终止进程,无法把页面超时变成取消,无法撤销远程请求,也无法保证 unload 处理程序已经完成。自动化清理仍由 Puppeteer 和测试 fixture 负责;应用与服务仍负责其支持的远程清理。

这一顺序强化了所有权规则:先创建上下文,通过受支持的集成应用文档明确且当前授权可用的上下文配置,然后才在该上下文中创建页面。每个上下文只使用一个获准的合成身份。基础浏览器配置不代表可以悄然复用另一上下文的账户;上下文专属配置也不是包含所有 Web 存储机制的 Puppeteer 序列化快照。

可用性也应写入契约。BotBrowser 文档列出了完整的按上下文指纹支持所需的前置条件,包括适用的企业授权。依赖上下文专属控制之前,团队应验证已安装的构建、配置兼容性和授权。安全的后备方案不是声称不受支持的标志已经生效,而是应在创建页面之前让设置失败,或运行只使用该环境实际可用能力的场景。

进行有界验证时,只记录浏览器版本、命名的合成场景、上下文创建结果、预期页面数和清理结果。确认两个测试上下文不共享测试所使用的已知 cookie 或缓存输入。然后通过各自所属的 fixture 关闭每个上下文,并根据进程契约关闭浏览器或断开连接。这能验证所选设置,但不能证明远程账户无法关联,也不能证明服务已经删除自身数据。

在将生命周期测试视为完成之前,请按以下顺序检查:

  1. 明确浏览器进程的所有者,并决定最终操作是 close() 还是 disconnect()。
  2. 为每个隔离场景创建非默认上下文,并从该上下文创建页面。
  3. 在可能满足条件的操作发生之前,注册弹窗、target 或导航等待。
  4. 为操作等待赋予明确含义,并在执行清理时保留首个故障。
  5. 单独验证应用清理,然后关闭上下文并报告任何未完成的清理。

这一顺序也让浏览器升级更易审查。API 调用清晰可见,上下文边界明确,泄漏的弹窗也无法隐藏在进程级关闭之后。对于 service worker 专属状态,请参阅 service worker 缓存生命周期指南,不要把关闭页面当作缓存或远程数据已清理的保证。

来源

#Puppeteer#BrowserContext#Page#测试隔离#清理

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。