# 自架視覺回歸測試(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) => ``);
const rest = withCard.slice(OPEN_CARDS);
const folded = rest.length
? [`其他 ${rest.length} 個快照
\n`, ...rest.map((i) => `\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
視覺測試報告
```