# 自架視覺回歸測試(VRT)工具包 這是一套自己寫的視覺回歸測試,用來取代 Chromatic / Percy 這類 SaaS。Playwright 截圖,pixelmatch 逐像素比對,報告是一份離線的 HTML,全部在本機或內網跑,截圖不會離開機器。 - 這份說明:`/notes/vrt/README.md` - 全部程式碼合成一個檔(給 AI 讀這個就夠):`/notes/vrt/kit.md` - 個別原始檔:`/notes/vrt/kit/` 底下,路徑和專案裡要放的位置一樣(`gitlab-ci.yml` 放進專案時要改名成 `.gitlab-ci.yml`) --- ## 給 AI 的指示(請先讀這段) 1. 程式碼是完整、已驗證可以跑的版本。請原封不動複製到專案,不要重寫、不要「簡化」、不要換成別的套件。 2. 你要做的事是「接到專案的頁面和 component 上」,照「需要依專案修改的地方」那一節一步一步做,只改那裡列出的位置,其他邏輯保持原樣。 3. 依賴只有 `@playwright/test`、`pixelmatch`、`pngjs`,不要額外引入 reg-cli、jest-image-snapshot、looks-same 之類的套件。 4. 比對報告頁已經做好了,就是 `scripts/vrt/report.html`,不要自己另外做比對頁面,也不需要讀懂或修改它。它是模板,裡面的 `__VRT_DATA__` 會被 `compare.mjs` 替換成資料,不要刪掉這個字串。 5. 如果 CI 不是 GitLab,請照「CI 的約定」一節改寫 CI 設定和 `ci-comment.mjs`,其他檔案不用動。 --- ## 核心想法 視覺測試失敗的原因幾乎都是「截圖本身不穩定」,不是比對演算法不夠聰明,所以這套的力氣都花在讓截圖可重現: 1. 基準(baseline)不存檔、不手動維護。每次都用 `git worktree` 把「目前分支從 main 分出來的那個 commit」(merge-base) 取出來,現場 build、現場拍。所以差異永遠等於「這個分支改了什麼」,別人後來合進 main 的東西不會混進來,也沒有「接受 baseline」這個動作,差異符合預期就直接核准 MR。 2. 兩邊在同一個環境拍。Mac 和 Linux 的字型渲染不同,CI 一律用 Playwright 官方 Docker image,基準和目前版本都在同一個容器裡拍。 3. 固定所有會變的東西:`page.clock` 固定時間、`page.route` mock 前端 API、擋掉 GA 等第三方、等字型與圖片載完、關掉動畫和游標,真的無法固定的區塊用 mask 蓋掉。 4. 連拍到兩張完全相同才存檔。兩張不一樣時會重新等一輪網路與圖片再拍,連拍 5 次都不一樣就讓測試失敗,並把最後兩張存到 `.vrt/unstable/`,看圖就知道是哪一塊在動,而不是存下一張會造成假差異的圖。 5. 一律用 production build 截圖(`next build && next start`),dev mode 有 overlay,行為也不同。 6. 分岔點的截圖會快取在 `.vrt/expected`,key 是「commit + Playwright 版本 + 平台 + playwright.config.ts 與 tests/ 的 hash」,四個都一樣才沿用,否則重拍。 ## 流程 ``` scripts/vrt.sh test ├─ 算出分岔點 commit(VRT_BASE_SHA,或 git merge-base HEAD main) ├─ key 相同 → 沿用 .vrt/expected │ key 不同 → git worktree 取出分岔點 → npm ci(lockfile 沒變就沿用)→ playwright 拍到 .vrt/expected ├─ playwright 拍目前程式碼 → .vrt/actual └─ node scripts/vrt/compare.mjs ├─ pixelmatch 逐張比對(尺寸不同時補透明邊再比) ├─ 差異像素以 8px 格子分群成「變更區域」,重疊的框合併 └─ 輸出 .vrt/report/{index.html, results.json, junit.xml, images/} ``` exit code:`0` 沒差異、`1` 有差異、`3` 截圖失敗(失敗時不會覆蓋上次的結果)。 ## 安裝 ```bash npm i -D @playwright/test pixelmatch pngjs npx playwright install chromium chmod +x scripts/vrt.sh ``` `package.json` 加: ```json "scripts": { "vrt": "scripts/vrt.sh test", "vrt:review": "scripts/vrt.sh review" } ``` `.gitignore` 加: ``` /.vrt/ /test-results/ /playwright-report/ ``` 日常使用: ```bash npm run vrt # 拍分岔點與目前程式碼並比對 npm run vrt:review # 開報告 VRT_BASE_REF=develop npm run vrt # target 分支不是 main 時 ``` `.vrt/report/` 是自給自足的靜態報告(HTML + 圖片),可以整個資料夾當成 CI artifact 保存。報告有四種模式:並排(同步捲動)、差異、滑桿、疊圖。快捷鍵:`J`/`K` 切換快照,`N`/`P` 跳到下一處 / 上一處變更,按住 `Space` 暫時看基準圖,`O` 總覽,`H` 開關差異標示,`F` 切換符合寬度 / 100%。 ## 檔案 | 檔案 | 用途 | 要不要改 | |---|---|---| | `playwright.config.ts` | 視窗尺寸(目前只拍 desktop 1440×900)、locale、時區、啟動 production build | 改 build / start 指令 | | `tests/visual.spec.ts` | 要截圖的頁面與互動狀態清單 | 一定要改,列出你的路由 | | `tests/vrt.ts` | 固定時間、mock API、擋第三方、等載入、連拍穩定檢查 | 改 mock 的 API | | `scripts/vrt.sh` | 拍分岔點與目前版本、快取、比對、開報告 | 不用改 | | `scripts/vrt/compare.mjs` | 像素比對、變更區域分群、產生報告與 JUnit | 不用改 | | `scripts/vrt/report.html` | 報告模板,單一檔案、無外部依賴 | 不用改 | | `scripts/vrt/ci-comment.mjs` | 在 GitLab MR 留下(或更新)結果留言 | 非 GitLab 要改 | | `scripts/vrt/render-cards.mjs` | 把每個差異渲染成卡片圖,貼進 MR 留言 | 不用改 | | `gitlab-ci.yml` | GitLab CI job(放進專案時改名 `.gitlab-ci.yml`) | 非 GitLab 要改 | ## 需要依專案修改的地方 照順序做,每一步都有要改的檔案和範例。 ### 1. 列出要截圖的頁面:`tests/visual.spec.ts` 先找出專案有哪些頁面(App Router 用 `find app -name page.tsx`,Pages Router 看 `pages/`),把主要頁面放進 `routes`。動態路由(`[slug]`、`[id]`)要填實際存在的網址,挑一兩個有代表性、資料不太會變的。 ```ts const routes = [ { name: "home", path: "/" }, { name: "product-list", path: "/products" }, { name: "product-detail", path: "/products/12345" }, // 動態路由填實際的 id ]; ``` `name` 只能用英數、底線、連字號,不能重複。 ### 2. 找出瀏覽器端會打的 API,mock 掉:`tests/vrt.ts` 的 `stabilize()` 搜尋 `"use client"` 的檔案裡的 `fetch(`、`axios`、`useSWR`、`useQuery`,把每個 API 用 `page.route` 回傳固定資料,範例裡的 `**/api/stats` 換掉。 - 會寫入資料的(POST、PUT,例如瀏覽數、點擊追蹤、按讚)一定要 mock,不然每次測試都在寫正式資料。 - 會讀資料而且每次結果不同的(線上人數、推薦、隨機排序)也要 mock。 - 第三方網域的正規表達式補上公司用的追蹤、客服 widget、廣告。 ```ts await page.route("**/api/track", (route) => route.fulfill({ json: { ok: true } })); await page.route("**/api/recommendations", (route) => route.fulfill({ json: { items: [{ id: 1, name: "固定商品" }] } }), ); ``` 固定時間 `2026-01-01T10:00:00+08:00` 可以留著。 ### 3. 找出 build 時需要的環境變數 先只給 `NEXT_PUBLIC_*` 跑一次 `npm run build`,失敗的地方就是 build 時在 server 端讀資料的頁面。需要密鑰的(例如資料庫的 service key),CI 給一個假值就好,前提是程式讀取失敗時有安全的預設值;沒有預設值會讓 build 直接失敗的,先在程式裡補上。不要把真的密鑰放進 CI。 ### 4. 遮掉無法 mock 的 component 時鐘、倒數計時、輪播、廣告、影片這類一直在動的 component,在它最外層加 `data-testid`,再傳給 `snap()` 的 `mask`,截圖時會蓋成灰色。 ```tsx // component 裡
...
``` ```ts // tests/visual.spec.ts await snap(page, name, testInfo.project.name, { mask: [page.getByTestId("countdown"), page.getByTestId("banner-carousel")], }); ``` ### 5. 重要 component 的互動狀態(選用) 下拉選單、彈窗、表單錯誤、hover 這類只有操作後才看得到的樣子,各寫一個 test,操作完再截圖: ```ts test("header: user menu open", async ({ page }, testInfo) => { await page.goto("/"); await page.getByRole("button", { name: "會員" }).click(); await snap(page, "header--menu-open", testInfo.project.name); }); test("login: validation error", async ({ page }, testInfo) => { await page.goto("/login"); await page.getByRole("button", { name: "登入" }).click(); await snap(page, "login--error", testInfo.project.name); }); ``` ### 6. 需要登入的頁面 在 `beforeEach` 裡登入,或用 Playwright 的 `storageState` 存一份登入狀態,測試帳號請用專門的帳號,不要用個人帳號。 ### 7. 啟動指令與預設分支 - `playwright.config.ts` 的 `webServer.command` 換成專案的 build + start 指令,port 用 `VRT_PORT`(預設 4173)。不是 Next.js 的話,例如 Vite,就改成 `npm run build && npx vite preview --port ${port}`。`cwd: appDir` 一定要保留,基準版本靠它在 worktree 裡啟動。 - 預設分支不是 `main`:用 `VRT_BASE_REF` 指定,或改 `scripts/vrt.sh` 裡的預設值。 ### 8. 驗收 1. 什麼都不改,跑 `npm run vrt`,結果必須是全部「無變化」。有差異或「連拍不一致」,回到第 2、4 步把動態的東西 mock 或遮掉。 2. 故意改一個 component 的樣式(例如按鈕顏色),不用 commit,再跑一次,應該只有用到那個 component 的頁面出現差異,報告上的綠框框在那個 component 上。 3. 兩項都對了再接 CI。 ## CI 的約定 CI 只需要做這幾件事,換成 GitHub Actions、Jenkins 都一樣: 1. 用 `mcr.microsoft.com/playwright:v<與 @playwright/test 完全相同的版本>-noble` 當執行環境。 2. checkout 要拿完整歷史(GitLab `GIT_DEPTH: 0`、GitHub `fetch-depth: 0`),不然算不出 merge-base。 3. 執行 `VRT_BASE_SHA= VRT_BASE_NAME= scripts/vrt.sh test`。 4. exit code `1` = 有視覺差異,標成警告、等人審閱,不擋合併;其他非 0 = 真的壞掉,讓 job 失敗。 5. 不管成功失敗都上傳 `.vrt/report/` 與 `.vrt/unstable/` 當 artifact,`junit.xml` 給 CI 的測試報告區塊。 6. (選用)`node scripts/vrt/ci-comment.mjs` 在 MR 留言。它需要 `VRT_GITLAB_TOKEN`(api scope),沒設定就只印 log、不會讓 job 失敗。 GitLab 對應的變數:`CI_MERGE_REQUEST_DIFF_BASE_SHA`、`CI_MERGE_REQUEST_TARGET_BRANCH_NAME`。GitHub Actions 對應的是 `github.event.pull_request.base.sha` 與 `github.base_ref`,並且要把 `ci-comment.mjs` 改成呼叫 GitHub 的 issue comment API。 ## 常見坑 - `page.route()` 只攔得到瀏覽器發出的請求。Server Component 或 SSR 在伺服器端 fetch 的資料攔不到,要用環境變數讓伺服器改打 mock server,或在測試環境改接 fixture。 - `next/font/google` 在 build 時需要外網,內網 CI 要改用 `next/font/local`。 - Playwright 版本一升級,抗鋸齒可能有細微變化,所以版本也是快取 key 的一部分,升級後第一次會自動重拍基準。 - 截圖一直報「連拍 5 次都不一致」,代表頁面上還有東西在動,通常是輪播、skeleton、相對時間("3 分鐘前"),請 mock 或 mask 掉,不要調高次數。先打開 `.vrt/unstable/` 裡的兩張圖對照,CI 上在 job 的 artifacts 裡。 - build 時在 server 端讀資料、需要密鑰的頁面(例如用 service role 讀資料庫):CI 不要給真的密鑰,給一個假值,前提是程式讀取失敗時有安全的預設值(例如當成 0 筆)。基準和目前版本兩邊會一樣,比對是穩定的。沒有預設值會直接讓 build 失敗的,就要先在程式裡補上。 - 會寫入資料的前端 API(來訪人數、點擊追蹤、按讚)一定要用 `page.route` mock 掉,不然每次 CI 都在灌正式資料。 - 兩邊都對同一個正式資料庫讀資料是可以的:基準和目前版本在同一個 job 裡前後幾分鐘拍完,資料一樣,不用另外準備測試資料。 - 長頁面的 lazy 圖片、載入失敗後才換上的 fallback 圖片,`tests/vrt.ts` 都已經處理,不要拿掉 `img.loading = "eager"` 和連拍迴圈裡的 `waitForStable`。 - 快照名稱只能用英數、底線、連字號,重複的名稱會直接報錯,避免兩個測試互相覆蓋。 - 本機在 Mac 拍的結果不要拿去跟 CI 比,兩邊一定要在同一個環境拍,這套的做法本來就是每次兩邊一起重拍。 --- # 完整原始碼 以下每個檔案都是完整內容,照標題的路徑存檔即可。`gitlab-ci.yml` 存成 `.gitlab-ci.yml`,`scripts/vrt.sh` 記得 `chmod +x`。 ## `playwright.config.ts` ```ts import { defineConfig, devices } from "@playwright/test"; // VRT_APP_DIR:要啟動哪一份程式碼(預設當前目錄;比對 main 時指向 git worktree) // VRT_OUT:截圖輸出目錄(.vrt/expected 或 .vrt/actual) const appDir = process.env.VRT_APP_DIR ?? "."; const port = Number(process.env.VRT_PORT ?? 4173); export default defineConfig({ testDir: "./tests", fullyParallel: true, workers: process.env.CI ? 2 : undefined, reporter: [["list"]], use: { baseURL: `http://localhost:${port}`, locale: "zh-TW", timezoneId: "Asia/Taipei", colorScheme: "light", }, projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } }, ], webServer: { // 一律用 production build 截圖,dev mode 有 overlay 且行為不同 command: `npm run build && npx next start -p ${port}`, cwd: appDir, port, reuseExistingServer: false, timeout: 180_000, }, }); ``` ## `tests/vrt.ts` ```ts import fs from "node:fs"; import path from "node:path"; import type { Locator, Page } from "@playwright/test"; const OUT_DIR = process.env.VRT_OUT ?? ".vrt/actual"; const MAX_SHOTS = 5; // 最多拍幾次來等畫面穩定 // 固定所有「每次都不一樣」的東西,讓截圖可重現 export async function stabilize(page: Page) { // 固定時間:所有 Date.now() / new Date() 都回傳這個時間點 await page.clock.setFixedTime(new Date("2026-01-01T10:00:00+08:00")); // Mock 前端 API:回傳固定 fixture await page.route("**/api/stats", (route) => route.fulfill({ json: { online: 128, orders: 42 } }), ); // 擋掉第三方腳本(GA、客服 widget、廣告⋯) await page.route(/googletagmanager|google-analytics|hotjar|intercom/, (route) => route.abort()); } export async function waitForStable(page: Page) { await page.waitForLoadState("networkidle"); await page.evaluate(() => document.fonts.ready); // 等所有圖片(含 next/image lazy load)載入 await page.evaluate(async () => { // lazy 圖片要捲到附近才會載入,長頁面中段的圖光是捲到底再捲回來不會觸發,會一直等不到 for (const img of Array.from(document.images)) img.loading = "eager"; window.scrollTo(0, document.body.scrollHeight); await Promise.all( Array.from(document.images) .filter((img) => !img.complete) .map((img) => new Promise((r) => { img.onload = img.onerror = r; })), ); window.scrollTo(0, 0); }); } export async function snap( page: Page, name: string, projectName: string, opts: { mask?: Locator[] } = {}, ) { if (!/^[\w-]+$/.test(name)) throw new Error(`快照名稱只能用英數、底線、連字號:${name}`); await waitForStable(page); // 連續兩次截圖完全相同才算穩定;一直不穩定就讓測試失敗,不要存下會造成假差異的圖 let prev: Buffer | null = null; let shot: Buffer | null = null; for (let n = 0; n < MAX_SHOTS; n++) { shot = await page.screenshot({ fullPage: true, animations: "disabled", caret: "hide", mask: opts.mask, maskColor: "#a1a1aa", }); if (prev?.equals(shot)) { const file = path.join(OUT_DIR, projectName, `${name}.png`); fs.mkdirSync(path.dirname(file), { recursive: true }); try { // wx:檔案已存在就報錯,避免兩個測試用了同一個名稱而互相覆蓋 fs.writeFileSync(file, shot, { flag: "wx" }); } catch (e) { if ((e as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`快照名稱重複:${projectName}/${name}`); throw e; } return; } prev = shot; // 不一樣時重新等一輪網路與圖片:載入失敗後才換上的圖片(例如 next/image 的 fallback)是新的 ,要重新等 await page.waitForTimeout(200); await waitForStable(page); } // 把最後兩張不一樣的截圖留在 .vrt/unstable/,CI 會一起上傳,直接比對就知道是哪一塊在動 const side = path.basename(OUT_DIR).replace(/\.tmp$/, ""); const dir = path.join(path.dirname(OUT_DIR), "unstable", side, projectName); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `${name}-1.png`), prev!); fs.writeFileSync(path.join(dir, `${name}-2.png`), shot!); throw new Error( `${projectName}/${name}:連拍 ${MAX_SHOTS} 次畫面都不一致,請把動態區塊 mock 掉或加進 mask(最後兩張在 .vrt/unstable/${side}/${projectName}/)`, ); } ``` ## `tests/visual.spec.ts` ```ts import { test } from "@playwright/test"; import { snap, stabilize } from "./vrt"; // 要保護的頁面清單:接手專案時先把主要路由都列進來 const routes = [ { name: "home", path: "/" }, { name: "pricing", path: "/pricing" }, ]; test.beforeEach(async ({ page }) => { await stabilize(page); }); for (const { name, path } of routes) { test(`page ${name}`, async ({ page }, testInfo) => { await page.goto(path); await snap(page, name, testInfo.project.name, { // 無法 mock 的動態區塊直接遮罩 mask: [page.getByTestId("clock")], }); }); } // 互動狀態也可以截:hover、展開選單、表單錯誤⋯ test("home: button hover", async ({ page }, testInfo) => { await page.goto("/"); await page.getByRole("button", { name: "加入購物車" }).first().hover(); await snap(page, "home--hover", testInfo.project.name, { mask: [page.getByTestId("clock")], }); }); ``` ## `scripts/vrt.sh` ```bash #!/usr/bin/env bash # 自架版視覺回歸測試(Chromatic 替代方案) # 和 MR 一樣的比法:拍「target 分岔點」與「目前程式碼」,兩邊比對,一看就知道這個分支改了什麼。 # # scripts/vrt.sh test 拍分岔點(git worktree)與目前程式碼,比對並產生報告;有差異時 exit 1 # scripts/vrt.sh review 開啟報告 # # VRT_BASE_REF target 分支(預設 main);分岔點 = git merge-base HEAD $VRT_BASE_REF # VRT_BASE_SHA 直接指定分岔點 commit(CI 用 $CI_MERGE_REQUEST_DIFF_BASE_SHA) set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" VRT="$ROOT/.vrt" cd "$ROOT" capture() { # $1=輸出目錄 $2=程式碼目錄;先拍到暫存目錄,成功才取代,失敗回傳 3(與「有差異」的 1 區分) rm -rf "$1.tmp" VRT_OUT="$1.tmp" VRT_APP_DIR="$2" npx playwright test || { rm -rf "$1.tmp"; echo "截圖失敗" >&2; exit 3; } rm -rf "$1" mv "$1.tmp" "$1" } prepare_worktree() { # $1=commit,印出 worktree 路徑 local wt="${TMPDIR:-/tmp}/vrt-base/$(basename "$ROOT")" if [ -d "$wt" ] && git -C "$wt" rev-parse >/dev/null 2>&1; then # --force + clean:確保 worktree 與該 commit 完全一致(保留 node_modules 等 ignored 檔案) git -C "$wt" checkout --quiet --force --detach "$1" >&2 git -C "$wt" clean -fdq -e .vrt-lock-hash >&2 else rm -rf "$wt" git worktree prune git worktree add --detach "$wt" "$1" >&2 fi # lockfile 沒變就沿用上次安裝的 node_modules local hash; hash="$(shasum "$wt/package-lock.json" | cut -d' ' -f1)" if [ ! -d "$wt/node_modules" ] || [ "$(cat "$wt/.vrt-lock-hash" 2>/dev/null)" != "$hash" ]; then echo "▸ 在 worktree 安裝依賴⋯" >&2 (cd "$wt" && npm ci --no-audit --no-fund >&2) echo "$hash" > "$wt/.vrt-lock-hash" fi echo "$wt" } base_commit() { if [ -n "${VRT_BASE_SHA:-}" ]; then git rev-parse --verify "$VRT_BASE_SHA^{commit}"; return; fi local ref="${VRT_BASE_REF:-main}" git rev-parse --verify --quiet "$ref^{commit}" >/dev/null || ref="origin/$ref" git merge-base HEAD "$ref" || { echo "找不到與 ${VRT_BASE_REF:-main} 的分岔點" >&2; exit 3; } } # 分岔點的截圖只在「commit、Playwright 版本、平台、截圖設定」都相同時才沿用 capture_key() { # $1=commit local pw; pw="$(node -p 'require("@playwright/test/package.json").version')" local spec; spec="$(cat playwright.config.ts $(find tests -type f | sort) | shasum | cut -d' ' -f1)" echo "$1 playwright@$pw $(uname -sm | tr ' ' -) spec@${spec:0:12}" } case "${1:-}" in test) rm -rf "$VRT/unstable" commit="$(base_commit)" sha="${commit:0:7}" # 報告上顯示的名稱:直接指定 commit 時就顯示那個 ref,不冒用分支名 if [ -n "${VRT_BASE_SHA:-}" ]; then ref="${VRT_BASE_NAME:-$VRT_BASE_SHA}"; else ref="${VRT_BASE_NAME:-${VRT_BASE_REF:-main}}"; fi key="$(capture_key "$commit")" if [ "$(cat "$VRT/expected/.key" 2>/dev/null)" = "$key" ]; then echo "▸ 分岔點 $ref $sha 已拍過,沿用" else echo "▸ 拍攝分岔點 $ref $sha" wt="$(prepare_worktree "$commit")" capture "$VRT/expected" "$wt" echo "$key" > "$VRT/expected/.key" fi printf '{"ref":"%s","sha":"%s"}\n' "$ref" "$sha" > "$VRT/expected/.meta.json" echo "▸ 拍攝目前程式碼" capture "$VRT/actual" "$ROOT" echo "▸ 比對中" set +e node "$ROOT/scripts/vrt/compare.mjs" status=$? set -e echo echo " 報告:npm run vrt:review" exit $status ;; review) report="$VRT/report/index.html" [ -f "$report" ] || { echo "還沒有報告,先跑:npm run vrt" >&2; exit 1; } case "$(uname)" in Darwin) open "$report" ;; Linux) xdg-open "$report" >/dev/null 2>&1 || echo "$report" ;; *) echo "$report" ;; esac ;; *) sed -n '2,10p' "$0" exit 1 ;; esac ``` ## `scripts/vrt/compare.mjs` ```js // 比對 .vrt/expected 與 .vrt/actual,輸出 .vrt/report(results + 圖片 + index.html) // 有任何差異(變更、新增、刪除)時 exit 1 import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pixelmatch from "pixelmatch"; import { PNG } from "pngjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const VRT = path.join(ROOT, ".vrt"); const EXPECTED = path.join(VRT, "expected"); const ACTUAL = path.join(VRT, "actual"); const REPORT = path.join(VRT, "report"); const THRESHOLD = 0.1; // 單一像素的 YIQ 色差容忍度 const DIFF_COLOR = [0, 240, 180]; const CELL = 8; // 變更區域分群的格子大小(px) const GAP = 3; // 相距幾格內的變更視為同一區域 function listPngs(dir) { if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir, { recursive: true }) .filter((f) => f.endsWith(".png")) .map((f) => f.split(path.sep).join("/").replace(/\.png$/, "")); } const readPng = (file) => PNG.sync.read(fs.readFileSync(file)); // 把圖貼到 W×H 的透明畫布左上角,讓不同尺寸的截圖可以逐像素比對 function pad(png, W, H) { if (png.width === W && png.height === H) return png.data; const out = Buffer.alloc(W * H * 4); for (let y = 0; y < png.height; y++) { png.data.copy(out, y * W * 4, y * png.width * 4, (y + 1) * png.width * 4); } return out; } // 把差異像素分群成矩形區域,讓報告可以逐一跳轉 function findRegions(mask, W, H) { const cols = Math.ceil(W / CELL); const rows = Math.ceil(H / CELL); const hot = new Uint8Array(cols * rows); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (mask[(y * W + x) * 4 + 3]) hot[Math.floor(y / CELL) * cols + Math.floor(x / CELL)] = 1; } } const seen = new Uint8Array(cols * rows); const regions = []; for (let i = 0; i < hot.length; i++) { if (!hot[i] || seen[i]) continue; let [x0, y0, x1, y1] = [Infinity, Infinity, -1, -1]; const stack = [i]; seen[i] = 1; while (stack.length) { const c = stack.pop(); const cx = c % cols; const cy = (c - cx) / cols; x0 = Math.min(x0, cx); y0 = Math.min(y0, cy); x1 = Math.max(x1, cx); y1 = Math.max(y1, cy); for (let dy = -GAP; dy <= GAP; dy++) { for (let dx = -GAP; dx <= GAP; dx++) { const nx = cx + dx; const ny = cy + dy; if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; const n = ny * cols + nx; if (hot[n] && !seen[n]) { seen[n] = 1; stack.push(n); } } } } const p = 6; const x = Math.max(0, x0 * CELL - p); const y = Math.max(0, y0 * CELL - p); regions.push({ x, y, w: Math.min(W, (x1 + 1) * CELL + p) - x, h: Math.min(H, (y1 + 1) * CELL + p) - y, }); } return mergeOverlapping(regions).sort((a, b) => a.y - b.y || a.x - b.x); } // 外接矩形可能互相重疊(甚至整個包住另一個),合併後才不會出現重複的編號框 function mergeOverlapping(regions) { const rs = regions.map((r) => ({ ...r })); for (let merged = true; merged; ) { merged = false; for (let i = 0; i < rs.length && !merged; i++) { for (let j = i + 1; j < rs.length; j++) { const a = rs[i], b = rs[j]; if (a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h) { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); rs[i] = { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y }; rs.splice(j, 1); merged = true; break; } } } } return rs; } function copy(src, rel) { const dest = path.join(REPORT, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); return rel; } function git(cmd) { try { return execSync(`git ${cmd}`, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return null; } } function compareOne(id) { const [viewport, ...rest] = id.split("/"); const name = rest.join("/"); const exp = path.join(EXPECTED, `${id}.png`); const act = path.join(ACTUAL, `${id}.png`); const item = { id, name, viewport, files: {} }; if (!fs.existsSync(exp)) { const png = readPng(act); return { ...item, status: "added", after: { w: png.width, h: png.height }, files: { after: copy(act, `images/actual/${id}.png`) } }; } if (!fs.existsSync(act)) { const png = readPng(exp); return { ...item, status: "removed", before: { w: png.width, h: png.height }, files: { before: copy(exp, `images/expected/${id}.png`) } }; } const a = readPng(exp); const b = readPng(act); const W = Math.max(a.width, b.width); const H = Math.max(a.height, b.height); const mask = new PNG({ width: W, height: H }); const diffPixels = pixelmatch(pad(a, W, H), pad(b, W, H), mask.data, W, H, { threshold: THRESHOLD, diffMask: true, diffColor: DIFF_COLOR, }); item.before = { w: a.width, h: a.height }; item.after = { w: b.width, h: b.height }; item.files.before = copy(exp, `images/expected/${id}.png`); item.files.after = copy(act, `images/actual/${id}.png`); if (diffPixels === 0) return { ...item, status: "unchanged", diffPixels: 0, diffRatio: 0, regions: [] }; const diffRel = `images/diff/${id}.png`; fs.mkdirSync(path.dirname(path.join(REPORT, diffRel)), { recursive: true }); fs.writeFileSync(path.join(REPORT, diffRel), PNG.sync.write(mask)); item.files.diff = diffRel; const regions = findRegions(mask.data, W, H); return { ...item, status: "changed", diffPixels, diffRatio: diffPixels / (W * H), regions }; } const ids = [...new Set([...listPngs(EXPECTED), ...listPngs(ACTUAL)])].sort(); fs.rmSync(REPORT, { recursive: true, force: true }); fs.mkdirSync(REPORT, { recursive: true }); const ORDER = { changed: 0, added: 1, removed: 2, unchanged: 3 }; const items = ids.map(compareOne).sort((a, b) => ORDER[a.status] - ORDER[b.status] || a.id.localeCompare(b.id)); let baseline = null; try { baseline = JSON.parse(fs.readFileSync(path.join(EXPECTED, ".meta.json"), "utf8")); } catch {} const results = { generatedAt: new Date().toISOString(), baseline, head: { // CI 是 detached HEAD,改用 GitLab 提供的分支名稱 ref: process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME ?? process.env.CI_COMMIT_REF_NAME ?? git("rev-parse --abbrev-ref HEAD"), sha: git("rev-parse --short HEAD"), dirty: Boolean(git("status --porcelain")), }, items, }; fs.writeFileSync(path.join(REPORT, "results.json"), JSON.stringify(results, null, 2)); const template = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "report.html"), "utf8"); fs.writeFileSync( path.join(REPORT, "index.html"), template.replace("__VRT_DATA__", () => JSON.stringify(results).replace(/ String(v).replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[c]); const failMsg = { changed: (i) => `${(i.diffRatio * 100).toFixed(2)}% 像素不同,${i.regions.length} 處變更`, added: () => "新增的快照,沒有基準", removed: () => "快照已被移除", }; const failures = items.filter((i) => i.status !== "unchanged"); fs.writeFileSync(path.join(REPORT, "junit.xml"), ` ${items.map((i) => ` ${i.status === "unchanged" ? "" : ``}`).join("\n")} `); const count = (s) => items.filter((i) => i.status === s).length; const icon = { changed: "●", added: "+", removed: "−", unchanged: "✓" }; for (const i of items) { const extra = i.status === "changed" ? ` ${(i.diffRatio * 100).toFixed(2)}% ${i.regions.length} 處` : ""; console.log(` ${icon[i.status]} ${i.status.padEnd(9)} ${i.id}${extra}`); } console.log(`\n 變更 ${count("changed")} · 新增 ${count("added")} · 刪除 ${count("removed")} · 無變化 ${count("unchanged")}`); process.exit(items.some((i) => i.status !== "unchanged") ? 1 : 0); ``` ## `scripts/vrt/ci-comment.mjs` ```js // 在 GitLab MR 留下(或更新)視覺測試結果留言 // 需要 CI/CD 變數 VRT_GITLAB_TOKEN(api scope);沒有設定時只在 log 印出摘要 import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { renderCards } from "./render-cards.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const REPORT = path.join(ROOT, ".vrt/report"); const MARKER = ""; const MAX_CARDS = 8; // 最多附幾張卡片圖 const OPEN_CARDS = 3; // 前幾張直接展開,其餘收合 const { VRT_GITLAB_TOKEN: TOKEN, CI_API_V4_URL: API, CI_PROJECT_ID: PROJECT, CI_PROJECT_URL, CI_MERGE_REQUEST_IID: MR, CI_JOB_ID, CI_JOB_URL, } = process.env; const resultsFile = path.join(REPORT, "results.json"); const results = fs.existsSync(resultsFile) ? JSON.parse(fs.readFileSync(resultsFile, "utf8")) : null; async function gitlab(method, url, body) { const res = await fetch(`${API}/projects/${PROJECT}${url}`, { method, headers: { "PRIVATE-TOKEN": TOKEN, ...(body && !(body instanceof FormData) ? { "content-type": "application/json" } : {}) }, body: body instanceof FormData ? body : body && JSON.stringify(body), }); if (!res.ok) throw new Error(`${method} ${url} → ${res.status} ${await res.text()}`); return res.json(); } async function upload(rel) { const form = new FormData(); form.append("file", new Blob([fs.readFileSync(path.join(REPORT, rel))], { type: "image/png" }), path.basename(rel)); const { url } = await gitlab("POST", "/uploads", form); return url; } function buildBody(previews) { const reportUrl = `${CI_PROJECT_URL}/-/jobs/${CI_JOB_ID}/artifacts/file/.vrt/report/index.html`; if (!results) { return `${MARKER}\n### 視覺測試:執行失敗\n\n截圖或比對過程出錯,請看 [job log](${CI_JOB_URL})。如果是「連拍畫面都不一致」,最後兩張截圖在 [artifacts 的 .vrt/unstable/](${CI_JOB_URL}/artifacts/browse/.vrt/unstable/)。`; } const { items, baseline, head } = results; const count = (s) => items.filter((i) => i.status === s).length; const changed = items.filter((i) => i.status !== "unchanged"); const base = `\`${baseline?.ref ?? "baseline"}\` ${baseline?.sha ?? ""}`.trim(); const cur = `\`${head.ref}\` ${head.sha}`; if (!changed.length) { return `${MARKER}\n### ✅ 視覺測試:沒有差異\n\n${items.length} 個快照與基準一致。${base} → ${cur}`; } const label = { changed: "🟠 變更", added: "🔵 新增", removed: "🔴 刪除" }; const rows = changed.map((i) => { const detail = i.status === "changed" ? `${(i.diffRatio * 100).toFixed(2)}% · ${i.regions.length} 處${i.before.h !== i.after.h ? ` · 高度 ${i.before.h}→${i.after.h}` : ""}` : ""; return `| ${label[i.status]} | \`${i.name}\` | ${i.viewport} | ${detail} |`; }); // 卡片圖本身已含名稱、視窗、差異比例,前幾張直接展開,其餘收合 const withCard = changed.filter((i) => previews[i.id]); const shown = withCard.slice(0, OPEN_CARDS).map((i) => `![${i.id}](${previews[i.id]})`); const rest = withCard.slice(OPEN_CARDS); const folded = rest.length ? [`
其他 ${rest.length} 個快照\n`, ...rest.map((i) => `![${i.id}](${previews[i.id]})\n`), "
"] : []; return [ MARKER, `### 👀 視覺測試:${changed.length} 個快照需要審閱`, "", `${base} → ${cur} · 變更 ${count("changed")} · 新增 ${count("added")} · 刪除 ${count("removed")} · 無變化 ${count("unchanged")}`, "", `**[開啟審閱報告 →](${reportUrl})**`, "", "| 狀態 | 快照 | 視窗 | 差異 |", "|---|---|---|---|", ...rows, "", ...shown.flatMap((md) => [md, ""]), ...folded, "", "差異符合預期就直接核准 MR;不符合預期請修正後再推。", ].join("\n"); } async function main() { if (!MR) return console.log("不是 MR pipeline,略過留言"); if (!TOKEN) { console.log("未設定 VRT_GITLAB_TOKEN,略過 MR 留言(結果仍會顯示在 MR 的測試報告區塊)"); return; } const previews = {}; const ids = (results?.items ?? []).filter((i) => i.status !== "unchanged").slice(0, MAX_CARDS).map((i) => i.id); if (ids.length) { try { const cards = await renderCards(results, REPORT, ids); for (const [id, rel] of Object.entries(cards)) previews[id] = await upload(rel); } catch (e) { // 圖片失敗時仍然發文字留言 console.error("卡片圖產生或上傳失敗,改發純文字留言:", e.message); } } const body = buildBody(previews); // 同一個 MR 只保留一則結果留言,每次推送就更新它 const notes = await gitlab("GET", `/merge_requests/${MR}/notes?per_page=100&sort=desc`); const existing = notes.find((n) => n.body?.includes(MARKER)); if (existing) await gitlab("PUT", `/merge_requests/${MR}/notes/${existing.id}`, { body }); else await gitlab("POST", `/merge_requests/${MR}/notes`, { body }); console.log(existing ? "已更新 MR 留言" : "已新增 MR 留言"); } main().catch((e) => { // 留言失敗不應該讓整個 job 失敗 console.error("MR 留言失敗:", e.message); }); ``` ## `scripts/vrt/render-cards.mjs` ```js // 把每個有差異的快照渲染成「卡片」圖片(給 MR 留言用),樣式與審閱報告一致 // 做法:在報告目錄寫一個 card.html,用 Playwright 逐張開啟並截圖 import fs from "node:fs"; import path from "node:path"; import { chromium } from "@playwright/test"; const CARD_HTML = String.raw`
`; /** * @param {object} results compare.mjs 產生的 results.json * @param {string} reportDir .vrt/report * @param {string[]} ids 要渲染的快照 * @returns {Promise>} id → 卡片圖片相對路徑 */ export async function renderCards(results, reportDir, ids) { reportDir = path.resolve(reportDir); const htmlPath = path.join(reportDir, "card.html"); fs.writeFileSync(htmlPath, CARD_HTML); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1400, height: 900 }, deviceScaleFactor: 2 }); const base = [results.baseline?.ref, results.baseline?.sha].filter(Boolean).join(" ") || "baseline"; const head = [results.head.ref, results.head.sha].filter(Boolean).join(" "); const out = {}; try { for (const [n, id] of ids.entries()) { const item = results.items.find((i) => i.id === id); const hash = encodeURIComponent(JSON.stringify({ item, base, head })); await page.goto(`file://${htmlPath}?n=${n}#${hash}`); // 加 query 強制重新載入 await page.waitForSelector("body[data-ready]"); const rel = `images/card/${id}.png`; fs.mkdirSync(path.dirname(path.join(reportDir, rel)), { recursive: true }); await page.locator("body").screenshot({ path: path.join(reportDir, rel), omitBackground: true }); out[id] = rel; } } finally { await browser.close(); } return out; } ``` ## `.gitlab-ci.yml` ```yaml # 視覺回歸測試:每個 MR 自動執行 # 以 MR 分岔點(diff base)為基準重新截圖,與 MR 最新版本比對; # 兩邊都在同一個容器裡截圖,所以不會有 Mac / Linux 字型渲染差異。 workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH visual-tests: stage: test # 版本必須與 package.json 的 @playwright/test 一致 image: mcr.microsoft.com/playwright:v1.63.0-noble rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" variables: GIT_DEPTH: 0 CI: "true" npm_config_cache: $CI_PROJECT_DIR/.npm cache: key: files: [package-lock.json] paths: [.npm/] before_script: - command -v git >/dev/null || (apt-get update -qq && apt-get install -y -qq git >/dev/null) - git config --global --add safe.directory '*' - npm ci --no-audit --no-fund script: - status=0; VRT_BASE_SHA="$CI_MERGE_REQUEST_DIFF_BASE_SHA" VRT_BASE_NAME="$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" scripts/vrt.sh test || status=$? - node scripts/vrt/ci-comment.mjs # 1 = 有視覺差異:標成警告(橘色)等人審閱,不擋合併;其他非 0 = 真的壞掉 - if [ "$status" = 1 ]; then exit 42; fi - exit $status allow_failure: exit_codes: [42] artifacts: when: always expire_in: 30 days expose_as: "Visual test report" paths: - .vrt/report/ - .vrt/unstable/ reports: junit: .vrt/report/junit.xml ``` ## `scripts/vrt/report.html` ```html 視覺測試報告
```