v8 coverage 在循環引用與深層元件樹下會死鎖

症狀

MR 828 的 test:unit job 連續兩次跑滿 1 小時被 GitLab runner kill,failure_reason: job_execution_timeout。trace 沒有任何 fail,只是某個檔案進去就再也沒出來。

本機 yarn vitest run <file> 8 秒過,但 yarn vitest run --coverage <file> 卡 4 分鐘。CI script 是 yarn testvitest run --coverage,差別就在 --coverage

Root Cause

@vitest/coverage-v8 在 instrument 整張 module graph 時死鎖。

毒在 graph 太深:

  • 測試 render 的是真實 page,page 拉真實 child(UncertaintyAssessmentTable / UncertaintyAssessmentItemDrawer)
  • child 拉 Chakra Drawer / Portal / TruncatedHoverText(含 ResizeObserver + rAF)/ HighlightMatchedText
  • 還疊上一條循環引用:page → @/utils/route → @/RouteConfig → page

v8 一邊 instrument 一邊解析這張 graph + circular,worker 進去就出不來。沒 --coverage 不會死,因為 v8 不需要 instrument。

解法

Mock 掉真實 child 元件,把 graph 縮到只剩 page 自己。Production code 0 行改動。child mock 要對齊 page 對 props 的契約(form、按鈕、cancel → useModal、edit/delete、disable 條件)。

關鍵 commit 42bdb1c9

教訓

  1. CI timeout 不一定是測試慢,可能是 coverage instrumentation 死鎖。 本機跑 --coverage 是必要的 reproduce 步驟,光跑 vitest run 看不出來。
  2. Page-level test 用真實 child 在 v8 coverage 下是地雷。 一旦 child 拉到 Chakra Portal、ResizeObserver、rAF 這類東西,加上任何循環引用就有風險。Page 測試該 mock child,保留覆蓋率給 child 自己的 test。
  3. Timeout 會吃掉真實 assertion failure。 修死鎖後才看到一個 stale description assertion,本來 CI 也會 fail,只是被 v8 timeout 蓋掉。
  4. 不要動 production code 解循環引用 是用戶當下的選擇 — mock 是更小的 blast radius;真要根治 circular 是另一個 PR 的事。

比較

維度改前改後
v8 要 instrument 的 graphpage + Drawer + Portal + Chakra 一大坨 + circularpage 自己(child 是純函式 stub)
circular import 影響RouteConfig 把整個 page enum 拉進來mock factory 直接擋掉 child import 鏈
測試契約真實 Drawer/TableMock 對齊 props 契約