如何在Playwright中修改HTTP响应触发Service Worker更新
问题背景
开发单页应用(SPA)时接入了Service Worker能力:
- 采用cache-first策略预缓存应用外壳
- 采用network-first策略缓存导航请求
应用内置版本提示逻辑:当检测到处于waiting状态的Service Worker时,页面顶部会展示*A new version is available!*提示横幅,附带「Reload」按钮。在针对该提示逻辑和Reload按钮交互编写端到端测试时,始终无法找到在Playwright中触发Service Worker更新的有效方法。
初始实现思路为:Service Worker完成安装、触发页面重载后,修改Service Worker文件的HTTP响应。实际测试中可以正常修改响应体及响应头,但操作始终无法触发浏览器的Service Worker更新事件。
初始测试代码
test("shows an update button when the service worker file has changed", async ({ page, context }, testInfo) => { const newScriptBytes = "/*test*/" const timeout = testInfo.timeout await page.goto("/") await waitForReadyServiceWorker({ context, page, timeout }) // change the bytes of all additional service worker requests await page.route("/service-worker.js", async route => { const serverResponse = await page.request.fetch(serviceWorkerUrl, { ignoreHTTPSErrors: true }) let serviceWorkerScript = await serverResponse.text() serviceWorkerScript = serviceWorkerScript.concat(newScriptBytes) await route.fulfill({ response: serverResponse, body: serviceWorkerScript, headers: { ...serverResponse.headers(), "Etag": "v1", "Last-Modified": new Date().toUTCString(), "Content-Length": serviceWorkerScript.length.toString() } }) }) await page.reload() await expect( page.locator(`text=A new version is available!`) ).toBeVisible() }) export async function waitForReadyServiceWorker(args: { context: BrowserContext page: Page timeout?: number }): Promise<Worker | undefined> { const timeoutId = createOperationTimeout(args.timeout) const url = await args.page.evaluate(async () => { const readySw = await navigator.serviceWorker.ready return readySw.active.scriptURL }) clearTimeout(timeoutId) return args.context.serviceWorkers().find(worker => worker.url() === url) }
相关截图
- 初始Service Worker响应:

- 修改后的Service Worker响应:

失败原因
原有方案无法触发更新核心有三个问题:
- 拦截层级错误:
page.route仅能拦截当前页面上下文发起的普通资源请求,Service Worker的更新检查是浏览器在BrowserContext层面独立发起的请求,不会经过page级路由拦截,因此修改SW响应的逻辑从未命中浏览器的SW更新请求。 - 拦截注册时机过晚:等待首个SW完全激活后才注册路由规则,即使使用正确层级的拦截,也可能错过导航流程早期的SW更新检查时机。
- 触发逻辑不可靠:依赖页面重载触发SW更新会受浏览器缓存规则影响,若透传的原始响应头携带强缓存字段(如长周期
Cache-Control),浏览器会直接跳过SW更新请求,不会进入拦截逻辑。同时重载后未等待新SW进入waiting状态就直接断言,时序不匹配也会导致断言失败。
正确实现方案
按照以下步骤调整即可稳定复现SW更新场景:
- 替换拦截层级:使用
context.route代替page.route,确保能拦截到浏览器层面发起的SW更新请求。 - 提前注册拦截规则:在页面跳转前就注册SW路由拦截,通过标志位控制返回原始/修改后的SW内容,避免错过请求时机。
- 强制跳过缓存:拦截SW响应时覆盖缓存相关头,返回
Cache-Control: no-cache,避免浏览器走本地缓存跳过更新检查。 - 主动触发更新:通过页面JS调用
registration.update()主动触发SW更新检查,同时监听SW的updatefound和statechange事件,等待新SW确实进入waiting状态后再做断言,避免时序问题。
修正后的测试代码如下:
import { test, expect, BrowserContext, Page, Worker } from "@playwright/test"; test("shows an update button when the service worker file has changed", async ({ page, context }, testInfo) => { let returnModifiedSw = false; const newScriptBytes = "/*test*/"; const timeout = testInfo.timeout; // 提前在context层面注册SW拦截规则 await context.route("/service-worker.js", async route => { const serverResponse = await route.fetch(); let swScript = await serverResponse.text(); if (returnModifiedSw) { swScript = swScript.concat(newScriptBytes); } await route.fulfill({ response: serverResponse, body: swScript, headers: { ...serverResponse.headers(), // 强制无缓存,确保浏览器发起更新请求 "Cache-Control": "no-cache", "Etag": returnModifiedSw ? "v2" : "v1", "Last-Modified": new Date().toUTCString(), "Content-Length": swScript.length.toString() } }) }) await page.goto("/") await waitForReadyServiceWorker({ context, page, timeout }) // 切换为返回修改后的SW内容 returnModifiedSw = true; // 主动触发SW更新,等待新SW进入waiting状态 await page.evaluate(async () => { const registration = await navigator.serviceWorker.getRegistration(); if (!registration) throw new Error("Service Worker registration not found"); return new Promise<void>((resolve) => { // 新SW已经在waiting状态直接返回 if (registration.waiting) { resolve(); return; } // 监听新SW安装流程 registration.addEventListener("updatefound", () => { const newWorker = registration.installing; if (!newWorker) return; newWorker.addEventListener("statechange", () => { if (newWorker.state === "installed") { resolve(); } }) }) // 主动触发更新检查 registration.update(); }) }) // 断言更新提示可见 await expect( page.locator(`text=A new version is available!`) ).toBeVisible() }) async function waitForReadyServiceWorker(args: { context: BrowserContext page: Page timeout?: number }): Promise<Worker | undefined> { const timeoutId = setTimeout(() => { throw new Error("Wait for Service Worker ready timeout") }, args.timeout ?? 30000) const swUrl = await args.page.evaluate(async () => { const readySw = await navigator.serviceWorker.ready return readySw.active?.scriptURL }) clearTimeout(timeoutId) return args.context.serviceWorkers().find(worker => worker.url() === swUrl) }
额外注意事项
- 如果Service Worker中通过
importScripts引入了其他拆分脚本,需要同步拦截这些脚本并修改内容,浏览器判断SW是否更新时会校验SW文件及其所有依赖脚本的总字节哈希,任意一个资源字节变化才会触发更新流程。 - 测试Reload按钮激活新SW的逻辑时,点击按钮后可通过
context.waitForEvent("serviceworker")监听新SW激活事件,确认新SW完全接管页面后再做后续断言即可。
内容的提问来源于stack exchange,提问作者Ethan Lemke
相关产品推荐
相关产品推荐

