截圖座標系差一個 scrollY、PNG 不一定是 RGBA、disabled 按鈕、Tailwind v4 的 translate 是獨立屬性、捲動驅動的動畫等再久也沒用。
發布於 2026-09-06・約 5 分鐘
我寫了幾支 Playwright 腳本,自動量自己網站的視覺品質:字級有幾種、觸控目標夠不夠大、文字對比達不達標、有沒有橫向溢出、hover 有沒有回饋。
跑起來很有成就感,因為它會吐出一份漂亮的 JSON。問題是前幾版的數字全部是錯的,而且錯得很有說服力——不是壞掉噴例外,是給你一個合理範圍內的數字。
五個坑,每一個都讓我做出過錯誤的結論。
想量某個元素的對比度,直覺寫法是拿它的位置去 clip:
const box = await el.boundingBox() await page.screenshot({ clip: box }) // ❌
boundingBox() 給的是頁面座標,screenshot({clip}) 用的是視窗座標。沒捲動的時候兩者一樣,捲動之後差一個 scrollY。
boundingBox()
screenshot({clip})
scrollY
於是你截到的是頁面上完全另一個地方。我量出來的結果是「背景全黑、文字全白、對比 21」——一個完美到不可能的分數,因為那截到的是頁尾的深色區塊。
正確作法是讓元素自己截自己:
const buf = await el.screenshot() // ✅ 它自己處理座標
自己解析 PNG 取像素的話會踩到這個。Playwright 視情況輸出 color type 2(RGB,每像素 3 bytes)或 color type 6(RGBA,4 bytes)。
把 bytes-per-pixel 寫死成 4,遇到 RGB 圖就會讓 unfilter 整張錯位,輸出又是「全黑或全白」。跟第一個坑症狀一樣,但成因完全不同——我為此查錯方向一次。
要讀 IHDR 裡的 color type:
// PNG: 8 bytes 簽章 + IHDR chunk(長度4 + 型別4 + 資料13) // 資料前 8 bytes 是寬高,第 9 byte 是 bit depth,第 10 byte 是 color type const colorType = buf[25] const bpp = colorType === 6 ? 4 : colorType === 2 ? 3 : null if (bpp === null) throw new Error(`不支援的 PNG color type: ${colorType}`)
寫死一個值然後「看起來能跑」,是這類工具最常見的失效方式。
我第一次掃全站按鈕的對比度,20 顆未達標。看起來是個大問題。
其中 13 顆是 disabled + opacity-30 的主要 CTA——在使用者填完輸入之前它們本來就是灰的。
disabled + opacity-30
WCAG 對 disabled 控制項本來就豁免。 把它們算進去等於自己製造一份假的待辦清單。
const buttons = await page.$$eval('button, a[class*="btn"]', els => els.filter(el => !el.disabled && el.getAttribute('aria-disabled') !== 'true') .map(el => /* 量測 */))
連帶一提:disabled 元素也不可 focus,所以它會同時讓你的「焦點指示」檢查誤報。一個沒排除的條件會污染兩份報告。
translate
想確認每個可點的東西都有 hover 回饋,於是比對 hover 前後的 transform:
transform
const before = getComputedStyle(el).transform // ❌ 只讀這個
結果報告說 29 個控制項「完全沒有 hover 回饋」。實際上它們都有。
Tailwind v4 的 hover:-translate-y-1 用的是 CSS 的獨立 translate 屬性,不是 transform。同理還有 scale 和 rotate,它們在現代 CSS 裡都是各自獨立的屬性。
hover:-translate-y-1
scale
rotate
function motionSignature(el) { const s = getComputedStyle(el) return [s.transform, s.translate, s.scale, s.rotate, s.opacity, s.boxShadow].join('|') }
比對這個字串就不會漏。
這是我覺得最有教育意義的一個。
站上有些區塊用 animation-timeline: view() 做進場動畫:
animation-timeline: view()
.rise-in { animation: rise linear both; animation-timeline: view(); animation-range: entry 6% cover 28%; }
量這些元素的對比度,量到 2.01,遠低於門檻。我加了 waitForTimeout,再加長,再加長。數字不動。
waitForTimeout
因為進度取決於元素在視窗裡的位置,不是時間。動畫沒有在「播放」,它是被捲動位置驅動的。等一百年也不會前進。
而我用的捲動方式剛好是最糟的一種:
await el.scrollIntoViewIfNeeded() // ❌
它只把元素勉強捲進視野,剛好停在 animation-range 的起點,也就是幾乎全透明的那一格。
animation-range
await el.scrollIntoView({ block: 'center' }) // ✅
捲到正中央讓動畫走完,同一個元素量出來是 15.43。
2.01 跟 15.43,差別只在捲動位置。 而 2.01 是一個完全合理的數字,你不會懷疑它。
最後一條不是 bug,是這類工具的本質限制。
12px 中文的抗鋸齒讓字心達不到宣告的顏色。我有一頁的文字色計算下來大約 9:1,探針只量到 4.0。
所以它抓「明顯不及格」(低於 3)有效,不能拿來精算 4.5 附近的值。落在門檻附近的時候要回去算宣告色的相對亮度,而不是相信截圖量出來的數字。
工具的用途要跟它的精度匹配。把一個下界當成精確值用,會讓你去修一堆根本沒問題的地方。
五個都不會拋例外。它們給你一個型別正確、範圍合理、格式漂亮的數字。
所以驗證這類工具不能靠「它有沒有跑完」,要靠已知答案的對照。我後來的作法是先挑三個用眼睛看得出答案的案例(一個明顯很好、一個明顯很差、一個在邊界),確認工具給的答案跟眼睛一致,再讓它去跑全站。
這一步大概花二十分鐘。省下它的代價是我根據錯誤數字改過的那幾版 UI。
標籤:Playwright、測試、無障礙、自動化、前端