E2E 開始紅。而那次 commit 沒有動任何 spec、沒有動任何元件,只加了 PWA 設定。
git diff 看不出任何關聯。這是最難查的一種紅燈——因為關聯不在 diff 裡。
症狀
spec 在 page.reload() 之後的那一步等不到畫面,而且錯誤是 element(s) not found,不是 assertion 值不符:
Error: element(s) not found
Call log:
- Expect "toBeVisible" with timeout 5000ms
- waiting for getByText('目前沒有輪到你判的項目。')
同一個檔案裡 reload() 之前的斷言全部通過。reload() 緊接著的那一條——讀 client 端狀態的那個——也通過。
只有再往後、需要打 API 的那一步掛掉。
這個「部分通過」的形狀提供了關鍵線索,但它很容易被讀成隨機的 flaky。
根因
page.route() 攔不到 service worker 自己發出的請求。
Playwright 的 page.route 掛在 page 的網路層。service worker 接管 client 之後:
- 頁面的
fetch先進 SW 的fetchevent - SW 對該 route 若使用
NetworkOnly(或任何 handler),它會由 SW 自己重新發出請求 - 那是 service-worker-initiated request,不經過 page 的攔截點
- 請求打到真的 server,mock 完全不生效
為什麼只有 reload 之後才壞
這是時序問題。
PWA 整合套件在 registerType: 'autoUpdate' 且使用預設外掛設定時,會自動設 clientsClaim: true 與 skipWaiting: true。於是:
| 時間點 | page 狀態 | mock |
|---|---|---|
第一次 goto() | 還沒被 SW controlled | 正常 |
| SW 註冊 → activate → claim | 過渡 | — |
reload() 之後 | controlled | 全部失效 |
所以 reload 之前的斷言全綠,reload 之後只要碰 API 就掛。而讀 client 端狀態的那條不碰 API,所以它也綠——這條「例外」正是拼圖的最後一塊。
為什麼沒有任何一層接住它
這件事本身就發生在測試裡。 沒有「另一層測試」能接住它。
而其他幾個因素讓它更難查:
- 失敗訊息完全指向錯的方向。
element(s) not found講的是 UI,完全沒提到 service worker、快取或網路。照著訊息查會去查元件與 selector,而那兩處都沒有改 - 手動驗 PWA 時一切正常。 production 沒有 mock,SW 的
NetworkOnly就只是原樣轉發。「production 是對的」與「測試會壞」同時成立,而只驗前者不會發現後者 - 這個坑會遲到。 加 PWA 的那個 commit 未必馬上跑到會受影響的那組 E2E。等它紅的時候,作者已經離開那個 context 了
第二點值得單獨強調。這是一個很常見的盲區形狀:
一個改動讓 production 正確、同時讓測試環境失效。而驗收流程只驗 production 正確。
這種改動會通過所有人工驗收,然後在 CI 上以一個看似無關的形式爆炸。
判定
風險條件是三件事同時成立:
- 專案有 service worker
- E2E 用
page.route()餵資料 - Playwright config 沒有對
serviceWorkers表態
第三點是重點。顯式寫了 serviceWorkers: 'allow' 代表作者知道並接受這個行為,那不是風險——沒有表態才是。
sw=$(rg -l --glob '!node_modules' 'vite-plugin-pwa|serviceWorker\.register' . | wc -l)
pr=$(rg -l --glob '!node_modules' 'page\.route\(' . | wc -l)
opt=$(rg -l --glob '!node_modules' "serviceWorkers:\s*'(block|allow)'" . | wc -l)
[ "$sw" -gt 0 ] && [ "$pr" -gt 0 ] && [ "$opt" -eq 0 ] && echo "AT RISK"
兩個條件要一起看,交集才是風險。 單獨「有 SW」或單獨「有 mock」都完全沒問題。
這也帶出一個規劃上的提醒:目前沒有 SW、但 mock 依賴很深的專案,是未來最痛的那些。加 PWA 的那一天,所有既有的 E2E 會一起紅,而 root cause 與那次改動看起來毫無關係。
修法
在 Playwright config 的 use 區塊表態:
use: {
baseURL: '…',
trace: 'retain-on-failure',
serviceWorkers: 'block',
},
serviceWorkers: 'block' 讓 context 完全不註冊 SW,page 永遠是 uncontrolled,page.route 回到加 PWA 之前的行為。
我們實測的結果是 2 failed 變成 28 passed,spec 一個字都沒改。
但這是有代價的,要一起決定
擋掉 SW 之後,E2E 就不再跑在 production 的請求路徑上,而 SW 本身零覆蓋。
要驗 SW 的行為必須另外寫一組不 mock 的 spec(serviceWorkers: 'allow' 加上真的 server)。不要指望原本那組 mock spec 順便驗到——它們現在明確地跑在一個沒有 SW 的世界裡。
所以正確的收尾是兩件事:
- 主要的 E2E 加
serviceWorkers: 'block',換回可預測的 mock - 另開一組小的 SW spec,明確驗離線行為與快取策略
只做第一件事會得到綠燈,但你的 PWA 從此沒有任何自動化覆蓋。這是一個必須被明確決定的取捨,不是一個可以順手跳過的細節。
不要走的那條路
不要為了讓測試通過,改成讓 SW 對測試環境 passthrough。
那等於在 production code 裡開一條只為測試存在的分支,而它會在真實使用者身上生效。測試環境的問題要在測試設定裡解決,不要滲進產品程式碼。
這條線值得畫得很硬:production code 裡任何「如果是測試就……」的分支,都是把測試的問題轉嫁給使用者。
收束
這個坑的教訓不在 Playwright,也不在 PWA,而在兩層之間的假設。
page.route 的心智模型是「攔截這一頁的所有請求」。實際上它攔截的是「這一頁的網路層發出的請求」,而 service worker 是另一個發出請求的主體。兩個模型在沒有 SW 的世界裡完全等價,所以那個差別在你裝 PWA 之前不存在。
一般化之後是:任何在你與網路之間插入一層的東西,都會讓「攔在你這一側」的工具失去覆蓋範圍。 service worker 是一個,proxy、CDN、mock server、瀏覽器擴充套件都是。
裝上這類中間層的時候,值得列一次清單:現在有哪些東西是掛在中間層的錯誤那一側的?