web-corders-vrt 0.1.4 → 0.1.7
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 +44 -41
- package/bin/vrt.ts +5 -19
- package/dist/bin/vrt.js +83 -103
- package/dist/bin/vrt.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/init.ts +36 -48
- package/src/commands/run.ts +34 -38
- package/src/constants.ts +1 -0
- 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 -3
- package/src/templates/SKILL-TEMPLATE.md +34 -0
- package/src/templates/report.html +1 -1
- package/src/templates/types.d.ts +4 -0
- package/src/types.ts +6 -8
- package/test/comparator.test.ts +5 -5
- package/tsup.config.ts +1 -0
package/README.md
CHANGED
|
@@ -2,28 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
Webページのビジュアルリグレッションテスト (VRT) を行うCLIツール。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
開発マシンに入っているブラウザを利用して、本番環境と開発/プレビュー環境のスクリーンショットの差分を検出する。
|
|
6
|
+
テスト結果は
|
|
7
|
+
|
|
8
|
+
- diff画像
|
|
9
|
+
- 人間向けHTMLレポート
|
|
10
|
+
- LLM/Claude向けJSONレポート
|
|
11
|
+
|
|
12
|
+
で出力される。
|
|
7
13
|
|
|
8
14
|
## セットアップ
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
初回のみ Playwright のブラウザが必要:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx playwright install chromium #通常はインストール済みのChromeが使われるはず
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`init` コマンドで、Claude Codeのスキルファイル (`.claude/skills/web-corders-vrt/SKILL.md`) を自動生成する
|
|
11
23
|
|
|
12
24
|
```bash
|
|
13
|
-
npx
|
|
25
|
+
npx web-corders-vrt init --refDomain https://example.com
|
|
14
26
|
```
|
|
15
27
|
|
|
16
|
-
##
|
|
28
|
+
## 手動での使い方
|
|
17
29
|
|
|
18
30
|
```bash
|
|
19
31
|
npx web-corders-vrt run \
|
|
20
|
-
--
|
|
32
|
+
--reference https://production.com \
|
|
21
33
|
--after http://localhost:3000 \
|
|
22
34
|
--paths /,/about,/pricing
|
|
23
35
|
```
|
|
24
36
|
|
|
25
|
-
- `--
|
|
26
|
-
- `--after` —
|
|
37
|
+
- `--reference` — リファレンスのプロトコル+ドメイン(本番環境)
|
|
38
|
+
- `--after` — 比較するプロトコル+ドメイン(開発 / プレビュー環境)
|
|
27
39
|
- `--paths` — テスト対象のページパス(カンマ区切り)
|
|
28
40
|
|
|
29
41
|
実行すると `./vrt-results/` 以下にタイムスタンプ付きのディレクトリが生成され、スクリーンショット・diff画像・レポートが保存される。
|
|
@@ -35,10 +47,10 @@ vrt-results/2026-02-28T10-00-00/
|
|
|
35
47
|
├── report.json # JSON レポート(Claude/LLM向け)
|
|
36
48
|
├── report.html # HTML レポート(人間向け・ブラウザで開く)
|
|
37
49
|
└── screenshots/
|
|
38
|
-
├── top--sp--
|
|
50
|
+
├── top--sp--reference.png
|
|
39
51
|
├── top--sp--after.png
|
|
40
52
|
├── top--sp--diff.png
|
|
41
|
-
├── top--pc--
|
|
53
|
+
├── top--pc--reference.png
|
|
42
54
|
├── top--pc--after.png
|
|
43
55
|
└── top--pc--diff.png
|
|
44
56
|
```
|
|
@@ -72,67 +84,58 @@ vrt-results/2026-02-28T10-00-00/
|
|
|
72
84
|
|
|
73
85
|
| オプション | デフォルト | 説明 |
|
|
74
86
|
| -------------------- | ---------- | ----------------------------------------------------------------------- |
|
|
75
|
-
| `--
|
|
87
|
+
| `--reference <url>` | (必須) | リファレンスURL(本番環境) |
|
|
76
88
|
| `--after <url>` | (必須) | 比較先URL(開発環境 / ステージング) |
|
|
77
89
|
| `--paths <paths>` | (必須) | テスト対象のページパス(カンマ区切り) |
|
|
78
90
|
| `--threshold <n>` | `0.1` | 差分許容率(%)。これ以下の差分はPASS扱い |
|
|
79
91
|
| `--hide <selectors>` | — | 非表示にするCSSセレクタ(カンマ区切り)。例: `.ad-banner,.cookie-popup` |
|
|
80
|
-
| `--no-html` | — | HTMLレポートを生成しない |
|
|
81
|
-
| `--no-open` | — | HTMLレポートをブラウザで自動的に開かない |
|
|
82
92
|
|
|
83
93
|
ビューポートはSP(375x812)とPC(1440x900)の2種類で固定。フルページスクリーンショットを取得し、結果は `./vrt-results/` に出力される。
|
|
84
94
|
|
|
95
|
+
## Claude Code連携
|
|
96
|
+
|
|
97
|
+
### スキルファイルの仕組み
|
|
98
|
+
|
|
99
|
+
`init`で生成される `.claude/skills/web-corders-vrt/SKILL.md` には以下の手順が記述される:
|
|
100
|
+
|
|
101
|
+
1. 開発サーバーを起動
|
|
102
|
+
2. VRTコマンドを実行
|
|
103
|
+
3. `report.json` を読んで差分を確認
|
|
104
|
+
4. diff画像を視覚的に確認
|
|
105
|
+
5. diff画像からCSS/HTMLの修正箇所を特定
|
|
106
|
+
6. コードを修正
|
|
107
|
+
7. 再度VRTを実行して確認
|
|
108
|
+
|
|
109
|
+
以降はCaludeに「VRTして」とか言うだけで使ってくれる はず。
|
|
110
|
+
|
|
85
111
|
## 実用的な例
|
|
86
112
|
|
|
87
113
|
### 動的要素を隠してテスト
|
|
88
114
|
|
|
89
|
-
|
|
115
|
+
テスト対象外にしたい要素を非表示にする。
|
|
90
116
|
|
|
91
117
|
```bash
|
|
92
118
|
npx web-corders-vrt run \
|
|
93
|
-
--
|
|
119
|
+
--reference https://example.com \
|
|
94
120
|
--after http://localhost:3000 \
|
|
95
121
|
--paths / \
|
|
96
122
|
--hide ".cookie-banner,.ad-slot,[data-testid='live-chat']"
|
|
97
123
|
```
|
|
98
124
|
|
|
125
|
+
Next.jsツールバーはデフォルトで非表示。
|
|
126
|
+
|
|
99
127
|
### 差分許容率を緩めに設定
|
|
100
128
|
|
|
101
129
|
フォントレンダリング差異などを無視したい場合:
|
|
102
130
|
|
|
103
131
|
```bash
|
|
104
132
|
npx web-corders-vrt run \
|
|
105
|
-
--
|
|
133
|
+
--reference https://example.com \
|
|
106
134
|
--after http://localhost:3000 \
|
|
107
135
|
--paths / \
|
|
108
136
|
--threshold 1
|
|
109
137
|
```
|
|
110
138
|
|
|
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
139
|
## 広告・計測系スクリプトのブロック
|
|
137
140
|
|
|
138
141
|
以下のドメインへのリクエストは自動的にブロックされる。見た目のテストに不要であり、ページ読み込み速度を改善するため。
|
package/bin/vrt.ts
CHANGED
|
@@ -14,25 +14,21 @@ 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")
|
|
21
21
|
.option("--hide <selectors>", "CSS selectors to hide (comma-separated)")
|
|
22
|
-
.option("--no-html", "Skip HTML report generation")
|
|
23
|
-
.option("--no-open", "Do not open HTML report in browser")
|
|
24
22
|
.action(async (rawOptions) => {
|
|
25
23
|
try {
|
|
26
24
|
const parsed = cliOptionsSchema.parse(rawOptions);
|
|
27
25
|
|
|
28
26
|
const options: ResolvedOptions = {
|
|
29
|
-
|
|
27
|
+
referenceUrl: parsed.reference,
|
|
30
28
|
afterUrl: parsed.after,
|
|
31
29
|
paths: parsed.paths,
|
|
32
30
|
threshold: parsed.threshold,
|
|
33
31
|
hideSelectors: parsed.hide,
|
|
34
|
-
html: parsed.html,
|
|
35
|
-
open: parsed.open,
|
|
36
32
|
};
|
|
37
33
|
|
|
38
34
|
const report = await runVrt(options);
|
|
@@ -50,22 +46,12 @@ program
|
|
|
50
46
|
program
|
|
51
47
|
.command("init")
|
|
52
48
|
.description(
|
|
53
|
-
"Generate .claude/
|
|
49
|
+
"Generate .claude/skills/web-corders-vrt/SKILL.md for this repository",
|
|
54
50
|
)
|
|
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)")
|
|
51
|
+
.requiredOption("--refDomain <domain>", "Reference domain URL (production)")
|
|
60
52
|
.action(async (options) => {
|
|
61
53
|
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
|
-
});
|
|
54
|
+
await runInit({ refDomain: options.refDomain });
|
|
69
55
|
} catch (error) {
|
|
70
56
|
if (error instanceof Error) {
|
|
71
57
|
console.error(`Error: ${error.message}`);
|
package/dist/bin/vrt.js
CHANGED
|
@@ -6,13 +6,11 @@ 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),
|
|
13
|
-
hide: z.string().optional().transform((val) => val ? val.split(",").map((s) => s.trim()) : [])
|
|
14
|
-
html: z.boolean().default(true),
|
|
15
|
-
open: z.boolean().default(true)
|
|
13
|
+
hide: z.string().optional().transform((val) => val ? val.split(",").map((s) => s.trim()) : [])
|
|
16
14
|
});
|
|
17
15
|
|
|
18
16
|
// src/commands/run.ts
|
|
@@ -193,16 +191,16 @@ var Screenshotter = class {
|
|
|
193
191
|
// src/core/comparator.ts
|
|
194
192
|
import pixelmatch from "pixelmatch";
|
|
195
193
|
import { PNG } from "pngjs";
|
|
196
|
-
function compareImages(
|
|
197
|
-
const
|
|
194
|
+
function compareImages(referenceBuffer, afterBuffer, threshold = 0.1) {
|
|
195
|
+
const reference = PNG.sync.read(referenceBuffer);
|
|
198
196
|
const after = PNG.sync.read(afterBuffer);
|
|
199
|
-
const width = Math.max(
|
|
200
|
-
const height = Math.max(
|
|
201
|
-
const
|
|
197
|
+
const width = Math.max(reference.width, after.width);
|
|
198
|
+
const height = Math.max(reference.height, after.height);
|
|
199
|
+
const normalizedReference = normalizeImage(reference, width, height);
|
|
202
200
|
const normalizedAfter = normalizeImage(after, width, height);
|
|
203
201
|
const diff = new PNG({ width, height });
|
|
204
202
|
const diffCount = pixelmatch(
|
|
205
|
-
|
|
203
|
+
normalizedReference.data,
|
|
206
204
|
normalizedAfter.data,
|
|
207
205
|
diff.data,
|
|
208
206
|
width,
|
|
@@ -226,7 +224,7 @@ function compareImages(beforeBuffer, afterBuffer, threshold = 0.1) {
|
|
|
226
224
|
dimensions: {
|
|
227
225
|
width,
|
|
228
226
|
height,
|
|
229
|
-
|
|
227
|
+
referenceHeight: reference.height,
|
|
230
228
|
afterHeight: after.height
|
|
231
229
|
}
|
|
232
230
|
};
|
|
@@ -264,7 +262,7 @@ function printTerminalReport(report) {
|
|
|
264
262
|
console.log("=".repeat(50));
|
|
265
263
|
console.log("");
|
|
266
264
|
console.log(
|
|
267
|
-
`Comparing: ${chalk.cyan(meta.
|
|
265
|
+
`Comparing: ${chalk.cyan(meta.referenceUrl)} vs ${chalk.cyan(meta.afterUrl)}`
|
|
268
266
|
);
|
|
269
267
|
console.log("");
|
|
270
268
|
const groupedByPage = /* @__PURE__ */ new Map();
|
|
@@ -323,7 +321,7 @@ import { writeFile as writeFile2 } from "fs/promises";
|
|
|
323
321
|
import { join as join2 } from "path";
|
|
324
322
|
|
|
325
323
|
// 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
|
|
324
|
+
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
325
|
|
|
328
326
|
// src/templates/report.css
|
|
329
327
|
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 +337,7 @@ function generateHtml(report) {
|
|
|
339
337
|
const { meta, summary, results } = report;
|
|
340
338
|
const resultCards = results.map((r) => generateResultCard(r)).join("\n");
|
|
341
339
|
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("{{
|
|
340
|
+
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
341
|
}
|
|
344
342
|
function generateResultCard(result) {
|
|
345
343
|
const { page, viewport, status, comparison, screenshots } = result;
|
|
@@ -347,9 +345,9 @@ function generateResultCard(result) {
|
|
|
347
345
|
const imagesHtml = `
|
|
348
346
|
<div class="comparison">
|
|
349
347
|
<div class="images">
|
|
350
|
-
<div class="img-container img-
|
|
351
|
-
<div class="label">
|
|
352
|
-
<img src="${screenshots.
|
|
348
|
+
<div class="img-container img-reference">
|
|
349
|
+
<div class="label">Reference</div>
|
|
350
|
+
<img src="${screenshots.reference}" alt="Reference" loading="lazy">
|
|
353
351
|
</div>
|
|
354
352
|
<div class="img-container img-after">
|
|
355
353
|
<div class="label">After</div>
|
|
@@ -384,9 +382,9 @@ async function runVrt(options) {
|
|
|
384
382
|
const spinner = ora("Initializing browser...").start();
|
|
385
383
|
try {
|
|
386
384
|
await screenshotter.initialize();
|
|
387
|
-
spinner.text = `Taking screenshots of ${chalk2.cyan(options.
|
|
388
|
-
const
|
|
389
|
-
options.
|
|
385
|
+
spinner.text = `Taking screenshots of ${chalk2.cyan(options.referenceUrl)}...`;
|
|
386
|
+
const referenceScreenshots = await screenshotter.captureAll(
|
|
387
|
+
options.referenceUrl,
|
|
390
388
|
options.paths,
|
|
391
389
|
VIEWPORTS,
|
|
392
390
|
options.hideSelectors
|
|
@@ -400,15 +398,15 @@ async function runVrt(options) {
|
|
|
400
398
|
);
|
|
401
399
|
spinner.text = "Comparing screenshots...";
|
|
402
400
|
const results = [];
|
|
403
|
-
for (const
|
|
401
|
+
for (const referenceShot of referenceScreenshots) {
|
|
404
402
|
const afterShot = afterScreenshots.find(
|
|
405
|
-
(a) => a.pagePath ===
|
|
403
|
+
(a) => a.pagePath === referenceShot.pagePath && a.viewportType === referenceShot.viewportType
|
|
406
404
|
);
|
|
407
405
|
if (!afterShot) {
|
|
408
406
|
results.push(
|
|
409
407
|
createErrorResult(
|
|
410
|
-
|
|
411
|
-
|
|
408
|
+
referenceShot.pagePath,
|
|
409
|
+
referenceShot.viewportType,
|
|
412
410
|
options,
|
|
413
411
|
"After screenshot not found"
|
|
414
412
|
)
|
|
@@ -417,33 +415,33 @@ async function runVrt(options) {
|
|
|
417
415
|
}
|
|
418
416
|
try {
|
|
419
417
|
const comparison = compareImages(
|
|
420
|
-
|
|
418
|
+
referenceShot.buffer,
|
|
421
419
|
afterShot.buffer,
|
|
422
420
|
options.threshold
|
|
423
421
|
);
|
|
424
|
-
const filePrefix = `${
|
|
425
|
-
const
|
|
422
|
+
const filePrefix = `${referenceShot.pageName}--${referenceShot.viewportType}`;
|
|
423
|
+
const referencePath = join3("screenshots", `${filePrefix}--reference.png`);
|
|
426
424
|
const afterPath = join3("screenshots", `${filePrefix}--after.png`);
|
|
427
425
|
const diffPath = join3("screenshots", `${filePrefix}--diff.png`);
|
|
428
|
-
await writeFile3(join3(runDir,
|
|
426
|
+
await writeFile3(join3(runDir, referencePath), referenceShot.buffer);
|
|
429
427
|
await writeFile3(join3(runDir, afterPath), afterShot.buffer);
|
|
430
428
|
await writeFile3(join3(runDir, diffPath), comparison.diffImage);
|
|
431
|
-
const pageName =
|
|
432
|
-
const viewport = getViewportDimensions(
|
|
429
|
+
const pageName = referenceShot.pagePath === "/" ? "\u30C8\u30C3\u30D7\u30DA\u30FC\u30B8" : referenceShot.pagePath.replace(/^\//, "");
|
|
430
|
+
const viewport = getViewportDimensions(referenceShot.viewportType);
|
|
433
431
|
results.push({
|
|
434
432
|
page: {
|
|
435
|
-
path:
|
|
433
|
+
path: referenceShot.pagePath,
|
|
436
434
|
name: pageName,
|
|
437
435
|
url: {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
options.
|
|
436
|
+
reference: new URL(
|
|
437
|
+
referenceShot.pagePath,
|
|
438
|
+
options.referenceUrl
|
|
441
439
|
).toString(),
|
|
442
|
-
after: new URL(
|
|
440
|
+
after: new URL(referenceShot.pagePath, options.afterUrl).toString()
|
|
443
441
|
}
|
|
444
442
|
},
|
|
445
443
|
viewport: {
|
|
446
|
-
type:
|
|
444
|
+
type: referenceShot.viewportType,
|
|
447
445
|
...viewport
|
|
448
446
|
},
|
|
449
447
|
status: comparison.passed ? "pass" : "fail",
|
|
@@ -455,7 +453,7 @@ async function runVrt(options) {
|
|
|
455
453
|
dimensions: comparison.dimensions
|
|
456
454
|
},
|
|
457
455
|
screenshots: {
|
|
458
|
-
|
|
456
|
+
reference: referencePath,
|
|
459
457
|
after: afterPath,
|
|
460
458
|
diff: diffPath
|
|
461
459
|
}
|
|
@@ -463,8 +461,8 @@ async function runVrt(options) {
|
|
|
463
461
|
} catch (error) {
|
|
464
462
|
results.push(
|
|
465
463
|
createErrorResult(
|
|
466
|
-
|
|
467
|
-
|
|
464
|
+
referenceShot.pagePath,
|
|
465
|
+
referenceShot.viewportType,
|
|
468
466
|
options,
|
|
469
467
|
error instanceof Error ? error.message : String(error)
|
|
470
468
|
)
|
|
@@ -479,7 +477,7 @@ async function runVrt(options) {
|
|
|
479
477
|
version: "1.0",
|
|
480
478
|
meta: {
|
|
481
479
|
timestamp,
|
|
482
|
-
|
|
480
|
+
referenceUrl: options.referenceUrl,
|
|
483
481
|
afterUrl: options.afterUrl,
|
|
484
482
|
duration,
|
|
485
483
|
command: buildCommandString(options)
|
|
@@ -497,14 +495,10 @@ async function runVrt(options) {
|
|
|
497
495
|
printTerminalReport(report);
|
|
498
496
|
const jsonPath = await writeJsonReport(report, runDir);
|
|
499
497
|
console.log(`JSON report: ${chalk2.dim(jsonPath)}`);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const { exec } = await import("child_process");
|
|
505
|
-
exec(`open "${htmlPath}"`);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
498
|
+
const htmlPath = await writeHtmlReport(report, runDir);
|
|
499
|
+
console.log(`HTML report: ${chalk2.dim(htmlPath)}`);
|
|
500
|
+
const { exec } = await import("child_process");
|
|
501
|
+
exec(`open "${htmlPath}"`);
|
|
508
502
|
console.log(`Output dir: ${chalk2.dim(runDir)}`);
|
|
509
503
|
return report;
|
|
510
504
|
} finally {
|
|
@@ -522,7 +516,7 @@ function createErrorResult(pagePath, viewportType, options, error) {
|
|
|
522
516
|
path: pagePath,
|
|
523
517
|
name: pageName,
|
|
524
518
|
url: {
|
|
525
|
-
|
|
519
|
+
reference: new URL(pagePath, options.referenceUrl).toString(),
|
|
526
520
|
after: new URL(pagePath, options.afterUrl).toString()
|
|
527
521
|
}
|
|
528
522
|
},
|
|
@@ -533,15 +527,15 @@ function createErrorResult(pagePath, viewportType, options, error) {
|
|
|
533
527
|
diffPixelCount: 0,
|
|
534
528
|
totalPixels: 0,
|
|
535
529
|
threshold: options.threshold,
|
|
536
|
-
dimensions: { width: 0, height: 0,
|
|
530
|
+
dimensions: { width: 0, height: 0, referenceHeight: 0, afterHeight: 0 }
|
|
537
531
|
},
|
|
538
|
-
screenshots: {
|
|
532
|
+
screenshots: { reference: "", after: "", diff: "" },
|
|
539
533
|
error
|
|
540
534
|
};
|
|
541
535
|
}
|
|
542
536
|
function buildCommandString(options) {
|
|
543
537
|
const parts = ["npx web-corders-vrt run"];
|
|
544
|
-
parts.push(`--
|
|
538
|
+
parts.push(`--reference ${options.referenceUrl}`);
|
|
545
539
|
parts.push(`--after ${options.afterUrl}`);
|
|
546
540
|
parts.push(`--paths ${options.paths.join(",")}`);
|
|
547
541
|
if (options.threshold !== DEFAULT_THRESHOLD) {
|
|
@@ -554,76 +548,68 @@ function buildCommandString(options) {
|
|
|
554
548
|
}
|
|
555
549
|
|
|
556
550
|
// src/commands/init.ts
|
|
557
|
-
import { mkdir as mkdir2, writeFile as writeFile4, access } from "fs/promises";
|
|
551
|
+
import { mkdir as mkdir2, writeFile as writeFile4, readFile, access } from "fs/promises";
|
|
558
552
|
import { join as join4 } from "path";
|
|
559
553
|
import chalk3 from "chalk";
|
|
554
|
+
|
|
555
|
+
// src/templates/SKILL-TEMPLATE.md
|
|
556
|
+
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\u3067\u304D\u308B\u306A\u3089\u4FEE\u6B63\u306B\u306F\u3044\u308B\u3002\u518D\u5EA6VRT\u3092\u5B9F\u884C\u3057\u3066\u4FEE\u6B63\u304C\u53CD\u6620\u3055\u308C\u305F\u3053\u3068\u3092\u78BA\u8A8D\u3059\u308B\n7. \u4FEE\u6B63\u304C\u4E0D\u53EF\u80FD\u306A\u3089\u30E6\u30FC\u30B6\u30FC\u306B\u305D\u306E\u65E8\u3092\u4F1D\u3048\u3001\u30EC\u30DD\u30FC\u30C8\u304C\u51FA\u6765\u305F\u3053\u3068\u3092\u4F1D\u3048\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';
|
|
557
|
+
|
|
558
|
+
// src/commands/init.ts
|
|
560
559
|
async function runInit(options) {
|
|
561
|
-
const outDir = join4(process.cwd(), ".claude", "
|
|
562
|
-
const outFile = join4(outDir, "
|
|
560
|
+
const outDir = join4(process.cwd(), ".claude", "skills", "web-corders-vrt");
|
|
561
|
+
const outFile = join4(outDir, "SKILL.md");
|
|
563
562
|
try {
|
|
564
563
|
await access(outFile);
|
|
565
564
|
console.log(chalk3.yellow(`\u26A0 ${outFile} already exists. Overwriting...`));
|
|
566
565
|
} catch {
|
|
567
566
|
}
|
|
568
567
|
await mkdir2(outDir, { recursive: true });
|
|
569
|
-
const content =
|
|
568
|
+
const content = SKILL_TEMPLATE_default.replace(
|
|
569
|
+
/\$\{referenceDomain\}/g,
|
|
570
|
+
options.refDomain
|
|
571
|
+
);
|
|
570
572
|
await writeFile4(outFile, content, "utf-8");
|
|
571
573
|
console.log(chalk3.green(`\u2705 Created ${outFile}`));
|
|
574
|
+
await ensureGitignore(process.cwd());
|
|
572
575
|
console.log("");
|
|
573
576
|
console.log(
|
|
574
|
-
"
|
|
575
|
-
);
|
|
576
|
-
console.log(
|
|
577
|
-
"Review and customize the file, then commit it to your repository."
|
|
577
|
+
"Review and customize the skill file, then commit it to your repository."
|
|
578
578
|
);
|
|
579
579
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
580
|
+
var VRT_RESULTS_PATTERN = "vrt-results/";
|
|
581
|
+
async function ensureGitignore(cwd) {
|
|
582
|
+
const gitignorePath = join4(cwd, ".gitignore");
|
|
583
|
+
let content = "";
|
|
584
|
+
try {
|
|
585
|
+
content = await readFile(gitignorePath, "utf-8");
|
|
586
|
+
} catch {
|
|
587
587
|
}
|
|
588
|
-
|
|
589
|
-
|
|
588
|
+
const lines = content.split("\n");
|
|
589
|
+
if (lines.some((line) => line.trim() === VRT_RESULTS_PATTERN)) {
|
|
590
|
+
return;
|
|
590
591
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
`;
|
|
592
|
+
const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
593
|
+
await writeFile4(
|
|
594
|
+
gitignorePath,
|
|
595
|
+
content + separator + VRT_RESULTS_PATTERN + "\n",
|
|
596
|
+
"utf-8"
|
|
597
|
+
);
|
|
598
|
+
console.log(chalk3.green(`\u2705 Added '${VRT_RESULTS_PATTERN}' to .gitignore`));
|
|
611
599
|
}
|
|
612
600
|
|
|
613
601
|
// bin/vrt.ts
|
|
614
602
|
var program = new Command();
|
|
615
603
|
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("--
|
|
604
|
+
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)").action(async (rawOptions) => {
|
|
617
605
|
try {
|
|
618
606
|
const parsed = cliOptionsSchema.parse(rawOptions);
|
|
619
607
|
const options = {
|
|
620
|
-
|
|
608
|
+
referenceUrl: parsed.reference,
|
|
621
609
|
afterUrl: parsed.after,
|
|
622
610
|
paths: parsed.paths,
|
|
623
611
|
threshold: parsed.threshold,
|
|
624
|
-
hideSelectors: parsed.hide
|
|
625
|
-
html: parsed.html,
|
|
626
|
-
open: parsed.open
|
|
612
|
+
hideSelectors: parsed.hide
|
|
627
613
|
};
|
|
628
614
|
const report = await runVrt(options);
|
|
629
615
|
process.exit(report.summary.overallStatus === "pass" ? 0 : 1);
|
|
@@ -637,16 +623,10 @@ program.command("run").description("Run visual regression tests").requiredOption
|
|
|
637
623
|
}
|
|
638
624
|
});
|
|
639
625
|
program.command("init").description(
|
|
640
|
-
"Generate .claude/
|
|
641
|
-
).requiredOption("--
|
|
626
|
+
"Generate .claude/skills/web-corders-vrt/SKILL.md for this repository"
|
|
627
|
+
).requiredOption("--refDomain <domain>", "Reference domain URL (production)").action(async (options) => {
|
|
642
628
|
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
|
-
});
|
|
629
|
+
await runInit({ refDomain: options.refDomain });
|
|
650
630
|
} catch (error) {
|
|
651
631
|
if (error instanceof Error) {
|
|
652
632
|
console.error(`Error: ${error.message}`);
|