web-corders-vrt 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -36
- package/bin/vrt.ts +5 -15
- package/dist/bin/vrt.js +57 -87
- package/dist/bin/vrt.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/init.ts +9 -50
- package/src/commands/run.ts +30 -30
- package/src/core/comparator.ts +7 -7
- package/src/reporters/html.ts +4 -4
- package/src/reporters/terminal.ts +1 -1
- package/src/schemas.ts +1 -1
- package/src/templates/SKILL-TEMPLATE.md +35 -0
- package/src/templates/report.html +1 -1
- package/src/templates/types.d.ts +4 -0
- package/src/types.ts +6 -6
- package/test/comparator.test.ts +5 -5
- package/tsup.config.ts +1 -0
package/README.md
CHANGED
|
@@ -2,28 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
Webページのビジュアルリグレッションテスト (VRT) を行うCLIツール。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
本番環境と開発/プレビュー環境のスクリーンショットの差分を検出する。
|
|
6
6
|
テスト結果は人間向け(HTMLレポート + diff画像)とLLM/Claude向け(JSONレポート)の両方で出力される。
|
|
7
7
|
|
|
8
8
|
## セットアップ
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
初回のみ Playwright のブラウザが必要:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
npx playwright install chromium
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
`init` コマンドで、Claude Codeのスキルファイル (`.claude/skills/web-corders-vrt/SKILL.md`) を自動生成できる。
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx web-corders-vrt init --refDomain https://example.com
|
|
20
|
+
```
|
|
21
|
+
|
|
16
22
|
## 基本的な使い方
|
|
17
23
|
|
|
18
24
|
```bash
|
|
19
25
|
npx web-corders-vrt run \
|
|
20
|
-
--
|
|
26
|
+
--reference https://production.com \
|
|
21
27
|
--after http://localhost:3000 \
|
|
22
28
|
--paths /,/about,/pricing
|
|
23
29
|
```
|
|
24
30
|
|
|
25
|
-
- `--
|
|
26
|
-
- `--after` —
|
|
31
|
+
- `--reference` — リファレンスのプロトコル+ドメイン(本番環境)
|
|
32
|
+
- `--after` — 比較するプロトコル+ドメイン(開発 / プレビュー環境)
|
|
27
33
|
- `--paths` — テスト対象のページパス(カンマ区切り)
|
|
28
34
|
|
|
29
35
|
実行すると `./vrt-results/` 以下にタイムスタンプ付きのディレクトリが生成され、スクリーンショット・diff画像・レポートが保存される。
|
|
@@ -35,10 +41,10 @@ vrt-results/2026-02-28T10-00-00/
|
|
|
35
41
|
├── report.json # JSON レポート(Claude/LLM向け)
|
|
36
42
|
├── report.html # HTML レポート(人間向け・ブラウザで開く)
|
|
37
43
|
└── screenshots/
|
|
38
|
-
├── top--sp--
|
|
44
|
+
├── top--sp--reference.png
|
|
39
45
|
├── top--sp--after.png
|
|
40
46
|
├── top--sp--diff.png
|
|
41
|
-
├── top--pc--
|
|
47
|
+
├── top--pc--reference.png
|
|
42
48
|
├── top--pc--after.png
|
|
43
49
|
└── top--pc--diff.png
|
|
44
50
|
```
|
|
@@ -72,7 +78,7 @@ vrt-results/2026-02-28T10-00-00/
|
|
|
72
78
|
|
|
73
79
|
| オプション | デフォルト | 説明 |
|
|
74
80
|
| -------------------- | ---------- | ----------------------------------------------------------------------- |
|
|
75
|
-
| `--
|
|
81
|
+
| `--reference <url>` | (必須) | リファレンスURL(本番環境) |
|
|
76
82
|
| `--after <url>` | (必須) | 比較先URL(開発環境 / ステージング) |
|
|
77
83
|
| `--paths <paths>` | (必須) | テスト対象のページパス(カンマ区切り) |
|
|
78
84
|
| `--threshold <n>` | `0.1` | 差分許容率(%)。これ以下の差分はPASS扱い |
|
|
@@ -82,57 +88,50 @@ vrt-results/2026-02-28T10-00-00/
|
|
|
82
88
|
|
|
83
89
|
ビューポートはSP(375x812)とPC(1440x900)の2種類で固定。フルページスクリーンショットを取得し、結果は `./vrt-results/` に出力される。
|
|
84
90
|
|
|
91
|
+
## Claude Code連携
|
|
92
|
+
|
|
93
|
+
### スキルファイルの仕組み
|
|
94
|
+
|
|
95
|
+
`init`で生成される `.claude/skills/web-corders-vrt/SKILL.md` には以下の手順が記述される:
|
|
96
|
+
|
|
97
|
+
1. 開発サーバーを起動
|
|
98
|
+
2. VRTコマンドを実行
|
|
99
|
+
3. `report.json` を読んで差分を確認
|
|
100
|
+
4. diff画像を視覚的に確認
|
|
101
|
+
5. diff画像からCSS/HTMLの修正箇所を特定
|
|
102
|
+
6. コードを修正
|
|
103
|
+
7. 再度VRTを実行して確認
|
|
104
|
+
|
|
105
|
+
以降はCaludeに「VRTして」とか言うだけで使ってくれる はず。
|
|
106
|
+
|
|
85
107
|
## 実用的な例
|
|
86
108
|
|
|
87
109
|
### 動的要素を隠してテスト
|
|
88
110
|
|
|
89
|
-
|
|
111
|
+
テスト対象外にしたい要素を非表示にする。
|
|
90
112
|
|
|
91
113
|
```bash
|
|
92
114
|
npx web-corders-vrt run \
|
|
93
|
-
--
|
|
115
|
+
--reference https://example.com \
|
|
94
116
|
--after http://localhost:3000 \
|
|
95
117
|
--paths / \
|
|
96
118
|
--hide ".cookie-banner,.ad-slot,[data-testid='live-chat']"
|
|
97
119
|
```
|
|
98
120
|
|
|
121
|
+
Next.jsツールバーはデフォルトで非表示。
|
|
122
|
+
|
|
99
123
|
### 差分許容率を緩めに設定
|
|
100
124
|
|
|
101
125
|
フォントレンダリング差異などを無視したい場合:
|
|
102
126
|
|
|
103
127
|
```bash
|
|
104
128
|
npx web-corders-vrt run \
|
|
105
|
-
--
|
|
129
|
+
--reference https://example.com \
|
|
106
130
|
--after http://localhost:3000 \
|
|
107
131
|
--paths / \
|
|
108
132
|
--threshold 1
|
|
109
133
|
```
|
|
110
134
|
|
|
111
|
-
## Claude Code連携
|
|
112
|
-
|
|
113
|
-
`vrt init` コマンドで、Claude Codeのスキルファイル (`.claude/commands/vrt.md`) を自動生成できる。
|
|
114
|
-
|
|
115
|
-
```bash
|
|
116
|
-
npx web-corders-vrt init \
|
|
117
|
-
--before https://example.com \
|
|
118
|
-
--after http://localhost:3000 \
|
|
119
|
-
--paths /,/about,/pricing
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
生成されたファイルをリポジトリにコミットすると、Claude Code上で `/vrt` コマンドを実行するだけで、VRTの実行 → 結果の読み取り → 差分箇所の特定 → コード修正 → 再テストまでを自律的に行えるようになる。
|
|
123
|
-
|
|
124
|
-
### スキルファイルの仕組み
|
|
125
|
-
|
|
126
|
-
生成される `.claude/commands/vrt.md` には以下の手順が記述される:
|
|
127
|
-
|
|
128
|
-
1. 開発サーバーを起動
|
|
129
|
-
2. VRTコマンドを実行
|
|
130
|
-
3. `report.json` を読んで差分を確認
|
|
131
|
-
4. diff画像を視覚的に確認
|
|
132
|
-
5. diff画像からCSS/HTMLの修正箇所を特定
|
|
133
|
-
6. コードを修正
|
|
134
|
-
7. 再度VRTを実行して確認
|
|
135
|
-
|
|
136
135
|
## 広告・計測系スクリプトのブロック
|
|
137
136
|
|
|
138
137
|
以下のドメインへのリクエストは自動的にブロックされる。見た目のテストに不要であり、ページ読み込み速度を改善するため。
|
package/bin/vrt.ts
CHANGED
|
@@ -14,7 +14,7 @@ program
|
|
|
14
14
|
program
|
|
15
15
|
.command("run")
|
|
16
16
|
.description("Run visual regression tests")
|
|
17
|
-
.requiredOption("--
|
|
17
|
+
.requiredOption("--reference <url>", "Reference URL (production)")
|
|
18
18
|
.requiredOption("--after <url>", "Comparison URL (local/staging)")
|
|
19
19
|
.requiredOption("--paths <paths>", "Page paths to compare (comma-separated)")
|
|
20
20
|
.option("--threshold <n>", "Diff tolerance percentage", "0.1")
|
|
@@ -26,7 +26,7 @@ program
|
|
|
26
26
|
const parsed = cliOptionsSchema.parse(rawOptions);
|
|
27
27
|
|
|
28
28
|
const options: ResolvedOptions = {
|
|
29
|
-
|
|
29
|
+
referenceUrl: parsed.reference,
|
|
30
30
|
afterUrl: parsed.after,
|
|
31
31
|
paths: parsed.paths,
|
|
32
32
|
threshold: parsed.threshold,
|
|
@@ -50,22 +50,12 @@ program
|
|
|
50
50
|
program
|
|
51
51
|
.command("init")
|
|
52
52
|
.description(
|
|
53
|
-
"Generate .claude/
|
|
53
|
+
"Generate .claude/skills/web-corders-vrt/SKILL.md for this repository",
|
|
54
54
|
)
|
|
55
|
-
.requiredOption("--
|
|
56
|
-
.requiredOption("--after <url>", "Comparison URL (local/staging)")
|
|
57
|
-
.requiredOption("--paths <paths>", "Page paths to compare (comma-separated)")
|
|
58
|
-
.option("--threshold <n>", "Diff tolerance percentage", "0.1")
|
|
59
|
-
.option("--hide <selectors>", "CSS selectors to hide (comma-separated)")
|
|
55
|
+
.requiredOption("--refDomain <domain>", "Reference domain URL (production)")
|
|
60
56
|
.action(async (options) => {
|
|
61
57
|
try {
|
|
62
|
-
await runInit({
|
|
63
|
-
before: options.before,
|
|
64
|
-
after: options.after,
|
|
65
|
-
paths: options.paths,
|
|
66
|
-
threshold: parseFloat(options.threshold),
|
|
67
|
-
hide: options.hide,
|
|
68
|
-
});
|
|
58
|
+
await runInit({ refDomain: options.refDomain });
|
|
69
59
|
} catch (error) {
|
|
70
60
|
if (error instanceof Error) {
|
|
71
61
|
console.error(`Error: ${error.message}`);
|
package/dist/bin/vrt.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Command } from "commander";
|
|
|
6
6
|
// src/schemas.ts
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
var cliOptionsSchema = z.object({
|
|
9
|
-
|
|
9
|
+
reference: z.string().url("--reference must be a valid URL"),
|
|
10
10
|
after: z.string().url("--after must be a valid URL"),
|
|
11
11
|
paths: z.string().transform((val) => val.split(",").map((p) => p.trim())),
|
|
12
12
|
threshold: z.coerce.number().min(0).max(100).default(0.1),
|
|
@@ -193,16 +193,16 @@ var Screenshotter = class {
|
|
|
193
193
|
// src/core/comparator.ts
|
|
194
194
|
import pixelmatch from "pixelmatch";
|
|
195
195
|
import { PNG } from "pngjs";
|
|
196
|
-
function compareImages(
|
|
197
|
-
const
|
|
196
|
+
function compareImages(referenceBuffer, afterBuffer, threshold = 0.1) {
|
|
197
|
+
const reference = PNG.sync.read(referenceBuffer);
|
|
198
198
|
const after = PNG.sync.read(afterBuffer);
|
|
199
|
-
const width = Math.max(
|
|
200
|
-
const height = Math.max(
|
|
201
|
-
const
|
|
199
|
+
const width = Math.max(reference.width, after.width);
|
|
200
|
+
const height = Math.max(reference.height, after.height);
|
|
201
|
+
const normalizedReference = normalizeImage(reference, width, height);
|
|
202
202
|
const normalizedAfter = normalizeImage(after, width, height);
|
|
203
203
|
const diff = new PNG({ width, height });
|
|
204
204
|
const diffCount = pixelmatch(
|
|
205
|
-
|
|
205
|
+
normalizedReference.data,
|
|
206
206
|
normalizedAfter.data,
|
|
207
207
|
diff.data,
|
|
208
208
|
width,
|
|
@@ -226,7 +226,7 @@ function compareImages(beforeBuffer, afterBuffer, threshold = 0.1) {
|
|
|
226
226
|
dimensions: {
|
|
227
227
|
width,
|
|
228
228
|
height,
|
|
229
|
-
|
|
229
|
+
referenceHeight: reference.height,
|
|
230
230
|
afterHeight: after.height
|
|
231
231
|
}
|
|
232
232
|
};
|
|
@@ -264,7 +264,7 @@ function printTerminalReport(report) {
|
|
|
264
264
|
console.log("=".repeat(50));
|
|
265
265
|
console.log("");
|
|
266
266
|
console.log(
|
|
267
|
-
`Comparing: ${chalk.cyan(meta.
|
|
267
|
+
`Comparing: ${chalk.cyan(meta.referenceUrl)} vs ${chalk.cyan(meta.afterUrl)}`
|
|
268
268
|
);
|
|
269
269
|
console.log("");
|
|
270
270
|
const groupedByPage = /* @__PURE__ */ new Map();
|
|
@@ -323,7 +323,7 @@ import { writeFile as writeFile2 } from "fs/promises";
|
|
|
323
323
|
import { join as join2 } from "path";
|
|
324
324
|
|
|
325
325
|
// src/templates/report.html
|
|
326
|
-
var report_default = '<!doctype html>\n<html lang="ja">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>VRT Report - {{TIMESTAMP}}</title>\n <style>\n {{CSS}}\n </style>\n </head>\n <body>\n <div class="header">\n <h1>web-corders-vrt</h1>\n <div class="meta">\n {{TIMESTAMP}} | Duration: {{DURATION}}s<br />\n
|
|
326
|
+
var report_default = '<!doctype html>\n<html lang="ja">\n <head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>VRT Report - {{TIMESTAMP}}</title>\n <style>\n {{CSS}}\n </style>\n </head>\n <body>\n <div class="header">\n <h1>web-corders-vrt</h1>\n <div class="meta">\n {{TIMESTAMP}} | Duration: {{DURATION}}s<br />\n Reference: {{REFERENCE_URL}} \u2192 After: {{AFTER_URL}}\n </div>\n </div>\n <div class="summary">\n <div class="stat total">{{TOTAL}} Total</div>\n <div class="stat pass">{{PASSED}} Passed</div>\n {{FAILED_STAT}}\n </div>\n <div class="results">\n {{RESULTS}}\n </div>\n </body>\n</html>\n';
|
|
327
327
|
|
|
328
328
|
// src/templates/report.css
|
|
329
329
|
var report_default2 = '* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n background: #f5f5f5;\n color: #333;\n}\n\n.header {\n background: #1a1a2e;\n color: white;\n padding: 24px 32px;\n text-align: center;\n}\n\n.header h1 {\n font-size: 20px;\n margin-bottom: 8px;\n}\n\n.header .meta {\n font-size: 13px;\n color: #aaa;\n}\n\n.summary {\n display: flex;\n gap: 16px;\n padding: 16px 32px;\n background: white;\n border-bottom: 1px solid #e0e0e0;\n}\n\n.summary .stat {\n padding: 8px 16px;\n border-radius: 6px;\n font-size: 14px;\n font-weight: 600;\n}\n\n.stat.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.stat.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.stat.total {\n background: #e3f2fd;\n color: #1565c0;\n}\n\n.results {\n padding: 24px 32px;\n display: flex;\n flex-direction: column;\n gap: 24px;\n}\n\n.card {\n background: white;\n border-radius: 8px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n overflow: hidden;\n}\n\n.card-header {\n padding: 16px 20px;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.card-header h3 {\n font-size: 15px;\n}\n\n.badge {\n padding: 4px 10px;\n border-radius: 12px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.badge.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.badge.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.badge.error {\n background: #fff3e0;\n color: #e65100;\n}\n\n.comparison {\n padding: 16px 20px;\n}\n\n.images {\n display: flex;\n gap: 8px;\n}\n\n.card.sp .img-container {\n flex: none;\n width: 375px;\n}\n\n.card.sp .img-container img {\n width: 375px;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.card.pc .img-container {\n flex: 1;\n min-width: 0;\n}\n\n.card.pc .img-container img {\n width: 100%;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.img-container .label {\n font-size: 11px;\n color: #888;\n margin-bottom: 4px;\n text-transform: uppercase;\n font-weight: 600;\n}\n\n.diff-info {\n padding: 8px 20px 16px;\n font-size: 13px;\n color: #666;\n}\n\n.diff-info .region {\n margin: 4px 0;\n padding-left: 16px;\n}\n\n/* Accordion for passed tests */\n.screenshot-accordion {\n border-top: 1px solid #eee;\n}\n\n.screenshot-accordion summary {\n padding: 12px 20px;\n cursor: pointer;\n font-size: 13px;\n font-weight: 600;\n color: #666;\n user-select: none;\n}\n\n.screenshot-accordion summary:hover {\n background: #fafafa;\n}\n';
|
|
@@ -339,7 +339,7 @@ function generateHtml(report) {
|
|
|
339
339
|
const { meta, summary, results } = report;
|
|
340
340
|
const resultCards = results.map((r) => generateResultCard(r)).join("\n");
|
|
341
341
|
const failedStat = summary.failed > 0 ? `<div class="stat fail">${summary.failed} Failed</div>` : "";
|
|
342
|
-
return report_default.replace("{{CSS}}", report_default2).replace(/\{\{TIMESTAMP\}\}/g, meta.timestamp).replace("{{DURATION}}", (meta.duration / 1e3).toFixed(1)).replace("{{
|
|
342
|
+
return report_default.replace("{{CSS}}", report_default2).replace(/\{\{TIMESTAMP\}\}/g, meta.timestamp).replace("{{DURATION}}", (meta.duration / 1e3).toFixed(1)).replace("{{REFERENCE_URL}}", meta.referenceUrl).replace("{{AFTER_URL}}", meta.afterUrl).replace("{{TOTAL}}", String(summary.totalTests)).replace("{{PASSED}}", String(summary.passed)).replace("{{FAILED_STAT}}", failedStat).replace("{{RESULTS}}", resultCards);
|
|
343
343
|
}
|
|
344
344
|
function generateResultCard(result) {
|
|
345
345
|
const { page, viewport, status, comparison, screenshots } = result;
|
|
@@ -347,9 +347,9 @@ function generateResultCard(result) {
|
|
|
347
347
|
const imagesHtml = `
|
|
348
348
|
<div class="comparison">
|
|
349
349
|
<div class="images">
|
|
350
|
-
<div class="img-container img-
|
|
351
|
-
<div class="label">
|
|
352
|
-
<img src="${screenshots.
|
|
350
|
+
<div class="img-container img-reference">
|
|
351
|
+
<div class="label">Reference</div>
|
|
352
|
+
<img src="${screenshots.reference}" alt="Reference" loading="lazy">
|
|
353
353
|
</div>
|
|
354
354
|
<div class="img-container img-after">
|
|
355
355
|
<div class="label">After</div>
|
|
@@ -384,9 +384,9 @@ async function runVrt(options) {
|
|
|
384
384
|
const spinner = ora("Initializing browser...").start();
|
|
385
385
|
try {
|
|
386
386
|
await screenshotter.initialize();
|
|
387
|
-
spinner.text = `Taking screenshots of ${chalk2.cyan(options.
|
|
388
|
-
const
|
|
389
|
-
options.
|
|
387
|
+
spinner.text = `Taking screenshots of ${chalk2.cyan(options.referenceUrl)}...`;
|
|
388
|
+
const referenceScreenshots = await screenshotter.captureAll(
|
|
389
|
+
options.referenceUrl,
|
|
390
390
|
options.paths,
|
|
391
391
|
VIEWPORTS,
|
|
392
392
|
options.hideSelectors
|
|
@@ -400,15 +400,15 @@ async function runVrt(options) {
|
|
|
400
400
|
);
|
|
401
401
|
spinner.text = "Comparing screenshots...";
|
|
402
402
|
const results = [];
|
|
403
|
-
for (const
|
|
403
|
+
for (const referenceShot of referenceScreenshots) {
|
|
404
404
|
const afterShot = afterScreenshots.find(
|
|
405
|
-
(a) => a.pagePath ===
|
|
405
|
+
(a) => a.pagePath === referenceShot.pagePath && a.viewportType === referenceShot.viewportType
|
|
406
406
|
);
|
|
407
407
|
if (!afterShot) {
|
|
408
408
|
results.push(
|
|
409
409
|
createErrorResult(
|
|
410
|
-
|
|
411
|
-
|
|
410
|
+
referenceShot.pagePath,
|
|
411
|
+
referenceShot.viewportType,
|
|
412
412
|
options,
|
|
413
413
|
"After screenshot not found"
|
|
414
414
|
)
|
|
@@ -417,33 +417,33 @@ async function runVrt(options) {
|
|
|
417
417
|
}
|
|
418
418
|
try {
|
|
419
419
|
const comparison = compareImages(
|
|
420
|
-
|
|
420
|
+
referenceShot.buffer,
|
|
421
421
|
afterShot.buffer,
|
|
422
422
|
options.threshold
|
|
423
423
|
);
|
|
424
|
-
const filePrefix = `${
|
|
425
|
-
const
|
|
424
|
+
const filePrefix = `${referenceShot.pageName}--${referenceShot.viewportType}`;
|
|
425
|
+
const referencePath = join3("screenshots", `${filePrefix}--reference.png`);
|
|
426
426
|
const afterPath = join3("screenshots", `${filePrefix}--after.png`);
|
|
427
427
|
const diffPath = join3("screenshots", `${filePrefix}--diff.png`);
|
|
428
|
-
await writeFile3(join3(runDir,
|
|
428
|
+
await writeFile3(join3(runDir, referencePath), referenceShot.buffer);
|
|
429
429
|
await writeFile3(join3(runDir, afterPath), afterShot.buffer);
|
|
430
430
|
await writeFile3(join3(runDir, diffPath), comparison.diffImage);
|
|
431
|
-
const pageName =
|
|
432
|
-
const viewport = getViewportDimensions(
|
|
431
|
+
const pageName = referenceShot.pagePath === "/" ? "\u30C8\u30C3\u30D7\u30DA\u30FC\u30B8" : referenceShot.pagePath.replace(/^\//, "");
|
|
432
|
+
const viewport = getViewportDimensions(referenceShot.viewportType);
|
|
433
433
|
results.push({
|
|
434
434
|
page: {
|
|
435
|
-
path:
|
|
435
|
+
path: referenceShot.pagePath,
|
|
436
436
|
name: pageName,
|
|
437
437
|
url: {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
options.
|
|
438
|
+
reference: new URL(
|
|
439
|
+
referenceShot.pagePath,
|
|
440
|
+
options.referenceUrl
|
|
441
441
|
).toString(),
|
|
442
|
-
after: new URL(
|
|
442
|
+
after: new URL(referenceShot.pagePath, options.afterUrl).toString()
|
|
443
443
|
}
|
|
444
444
|
},
|
|
445
445
|
viewport: {
|
|
446
|
-
type:
|
|
446
|
+
type: referenceShot.viewportType,
|
|
447
447
|
...viewport
|
|
448
448
|
},
|
|
449
449
|
status: comparison.passed ? "pass" : "fail",
|
|
@@ -455,7 +455,7 @@ async function runVrt(options) {
|
|
|
455
455
|
dimensions: comparison.dimensions
|
|
456
456
|
},
|
|
457
457
|
screenshots: {
|
|
458
|
-
|
|
458
|
+
reference: referencePath,
|
|
459
459
|
after: afterPath,
|
|
460
460
|
diff: diffPath
|
|
461
461
|
}
|
|
@@ -463,8 +463,8 @@ async function runVrt(options) {
|
|
|
463
463
|
} catch (error) {
|
|
464
464
|
results.push(
|
|
465
465
|
createErrorResult(
|
|
466
|
-
|
|
467
|
-
|
|
466
|
+
referenceShot.pagePath,
|
|
467
|
+
referenceShot.viewportType,
|
|
468
468
|
options,
|
|
469
469
|
error instanceof Error ? error.message : String(error)
|
|
470
470
|
)
|
|
@@ -479,7 +479,7 @@ async function runVrt(options) {
|
|
|
479
479
|
version: "1.0",
|
|
480
480
|
meta: {
|
|
481
481
|
timestamp,
|
|
482
|
-
|
|
482
|
+
referenceUrl: options.referenceUrl,
|
|
483
483
|
afterUrl: options.afterUrl,
|
|
484
484
|
duration,
|
|
485
485
|
command: buildCommandString(options)
|
|
@@ -522,7 +522,7 @@ function createErrorResult(pagePath, viewportType, options, error) {
|
|
|
522
522
|
path: pagePath,
|
|
523
523
|
name: pageName,
|
|
524
524
|
url: {
|
|
525
|
-
|
|
525
|
+
reference: new URL(pagePath, options.referenceUrl).toString(),
|
|
526
526
|
after: new URL(pagePath, options.afterUrl).toString()
|
|
527
527
|
}
|
|
528
528
|
},
|
|
@@ -533,15 +533,15 @@ function createErrorResult(pagePath, viewportType, options, error) {
|
|
|
533
533
|
diffPixelCount: 0,
|
|
534
534
|
totalPixels: 0,
|
|
535
535
|
threshold: options.threshold,
|
|
536
|
-
dimensions: { width: 0, height: 0,
|
|
536
|
+
dimensions: { width: 0, height: 0, referenceHeight: 0, afterHeight: 0 }
|
|
537
537
|
},
|
|
538
|
-
screenshots: {
|
|
538
|
+
screenshots: { reference: "", after: "", diff: "" },
|
|
539
539
|
error
|
|
540
540
|
};
|
|
541
541
|
}
|
|
542
542
|
function buildCommandString(options) {
|
|
543
543
|
const parts = ["npx web-corders-vrt run"];
|
|
544
|
-
parts.push(`--
|
|
544
|
+
parts.push(`--reference ${options.referenceUrl}`);
|
|
545
545
|
parts.push(`--after ${options.afterUrl}`);
|
|
546
546
|
parts.push(`--paths ${options.paths.join(",")}`);
|
|
547
547
|
if (options.threshold !== DEFAULT_THRESHOLD) {
|
|
@@ -557,16 +557,24 @@ function buildCommandString(options) {
|
|
|
557
557
|
import { mkdir as mkdir2, writeFile as writeFile4, access } from "fs/promises";
|
|
558
558
|
import { join as join4 } from "path";
|
|
559
559
|
import chalk3 from "chalk";
|
|
560
|
+
|
|
561
|
+
// src/templates/SKILL-TEMPLATE.md
|
|
562
|
+
var SKILL_TEMPLATE_default = '---\nname: web-corders-vrt\ndescription: WEB\u7528VRT(Visual Regression Test)\u30C4\u30FC\u30EB\u3002\u672C\u756A\u30C9\u30E1\u30A4\u30F3\u3068\u30ED\u30FC\u30AB\u30EB\u958B\u767A\u307E\u305F\u306F\u30D7\u30EC\u30D3\u30E5\u30FC\u30C9\u30E1\u30A4\u30F3\u306E\u9593\u3067\u3001\u7279\u5B9A\u30D1\u30B9\u306E\u30D3\u30B8\u30E5\u30A2\u30EB\u5DEE\u5206\u3092\u691C\u51FA\u3059\u308B\u3002\n---\n\n## \u4F7F\u3044\u65B9\n\n```bash\nnpx web-corders-vrt run \\\n --reference ${referenceDomain} \\\n --after <\u958B\u767A\u74B0\u5883\u306E\u30C9\u30E1\u30A4\u30F3|\u30E6\u30FC\u30B6\u30FC\u304B\u3089\u6307\u793A\u304C\u306A\u3044\u5834\u5408\u306Fhttp://localhost:3000> \\\n --paths <\u30C6\u30B9\u30C8\u5BFE\u8C61\u306E\u30D1\u30B9\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\uFF09> \\\n```\n\n## \u624B\u9806\n\n1. \u30E6\u30FC\u30B6\u30FC\u304B\u3089\u6BD4\u8F03\u5BFE\u8C61\u306E\u30C9\u30E1\u30A4\u30F3\u3092\u6307\u5B9A\u3055\u308C\u305F\u5834\u5408\u3001\u305D\u308C\u306B\u5F93\u3046\u3002\u6307\u793A\u304C\u306A\u3044\u5834\u5408\u3001after\u306F\u958B\u767A\u30B5\u30FC\u30D0\u30FC\u3068\u3057\u3001\u8D77\u52D5\u3057\u3066\u3044\u306A\u3051\u308C\u3070 `npm run dev` \u3067\u8D77\u52D5\u3057\u3066\u5F85\u6A5F\u3059\u308B\u3002\u3053\u3053\u3067\u8D77\u52D5\u3057\u305F\u30C9\u30E1\u30A4\u30F3\u3068\u30D7\u30ED\u30C8\u30B3\u30EB\u3092after\u306E\u30C9\u30E1\u30A4\u30F3\u3068\u3059\u308B\u3002\n2. \u4E0A\u8A18\u306E\u30B3\u30DE\u30F3\u30C9\u3067VRT\u3092\u5B9F\u884C\u3059\u308B\uFF08`--paths` \u306F\u30BF\u30B9\u30AF\u306B\u5FDC\u3058\u3066\u8A2D\u5B9A\u3059\u308B\uFF09\n3. `./vrt-results/` \u5185\u306E\u6700\u65B0\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u306B\u3042\u308B `report.json` \u3092\u8AAD\u3080\n4. `status: "fail"` \u306E\u30C6\u30B9\u30C8\u7D50\u679C\u306B\u6CE8\u76EE\u3059\u308B\n5. \u8A72\u5F53\u3059\u308Bdiff\u753B\u50CF\uFF08`*--diff.png`\uFF09\u3092Read\u30C4\u30FC\u30EB\u3067\u8996\u899A\u7684\u306B\u78BA\u8A8D\u3059\u308B\n6. diff\u753B\u50CF\u304B\u3089\u4FEE\u6B63\u3059\u3079\u304DCSS\u3084HTML\u306E\u5834\u6240\u3092\u7279\u5B9A\u3059\u308B\n7. \u30BD\u30FC\u30B9\u30B3\u30FC\u30C9\u3092\u4FEE\u6B63\u3059\u308B\n8. \u518D\u5EA6VRT\u3092\u5B9F\u884C\u3057\u3066\u4FEE\u6B63\u304C\u53CD\u6620\u3055\u308C\u305F\u3053\u3068\u3092\u78BA\u8A8D\u3059\u308B\n\n## \u30AA\u30D7\u30B7\u30E7\u30F3\n\n| \u30AA\u30D7\u30B7\u30E7\u30F3 | \u8AAC\u660E |\n| -------------------- | ------------------------------------------- |\n| `--reference <url>` | \u30EA\u30D5\u30A1\u30EC\u30F3\u30B9\u30D7\u30ED\u30C8\u30B3\u30EB+\u30C9\u30E1\u30A4\u30F3\uFF08\u672C\u756A\u74B0\u5883\uFF09 |\n| `--after <url>` | \u6BD4\u8F03\u5148U\u30D7\u30ED\u30C8\u30B3\u30EB+\u30C9\u30E1\u30A4\u30F3\uFF08\u958B\u767A\u74B0\u5883\uFF09 |\n| `--paths <paths>` | \u30C6\u30B9\u30C8\u5BFE\u8C61\u306E\u30DA\u30FC\u30B8\u30D1\u30B9\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\uFF09 |\n| `--threshold <n>` | \u5DEE\u5206\u8A31\u5BB9\u7387\uFF08%\uFF09\u3002\u30C7\u30D5\u30A9\u30EB\u30C8: 0.1 |\n| `--hide <selectors>` | \u975E\u8868\u793A\u306B\u3059\u308BCSS\u30BB\u30EC\u30AF\u30BF\uFF08\u30AB\u30F3\u30DE\u533A\u5207\u308A\uFF09 |\n| `--no-open` | HTML\u30EC\u30DD\u30FC\u30C8\u3092\u30D6\u30E9\u30A6\u30B6\u3067\u958B\u304B\u306A\u3044 |\n';
|
|
563
|
+
|
|
564
|
+
// src/commands/init.ts
|
|
560
565
|
async function runInit(options) {
|
|
561
|
-
const outDir = join4(process.cwd(), ".claude", "
|
|
562
|
-
const outFile = join4(outDir, "
|
|
566
|
+
const outDir = join4(process.cwd(), ".claude", "skills", "web-corders-vrt");
|
|
567
|
+
const outFile = join4(outDir, "SKILL.md");
|
|
563
568
|
try {
|
|
564
569
|
await access(outFile);
|
|
565
570
|
console.log(chalk3.yellow(`\u26A0 ${outFile} already exists. Overwriting...`));
|
|
566
571
|
} catch {
|
|
567
572
|
}
|
|
568
573
|
await mkdir2(outDir, { recursive: true });
|
|
569
|
-
const content =
|
|
574
|
+
const content = SKILL_TEMPLATE_default.replace(
|
|
575
|
+
/\$\{referenceDomain\}/g,
|
|
576
|
+
options.refDomain
|
|
577
|
+
);
|
|
570
578
|
await writeFile4(outFile, content, "utf-8");
|
|
571
579
|
console.log(chalk3.green(`\u2705 Created ${outFile}`));
|
|
572
580
|
console.log("");
|
|
@@ -577,47 +585,15 @@ async function runInit(options) {
|
|
|
577
585
|
"Review and customize the file, then commit it to your repository."
|
|
578
586
|
);
|
|
579
587
|
}
|
|
580
|
-
function generateSkillFile(options) {
|
|
581
|
-
const cmdParts = ["npx web-corders-vrt run"];
|
|
582
|
-
cmdParts.push(` --before ${options.before}`);
|
|
583
|
-
cmdParts.push(` --after ${options.after}`);
|
|
584
|
-
cmdParts.push(` --paths ${options.paths}`);
|
|
585
|
-
if (options.threshold !== void 0 && options.threshold !== DEFAULT_THRESHOLD) {
|
|
586
|
-
cmdParts.push(` --threshold ${options.threshold}`);
|
|
587
|
-
}
|
|
588
|
-
if (options.hide) {
|
|
589
|
-
cmdParts.push(` --hide "${options.hide}"`);
|
|
590
|
-
}
|
|
591
|
-
cmdParts.push(" --no-open");
|
|
592
|
-
const command = cmdParts.join(" \\\n");
|
|
593
|
-
return `VRT\u3092\u5B9F\u884C\u3057\u3066\u672C\u756A\u74B0\u5883\u3068\u306E\u30D3\u30B8\u30E5\u30A2\u30EB\u5DEE\u5206\u3092\u691C\u51FA\u3059\u308B\u3002
|
|
594
|
-
|
|
595
|
-
## \u624B\u9806
|
|
596
|
-
|
|
597
|
-
1. \u958B\u767A\u30B5\u30FC\u30D0\u30FC\u304C\u8D77\u52D5\u3057\u3066\u3044\u306A\u3051\u308C\u3070 \`npm run dev\` \u3067\u8D77\u52D5\u3057\u3066\u5F85\u6A5F\u3059\u308B
|
|
598
|
-
2. \u4EE5\u4E0B\u306E\u30B3\u30DE\u30F3\u30C9\u3067VRT\u3092\u5B9F\u884C:
|
|
599
|
-
|
|
600
|
-
\`\`\`bash
|
|
601
|
-
${command}
|
|
602
|
-
\`\`\`
|
|
603
|
-
|
|
604
|
-
3. \`./vrt-results/\` \u5185\u306E\u6700\u65B0\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u306B\u3042\u308B \`report.json\` \u3092\u8AAD\u3080
|
|
605
|
-
4. \`status: "fail"\` \u306E\u30C6\u30B9\u30C8\u7D50\u679C\u306B\u6CE8\u76EE\u3059\u308B
|
|
606
|
-
5. \u8A72\u5F53\u3059\u308Bdiff\u753B\u50CF\uFF08\`*--diff.png\`\uFF09\u3092Read\u30C4\u30FC\u30EB\u3067\u8996\u899A\u7684\u306B\u78BA\u8A8D\u3059\u308B
|
|
607
|
-
6. diff\u753B\u50CF\u304B\u3089\u4FEE\u6B63\u3059\u3079\u304DCSS\u3084HTML\u306E\u5834\u6240\u3092\u7279\u5B9A\u3059\u308B
|
|
608
|
-
7. \u30BD\u30FC\u30B9\u30B3\u30FC\u30C9\u3092\u4FEE\u6B63\u3059\u308B
|
|
609
|
-
8. \u518D\u5EA6VRT\u3092\u5B9F\u884C\u3057\u3066\u4FEE\u6B63\u304C\u53CD\u6620\u3055\u308C\u305F\u3053\u3068\u3092\u78BA\u8A8D\u3059\u308B
|
|
610
|
-
`;
|
|
611
|
-
}
|
|
612
588
|
|
|
613
589
|
// bin/vrt.ts
|
|
614
590
|
var program = new Command();
|
|
615
591
|
program.name("vrt").description("Visual Regression Testing CLI - Compare web pages visually").version("0.1.0");
|
|
616
|
-
program.command("run").description("Run visual regression tests").requiredOption("--
|
|
592
|
+
program.command("run").description("Run visual regression tests").requiredOption("--reference <url>", "Reference URL (production)").requiredOption("--after <url>", "Comparison URL (local/staging)").requiredOption("--paths <paths>", "Page paths to compare (comma-separated)").option("--threshold <n>", "Diff tolerance percentage", "0.1").option("--hide <selectors>", "CSS selectors to hide (comma-separated)").option("--no-html", "Skip HTML report generation").option("--no-open", "Do not open HTML report in browser").action(async (rawOptions) => {
|
|
617
593
|
try {
|
|
618
594
|
const parsed = cliOptionsSchema.parse(rawOptions);
|
|
619
595
|
const options = {
|
|
620
|
-
|
|
596
|
+
referenceUrl: parsed.reference,
|
|
621
597
|
afterUrl: parsed.after,
|
|
622
598
|
paths: parsed.paths,
|
|
623
599
|
threshold: parsed.threshold,
|
|
@@ -637,16 +613,10 @@ program.command("run").description("Run visual regression tests").requiredOption
|
|
|
637
613
|
}
|
|
638
614
|
});
|
|
639
615
|
program.command("init").description(
|
|
640
|
-
"Generate .claude/
|
|
641
|
-
).requiredOption("--
|
|
616
|
+
"Generate .claude/skills/web-corders-vrt/SKILL.md for this repository"
|
|
617
|
+
).requiredOption("--refDomain <domain>", "Reference domain URL (production)").action(async (options) => {
|
|
642
618
|
try {
|
|
643
|
-
await runInit({
|
|
644
|
-
before: options.before,
|
|
645
|
-
after: options.after,
|
|
646
|
-
paths: options.paths,
|
|
647
|
-
threshold: parseFloat(options.threshold),
|
|
648
|
-
hide: options.hide
|
|
649
|
-
});
|
|
619
|
+
await runInit({ refDomain: options.refDomain });
|
|
650
620
|
} catch (error) {
|
|
651
621
|
if (error instanceof Error) {
|
|
652
622
|
console.error(`Error: ${error.message}`);
|
package/dist/bin/vrt.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../bin/vrt.ts","../../src/schemas.ts","../../src/commands/run.ts","../../src/core/screenshotter.ts","../../src/constants.ts","../../src/core/stabilizer.ts","../../src/core/comparator.ts","../../src/reporters/terminal.ts","../../src/reporters/json.ts","../../src/reporters/html.ts","../../src/templates/report.html","../../src/templates/report.css","../../src/commands/init.ts"],"sourcesContent":["import { Command } from \"commander\";\nimport { cliOptionsSchema } from \"../src/schemas.js\";\nimport { runVrt } from \"../src/commands/run.js\";\nimport { runInit } from \"../src/commands/init.js\";\nimport type { ResolvedOptions } from \"../src/types.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"vrt\")\n .description(\"Visual Regression Testing CLI - Compare web pages visually\")\n .version(\"0.1.0\");\n\nprogram\n .command(\"run\")\n .description(\"Run visual regression tests\")\n .requiredOption(\"--before <url>\", \"Baseline URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .option(\"--no-html\", \"Skip HTML report generation\")\n .option(\"--no-open\", \"Do not open HTML report in browser\")\n .action(async (rawOptions) => {\n try {\n const parsed = cliOptionsSchema.parse(rawOptions);\n\n const options: ResolvedOptions = {\n beforeUrl: parsed.before,\n afterUrl: parsed.after,\n paths: parsed.paths,\n threshold: parsed.threshold,\n hideSelectors: parsed.hide,\n html: parsed.html,\n open: parsed.open,\n };\n\n const report = await runVrt(options);\n process.exit(report.summary.overallStatus === \"pass\" ? 0 : 1);\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n } else {\n console.error(\"An unexpected error occurred\");\n }\n process.exit(2);\n }\n });\n\nprogram\n .command(\"init\")\n .description(\n \"Generate .claude/commands/vrt.md skill file for this repository\",\n )\n .requiredOption(\"--before <url>\", \"Baseline URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .action(async (options) => {\n try {\n await runInit({\n before: options.before,\n after: options.after,\n paths: options.paths,\n threshold: parseFloat(options.threshold),\n hide: options.hide,\n });\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n }\n process.exit(1);\n }\n });\n\nprogram.parse();\n","import { z } from \"zod\";\n\nexport const cliOptionsSchema = z.object({\n before: z.string().url(\"--before must be a valid URL\"),\n after: z.string().url(\"--after must be a valid URL\"),\n paths: z.string().transform((val) => val.split(\",\").map((p) => p.trim())),\n threshold: z.coerce.number().min(0).max(100).default(0.1),\n hide: z\n .string()\n .optional()\n .transform((val) => (val ? val.split(\",\").map((s) => s.trim()) : [])),\n html: z.boolean().default(true),\n open: z.boolean().default(true),\n});\n\nexport type CliOptions = z.input<typeof cliOptionsSchema>;\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport ora from \"ora\";\nimport chalk from \"chalk\";\nimport { Screenshotter } from \"../core/screenshotter.js\";\nimport { compareImages } from \"../core/comparator.js\";\nimport { printTerminalReport } from \"../reporters/terminal.js\";\nimport { writeJsonReport } from \"../reporters/json.js\";\nimport { writeHtmlReport } from \"../reporters/html.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_OUT_DIR,\n DEFAULT_THRESHOLD,\n} from \"../constants.js\";\nimport type {\n ResolvedOptions,\n VrtReport,\n VrtTestResult,\n ViewportType,\n} from \"../types.js\";\n\nconst VIEWPORTS: ViewportType[] = [\"sp\", \"pc\"];\n\n/**\n * VRTのメイン実行コマンド。\n * スクリーンショット取得 → 比較 → レポート生成を一気に行う。\n */\nexport async function runVrt(options: ResolvedOptions): Promise<VrtReport> {\n const startTime = Date.now();\n\n // 出力ディレクトリを作成\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n const runDir = join(DEFAULT_OUT_DIR, timestamp);\n const screenshotsDir = join(runDir, \"screenshots\");\n await mkdir(screenshotsDir, { recursive: true });\n\n const screenshotter = new Screenshotter();\n const spinner = ora(\"Initializing browser...\").start();\n\n try {\n await screenshotter.initialize();\n\n // Before スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.beforeUrl)}...`;\n const beforeScreenshots = await screenshotter.captureAll(\n options.beforeUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n // After スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.afterUrl)}...`;\n const afterScreenshots = await screenshotter.captureAll(\n options.afterUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n spinner.text = \"Comparing screenshots...\";\n\n // 比較を実行\n const results: VrtTestResult[] = [];\n\n for (const beforeShot of beforeScreenshots) {\n const afterShot = afterScreenshots.find(\n (a) =>\n a.pagePath === beforeShot.pagePath &&\n a.viewportType === beforeShot.viewportType,\n );\n\n if (!afterShot) {\n results.push(\n createErrorResult(\n beforeShot.pagePath,\n beforeShot.viewportType,\n options,\n \"After screenshot not found\",\n ),\n );\n continue;\n }\n\n try {\n const comparison = compareImages(\n beforeShot.buffer,\n afterShot.buffer,\n options.threshold,\n );\n // スクリーンショットを保存\n const filePrefix = `${beforeShot.pageName}--${beforeShot.viewportType}`;\n const beforePath = join(\"screenshots\", `${filePrefix}--before.png`);\n const afterPath = join(\"screenshots\", `${filePrefix}--after.png`);\n const diffPath = join(\"screenshots\", `${filePrefix}--diff.png`);\n\n await writeFile(join(runDir, beforePath), beforeShot.buffer);\n await writeFile(join(runDir, afterPath), afterShot.buffer);\n await writeFile(join(runDir, diffPath), comparison.diffImage);\n\n const pageName =\n beforeShot.pagePath === \"/\"\n ? \"トップページ\"\n : beforeShot.pagePath.replace(/^\\//, \"\");\n const viewport = getViewportDimensions(beforeShot.viewportType);\n\n results.push({\n page: {\n path: beforeShot.pagePath,\n name: pageName,\n url: {\n before: new URL(\n beforeShot.pagePath,\n options.beforeUrl,\n ).toString(),\n after: new URL(beforeShot.pagePath, options.afterUrl).toString(),\n },\n },\n viewport: {\n type: beforeShot.viewportType,\n ...viewport,\n },\n status: comparison.passed ? \"pass\" : \"fail\",\n comparison: {\n diffPercentage: comparison.diffPercentage,\n diffPixelCount: comparison.diffCount,\n totalPixels: comparison.totalPixels,\n threshold: options.threshold,\n dimensions: comparison.dimensions,\n },\n screenshots: {\n before: beforePath,\n after: afterPath,\n diff: diffPath,\n },\n });\n } catch (error) {\n results.push(\n createErrorResult(\n beforeShot.pagePath,\n beforeShot.viewportType,\n options,\n error instanceof Error ? error.message : String(error),\n ),\n );\n }\n }\n\n const duration = Date.now() - startTime;\n const passed = results.filter((r) => r.status === \"pass\").length;\n const failed = results.filter((r) => r.status === \"fail\").length;\n const errored = results.filter((r) => r.status === \"error\").length;\n\n const report: VrtReport = {\n version: \"1.0\",\n meta: {\n timestamp,\n beforeUrl: options.beforeUrl,\n afterUrl: options.afterUrl,\n duration,\n command: buildCommandString(options),\n },\n summary: {\n totalTests: results.length,\n passed,\n failed,\n errored,\n overallStatus: failed > 0 || errored > 0 ? \"fail\" : \"pass\",\n },\n results,\n };\n\n spinner.stop();\n\n // レポート出力\n printTerminalReport(report);\n\n const jsonPath = await writeJsonReport(report, runDir);\n console.log(`JSON report: ${chalk.dim(jsonPath)}`);\n\n if (options.html) {\n const htmlPath = await writeHtmlReport(report, runDir);\n console.log(`HTML report: ${chalk.dim(htmlPath)}`);\n\n if (options.open) {\n const { exec } = await import(\"node:child_process\");\n exec(`open \"${htmlPath}\"`);\n }\n }\n\n console.log(`Output dir: ${chalk.dim(runDir)}`);\n\n return report;\n } finally {\n await screenshotter.cleanup();\n }\n}\n\nfunction getViewportDimensions(\n type: ViewportType,\n): { width: number; height: number } {\n return type === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n}\n\nfunction createErrorResult(\n pagePath: string,\n viewportType: ViewportType,\n options: ResolvedOptions,\n error: string,\n): VrtTestResult {\n const viewport = getViewportDimensions(viewportType);\n const pageName =\n pagePath === \"/\" ? \"トップページ\" : pagePath.replace(/^\\//, \"\");\n return {\n page: {\n path: pagePath,\n name: pageName,\n url: {\n before: new URL(pagePath, options.beforeUrl).toString(),\n after: new URL(pagePath, options.afterUrl).toString(),\n },\n },\n viewport: { type: viewportType, ...viewport },\n status: \"error\",\n comparison: {\n diffPercentage: 0,\n diffPixelCount: 0,\n totalPixels: 0,\n threshold: options.threshold,\n dimensions: { width: 0, height: 0, beforeHeight: 0, afterHeight: 0 },\n },\n screenshots: { before: \"\", after: \"\", diff: \"\" },\n error,\n };\n}\n\nfunction buildCommandString(options: ResolvedOptions): string {\n const parts = [\"npx web-corders-vrt run\"];\n parts.push(`--before ${options.beforeUrl}`);\n parts.push(`--after ${options.afterUrl}`);\n parts.push(`--paths ${options.paths.join(\",\")}`);\n if (options.threshold !== DEFAULT_THRESHOLD) {\n parts.push(`--threshold ${options.threshold}`);\n }\n if (options.hideSelectors.length > 0) {\n parts.push(`--hide \"${options.hideSelectors.join(\",\")}\"`);\n }\n return parts.join(\" \\\\\\n \");\n}\n","import { chromium, type Browser, type BrowserContext } from \"playwright\";\nimport type { ScreenshotResult, ViewportType } from \"../types.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_DELAY,\n DEFAULT_CONCURRENCY,\n SP_USER_AGENT,\n BLOCKED_DOMAINS,\n} from \"../constants.js\";\nimport { stabilizePage, getDateMockScript } from \"./stabilizer.js\";\n\nexport class Screenshotter {\n private browser: Browser | null = null;\n\n async initialize(): Promise<void> {\n this.browser = await chromium.launch();\n }\n\n /**\n * 指定URLのスクリーンショットを取得する。\n */\n async capture(\n url: string,\n pagePath: string,\n viewportType: ViewportType,\n hideSelectors: string[],\n ): Promise<ScreenshotResult> {\n if (!this.browser) {\n throw new Error(\"Browser not initialized. Call initialize() first.\");\n }\n\n const viewport =\n viewportType === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n\n const context: BrowserContext = await this.browser.newContext({\n viewport,\n deviceScaleFactor: viewportType === \"sp\" ? 2 : 1,\n userAgent: viewportType === \"sp\" ? SP_USER_AGENT : undefined,\n });\n\n const page = await context.newPage();\n\n // 日付をモック\n await page.addInitScript(getDateMockScript());\n\n // 広告・計測系リクエストをブロック\n await page.route(\"**/*\", (route) => {\n const reqUrl = route.request().url();\n const shouldBlock = BLOCKED_DOMAINS.some((domain) =>\n reqUrl.includes(domain),\n );\n if (shouldBlock) {\n return route.abort();\n }\n return route.continue();\n });\n\n // ページにアクセス\n await page.goto(url, { waitUntil: \"load\", timeout: 60000 });\n\n // ページの安定化\n await stabilizePage(page, {\n hideSelectors,\n delay: DEFAULT_DELAY,\n });\n\n // スクリーンショット取得\n const buffer = await page.screenshot({\n fullPage: true,\n type: \"png\",\n });\n\n // 画像サイズを取得(PNGヘッダから読む)\n const width = buffer.readUInt32BE(16);\n const height = buffer.readUInt32BE(20);\n\n await context.close();\n\n // パスからページ名を生成\n const pageName =\n pagePath === \"/\"\n ? \"top\"\n : pagePath.replace(/^\\//, \"\").replace(/\\//g, \"-\");\n\n return {\n pagePath,\n pageName,\n viewportType,\n buffer: Buffer.from(buffer),\n width,\n height,\n };\n }\n\n /**\n * 複数のページ・ビューポートのスクリーンショットを並行で取得する。\n */\n async captureAll(\n baseUrl: string,\n paths: string[],\n viewports: ViewportType[],\n hideSelectors: string[],\n ): Promise<ScreenshotResult[]> {\n const tasks: Array<{ url: string; path: string; viewport: ViewportType }> =\n [];\n\n for (const pagePath of paths) {\n const url = new URL(pagePath, baseUrl).toString();\n for (const viewport of viewports) {\n tasks.push({ url, path: pagePath, viewport });\n }\n }\n\n // 並行実行制御\n const results: ScreenshotResult[] = [];\n\n for (let i = 0; i < tasks.length; i += DEFAULT_CONCURRENCY) {\n const batch = tasks.slice(i, i + DEFAULT_CONCURRENCY);\n const batchResults = await Promise.all(\n batch.map((task) =>\n this.capture(task.url, task.path, task.viewport, hideSelectors),\n ),\n );\n results.push(...batchResults);\n }\n\n return results;\n }\n\n async cleanup(): Promise<void> {\n await this.browser?.close();\n this.browser = null;\n }\n}\n","export const DEFAULT_SP_VIEWPORT = { width: 375, height: 812 } as const;\nexport const DEFAULT_PC_VIEWPORT = { width: 1440, height: 900 } as const;\n\nexport const DEFAULT_THRESHOLD = 0.1;\nexport const DEFAULT_DELAY = 1000;\nexport const DEFAULT_CONCURRENCY = 3;\nexport const DEFAULT_OUT_DIR = \"./vrt-results\";\n\nexport const SP_USER_AGENT =\n \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1\";\n\n/** VRTに不要な広告・計測系ドメイン(常にブロック) */\nexport const BLOCKED_DOMAINS = [\n \"googletagmanager.com\",\n \"google-analytics.com\",\n \"googleadservices.com\",\n \"googlesyndication.com\",\n \"doubleclick.net\",\n \"facebook.net\",\n \"fbcdn.net\",\n \"analytics.yahoo.co.jp\",\n \"clarity.ms\",\n \"hotjar.com\",\n \"newrelic.com\",\n \"sentry.io\",\n \"datadoghq.com\",\n];\n\n/** VRTに不要な開発ツール系要素(常に非表示) */\nexport const DEFAULT_HIDE_SELECTORS = [\"#devtools-indicator\"];\n\n/** アニメーション・トランジションを無効化するCSS */\nexport const DISABLE_ANIMATIONS_CSS = `\n*, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n scroll-behavior: auto !important;\n caret-color: transparent !important;\n}\n`;\n","import type { Page } from \"playwright\";\nimport {\n DISABLE_ANIMATIONS_CSS,\n DEFAULT_HIDE_SELECTORS,\n} from \"../constants.js\";\n\nexport interface StabilizeOptions {\n disableAnimations?: boolean;\n hideSelectors?: string[];\n delay?: number;\n}\n\n/**\n * ページの動的コンテンツを安定化させる。\n * アニメーション無効化、要素の非表示、日付のモックなどを行う。\n */\nexport async function stabilizePage(\n page: Page,\n options: StabilizeOptions,\n): Promise<void> {\n // アニメーション・トランジションを無効化\n if (options.disableAnimations !== false) {\n await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });\n }\n\n // 指定要素を非表示にする(visibility: hidden でレイアウトを崩さない)\n const allHideSelectors = [\n ...DEFAULT_HIDE_SELECTORS,\n ...(options.hideSelectors ?? []),\n ];\n if (allHideSelectors.length > 0) {\n const hideCSS = allHideSelectors\n .map((s) => `${s} { visibility: hidden !important; }`)\n .join(\"\\n\");\n await page.addStyleTag({ content: hideCSS });\n }\n\n // 追加の待機時間(hydration完了を待つ)\n if (options.delay && options.delay > 0) {\n await page.waitForTimeout(options.delay);\n }\n\n // Shadow DOM内の要素はCSSが届かないのでJSで削除(Next.js devtools等)\n if (allHideSelectors.length > 0) {\n await page.evaluate(`\n for (const sel of ${JSON.stringify(allHideSelectors)}) {\n document.querySelectorAll(sel).forEach(el => el.remove());\n document.querySelectorAll(\"*\").forEach(el => {\n if (el.shadowRoot) {\n el.shadowRoot.querySelectorAll(sel).forEach(inner => inner.remove());\n }\n });\n }\n `);\n }\n}\n\n/**\n * 日付をモックして固定値にする初期化スクリプト。\n * page.goto() の前に page.addInitScript() で使う。\n */\nexport function getDateMockScript(): string {\n return `\n (() => {\n const fixedDate = new Date('2025-01-01T00:00:00Z');\n const OrigDate = Date;\n const MockDate = class extends OrigDate {\n constructor(...args) {\n if (args.length === 0) {\n super(fixedDate.getTime());\n } else {\n super(...args);\n }\n }\n static now() { return fixedDate.getTime(); }\n };\n globalThis.Date = MockDate;\n })();\n `;\n}\n","import pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\nimport type { ComparisonResult } from \"../types.js\";\n\n/**\n * 2つのPNG画像をピクセル単位で比較し、差分画像を生成する。\n * サイズが異なる場合は大きい方に合わせて白パディングで拡張する。\n */\nexport function compareImages(\n beforeBuffer: Buffer,\n afterBuffer: Buffer,\n threshold: number = 0.1,\n): ComparisonResult {\n const before = PNG.sync.read(beforeBuffer);\n const after = PNG.sync.read(afterBuffer);\n\n const width = Math.max(before.width, after.width);\n const height = Math.max(before.height, after.height);\n\n // サイズが異なる場合は正規化\n const normalizedBefore = normalizeImage(before, width, height);\n const normalizedAfter = normalizeImage(after, width, height);\n\n const diff = new PNG({ width, height });\n\n const diffCount = pixelmatch(\n normalizedBefore.data,\n normalizedAfter.data,\n diff.data,\n width,\n height,\n {\n threshold: 0.1,\n includeAA: false,\n alpha: 0.1,\n diffColor: [255, 0, 0],\n diffColorAlt: [0, 200, 0],\n },\n );\n\n const totalPixels = width * height;\n const diffPercentage = (diffCount / totalPixels) * 100;\n\n return {\n diffCount,\n totalPixels,\n diffPercentage,\n diffImage: PNG.sync.write(diff),\n passed: diffPercentage <= threshold,\n dimensions: {\n width,\n height,\n beforeHeight: before.height,\n afterHeight: after.height,\n },\n };\n}\n\n/**\n * 画像を指定サイズに正規化する。\n * 小さい場合は白(#ffffff)でパディングする。\n */\nfunction normalizeImage(\n png: PNG,\n targetWidth: number,\n targetHeight: number,\n): PNG {\n if (png.width === targetWidth && png.height === targetHeight) {\n return png;\n }\n\n const normalized = new PNG({ width: targetWidth, height: targetHeight });\n\n // 白で埋める\n for (let i = 0; i < normalized.data.length; i += 4) {\n normalized.data[i] = 255; // R\n normalized.data[i + 1] = 255; // G\n normalized.data[i + 2] = 255; // B\n normalized.data[i + 3] = 255; // A\n }\n\n // 元の画像をコピー\n for (let y = 0; y < png.height; y++) {\n for (let x = 0; x < png.width; x++) {\n const srcIdx = (y * png.width + x) * 4;\n const dstIdx = (y * targetWidth + x) * 4;\n normalized.data[dstIdx] = png.data[srcIdx];\n normalized.data[dstIdx + 1] = png.data[srcIdx + 1];\n normalized.data[dstIdx + 2] = png.data[srcIdx + 2];\n normalized.data[dstIdx + 3] = png.data[srcIdx + 3];\n }\n }\n\n return normalized;\n}\n","import chalk from \"chalk\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * ターミナルにカラー出力でVRT結果を表示する。\n */\nexport function printTerminalReport(report: VrtReport): void {\n const { meta, summary, results } = report;\n\n console.log(\"\");\n console.log(chalk.bold(`VRT Results - ${meta.timestamp}`));\n console.log(\"=\".repeat(50));\n console.log(\"\");\n console.log(\n `Comparing: ${chalk.cyan(meta.beforeUrl)} vs ${chalk.cyan(meta.afterUrl)}`,\n );\n console.log(\"\");\n\n // ページごとにグループ化\n const groupedByPage = new Map<string, typeof results>();\n for (const result of results) {\n const key = result.page.path;\n if (!groupedByPage.has(key)) {\n groupedByPage.set(key, []);\n }\n groupedByPage.get(key)!.push(result);\n }\n\n for (const [pagePath, pageResults] of groupedByPage) {\n const pageName = pageResults[0].page.name;\n console.log(` Page: ${chalk.bold(pageName)} (${pagePath})`);\n\n for (const result of pageResults) {\n const vpLabel = `${result.viewport.type.toUpperCase()} (${result.viewport.width}x${result.viewport.height})`;\n\n if (result.status === \"error\") {\n console.log(\n ` ${vpLabel} ${chalk.yellow(\"⚠ ERROR\")} ${result.error || \"Unknown error\"}`,\n );\n continue;\n }\n\n const diffStr = `diff: ${result.comparison.diffPercentage.toFixed(2)}%`;\n\n if (result.status === \"pass\") {\n console.log(\n ` ${vpLabel} ${chalk.green(\"✅ PASS\")} ${chalk.dim(diffStr)}`,\n );\n } else {\n const thresholdStr = `(threshold: ${result.comparison.threshold}%)`;\n console.log(\n ` ${vpLabel} ${chalk.red(\"❌ FAIL\")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`,\n );\n\n }\n }\n\n console.log(\"\");\n }\n\n // サマリー\n const statusColor =\n summary.overallStatus === \"pass\" ? chalk.green : chalk.red;\n console.log(\n `Summary: ${chalk.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? chalk.red(`${summary.failed} failed`) : `${summary.failed} failed`}${summary.errored > 0 ? `, ${chalk.yellow(`${summary.errored} errored`)}` : \"\"} / ${summary.totalTests} total`,\n );\n console.log(`Overall: ${statusColor(summary.overallStatus.toUpperCase())}`);\n console.log(`Duration: ${(meta.duration / 1000).toFixed(1)}s`);\n console.log(\"\");\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * VrtReport を JSON ファイルとして保存する。\n */\nexport async function writeJsonReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const filePath = join(outDir, \"report.json\");\n await writeFile(filePath, JSON.stringify(report, null, 2), \"utf-8\");\n return filePath;\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport, VrtTestResult } from \"../types.js\";\nimport reportTemplate from \"../templates/report.html\";\nimport reportStyles from \"../templates/report.css\";\n\n/**\n * 単一HTMLファイルのレポートを生成する。\n * 外部依存なし、CSSはインライン。\n */\nexport async function writeHtmlReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const html = generateHtml(report);\n const filePath = join(outDir, \"report.html\");\n await writeFile(filePath, html, \"utf-8\");\n return filePath;\n}\n\nfunction generateHtml(report: VrtReport): string {\n const { meta, summary, results } = report;\n\n const resultCards = results.map((r) => generateResultCard(r)).join(\"\\n\");\n\n const failedStat =\n summary.failed > 0\n ? `<div class=\"stat fail\">${summary.failed} Failed</div>`\n : \"\";\n\n return (reportTemplate as string)\n .replace(\"{{CSS}}\", reportStyles as string)\n .replace(/\\{\\{TIMESTAMP\\}\\}/g, meta.timestamp)\n .replace(\"{{DURATION}}\", (meta.duration / 1000).toFixed(1))\n .replace(\"{{BEFORE_URL}}\", meta.beforeUrl)\n .replace(\"{{AFTER_URL}}\", meta.afterUrl)\n .replace(\"{{TOTAL}}\", String(summary.totalTests))\n .replace(\"{{PASSED}}\", String(summary.passed))\n .replace(\"{{FAILED_STAT}}\", failedStat)\n .replace(\"{{RESULTS}}\", resultCards);\n}\n\nfunction generateResultCard(result: VrtTestResult): string {\n const { page, viewport, status, comparison, screenshots } = result;\n const vpLabel = viewport.type.toUpperCase();\n\n const imagesHtml = `\n <div class=\"comparison\">\n <div class=\"images\">\n <div class=\"img-container img-before\">\n <div class=\"label\">Before</div>\n <img src=\"${screenshots.before}\" alt=\"Before\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-after\">\n <div class=\"label\">After</div>\n <img src=\"${screenshots.after}\" alt=\"After\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-diff\">\n <div class=\"label\">Diff</div>\n <img src=\"${screenshots.diff}\" alt=\"Diff\" loading=\"lazy\">\n </div>\n </div>\n </div>`;\n\n // Passしたテストはアコーディオンに格納\n const screenshotSection =\n status === \"pass\"\n ? `<details class=\"screenshot-accordion\"><summary>Show</summary>${imagesHtml}</details>`\n : imagesHtml;\n\n return `\n <div class=\"card ${viewport.type}\">\n <div class=\"card-header\">\n <h3>${page.path} - ${vpLabel}</h3>\n <span class=\"badge ${status}\">${status === \"error\" ? \"ERROR\" : `${status.toUpperCase()} ${comparison.diffPercentage.toFixed(2)}%`}</span>\n </div>\n ${screenshotSection}\n </div>`;\n}\n","<!doctype html>\n<html lang=\"ja\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>VRT Report - {{TIMESTAMP}}</title>\n <style>\n {{CSS}}\n </style>\n </head>\n <body>\n <div class=\"header\">\n <h1>web-corders-vrt</h1>\n <div class=\"meta\">\n {{TIMESTAMP}} | Duration: {{DURATION}}s<br />\n Before: {{BEFORE_URL}} → After: {{AFTER_URL}}\n </div>\n </div>\n <div class=\"summary\">\n <div class=\"stat total\">{{TOTAL}} Total</div>\n <div class=\"stat pass\">{{PASSED}} Passed</div>\n {{FAILED_STAT}}\n </div>\n <div class=\"results\">\n {{RESULTS}}\n </div>\n </body>\n</html>\n","* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #f5f5f5;\n color: #333;\n}\n\n.header {\n background: #1a1a2e;\n color: white;\n padding: 24px 32px;\n text-align: center;\n}\n\n.header h1 {\n font-size: 20px;\n margin-bottom: 8px;\n}\n\n.header .meta {\n font-size: 13px;\n color: #aaa;\n}\n\n.summary {\n display: flex;\n gap: 16px;\n padding: 16px 32px;\n background: white;\n border-bottom: 1px solid #e0e0e0;\n}\n\n.summary .stat {\n padding: 8px 16px;\n border-radius: 6px;\n font-size: 14px;\n font-weight: 600;\n}\n\n.stat.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.stat.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.stat.total {\n background: #e3f2fd;\n color: #1565c0;\n}\n\n.results {\n padding: 24px 32px;\n display: flex;\n flex-direction: column;\n gap: 24px;\n}\n\n.card {\n background: white;\n border-radius: 8px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n overflow: hidden;\n}\n\n.card-header {\n padding: 16px 20px;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.card-header h3 {\n font-size: 15px;\n}\n\n.badge {\n padding: 4px 10px;\n border-radius: 12px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.badge.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.badge.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.badge.error {\n background: #fff3e0;\n color: #e65100;\n}\n\n.comparison {\n padding: 16px 20px;\n}\n\n.images {\n display: flex;\n gap: 8px;\n}\n\n.card.sp .img-container {\n flex: none;\n width: 375px;\n}\n\n.card.sp .img-container img {\n width: 375px;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.card.pc .img-container {\n flex: 1;\n min-width: 0;\n}\n\n.card.pc .img-container img {\n width: 100%;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.img-container .label {\n font-size: 11px;\n color: #888;\n margin-bottom: 4px;\n text-transform: uppercase;\n font-weight: 600;\n}\n\n.diff-info {\n padding: 8px 20px 16px;\n font-size: 13px;\n color: #666;\n}\n\n.diff-info .region {\n margin: 4px 0;\n padding-left: 16px;\n}\n\n/* Accordion for passed tests */\n.screenshot-accordion {\n border-top: 1px solid #eee;\n}\n\n.screenshot-accordion summary {\n padding: 12px 20px;\n cursor: pointer;\n font-size: 13px;\n font-weight: 600;\n color: #666;\n user-select: none;\n}\n\n.screenshot-accordion summary:hover {\n background: #fafafa;\n}\n","import { mkdir, writeFile, access } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport { DEFAULT_THRESHOLD } from \"../constants.js\";\n\nexport interface InitOptions {\n before: string;\n after: string;\n paths: string;\n threshold?: number;\n hide?: string;\n}\n\n/**\n * .claude/commands/vrt.md スキルファイルを生成する。\n */\nexport async function runInit(options: InitOptions): Promise<void> {\n const outDir = join(process.cwd(), \".claude\", \"commands\");\n const outFile = join(outDir, \"vrt.md\");\n\n // 既存ファイルの確認\n try {\n await access(outFile);\n console.log(chalk.yellow(`⚠ ${outFile} already exists. Overwriting...`));\n } catch {\n // ファイルが存在しない場合は正常\n }\n\n await mkdir(outDir, { recursive: true });\n\n const content = generateSkillFile(options);\n await writeFile(outFile, content, \"utf-8\");\n\n console.log(chalk.green(`✅ Created ${outFile}`));\n console.log(\"\");\n console.log(\n \"This skill file allows Claude Code to run VRT with the /vrt command.\",\n );\n console.log(\n \"Review and customize the file, then commit it to your repository.\",\n );\n}\n\nfunction generateSkillFile(options: InitOptions): string {\n // CLI コマンドを構築\n const cmdParts = [\"npx web-corders-vrt run\"];\n cmdParts.push(` --before ${options.before}`);\n cmdParts.push(` --after ${options.after}`);\n cmdParts.push(` --paths ${options.paths}`);\n\n if (options.threshold !== undefined && options.threshold !== DEFAULT_THRESHOLD) {\n cmdParts.push(` --threshold ${options.threshold}`);\n }\n\n if (options.hide) {\n cmdParts.push(` --hide \"${options.hide}\"`);\n }\n\n // --no-open をデフォルトでつける(Claude実行時にブラウザを開かないため)\n cmdParts.push(\" --no-open\");\n\n const command = cmdParts.join(\" \\\\\\n\");\n\n return `VRTを実行して本番環境とのビジュアル差分を検出する。\n\n## 手順\n\n1. 開発サーバーが起動していなければ \\`npm run dev\\` で起動して待機する\n2. 以下のコマンドでVRTを実行:\n\n\\`\\`\\`bash\n${command}\n\\`\\`\\`\n\n3. \\`./vrt-results/\\` 内の最新ディレクトリにある \\`report.json\\` を読む\n4. \\`status: \"fail\"\\` のテスト結果に注目する\n5. 該当するdiff画像(\\`*--diff.png\\`)をReadツールで視覚的に確認する\n6. diff画像から修正すべきCSSやHTMLの場所を特定する\n7. ソースコードを修正する\n8. 再度VRTを実行して修正が反映されたことを確認する\n`;\n}\n"],"mappings":"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","names":["writeFile","join","chalk","writeFile","join","report_default","join","writeFile","report_default","join","chalk","writeFile","mkdir","writeFile","join","chalk","join","chalk","mkdir","writeFile"]}
|
|
1
|
+
{"version":3,"sources":["../../bin/vrt.ts","../../src/schemas.ts","../../src/commands/run.ts","../../src/core/screenshotter.ts","../../src/constants.ts","../../src/core/stabilizer.ts","../../src/core/comparator.ts","../../src/reporters/terminal.ts","../../src/reporters/json.ts","../../src/reporters/html.ts","../../src/templates/report.html","../../src/templates/report.css","../../src/commands/init.ts","../../src/templates/SKILL-TEMPLATE.md"],"sourcesContent":["import { Command } from \"commander\";\nimport { cliOptionsSchema } from \"../src/schemas.js\";\nimport { runVrt } from \"../src/commands/run.js\";\nimport { runInit } from \"../src/commands/init.js\";\nimport type { ResolvedOptions } from \"../src/types.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"vrt\")\n .description(\"Visual Regression Testing CLI - Compare web pages visually\")\n .version(\"0.1.0\");\n\nprogram\n .command(\"run\")\n .description(\"Run visual regression tests\")\n .requiredOption(\"--reference <url>\", \"Reference URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .option(\"--no-html\", \"Skip HTML report generation\")\n .option(\"--no-open\", \"Do not open HTML report in browser\")\n .action(async (rawOptions) => {\n try {\n const parsed = cliOptionsSchema.parse(rawOptions);\n\n const options: ResolvedOptions = {\n referenceUrl: parsed.reference,\n afterUrl: parsed.after,\n paths: parsed.paths,\n threshold: parsed.threshold,\n hideSelectors: parsed.hide,\n html: parsed.html,\n open: parsed.open,\n };\n\n const report = await runVrt(options);\n process.exit(report.summary.overallStatus === \"pass\" ? 0 : 1);\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n } else {\n console.error(\"An unexpected error occurred\");\n }\n process.exit(2);\n }\n });\n\nprogram\n .command(\"init\")\n .description(\n \"Generate .claude/skills/web-corders-vrt/SKILL.md for this repository\",\n )\n .requiredOption(\"--refDomain <domain>\", \"Reference domain URL (production)\")\n .action(async (options) => {\n try {\n await runInit({ refDomain: options.refDomain });\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n }\n process.exit(1);\n }\n });\n\nprogram.parse();\n","import { z } from \"zod\";\n\nexport const cliOptionsSchema = z.object({\n reference: z.string().url(\"--reference must be a valid URL\"),\n after: z.string().url(\"--after must be a valid URL\"),\n paths: z.string().transform((val) => val.split(\",\").map((p) => p.trim())),\n threshold: z.coerce.number().min(0).max(100).default(0.1),\n hide: z\n .string()\n .optional()\n .transform((val) => (val ? val.split(\",\").map((s) => s.trim()) : [])),\n html: z.boolean().default(true),\n open: z.boolean().default(true),\n});\n\nexport type CliOptions = z.input<typeof cliOptionsSchema>;\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport ora from \"ora\";\nimport chalk from \"chalk\";\nimport { Screenshotter } from \"../core/screenshotter.js\";\nimport { compareImages } from \"../core/comparator.js\";\nimport { printTerminalReport } from \"../reporters/terminal.js\";\nimport { writeJsonReport } from \"../reporters/json.js\";\nimport { writeHtmlReport } from \"../reporters/html.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_OUT_DIR,\n DEFAULT_THRESHOLD,\n} from \"../constants.js\";\nimport type {\n ResolvedOptions,\n VrtReport,\n VrtTestResult,\n ViewportType,\n} from \"../types.js\";\n\nconst VIEWPORTS: ViewportType[] = [\"sp\", \"pc\"];\n\n/**\n * VRTのメイン実行コマンド。\n * スクリーンショット取得 → 比較 → レポート生成を一気に行う。\n */\nexport async function runVrt(options: ResolvedOptions): Promise<VrtReport> {\n const startTime = Date.now();\n\n // 出力ディレクトリを作成\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n const runDir = join(DEFAULT_OUT_DIR, timestamp);\n const screenshotsDir = join(runDir, \"screenshots\");\n await mkdir(screenshotsDir, { recursive: true });\n\n const screenshotter = new Screenshotter();\n const spinner = ora(\"Initializing browser...\").start();\n\n try {\n await screenshotter.initialize();\n\n // Reference スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.referenceUrl)}...`;\n const referenceScreenshots = await screenshotter.captureAll(\n options.referenceUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n // After スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.afterUrl)}...`;\n const afterScreenshots = await screenshotter.captureAll(\n options.afterUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n spinner.text = \"Comparing screenshots...\";\n\n // 比較を実行\n const results: VrtTestResult[] = [];\n\n for (const referenceShot of referenceScreenshots) {\n const afterShot = afterScreenshots.find(\n (a) =>\n a.pagePath === referenceShot.pagePath &&\n a.viewportType === referenceShot.viewportType,\n );\n\n if (!afterShot) {\n results.push(\n createErrorResult(\n referenceShot.pagePath,\n referenceShot.viewportType,\n options,\n \"After screenshot not found\",\n ),\n );\n continue;\n }\n\n try {\n const comparison = compareImages(\n referenceShot.buffer,\n afterShot.buffer,\n options.threshold,\n );\n // スクリーンショットを保存\n const filePrefix = `${referenceShot.pageName}--${referenceShot.viewportType}`;\n const referencePath = join(\"screenshots\", `${filePrefix}--reference.png`);\n const afterPath = join(\"screenshots\", `${filePrefix}--after.png`);\n const diffPath = join(\"screenshots\", `${filePrefix}--diff.png`);\n\n await writeFile(join(runDir, referencePath), referenceShot.buffer);\n await writeFile(join(runDir, afterPath), afterShot.buffer);\n await writeFile(join(runDir, diffPath), comparison.diffImage);\n\n const pageName =\n referenceShot.pagePath === \"/\"\n ? \"トップページ\"\n : referenceShot.pagePath.replace(/^\\//, \"\");\n const viewport = getViewportDimensions(referenceShot.viewportType);\n\n results.push({\n page: {\n path: referenceShot.pagePath,\n name: pageName,\n url: {\n reference: new URL(\n referenceShot.pagePath,\n options.referenceUrl,\n ).toString(),\n after: new URL(referenceShot.pagePath, options.afterUrl).toString(),\n },\n },\n viewport: {\n type: referenceShot.viewportType,\n ...viewport,\n },\n status: comparison.passed ? \"pass\" : \"fail\",\n comparison: {\n diffPercentage: comparison.diffPercentage,\n diffPixelCount: comparison.diffCount,\n totalPixels: comparison.totalPixels,\n threshold: options.threshold,\n dimensions: comparison.dimensions,\n },\n screenshots: {\n reference: referencePath,\n after: afterPath,\n diff: diffPath,\n },\n });\n } catch (error) {\n results.push(\n createErrorResult(\n referenceShot.pagePath,\n referenceShot.viewportType,\n options,\n error instanceof Error ? error.message : String(error),\n ),\n );\n }\n }\n\n const duration = Date.now() - startTime;\n const passed = results.filter((r) => r.status === \"pass\").length;\n const failed = results.filter((r) => r.status === \"fail\").length;\n const errored = results.filter((r) => r.status === \"error\").length;\n\n const report: VrtReport = {\n version: \"1.0\",\n meta: {\n timestamp,\n referenceUrl: options.referenceUrl,\n afterUrl: options.afterUrl,\n duration,\n command: buildCommandString(options),\n },\n summary: {\n totalTests: results.length,\n passed,\n failed,\n errored,\n overallStatus: failed > 0 || errored > 0 ? \"fail\" : \"pass\",\n },\n results,\n };\n\n spinner.stop();\n\n // レポート出力\n printTerminalReport(report);\n\n const jsonPath = await writeJsonReport(report, runDir);\n console.log(`JSON report: ${chalk.dim(jsonPath)}`);\n\n if (options.html) {\n const htmlPath = await writeHtmlReport(report, runDir);\n console.log(`HTML report: ${chalk.dim(htmlPath)}`);\n\n if (options.open) {\n const { exec } = await import(\"node:child_process\");\n exec(`open \"${htmlPath}\"`);\n }\n }\n\n console.log(`Output dir: ${chalk.dim(runDir)}`);\n\n return report;\n } finally {\n await screenshotter.cleanup();\n }\n}\n\nfunction getViewportDimensions(\n type: ViewportType,\n): { width: number; height: number } {\n return type === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n}\n\nfunction createErrorResult(\n pagePath: string,\n viewportType: ViewportType,\n options: ResolvedOptions,\n error: string,\n): VrtTestResult {\n const viewport = getViewportDimensions(viewportType);\n const pageName =\n pagePath === \"/\" ? \"トップページ\" : pagePath.replace(/^\\//, \"\");\n return {\n page: {\n path: pagePath,\n name: pageName,\n url: {\n reference: new URL(pagePath, options.referenceUrl).toString(),\n after: new URL(pagePath, options.afterUrl).toString(),\n },\n },\n viewport: { type: viewportType, ...viewport },\n status: \"error\",\n comparison: {\n diffPercentage: 0,\n diffPixelCount: 0,\n totalPixels: 0,\n threshold: options.threshold,\n dimensions: { width: 0, height: 0, referenceHeight: 0, afterHeight: 0 },\n },\n screenshots: { reference: \"\", after: \"\", diff: \"\" },\n error,\n };\n}\n\nfunction buildCommandString(options: ResolvedOptions): string {\n const parts = [\"npx web-corders-vrt run\"];\n parts.push(`--reference ${options.referenceUrl}`);\n parts.push(`--after ${options.afterUrl}`);\n parts.push(`--paths ${options.paths.join(\",\")}`);\n if (options.threshold !== DEFAULT_THRESHOLD) {\n parts.push(`--threshold ${options.threshold}`);\n }\n if (options.hideSelectors.length > 0) {\n parts.push(`--hide \"${options.hideSelectors.join(\",\")}\"`);\n }\n return parts.join(\" \\\\\\n \");\n}\n","import { chromium, type Browser, type BrowserContext } from \"playwright\";\nimport type { ScreenshotResult, ViewportType } from \"../types.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_DELAY,\n DEFAULT_CONCURRENCY,\n SP_USER_AGENT,\n BLOCKED_DOMAINS,\n} from \"../constants.js\";\nimport { stabilizePage, getDateMockScript } from \"./stabilizer.js\";\n\nexport class Screenshotter {\n private browser: Browser | null = null;\n\n async initialize(): Promise<void> {\n this.browser = await chromium.launch();\n }\n\n /**\n * 指定URLのスクリーンショットを取得する。\n */\n async capture(\n url: string,\n pagePath: string,\n viewportType: ViewportType,\n hideSelectors: string[],\n ): Promise<ScreenshotResult> {\n if (!this.browser) {\n throw new Error(\"Browser not initialized. Call initialize() first.\");\n }\n\n const viewport =\n viewportType === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n\n const context: BrowserContext = await this.browser.newContext({\n viewport,\n deviceScaleFactor: viewportType === \"sp\" ? 2 : 1,\n userAgent: viewportType === \"sp\" ? SP_USER_AGENT : undefined,\n });\n\n const page = await context.newPage();\n\n // 日付をモック\n await page.addInitScript(getDateMockScript());\n\n // 広告・計測系リクエストをブロック\n await page.route(\"**/*\", (route) => {\n const reqUrl = route.request().url();\n const shouldBlock = BLOCKED_DOMAINS.some((domain) =>\n reqUrl.includes(domain),\n );\n if (shouldBlock) {\n return route.abort();\n }\n return route.continue();\n });\n\n // ページにアクセス\n await page.goto(url, { waitUntil: \"load\", timeout: 60000 });\n\n // ページの安定化\n await stabilizePage(page, {\n hideSelectors,\n delay: DEFAULT_DELAY,\n });\n\n // スクリーンショット取得\n const buffer = await page.screenshot({\n fullPage: true,\n type: \"png\",\n });\n\n // 画像サイズを取得(PNGヘッダから読む)\n const width = buffer.readUInt32BE(16);\n const height = buffer.readUInt32BE(20);\n\n await context.close();\n\n // パスからページ名を生成\n const pageName =\n pagePath === \"/\"\n ? \"top\"\n : pagePath.replace(/^\\//, \"\").replace(/\\//g, \"-\");\n\n return {\n pagePath,\n pageName,\n viewportType,\n buffer: Buffer.from(buffer),\n width,\n height,\n };\n }\n\n /**\n * 複数のページ・ビューポートのスクリーンショットを並行で取得する。\n */\n async captureAll(\n baseUrl: string,\n paths: string[],\n viewports: ViewportType[],\n hideSelectors: string[],\n ): Promise<ScreenshotResult[]> {\n const tasks: Array<{ url: string; path: string; viewport: ViewportType }> =\n [];\n\n for (const pagePath of paths) {\n const url = new URL(pagePath, baseUrl).toString();\n for (const viewport of viewports) {\n tasks.push({ url, path: pagePath, viewport });\n }\n }\n\n // 並行実行制御\n const results: ScreenshotResult[] = [];\n\n for (let i = 0; i < tasks.length; i += DEFAULT_CONCURRENCY) {\n const batch = tasks.slice(i, i + DEFAULT_CONCURRENCY);\n const batchResults = await Promise.all(\n batch.map((task) =>\n this.capture(task.url, task.path, task.viewport, hideSelectors),\n ),\n );\n results.push(...batchResults);\n }\n\n return results;\n }\n\n async cleanup(): Promise<void> {\n await this.browser?.close();\n this.browser = null;\n }\n}\n","export const DEFAULT_SP_VIEWPORT = { width: 375, height: 812 } as const;\nexport const DEFAULT_PC_VIEWPORT = { width: 1440, height: 900 } as const;\n\nexport const DEFAULT_THRESHOLD = 0.1;\nexport const DEFAULT_DELAY = 1000;\nexport const DEFAULT_CONCURRENCY = 3;\nexport const DEFAULT_OUT_DIR = \"./vrt-results\";\n\nexport const SP_USER_AGENT =\n \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1\";\n\n/** VRTに不要な広告・計測系ドメイン(常にブロック) */\nexport const BLOCKED_DOMAINS = [\n \"googletagmanager.com\",\n \"google-analytics.com\",\n \"googleadservices.com\",\n \"googlesyndication.com\",\n \"doubleclick.net\",\n \"facebook.net\",\n \"fbcdn.net\",\n \"analytics.yahoo.co.jp\",\n \"clarity.ms\",\n \"hotjar.com\",\n \"newrelic.com\",\n \"sentry.io\",\n \"datadoghq.com\",\n];\n\n/** VRTに不要な開発ツール系要素(常に非表示) */\nexport const DEFAULT_HIDE_SELECTORS = [\"#devtools-indicator\"];\n\n/** アニメーション・トランジションを無効化するCSS */\nexport const DISABLE_ANIMATIONS_CSS = `\n*, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n scroll-behavior: auto !important;\n caret-color: transparent !important;\n}\n`;\n","import type { Page } from \"playwright\";\nimport {\n DISABLE_ANIMATIONS_CSS,\n DEFAULT_HIDE_SELECTORS,\n} from \"../constants.js\";\n\nexport interface StabilizeOptions {\n disableAnimations?: boolean;\n hideSelectors?: string[];\n delay?: number;\n}\n\n/**\n * ページの動的コンテンツを安定化させる。\n * アニメーション無効化、要素の非表示、日付のモックなどを行う。\n */\nexport async function stabilizePage(\n page: Page,\n options: StabilizeOptions,\n): Promise<void> {\n // アニメーション・トランジションを無効化\n if (options.disableAnimations !== false) {\n await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });\n }\n\n // 指定要素を非表示にする(visibility: hidden でレイアウトを崩さない)\n const allHideSelectors = [\n ...DEFAULT_HIDE_SELECTORS,\n ...(options.hideSelectors ?? []),\n ];\n if (allHideSelectors.length > 0) {\n const hideCSS = allHideSelectors\n .map((s) => `${s} { visibility: hidden !important; }`)\n .join(\"\\n\");\n await page.addStyleTag({ content: hideCSS });\n }\n\n // 追加の待機時間(hydration完了を待つ)\n if (options.delay && options.delay > 0) {\n await page.waitForTimeout(options.delay);\n }\n\n // Shadow DOM内の要素はCSSが届かないのでJSで削除(Next.js devtools等)\n if (allHideSelectors.length > 0) {\n await page.evaluate(`\n for (const sel of ${JSON.stringify(allHideSelectors)}) {\n document.querySelectorAll(sel).forEach(el => el.remove());\n document.querySelectorAll(\"*\").forEach(el => {\n if (el.shadowRoot) {\n el.shadowRoot.querySelectorAll(sel).forEach(inner => inner.remove());\n }\n });\n }\n `);\n }\n}\n\n/**\n * 日付をモックして固定値にする初期化スクリプト。\n * page.goto() の前に page.addInitScript() で使う。\n */\nexport function getDateMockScript(): string {\n return `\n (() => {\n const fixedDate = new Date('2025-01-01T00:00:00Z');\n const OrigDate = Date;\n const MockDate = class extends OrigDate {\n constructor(...args) {\n if (args.length === 0) {\n super(fixedDate.getTime());\n } else {\n super(...args);\n }\n }\n static now() { return fixedDate.getTime(); }\n };\n globalThis.Date = MockDate;\n })();\n `;\n}\n","import pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\nimport type { ComparisonResult } from \"../types.js\";\n\n/**\n * 2つのPNG画像をピクセル単位で比較し、差分画像を生成する。\n * サイズが異なる場合は大きい方に合わせて白パディングで拡張する。\n */\nexport function compareImages(\n referenceBuffer: Buffer,\n afterBuffer: Buffer,\n threshold: number = 0.1,\n): ComparisonResult {\n const reference = PNG.sync.read(referenceBuffer);\n const after = PNG.sync.read(afterBuffer);\n\n const width = Math.max(reference.width, after.width);\n const height = Math.max(reference.height, after.height);\n\n // サイズが異なる場合は正規化\n const normalizedReference = normalizeImage(reference, width, height);\n const normalizedAfter = normalizeImage(after, width, height);\n\n const diff = new PNG({ width, height });\n\n const diffCount = pixelmatch(\n normalizedReference.data,\n normalizedAfter.data,\n diff.data,\n width,\n height,\n {\n threshold: 0.1,\n includeAA: false,\n alpha: 0.1,\n diffColor: [255, 0, 0],\n diffColorAlt: [0, 200, 0],\n },\n );\n\n const totalPixels = width * height;\n const diffPercentage = (diffCount / totalPixels) * 100;\n\n return {\n diffCount,\n totalPixels,\n diffPercentage,\n diffImage: PNG.sync.write(diff),\n passed: diffPercentage <= threshold,\n dimensions: {\n width,\n height,\n referenceHeight: reference.height,\n afterHeight: after.height,\n },\n };\n}\n\n/**\n * 画像を指定サイズに正規化する。\n * 小さい場合は白(#ffffff)でパディングする。\n */\nfunction normalizeImage(\n png: PNG,\n targetWidth: number,\n targetHeight: number,\n): PNG {\n if (png.width === targetWidth && png.height === targetHeight) {\n return png;\n }\n\n const normalized = new PNG({ width: targetWidth, height: targetHeight });\n\n // 白で埋める\n for (let i = 0; i < normalized.data.length; i += 4) {\n normalized.data[i] = 255; // R\n normalized.data[i + 1] = 255; // G\n normalized.data[i + 2] = 255; // B\n normalized.data[i + 3] = 255; // A\n }\n\n // 元の画像をコピー\n for (let y = 0; y < png.height; y++) {\n for (let x = 0; x < png.width; x++) {\n const srcIdx = (y * png.width + x) * 4;\n const dstIdx = (y * targetWidth + x) * 4;\n normalized.data[dstIdx] = png.data[srcIdx];\n normalized.data[dstIdx + 1] = png.data[srcIdx + 1];\n normalized.data[dstIdx + 2] = png.data[srcIdx + 2];\n normalized.data[dstIdx + 3] = png.data[srcIdx + 3];\n }\n }\n\n return normalized;\n}\n","import chalk from \"chalk\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * ターミナルにカラー出力でVRT結果を表示する。\n */\nexport function printTerminalReport(report: VrtReport): void {\n const { meta, summary, results } = report;\n\n console.log(\"\");\n console.log(chalk.bold(`VRT Results - ${meta.timestamp}`));\n console.log(\"=\".repeat(50));\n console.log(\"\");\n console.log(\n `Comparing: ${chalk.cyan(meta.referenceUrl)} vs ${chalk.cyan(meta.afterUrl)}`,\n );\n console.log(\"\");\n\n // ページごとにグループ化\n const groupedByPage = new Map<string, typeof results>();\n for (const result of results) {\n const key = result.page.path;\n if (!groupedByPage.has(key)) {\n groupedByPage.set(key, []);\n }\n groupedByPage.get(key)!.push(result);\n }\n\n for (const [pagePath, pageResults] of groupedByPage) {\n const pageName = pageResults[0].page.name;\n console.log(` Page: ${chalk.bold(pageName)} (${pagePath})`);\n\n for (const result of pageResults) {\n const vpLabel = `${result.viewport.type.toUpperCase()} (${result.viewport.width}x${result.viewport.height})`;\n\n if (result.status === \"error\") {\n console.log(\n ` ${vpLabel} ${chalk.yellow(\"⚠ ERROR\")} ${result.error || \"Unknown error\"}`,\n );\n continue;\n }\n\n const diffStr = `diff: ${result.comparison.diffPercentage.toFixed(2)}%`;\n\n if (result.status === \"pass\") {\n console.log(\n ` ${vpLabel} ${chalk.green(\"✅ PASS\")} ${chalk.dim(diffStr)}`,\n );\n } else {\n const thresholdStr = `(threshold: ${result.comparison.threshold}%)`;\n console.log(\n ` ${vpLabel} ${chalk.red(\"❌ FAIL\")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`,\n );\n\n }\n }\n\n console.log(\"\");\n }\n\n // サマリー\n const statusColor =\n summary.overallStatus === \"pass\" ? chalk.green : chalk.red;\n console.log(\n `Summary: ${chalk.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? chalk.red(`${summary.failed} failed`) : `${summary.failed} failed`}${summary.errored > 0 ? `, ${chalk.yellow(`${summary.errored} errored`)}` : \"\"} / ${summary.totalTests} total`,\n );\n console.log(`Overall: ${statusColor(summary.overallStatus.toUpperCase())}`);\n console.log(`Duration: ${(meta.duration / 1000).toFixed(1)}s`);\n console.log(\"\");\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * VrtReport を JSON ファイルとして保存する。\n */\nexport async function writeJsonReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const filePath = join(outDir, \"report.json\");\n await writeFile(filePath, JSON.stringify(report, null, 2), \"utf-8\");\n return filePath;\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport, VrtTestResult } from \"../types.js\";\nimport reportTemplate from \"../templates/report.html\";\nimport reportStyles from \"../templates/report.css\";\n\n/**\n * 単一HTMLファイルのレポートを生成する。\n * 外部依存なし、CSSはインライン。\n */\nexport async function writeHtmlReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const html = generateHtml(report);\n const filePath = join(outDir, \"report.html\");\n await writeFile(filePath, html, \"utf-8\");\n return filePath;\n}\n\nfunction generateHtml(report: VrtReport): string {\n const { meta, summary, results } = report;\n\n const resultCards = results.map((r) => generateResultCard(r)).join(\"\\n\");\n\n const failedStat =\n summary.failed > 0\n ? `<div class=\"stat fail\">${summary.failed} Failed</div>`\n : \"\";\n\n return (reportTemplate as string)\n .replace(\"{{CSS}}\", reportStyles as string)\n .replace(/\\{\\{TIMESTAMP\\}\\}/g, meta.timestamp)\n .replace(\"{{DURATION}}\", (meta.duration / 1000).toFixed(1))\n .replace(\"{{REFERENCE_URL}}\", meta.referenceUrl)\n .replace(\"{{AFTER_URL}}\", meta.afterUrl)\n .replace(\"{{TOTAL}}\", String(summary.totalTests))\n .replace(\"{{PASSED}}\", String(summary.passed))\n .replace(\"{{FAILED_STAT}}\", failedStat)\n .replace(\"{{RESULTS}}\", resultCards);\n}\n\nfunction generateResultCard(result: VrtTestResult): string {\n const { page, viewport, status, comparison, screenshots } = result;\n const vpLabel = viewport.type.toUpperCase();\n\n const imagesHtml = `\n <div class=\"comparison\">\n <div class=\"images\">\n <div class=\"img-container img-reference\">\n <div class=\"label\">Reference</div>\n <img src=\"${screenshots.reference}\" alt=\"Reference\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-after\">\n <div class=\"label\">After</div>\n <img src=\"${screenshots.after}\" alt=\"After\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-diff\">\n <div class=\"label\">Diff</div>\n <img src=\"${screenshots.diff}\" alt=\"Diff\" loading=\"lazy\">\n </div>\n </div>\n </div>`;\n\n // Passしたテストはアコーディオンに格納\n const screenshotSection =\n status === \"pass\"\n ? `<details class=\"screenshot-accordion\"><summary>Show</summary>${imagesHtml}</details>`\n : imagesHtml;\n\n return `\n <div class=\"card ${viewport.type}\">\n <div class=\"card-header\">\n <h3>${page.path} - ${vpLabel}</h3>\n <span class=\"badge ${status}\">${status === \"error\" ? \"ERROR\" : `${status.toUpperCase()} ${comparison.diffPercentage.toFixed(2)}%`}</span>\n </div>\n ${screenshotSection}\n </div>`;\n}\n","<!doctype html>\n<html lang=\"ja\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>VRT Report - {{TIMESTAMP}}</title>\n <style>\n {{CSS}}\n </style>\n </head>\n <body>\n <div class=\"header\">\n <h1>web-corders-vrt</h1>\n <div class=\"meta\">\n {{TIMESTAMP}} | Duration: {{DURATION}}s<br />\n Reference: {{REFERENCE_URL}} → After: {{AFTER_URL}}\n </div>\n </div>\n <div class=\"summary\">\n <div class=\"stat total\">{{TOTAL}} Total</div>\n <div class=\"stat pass\">{{PASSED}} Passed</div>\n {{FAILED_STAT}}\n </div>\n <div class=\"results\">\n {{RESULTS}}\n </div>\n </body>\n</html>\n","* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #f5f5f5;\n color: #333;\n}\n\n.header {\n background: #1a1a2e;\n color: white;\n padding: 24px 32px;\n text-align: center;\n}\n\n.header h1 {\n font-size: 20px;\n margin-bottom: 8px;\n}\n\n.header .meta {\n font-size: 13px;\n color: #aaa;\n}\n\n.summary {\n display: flex;\n gap: 16px;\n padding: 16px 32px;\n background: white;\n border-bottom: 1px solid #e0e0e0;\n}\n\n.summary .stat {\n padding: 8px 16px;\n border-radius: 6px;\n font-size: 14px;\n font-weight: 600;\n}\n\n.stat.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.stat.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.stat.total {\n background: #e3f2fd;\n color: #1565c0;\n}\n\n.results {\n padding: 24px 32px;\n display: flex;\n flex-direction: column;\n gap: 24px;\n}\n\n.card {\n background: white;\n border-radius: 8px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n overflow: hidden;\n}\n\n.card-header {\n padding: 16px 20px;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.card-header h3 {\n font-size: 15px;\n}\n\n.badge {\n padding: 4px 10px;\n border-radius: 12px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.badge.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.badge.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.badge.error {\n background: #fff3e0;\n color: #e65100;\n}\n\n.comparison {\n padding: 16px 20px;\n}\n\n.images {\n display: flex;\n gap: 8px;\n}\n\n.card.sp .img-container {\n flex: none;\n width: 375px;\n}\n\n.card.sp .img-container img {\n width: 375px;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.card.pc .img-container {\n flex: 1;\n min-width: 0;\n}\n\n.card.pc .img-container img {\n width: 100%;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.img-container .label {\n font-size: 11px;\n color: #888;\n margin-bottom: 4px;\n text-transform: uppercase;\n font-weight: 600;\n}\n\n.diff-info {\n padding: 8px 20px 16px;\n font-size: 13px;\n color: #666;\n}\n\n.diff-info .region {\n margin: 4px 0;\n padding-left: 16px;\n}\n\n/* Accordion for passed tests */\n.screenshot-accordion {\n border-top: 1px solid #eee;\n}\n\n.screenshot-accordion summary {\n padding: 12px 20px;\n cursor: pointer;\n font-size: 13px;\n font-weight: 600;\n color: #666;\n user-select: none;\n}\n\n.screenshot-accordion summary:hover {\n background: #fafafa;\n}\n","import { mkdir, writeFile, access } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport skillTemplate from \"../templates/SKILL-TEMPLATE.md\";\n\nexport interface InitOptions {\n refDomain: string;\n}\n\n/**\n * .claude/skills/web-corders-vrt/SKILL.md スキルファイルを生成する。\n */\nexport async function runInit(options: InitOptions): Promise<void> {\n const outDir = join(process.cwd(), \".claude\", \"skills\", \"web-corders-vrt\");\n const outFile = join(outDir, \"SKILL.md\");\n\n // 既存ファイルの確認\n try {\n await access(outFile);\n console.log(chalk.yellow(`⚠ ${outFile} already exists. Overwriting...`));\n } catch {\n // ファイルが存在しない場合は正常\n }\n\n await mkdir(outDir, { recursive: true });\n\n const content = (skillTemplate as string).replace(\n /\\$\\{referenceDomain\\}/g,\n options.refDomain,\n );\n await writeFile(outFile, content, \"utf-8\");\n\n console.log(chalk.green(`✅ Created ${outFile}`));\n console.log(\"\");\n console.log(\n \"This skill file allows Claude Code to run VRT with the /vrt command.\",\n );\n console.log(\n \"Review and customize the file, then commit it to your repository.\",\n );\n}\n","---\nname: web-corders-vrt\ndescription: WEB用VRT(Visual Regression Test)ツール。本番ドメインとローカル開発またはプレビュードメインの間で、特定パスのビジュアル差分を検出する。\n---\n\n## 使い方\n\n```bash\nnpx web-corders-vrt run \\\n --reference ${referenceDomain} \\\n --after <開発環境のドメイン|ユーザーから指示がない場合はhttp://localhost:3000> \\\n --paths <テスト対象のパス(カンマ区切り)> \\\n```\n\n## 手順\n\n1. ユーザーから比較対象のドメインを指定された場合、それに従う。指示がない場合、afterは開発サーバーとし、起動していなければ `npm run dev` で起動して待機する。ここで起動したドメインとプロトコルをafterのドメインとする。\n2. 上記のコマンドでVRTを実行する(`--paths` はタスクに応じて設定する)\n3. `./vrt-results/` 内の最新ディレクトリにある `report.json` を読む\n4. `status: \"fail\"` のテスト結果に注目する\n5. 該当するdiff画像(`*--diff.png`)をReadツールで視覚的に確認する\n6. diff画像から修正すべきCSSやHTMLの場所を特定する\n7. ソースコードを修正する\n8. 再度VRTを実行して修正が反映されたことを確認する\n\n## オプション\n\n| オプション | 説明 |\n| -------------------- | ------------------------------------------- |\n| `--reference <url>` | リファレンスプロトコル+ドメイン(本番環境) |\n| `--after <url>` | 比較先Uプロトコル+ドメイン(開発環境) |\n| `--paths <paths>` | テスト対象のページパス(カンマ区切り) |\n| `--threshold <n>` | 差分許容率(%)。デフォルト: 0.1 |\n| `--hide <selectors>` | 非表示にするCSSセレクタ(カンマ区切り) |\n| `--no-open` | HTMLレポートをブラウザで開かない |\n"],"mappings":"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","names":["writeFile","join","chalk","writeFile","join","report_default","join","writeFile","report_default","join","chalk","writeFile","mkdir","writeFile","join","chalk","join","chalk","mkdir","writeFile"]}
|
package/package.json
CHANGED
package/src/commands/init.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import { mkdir, writeFile, access } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
-
import
|
|
4
|
+
import skillTemplate from "../templates/SKILL-TEMPLATE.md";
|
|
5
5
|
|
|
6
6
|
export interface InitOptions {
|
|
7
|
-
|
|
8
|
-
after: string;
|
|
9
|
-
paths: string;
|
|
10
|
-
threshold?: number;
|
|
11
|
-
hide?: string;
|
|
7
|
+
refDomain: string;
|
|
12
8
|
}
|
|
13
9
|
|
|
14
10
|
/**
|
|
15
|
-
* .claude/
|
|
11
|
+
* .claude/skills/web-corders-vrt/SKILL.md スキルファイルを生成する。
|
|
16
12
|
*/
|
|
17
13
|
export async function runInit(options: InitOptions): Promise<void> {
|
|
18
|
-
const outDir = join(process.cwd(), ".claude", "
|
|
19
|
-
const outFile = join(outDir, "
|
|
14
|
+
const outDir = join(process.cwd(), ".claude", "skills", "web-corders-vrt");
|
|
15
|
+
const outFile = join(outDir, "SKILL.md");
|
|
20
16
|
|
|
21
17
|
// 既存ファイルの確認
|
|
22
18
|
try {
|
|
@@ -28,7 +24,10 @@ export async function runInit(options: InitOptions): Promise<void> {
|
|
|
28
24
|
|
|
29
25
|
await mkdir(outDir, { recursive: true });
|
|
30
26
|
|
|
31
|
-
const content =
|
|
27
|
+
const content = (skillTemplate as string).replace(
|
|
28
|
+
/\$\{referenceDomain\}/g,
|
|
29
|
+
options.refDomain,
|
|
30
|
+
);
|
|
32
31
|
await writeFile(outFile, content, "utf-8");
|
|
33
32
|
|
|
34
33
|
console.log(chalk.green(`✅ Created ${outFile}`));
|
|
@@ -40,43 +39,3 @@ export async function runInit(options: InitOptions): Promise<void> {
|
|
|
40
39
|
"Review and customize the file, then commit it to your repository.",
|
|
41
40
|
);
|
|
42
41
|
}
|
|
43
|
-
|
|
44
|
-
function generateSkillFile(options: InitOptions): string {
|
|
45
|
-
// CLI コマンドを構築
|
|
46
|
-
const cmdParts = ["npx web-corders-vrt run"];
|
|
47
|
-
cmdParts.push(` --before ${options.before}`);
|
|
48
|
-
cmdParts.push(` --after ${options.after}`);
|
|
49
|
-
cmdParts.push(` --paths ${options.paths}`);
|
|
50
|
-
|
|
51
|
-
if (options.threshold !== undefined && options.threshold !== DEFAULT_THRESHOLD) {
|
|
52
|
-
cmdParts.push(` --threshold ${options.threshold}`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (options.hide) {
|
|
56
|
-
cmdParts.push(` --hide "${options.hide}"`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// --no-open をデフォルトでつける(Claude実行時にブラウザを開かないため)
|
|
60
|
-
cmdParts.push(" --no-open");
|
|
61
|
-
|
|
62
|
-
const command = cmdParts.join(" \\\n");
|
|
63
|
-
|
|
64
|
-
return `VRTを実行して本番環境とのビジュアル差分を検出する。
|
|
65
|
-
|
|
66
|
-
## 手順
|
|
67
|
-
|
|
68
|
-
1. 開発サーバーが起動していなければ \`npm run dev\` で起動して待機する
|
|
69
|
-
2. 以下のコマンドでVRTを実行:
|
|
70
|
-
|
|
71
|
-
\`\`\`bash
|
|
72
|
-
${command}
|
|
73
|
-
\`\`\`
|
|
74
|
-
|
|
75
|
-
3. \`./vrt-results/\` 内の最新ディレクトリにある \`report.json\` を読む
|
|
76
|
-
4. \`status: "fail"\` のテスト結果に注目する
|
|
77
|
-
5. 該当するdiff画像(\`*--diff.png\`)をReadツールで視覚的に確認する
|
|
78
|
-
6. diff画像から修正すべきCSSやHTMLの場所を特定する
|
|
79
|
-
7. ソースコードを修正する
|
|
80
|
-
8. 再度VRTを実行して修正が反映されたことを確認する
|
|
81
|
-
`;
|
|
82
|
-
}
|
package/src/commands/run.ts
CHANGED
|
@@ -41,10 +41,10 @@ export async function runVrt(options: ResolvedOptions): Promise<VrtReport> {
|
|
|
41
41
|
try {
|
|
42
42
|
await screenshotter.initialize();
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
spinner.text = `Taking screenshots of ${chalk.cyan(options.
|
|
46
|
-
const
|
|
47
|
-
options.
|
|
44
|
+
// Reference スクリーンショット取得
|
|
45
|
+
spinner.text = `Taking screenshots of ${chalk.cyan(options.referenceUrl)}...`;
|
|
46
|
+
const referenceScreenshots = await screenshotter.captureAll(
|
|
47
|
+
options.referenceUrl,
|
|
48
48
|
options.paths,
|
|
49
49
|
VIEWPORTS,
|
|
50
50
|
options.hideSelectors,
|
|
@@ -64,18 +64,18 @@ export async function runVrt(options: ResolvedOptions): Promise<VrtReport> {
|
|
|
64
64
|
// 比較を実行
|
|
65
65
|
const results: VrtTestResult[] = [];
|
|
66
66
|
|
|
67
|
-
for (const
|
|
67
|
+
for (const referenceShot of referenceScreenshots) {
|
|
68
68
|
const afterShot = afterScreenshots.find(
|
|
69
69
|
(a) =>
|
|
70
|
-
a.pagePath ===
|
|
71
|
-
a.viewportType ===
|
|
70
|
+
a.pagePath === referenceShot.pagePath &&
|
|
71
|
+
a.viewportType === referenceShot.viewportType,
|
|
72
72
|
);
|
|
73
73
|
|
|
74
74
|
if (!afterShot) {
|
|
75
75
|
results.push(
|
|
76
76
|
createErrorResult(
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
referenceShot.pagePath,
|
|
78
|
+
referenceShot.viewportType,
|
|
79
79
|
options,
|
|
80
80
|
"After screenshot not found",
|
|
81
81
|
),
|
|
@@ -85,40 +85,40 @@ export async function runVrt(options: ResolvedOptions): Promise<VrtReport> {
|
|
|
85
85
|
|
|
86
86
|
try {
|
|
87
87
|
const comparison = compareImages(
|
|
88
|
-
|
|
88
|
+
referenceShot.buffer,
|
|
89
89
|
afterShot.buffer,
|
|
90
90
|
options.threshold,
|
|
91
91
|
);
|
|
92
92
|
// スクリーンショットを保存
|
|
93
|
-
const filePrefix = `${
|
|
94
|
-
const
|
|
93
|
+
const filePrefix = `${referenceShot.pageName}--${referenceShot.viewportType}`;
|
|
94
|
+
const referencePath = join("screenshots", `${filePrefix}--reference.png`);
|
|
95
95
|
const afterPath = join("screenshots", `${filePrefix}--after.png`);
|
|
96
96
|
const diffPath = join("screenshots", `${filePrefix}--diff.png`);
|
|
97
97
|
|
|
98
|
-
await writeFile(join(runDir,
|
|
98
|
+
await writeFile(join(runDir, referencePath), referenceShot.buffer);
|
|
99
99
|
await writeFile(join(runDir, afterPath), afterShot.buffer);
|
|
100
100
|
await writeFile(join(runDir, diffPath), comparison.diffImage);
|
|
101
101
|
|
|
102
102
|
const pageName =
|
|
103
|
-
|
|
103
|
+
referenceShot.pagePath === "/"
|
|
104
104
|
? "トップページ"
|
|
105
|
-
:
|
|
106
|
-
const viewport = getViewportDimensions(
|
|
105
|
+
: referenceShot.pagePath.replace(/^\//, "");
|
|
106
|
+
const viewport = getViewportDimensions(referenceShot.viewportType);
|
|
107
107
|
|
|
108
108
|
results.push({
|
|
109
109
|
page: {
|
|
110
|
-
path:
|
|
110
|
+
path: referenceShot.pagePath,
|
|
111
111
|
name: pageName,
|
|
112
112
|
url: {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
options.
|
|
113
|
+
reference: new URL(
|
|
114
|
+
referenceShot.pagePath,
|
|
115
|
+
options.referenceUrl,
|
|
116
116
|
).toString(),
|
|
117
|
-
after: new URL(
|
|
117
|
+
after: new URL(referenceShot.pagePath, options.afterUrl).toString(),
|
|
118
118
|
},
|
|
119
119
|
},
|
|
120
120
|
viewport: {
|
|
121
|
-
type:
|
|
121
|
+
type: referenceShot.viewportType,
|
|
122
122
|
...viewport,
|
|
123
123
|
},
|
|
124
124
|
status: comparison.passed ? "pass" : "fail",
|
|
@@ -130,7 +130,7 @@ export async function runVrt(options: ResolvedOptions): Promise<VrtReport> {
|
|
|
130
130
|
dimensions: comparison.dimensions,
|
|
131
131
|
},
|
|
132
132
|
screenshots: {
|
|
133
|
-
|
|
133
|
+
reference: referencePath,
|
|
134
134
|
after: afterPath,
|
|
135
135
|
diff: diffPath,
|
|
136
136
|
},
|
|
@@ -138,8 +138,8 @@ export async function runVrt(options: ResolvedOptions): Promise<VrtReport> {
|
|
|
138
138
|
} catch (error) {
|
|
139
139
|
results.push(
|
|
140
140
|
createErrorResult(
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
referenceShot.pagePath,
|
|
142
|
+
referenceShot.viewportType,
|
|
143
143
|
options,
|
|
144
144
|
error instanceof Error ? error.message : String(error),
|
|
145
145
|
),
|
|
@@ -156,7 +156,7 @@ export async function runVrt(options: ResolvedOptions): Promise<VrtReport> {
|
|
|
156
156
|
version: "1.0",
|
|
157
157
|
meta: {
|
|
158
158
|
timestamp,
|
|
159
|
-
|
|
159
|
+
referenceUrl: options.referenceUrl,
|
|
160
160
|
afterUrl: options.afterUrl,
|
|
161
161
|
duration,
|
|
162
162
|
command: buildCommandString(options),
|
|
@@ -219,7 +219,7 @@ function createErrorResult(
|
|
|
219
219
|
path: pagePath,
|
|
220
220
|
name: pageName,
|
|
221
221
|
url: {
|
|
222
|
-
|
|
222
|
+
reference: new URL(pagePath, options.referenceUrl).toString(),
|
|
223
223
|
after: new URL(pagePath, options.afterUrl).toString(),
|
|
224
224
|
},
|
|
225
225
|
},
|
|
@@ -230,16 +230,16 @@ function createErrorResult(
|
|
|
230
230
|
diffPixelCount: 0,
|
|
231
231
|
totalPixels: 0,
|
|
232
232
|
threshold: options.threshold,
|
|
233
|
-
dimensions: { width: 0, height: 0,
|
|
233
|
+
dimensions: { width: 0, height: 0, referenceHeight: 0, afterHeight: 0 },
|
|
234
234
|
},
|
|
235
|
-
screenshots: {
|
|
235
|
+
screenshots: { reference: "", after: "", diff: "" },
|
|
236
236
|
error,
|
|
237
237
|
};
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
function buildCommandString(options: ResolvedOptions): string {
|
|
241
241
|
const parts = ["npx web-corders-vrt run"];
|
|
242
|
-
parts.push(`--
|
|
242
|
+
parts.push(`--reference ${options.referenceUrl}`);
|
|
243
243
|
parts.push(`--after ${options.afterUrl}`);
|
|
244
244
|
parts.push(`--paths ${options.paths.join(",")}`);
|
|
245
245
|
if (options.threshold !== DEFAULT_THRESHOLD) {
|
package/src/core/comparator.ts
CHANGED
|
@@ -7,24 +7,24 @@ import type { ComparisonResult } from "../types.js";
|
|
|
7
7
|
* サイズが異なる場合は大きい方に合わせて白パディングで拡張する。
|
|
8
8
|
*/
|
|
9
9
|
export function compareImages(
|
|
10
|
-
|
|
10
|
+
referenceBuffer: Buffer,
|
|
11
11
|
afterBuffer: Buffer,
|
|
12
12
|
threshold: number = 0.1,
|
|
13
13
|
): ComparisonResult {
|
|
14
|
-
const
|
|
14
|
+
const reference = PNG.sync.read(referenceBuffer);
|
|
15
15
|
const after = PNG.sync.read(afterBuffer);
|
|
16
16
|
|
|
17
|
-
const width = Math.max(
|
|
18
|
-
const height = Math.max(
|
|
17
|
+
const width = Math.max(reference.width, after.width);
|
|
18
|
+
const height = Math.max(reference.height, after.height);
|
|
19
19
|
|
|
20
20
|
// サイズが異なる場合は正規化
|
|
21
|
-
const
|
|
21
|
+
const normalizedReference = normalizeImage(reference, width, height);
|
|
22
22
|
const normalizedAfter = normalizeImage(after, width, height);
|
|
23
23
|
|
|
24
24
|
const diff = new PNG({ width, height });
|
|
25
25
|
|
|
26
26
|
const diffCount = pixelmatch(
|
|
27
|
-
|
|
27
|
+
normalizedReference.data,
|
|
28
28
|
normalizedAfter.data,
|
|
29
29
|
diff.data,
|
|
30
30
|
width,
|
|
@@ -50,7 +50,7 @@ export function compareImages(
|
|
|
50
50
|
dimensions: {
|
|
51
51
|
width,
|
|
52
52
|
height,
|
|
53
|
-
|
|
53
|
+
referenceHeight: reference.height,
|
|
54
54
|
afterHeight: after.height,
|
|
55
55
|
},
|
|
56
56
|
};
|
package/src/reporters/html.ts
CHANGED
|
@@ -32,7 +32,7 @@ function generateHtml(report: VrtReport): string {
|
|
|
32
32
|
.replace("{{CSS}}", reportStyles as string)
|
|
33
33
|
.replace(/\{\{TIMESTAMP\}\}/g, meta.timestamp)
|
|
34
34
|
.replace("{{DURATION}}", (meta.duration / 1000).toFixed(1))
|
|
35
|
-
.replace("{{
|
|
35
|
+
.replace("{{REFERENCE_URL}}", meta.referenceUrl)
|
|
36
36
|
.replace("{{AFTER_URL}}", meta.afterUrl)
|
|
37
37
|
.replace("{{TOTAL}}", String(summary.totalTests))
|
|
38
38
|
.replace("{{PASSED}}", String(summary.passed))
|
|
@@ -47,9 +47,9 @@ function generateResultCard(result: VrtTestResult): string {
|
|
|
47
47
|
const imagesHtml = `
|
|
48
48
|
<div class="comparison">
|
|
49
49
|
<div class="images">
|
|
50
|
-
<div class="img-container img-
|
|
51
|
-
<div class="label">
|
|
52
|
-
<img src="${screenshots.
|
|
50
|
+
<div class="img-container img-reference">
|
|
51
|
+
<div class="label">Reference</div>
|
|
52
|
+
<img src="${screenshots.reference}" alt="Reference" loading="lazy">
|
|
53
53
|
</div>
|
|
54
54
|
<div class="img-container img-after">
|
|
55
55
|
<div class="label">After</div>
|
|
@@ -12,7 +12,7 @@ export function printTerminalReport(report: VrtReport): void {
|
|
|
12
12
|
console.log("=".repeat(50));
|
|
13
13
|
console.log("");
|
|
14
14
|
console.log(
|
|
15
|
-
`Comparing: ${chalk.cyan(meta.
|
|
15
|
+
`Comparing: ${chalk.cyan(meta.referenceUrl)} vs ${chalk.cyan(meta.afterUrl)}`,
|
|
16
16
|
);
|
|
17
17
|
console.log("");
|
|
18
18
|
|
package/src/schemas.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
export const cliOptionsSchema = z.object({
|
|
4
|
-
|
|
4
|
+
reference: z.string().url("--reference must be a valid URL"),
|
|
5
5
|
after: z.string().url("--after must be a valid URL"),
|
|
6
6
|
paths: z.string().transform((val) => val.split(",").map((p) => p.trim())),
|
|
7
7
|
threshold: z.coerce.number().min(0).max(100).default(0.1),
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-corders-vrt
|
|
3
|
+
description: WEB用VRT(Visual Regression Test)ツール。本番ドメインとローカル開発またはプレビュードメインの間で、特定パスのビジュアル差分を検出する。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 使い方
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx web-corders-vrt run \
|
|
10
|
+
--reference ${referenceDomain} \
|
|
11
|
+
--after <開発環境のドメイン|ユーザーから指示がない場合はhttp://localhost:3000> \
|
|
12
|
+
--paths <テスト対象のパス(カンマ区切り)> \
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 手順
|
|
16
|
+
|
|
17
|
+
1. ユーザーから比較対象のドメインを指定された場合、それに従う。指示がない場合、afterは開発サーバーとし、起動していなければ `npm run dev` で起動して待機する。ここで起動したドメインとプロトコルをafterのドメインとする。
|
|
18
|
+
2. 上記のコマンドでVRTを実行する(`--paths` はタスクに応じて設定する)
|
|
19
|
+
3. `./vrt-results/` 内の最新ディレクトリにある `report.json` を読む
|
|
20
|
+
4. `status: "fail"` のテスト結果に注目する
|
|
21
|
+
5. 該当するdiff画像(`*--diff.png`)をReadツールで視覚的に確認する
|
|
22
|
+
6. diff画像から修正すべきCSSやHTMLの場所を特定する
|
|
23
|
+
7. ソースコードを修正する
|
|
24
|
+
8. 再度VRTを実行して修正が反映されたことを確認する
|
|
25
|
+
|
|
26
|
+
## オプション
|
|
27
|
+
|
|
28
|
+
| オプション | 説明 |
|
|
29
|
+
| -------------------- | ------------------------------------------- |
|
|
30
|
+
| `--reference <url>` | リファレンスプロトコル+ドメイン(本番環境) |
|
|
31
|
+
| `--after <url>` | 比較先Uプロトコル+ドメイン(開発環境) |
|
|
32
|
+
| `--paths <paths>` | テスト対象のページパス(カンマ区切り) |
|
|
33
|
+
| `--threshold <n>` | 差分許容率(%)。デフォルト: 0.1 |
|
|
34
|
+
| `--hide <selectors>` | 非表示にするCSSセレクタ(カンマ区切り) |
|
|
35
|
+
| `--no-open` | HTMLレポートをブラウザで開かない |
|
package/src/templates/types.d.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface VrtReport {
|
|
|
3
3
|
version: "1.0";
|
|
4
4
|
meta: {
|
|
5
5
|
timestamp: string;
|
|
6
|
-
|
|
6
|
+
referenceUrl: string;
|
|
7
7
|
afterUrl: string;
|
|
8
8
|
duration: number;
|
|
9
9
|
command: string;
|
|
@@ -24,7 +24,7 @@ export interface VrtTestResult {
|
|
|
24
24
|
path: string;
|
|
25
25
|
name: string;
|
|
26
26
|
url: {
|
|
27
|
-
|
|
27
|
+
reference: string;
|
|
28
28
|
after: string;
|
|
29
29
|
};
|
|
30
30
|
};
|
|
@@ -42,12 +42,12 @@ export interface VrtTestResult {
|
|
|
42
42
|
dimensions: {
|
|
43
43
|
width: number;
|
|
44
44
|
height: number;
|
|
45
|
-
|
|
45
|
+
referenceHeight: number;
|
|
46
46
|
afterHeight: number;
|
|
47
47
|
};
|
|
48
48
|
};
|
|
49
49
|
screenshots: {
|
|
50
|
-
|
|
50
|
+
reference: string;
|
|
51
51
|
after: string;
|
|
52
52
|
diff: string;
|
|
53
53
|
};
|
|
@@ -59,7 +59,7 @@ export type TestStatus = "pass" | "fail" | "error";
|
|
|
59
59
|
|
|
60
60
|
/** CLI実行時の解決済みオプション */
|
|
61
61
|
export interface ResolvedOptions {
|
|
62
|
-
|
|
62
|
+
referenceUrl: string;
|
|
63
63
|
afterUrl: string;
|
|
64
64
|
paths: string[];
|
|
65
65
|
threshold: number;
|
|
@@ -88,7 +88,7 @@ export interface ComparisonResult {
|
|
|
88
88
|
dimensions: {
|
|
89
89
|
width: number;
|
|
90
90
|
height: number;
|
|
91
|
-
|
|
91
|
+
referenceHeight: number;
|
|
92
92
|
afterHeight: number;
|
|
93
93
|
};
|
|
94
94
|
}
|
package/test/comparator.test.ts
CHANGED
|
@@ -85,7 +85,7 @@ describe("compareImages", () => {
|
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
it("一部だけ異なる画像の場合、差分率が正しく計算される", () => {
|
|
88
|
-
const
|
|
88
|
+
const reference = createSolidPng(100, 100, 255, 255, 255);
|
|
89
89
|
// 右下10x10だけ赤にする
|
|
90
90
|
const after = createPngWithRegion(100, 100, 255, 255, 255, {
|
|
91
91
|
x: 90,
|
|
@@ -97,7 +97,7 @@ describe("compareImages", () => {
|
|
|
97
97
|
b: 0,
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
const result = compareImages(
|
|
100
|
+
const result = compareImages(reference, after);
|
|
101
101
|
|
|
102
102
|
expect(result.diffCount).toBeGreaterThan(0);
|
|
103
103
|
// 10*10=100 / 100*100=10000 = 1%
|
|
@@ -112,14 +112,14 @@ describe("compareImages", () => {
|
|
|
112
112
|
|
|
113
113
|
expect(result.dimensions.width).toBe(100);
|
|
114
114
|
expect(result.dimensions.height).toBe(100);
|
|
115
|
-
expect(result.dimensions.
|
|
115
|
+
expect(result.dimensions.referenceHeight).toBe(50);
|
|
116
116
|
expect(result.dimensions.afterHeight).toBe(100);
|
|
117
117
|
// 白でパディングされるので一部は同じ、拡張部分も白=白で差分なし
|
|
118
118
|
expect(result.diffCount).toBe(0);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
it("threshold以下の差分はpassになる", () => {
|
|
122
|
-
const
|
|
122
|
+
const reference = createSolidPng(100, 100, 255, 255, 255);
|
|
123
123
|
// 1ピクセルだけ微妙に違う
|
|
124
124
|
const afterPng = new PNG({ width: 100, height: 100 });
|
|
125
125
|
for (let i = 0; i < afterPng.data.length; i += 4) {
|
|
@@ -132,7 +132,7 @@ describe("compareImages", () => {
|
|
|
132
132
|
afterPng.data[0] = 200;
|
|
133
133
|
const after = PNG.sync.write(afterPng);
|
|
134
134
|
|
|
135
|
-
const result = compareImages(
|
|
135
|
+
const result = compareImages(reference, after, 1.0); // threshold 1%
|
|
136
136
|
// 1px / 10000px = 0.01% < 1%
|
|
137
137
|
expect(result.passed).toBe(true);
|
|
138
138
|
});
|