web-corders-vrt 0.1.2 → 0.1.4
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/.claude/settings.local.json +10 -1
- package/README.md +3 -16
- package/dist/bin/vrt.js +58 -317
- package/dist/bin/vrt.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/init.ts +1 -1
- package/src/commands/run.ts +0 -7
- package/src/constants.ts +3 -0
- package/src/core/stabilizer.ts +25 -4
- package/src/reporters/html.ts +52 -136
- package/src/reporters/terminal.ts +0 -16
- package/src/templates/report.css +176 -0
- package/src/templates/report.html +28 -0
- package/src/templates/types.d.ts +8 -0
- package/src/types.ts +0 -23
- package/tsup.config.ts +4 -0
- package/src/core/region-detector.ts +0 -277
- package/test/region-detector.test.ts +0 -147
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
"Bash(node dist/bin/vrt.js init --help)",
|
|
8
8
|
"Bash(npm install -D prettier)",
|
|
9
9
|
"Bash(npx prettier --write .)",
|
|
10
|
-
"Bash(npm test)"
|
|
10
|
+
"Bash(npm test)",
|
|
11
|
+
"Bash(node dist/bin/vrt.js run --before https://www.video.unext.jp --after http://localhost:3000 --paths /lp/ppd_contents_h --no-open)",
|
|
12
|
+
"Bash(open vrt-results/2026-03-02T05-02-45/report.html)",
|
|
13
|
+
"Bash(open vrt-results/2026-03-02T06-41-44/report.html)",
|
|
14
|
+
"Bash(open vrt-results/2026-03-02T06-49-09/report.html)",
|
|
15
|
+
"Bash(node dist/bin/vrt.js run --before https://www.video.unext.jp --after http://localhost:3000 --paths /lp/unext_mobile_a263 --no-open)",
|
|
16
|
+
"Bash(node -e \"const r=JSON.parse\\(require\\(''fs''\\).readFileSync\\(''/dev/stdin'',''utf8''\\)\\); r.results.forEach\\(t => { console.log\\(t.viewport.type, ''before:'', t.comparison.dimensions.beforeHeight, ''after:'', t.comparison.dimensions.afterHeight, ''aligned:'', t.comparison.alignmentUsed ?? false, ''shifted:'', t.comparison.shiftedRows ?? 0, ''diff:'', t.comparison.diffPercentage.toFixed\\(2\\)+''%''\\) }\\)\")",
|
|
17
|
+
"Bash(node -e \"const r=JSON.parse\\(require\\(''fs''\\).readFileSync\\(''/dev/stdin'',''utf8''\\)\\); r.results.forEach\\(t => { console.log\\(t.viewport.type, ''width:'', t.comparison.dimensions.width, ''beforeH:'', t.comparison.dimensions.beforeHeight, ''afterH:'', t.comparison.dimensions.afterHeight\\) }\\)\")",
|
|
18
|
+
"Bash(node -e \"const r=JSON.parse\\(require\\(''fs''\\).readFileSync\\(''/dev/stdin'',''utf8''\\)\\); r.results.forEach\\(t => { console.log\\(t.viewport.type, ''aligned:'', t.comparison.alignmentUsed, ''shifted:'', t.comparison.shiftedRows, ''diff:'', t.comparison.diffPercentage.toFixed\\(2\\)+''%'', ''beforeH:'', t.comparison.dimensions.beforeHeight, ''afterH:'', t.comparison.dimensions.afterHeight\\) }\\)\")",
|
|
19
|
+
"Bash(open vrt-results/2026-03-02T07-22-33/report.html)"
|
|
11
20
|
]
|
|
12
21
|
}
|
|
13
22
|
}
|
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Webページのビジュアルリグレッションテスト (VRT) を行うCLIツール。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
本番環境と開発環境のスクリーンショットの差分を検出する。
|
|
6
|
+
テスト結果は人間向け(HTMLレポート + diff画像)とLLM/Claude向け(JSONレポート)の両方で出力される。
|
|
6
7
|
|
|
7
8
|
## セットアップ
|
|
8
9
|
|
|
@@ -62,25 +63,11 @@ vrt-results/2026-02-28T10-00-00/
|
|
|
62
63
|
"diffPercentage": 2.35,
|
|
63
64
|
"diffPixelCount": 12040,
|
|
64
65
|
},
|
|
65
|
-
"diffRegions": [
|
|
66
|
-
{
|
|
67
|
-
"id": 1,
|
|
68
|
-
"boundingBox": { "x": 0, "y": 480, "width": 750, "height": 200 },
|
|
69
|
-
"locationHint": {
|
|
70
|
-
"verticalPosition": "upper",
|
|
71
|
-
"horizontalPosition": "full-width",
|
|
72
|
-
"fromTopPx": 480,
|
|
73
|
-
"estimatedElement": "Likely a hero section or banner",
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
],
|
|
77
66
|
},
|
|
78
67
|
],
|
|
79
68
|
}
|
|
80
69
|
```
|
|
81
70
|
|
|
82
|
-
`diffRegions` の `locationHint` は、差分がページのどの位置にあるかを示す。Claudeがこれを読むことで、修正すべきCSS/HTMLの場所を推定できる。
|
|
83
|
-
|
|
84
71
|
## オプション一覧
|
|
85
72
|
|
|
86
73
|
| オプション | デフォルト | 説明 |
|
|
@@ -142,7 +129,7 @@ npx web-corders-vrt init \
|
|
|
142
129
|
2. VRTコマンドを実行
|
|
143
130
|
3. `report.json` を読んで差分を確認
|
|
144
131
|
4. diff画像を視覚的に確認
|
|
145
|
-
5.
|
|
132
|
+
5. diff画像からCSS/HTMLの修正箇所を特定
|
|
146
133
|
6. コードを修正
|
|
147
134
|
7. 再度VRTを実行して確認
|
|
148
135
|
|
package/dist/bin/vrt.js
CHANGED
|
@@ -47,6 +47,7 @@ var BLOCKED_DOMAINS = [
|
|
|
47
47
|
"sentry.io",
|
|
48
48
|
"datadoghq.com"
|
|
49
49
|
];
|
|
50
|
+
var DEFAULT_HIDE_SELECTORS = ["#devtools-indicator"];
|
|
50
51
|
var DISABLE_ANIMATIONS_CSS = `
|
|
51
52
|
*, *::before, *::after {
|
|
52
53
|
animation-duration: 0s !important;
|
|
@@ -63,13 +64,29 @@ async function stabilizePage(page, options) {
|
|
|
63
64
|
if (options.disableAnimations !== false) {
|
|
64
65
|
await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const allHideSelectors = [
|
|
68
|
+
...DEFAULT_HIDE_SELECTORS,
|
|
69
|
+
...options.hideSelectors ?? []
|
|
70
|
+
];
|
|
71
|
+
if (allHideSelectors.length > 0) {
|
|
72
|
+
const hideCSS = allHideSelectors.map((s) => `${s} { visibility: hidden !important; }`).join("\n");
|
|
68
73
|
await page.addStyleTag({ content: hideCSS });
|
|
69
74
|
}
|
|
70
75
|
if (options.delay && options.delay > 0) {
|
|
71
76
|
await page.waitForTimeout(options.delay);
|
|
72
77
|
}
|
|
78
|
+
if (allHideSelectors.length > 0) {
|
|
79
|
+
await page.evaluate(`
|
|
80
|
+
for (const sel of ${JSON.stringify(allHideSelectors)}) {
|
|
81
|
+
document.querySelectorAll(sel).forEach(el => el.remove());
|
|
82
|
+
document.querySelectorAll("*").forEach(el => {
|
|
83
|
+
if (el.shadowRoot) {
|
|
84
|
+
el.shadowRoot.querySelectorAll(sel).forEach(inner => inner.remove());
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
73
90
|
}
|
|
74
91
|
function getDateMockScript() {
|
|
75
92
|
return `
|
|
@@ -238,181 +255,6 @@ function normalizeImage(png, targetWidth, targetHeight) {
|
|
|
238
255
|
return normalized;
|
|
239
256
|
}
|
|
240
257
|
|
|
241
|
-
// src/core/region-detector.ts
|
|
242
|
-
import { PNG as PNG2 } from "pngjs";
|
|
243
|
-
function detectDiffRegions(diffImageBuffer, options = {}) {
|
|
244
|
-
const { minRegionSize = 10, mergingDistance = 50 } = options;
|
|
245
|
-
const png = PNG2.sync.read(diffImageBuffer);
|
|
246
|
-
const { width, height, data } = png;
|
|
247
|
-
const diffPixels = [];
|
|
248
|
-
for (let y = 0; y < height; y++) {
|
|
249
|
-
for (let x = 0; x < width; x++) {
|
|
250
|
-
const idx = (y * width + x) * 4;
|
|
251
|
-
const r = data[idx];
|
|
252
|
-
const g = data[idx + 1];
|
|
253
|
-
const b = data[idx + 2];
|
|
254
|
-
const a = data[idx + 3];
|
|
255
|
-
if (a > 100 && (r > 200 && g < 100 && b < 100 || r < 100 && g > 150 && b < 100)) {
|
|
256
|
-
diffPixels.push({ x, y });
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
if (diffPixels.length === 0) {
|
|
261
|
-
return [];
|
|
262
|
-
}
|
|
263
|
-
const clusters = clusterPixels(diffPixels, mergingDistance);
|
|
264
|
-
const regions = clusters.map((cluster, idx) => {
|
|
265
|
-
let minX = Infinity;
|
|
266
|
-
let maxX = -Infinity;
|
|
267
|
-
let minY = Infinity;
|
|
268
|
-
let maxY = -Infinity;
|
|
269
|
-
for (const p of cluster) {
|
|
270
|
-
if (p.x < minX) minX = p.x;
|
|
271
|
-
if (p.x > maxX) maxX = p.x;
|
|
272
|
-
if (p.y < minY) minY = p.y;
|
|
273
|
-
if (p.y > maxY) maxY = p.y;
|
|
274
|
-
}
|
|
275
|
-
const regionWidth = maxX - minX + 1;
|
|
276
|
-
const regionHeight = maxY - minY + 1;
|
|
277
|
-
return {
|
|
278
|
-
id: idx + 1,
|
|
279
|
-
boundingBox: {
|
|
280
|
-
x: minX,
|
|
281
|
-
y: minY,
|
|
282
|
-
width: regionWidth,
|
|
283
|
-
height: regionHeight
|
|
284
|
-
},
|
|
285
|
-
diffPixelCount: cluster.length,
|
|
286
|
-
diffPercentageInRegion: cluster.length / (regionWidth * regionHeight) * 100,
|
|
287
|
-
locationHint: estimatePosition(
|
|
288
|
-
minX,
|
|
289
|
-
minY,
|
|
290
|
-
regionWidth,
|
|
291
|
-
regionHeight,
|
|
292
|
-
width,
|
|
293
|
-
height
|
|
294
|
-
)
|
|
295
|
-
};
|
|
296
|
-
}).filter((r) => r.diffPixelCount >= minRegionSize).sort((a, b) => b.diffPixelCount - a.diffPixelCount);
|
|
297
|
-
return regions.map((r, i) => ({ ...r, id: i + 1 }));
|
|
298
|
-
}
|
|
299
|
-
function clusterPixels(pixels, distance) {
|
|
300
|
-
const cellSize = Math.max(distance, 1);
|
|
301
|
-
const grid = /* @__PURE__ */ new Map();
|
|
302
|
-
for (const p of pixels) {
|
|
303
|
-
const cellX = Math.floor(p.x / cellSize);
|
|
304
|
-
const cellY = Math.floor(p.y / cellSize);
|
|
305
|
-
const key = `${cellX},${cellY}`;
|
|
306
|
-
if (!grid.has(key)) {
|
|
307
|
-
grid.set(key, []);
|
|
308
|
-
}
|
|
309
|
-
grid.get(key).push(p);
|
|
310
|
-
}
|
|
311
|
-
const cellKeys = Array.from(grid.keys());
|
|
312
|
-
const parent = /* @__PURE__ */ new Map();
|
|
313
|
-
function find(key) {
|
|
314
|
-
if (!parent.has(key)) parent.set(key, key);
|
|
315
|
-
let root = key;
|
|
316
|
-
while (parent.get(root) !== root) {
|
|
317
|
-
root = parent.get(root);
|
|
318
|
-
}
|
|
319
|
-
let current = key;
|
|
320
|
-
while (current !== root) {
|
|
321
|
-
const next = parent.get(current);
|
|
322
|
-
parent.set(current, root);
|
|
323
|
-
current = next;
|
|
324
|
-
}
|
|
325
|
-
return root;
|
|
326
|
-
}
|
|
327
|
-
function union(a, b) {
|
|
328
|
-
const ra = find(a);
|
|
329
|
-
const rb = find(b);
|
|
330
|
-
if (ra !== rb) parent.set(ra, rb);
|
|
331
|
-
}
|
|
332
|
-
for (const key of cellKeys) {
|
|
333
|
-
const [cx, cy] = key.split(",").map(Number);
|
|
334
|
-
for (let dx = -1; dx <= 1; dx++) {
|
|
335
|
-
for (let dy = -1; dy <= 1; dy++) {
|
|
336
|
-
if (dx === 0 && dy === 0) continue;
|
|
337
|
-
const neighborKey = `${cx + dx},${cy + dy}`;
|
|
338
|
-
if (grid.has(neighborKey)) {
|
|
339
|
-
union(key, neighborKey);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
const clusters = /* @__PURE__ */ new Map();
|
|
345
|
-
for (const key of cellKeys) {
|
|
346
|
-
const root = find(key);
|
|
347
|
-
if (!clusters.has(root)) {
|
|
348
|
-
clusters.set(root, []);
|
|
349
|
-
}
|
|
350
|
-
clusters.get(root).push(...grid.get(key));
|
|
351
|
-
}
|
|
352
|
-
return Array.from(clusters.values());
|
|
353
|
-
}
|
|
354
|
-
function estimatePosition(x, y, regionWidth, regionHeight, pageWidth, pageHeight) {
|
|
355
|
-
const centerY = y + regionHeight / 2;
|
|
356
|
-
const yRatio = centerY / pageHeight;
|
|
357
|
-
let verticalPosition;
|
|
358
|
-
if (yRatio < 0.1) verticalPosition = "top";
|
|
359
|
-
else if (yRatio < 0.3) verticalPosition = "upper";
|
|
360
|
-
else if (yRatio < 0.7) verticalPosition = "middle";
|
|
361
|
-
else if (yRatio < 0.9) verticalPosition = "lower";
|
|
362
|
-
else verticalPosition = "bottom";
|
|
363
|
-
const centerX = x + regionWidth / 2;
|
|
364
|
-
const widthRatio = regionWidth / pageWidth;
|
|
365
|
-
let horizontalPosition;
|
|
366
|
-
if (widthRatio > 0.8) {
|
|
367
|
-
horizontalPosition = "full-width";
|
|
368
|
-
} else if (centerX < pageWidth * 0.33) {
|
|
369
|
-
horizontalPosition = "left";
|
|
370
|
-
} else if (centerX > pageWidth * 0.67) {
|
|
371
|
-
horizontalPosition = "right";
|
|
372
|
-
} else {
|
|
373
|
-
horizontalPosition = "center";
|
|
374
|
-
}
|
|
375
|
-
const estimatedElement = guessElement(
|
|
376
|
-
verticalPosition,
|
|
377
|
-
horizontalPosition,
|
|
378
|
-
regionWidth,
|
|
379
|
-
regionHeight,
|
|
380
|
-
pageWidth,
|
|
381
|
-
y
|
|
382
|
-
);
|
|
383
|
-
return {
|
|
384
|
-
verticalPosition,
|
|
385
|
-
horizontalPosition,
|
|
386
|
-
fromTopPx: y,
|
|
387
|
-
fromLeftPx: x,
|
|
388
|
-
estimatedElement
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
function guessElement(vPos, hPos, width, height, pageWidth, fromTop) {
|
|
392
|
-
if (vPos === "top" && hPos === "full-width" && fromTop < 100) {
|
|
393
|
-
return "Likely a header or navigation bar";
|
|
394
|
-
}
|
|
395
|
-
if (vPos === "bottom" && hPos === "full-width") {
|
|
396
|
-
return "Likely a footer";
|
|
397
|
-
}
|
|
398
|
-
if (vPos === "upper" && hPos === "full-width" && height > 200) {
|
|
399
|
-
return "Likely a hero section or banner";
|
|
400
|
-
}
|
|
401
|
-
if (width < 200 && height < 60) {
|
|
402
|
-
return "Likely a button or small UI element";
|
|
403
|
-
}
|
|
404
|
-
if (width > pageWidth * 0.5 && height < 40) {
|
|
405
|
-
return "Likely a text line or heading";
|
|
406
|
-
}
|
|
407
|
-
if (width > 200 && width < 500 && height > 100 && height < 400) {
|
|
408
|
-
return "Likely a card or content block";
|
|
409
|
-
}
|
|
410
|
-
if (width > 100 && height > 100 && width < pageWidth * 0.8) {
|
|
411
|
-
return "Likely an image or media element";
|
|
412
|
-
}
|
|
413
|
-
return `UI element at ~${fromTop}px from top`;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
258
|
// src/reporters/terminal.ts
|
|
417
259
|
import chalk from "chalk";
|
|
418
260
|
function printTerminalReport(report) {
|
|
@@ -454,21 +296,6 @@ function printTerminalReport(report) {
|
|
|
454
296
|
console.log(
|
|
455
297
|
` ${vpLabel} ${chalk.red("\u274C FAIL")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`
|
|
456
298
|
);
|
|
457
|
-
for (const region of result.diffRegions.slice(0, 5)) {
|
|
458
|
-
const { boundingBox: bb, locationHint: lh } = region;
|
|
459
|
-
console.log(
|
|
460
|
-
chalk.dim(
|
|
461
|
-
` \u2192 Region ${region.id}: ${bb.width}x${bb.height} at (${bb.x}, ${bb.y}) - ${lh.estimatedElement}`
|
|
462
|
-
)
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
if (result.diffRegions.length > 5) {
|
|
466
|
-
console.log(
|
|
467
|
-
chalk.dim(
|
|
468
|
-
` \u2192 ...and ${result.diffRegions.length - 5} more regions`
|
|
469
|
-
)
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
299
|
}
|
|
473
300
|
}
|
|
474
301
|
console.log("");
|
|
@@ -494,137 +321,54 @@ async function writeJsonReport(report, outDir) {
|
|
|
494
321
|
// src/reporters/html.ts
|
|
495
322
|
import { writeFile as writeFile2 } from "fs/promises";
|
|
496
323
|
import { join as join2 } from "path";
|
|
324
|
+
|
|
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 Before: {{BEFORE_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
|
+
|
|
328
|
+
// src/templates/report.css
|
|
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';
|
|
330
|
+
|
|
331
|
+
// src/reporters/html.ts
|
|
497
332
|
async function writeHtmlReport(report, outDir) {
|
|
498
|
-
const html = generateHtml(report
|
|
333
|
+
const html = generateHtml(report);
|
|
499
334
|
const filePath = join2(outDir, "report.html");
|
|
500
335
|
await writeFile2(filePath, html, "utf-8");
|
|
501
336
|
return filePath;
|
|
502
337
|
}
|
|
503
|
-
function generateHtml(report
|
|
338
|
+
function generateHtml(report) {
|
|
504
339
|
const { meta, summary, results } = report;
|
|
505
|
-
const resultCards = results.map((r) => generateResultCard(r
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
<head>
|
|
509
|
-
<meta charset="UTF-8">
|
|
510
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
511
|
-
<title>VRT Report - ${meta.timestamp}</title>
|
|
512
|
-
<style>
|
|
513
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
514
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
|
|
515
|
-
.header { background: #1a1a2e; color: white; padding: 24px 32px; }
|
|
516
|
-
.header h1 { font-size: 20px; margin-bottom: 8px; }
|
|
517
|
-
.header .meta { font-size: 13px; color: #aaa; }
|
|
518
|
-
.header .meta a { color: #7eb8da; }
|
|
519
|
-
.summary { display: flex; gap: 16px; padding: 16px 32px; background: white; border-bottom: 1px solid #e0e0e0; }
|
|
520
|
-
.summary .stat { padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 600; }
|
|
521
|
-
.stat.pass { background: #e8f5e9; color: #2e7d32; }
|
|
522
|
-
.stat.fail { background: #ffebee; color: #c62828; }
|
|
523
|
-
.stat.total { background: #e3f2fd; color: #1565c0; }
|
|
524
|
-
.results { padding: 24px 32px; display: flex; flex-direction: column; gap: 24px; }
|
|
525
|
-
.card { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
|
|
526
|
-
.card-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
|
|
527
|
-
.card-header h3 { font-size: 15px; }
|
|
528
|
-
.badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
|
529
|
-
.badge.pass { background: #e8f5e9; color: #2e7d32; }
|
|
530
|
-
.badge.fail { background: #ffebee; color: #c62828; }
|
|
531
|
-
.badge.error { background: #fff3e0; color: #e65100; }
|
|
532
|
-
.comparison { padding: 16px 20px; }
|
|
533
|
-
.comparison-controls { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
534
|
-
.comparison-controls button { padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
|
|
535
|
-
.comparison-controls button.active { background: #1a1a2e; color: white; border-color: #1a1a2e; }
|
|
536
|
-
.images { display: flex; gap: 8px; }
|
|
537
|
-
.images.side-by-side { flex-direction: row; }
|
|
538
|
-
.images.diff-only .img-before, .images.diff-only .img-after { display: none; }
|
|
539
|
-
.images.before-only .img-after, .images.before-only .img-diff { display: none; }
|
|
540
|
-
.images.after-only .img-before, .images.after-only .img-diff { display: none; }
|
|
541
|
-
.img-container { flex: 1; min-width: 0; }
|
|
542
|
-
.img-container img { width: 100%; height: auto; border: 1px solid #eee; border-radius: 4px; }
|
|
543
|
-
.img-container .label { font-size: 11px; color: #888; margin-bottom: 4px; text-transform: uppercase; font-weight: 600; }
|
|
544
|
-
.diff-info { padding: 8px 20px 16px; font-size: 13px; color: #666; }
|
|
545
|
-
.diff-info .region { margin: 4px 0; padding-left: 16px; }
|
|
546
|
-
.slider-container { position: relative; overflow: hidden; }
|
|
547
|
-
.slider-container img { width: 100%; display: block; }
|
|
548
|
-
.slider-overlay { position: absolute; top: 0; left: 0; height: 100%; overflow: hidden; border-right: 2px solid #ff0000; }
|
|
549
|
-
.slider-overlay img { width: 100%; height: 100%; object-fit: cover; object-position: left; }
|
|
550
|
-
input[type="range"].slider { width: 100%; margin-top: 8px; }
|
|
551
|
-
</style>
|
|
552
|
-
</head>
|
|
553
|
-
<body>
|
|
554
|
-
<div class="header">
|
|
555
|
-
<h1>VRT Report</h1>
|
|
556
|
-
<div class="meta">
|
|
557
|
-
${meta.timestamp} | Duration: ${(meta.duration / 1e3).toFixed(1)}s<br>
|
|
558
|
-
Before: ${meta.beforeUrl} \u2192 After: ${meta.afterUrl}
|
|
559
|
-
</div>
|
|
560
|
-
</div>
|
|
561
|
-
<div class="summary">
|
|
562
|
-
<div class="stat total">${summary.totalTests} Total</div>
|
|
563
|
-
<div class="stat pass">${summary.passed} Passed</div>
|
|
564
|
-
${summary.failed > 0 ? `<div class="stat fail">${summary.failed} Failed</div>` : ""}
|
|
565
|
-
</div>
|
|
566
|
-
<div class="results">
|
|
567
|
-
${resultCards}
|
|
568
|
-
</div>
|
|
569
|
-
<script>
|
|
570
|
-
document.querySelectorAll('.comparison-controls button').forEach(btn => {
|
|
571
|
-
btn.addEventListener('click', () => {
|
|
572
|
-
const card = btn.closest('.card');
|
|
573
|
-
const images = card.querySelector('.images');
|
|
574
|
-
const buttons = card.querySelectorAll('.comparison-controls button');
|
|
575
|
-
buttons.forEach(b => b.classList.remove('active'));
|
|
576
|
-
btn.classList.add('active');
|
|
577
|
-
images.className = 'images ' + btn.dataset.mode;
|
|
578
|
-
});
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
document.querySelectorAll('.slider').forEach(slider => {
|
|
582
|
-
slider.addEventListener('input', (e) => {
|
|
583
|
-
const container = e.target.closest('.comparison').querySelector('.slider-overlay');
|
|
584
|
-
if (container) {
|
|
585
|
-
container.style.width = e.target.value + '%';
|
|
586
|
-
}
|
|
587
|
-
});
|
|
588
|
-
});
|
|
589
|
-
</script>
|
|
590
|
-
</body>
|
|
591
|
-
</html>`;
|
|
340
|
+
const resultCards = results.map((r) => generateResultCard(r)).join("\n");
|
|
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("{{BEFORE_URL}}", meta.beforeUrl).replace("{{AFTER_URL}}", meta.afterUrl).replace("{{TOTAL}}", String(summary.totalTests)).replace("{{PASSED}}", String(summary.passed)).replace("{{FAILED_STAT}}", failedStat).replace("{{RESULTS}}", resultCards);
|
|
592
343
|
}
|
|
593
|
-
function generateResultCard(result
|
|
594
|
-
const { page, viewport, status, comparison,
|
|
595
|
-
const vpLabel =
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
<h3>${page.name} (${page.path}) \u2014 ${vpLabel}</h3>
|
|
603
|
-
<span class="badge ${status}">${status.toUpperCase()} ${status !== "error" ? `${comparison.diffPercentage.toFixed(2)}%` : ""}</span>
|
|
604
|
-
</div>
|
|
605
|
-
<div class="comparison">
|
|
606
|
-
<div class="comparison-controls">
|
|
607
|
-
<button class="active" data-mode="side-by-side">Side by Side</button>
|
|
608
|
-
<button data-mode="diff-only">Diff Only</button>
|
|
609
|
-
<button data-mode="before-only">Before</button>
|
|
610
|
-
<button data-mode="after-only">After</button>
|
|
344
|
+
function generateResultCard(result) {
|
|
345
|
+
const { page, viewport, status, comparison, screenshots } = result;
|
|
346
|
+
const vpLabel = viewport.type.toUpperCase();
|
|
347
|
+
const imagesHtml = `
|
|
348
|
+
<div class="comparison">
|
|
349
|
+
<div class="images">
|
|
350
|
+
<div class="img-container img-before">
|
|
351
|
+
<div class="label">Before</div>
|
|
352
|
+
<img src="${screenshots.before}" alt="Before" loading="lazy">
|
|
611
353
|
</div>
|
|
612
|
-
<div class="
|
|
613
|
-
<div class="
|
|
614
|
-
|
|
615
|
-
<img src="${screenshots.before}" alt="Before" loading="lazy">
|
|
616
|
-
</div>
|
|
617
|
-
<div class="img-container img-after">
|
|
618
|
-
<div class="label">After</div>
|
|
619
|
-
<img src="${screenshots.after}" alt="After" loading="lazy">
|
|
620
|
-
</div>
|
|
621
|
-
<div class="img-container img-diff">
|
|
622
|
-
<div class="label">Diff</div>
|
|
623
|
-
<img src="${screenshots.diff}" alt="Diff" loading="lazy">
|
|
624
|
-
</div>
|
|
354
|
+
<div class="img-container img-after">
|
|
355
|
+
<div class="label">After</div>
|
|
356
|
+
<img src="${screenshots.after}" alt="After" loading="lazy">
|
|
625
357
|
</div>
|
|
358
|
+
<div class="img-container img-diff">
|
|
359
|
+
<div class="label">Diff</div>
|
|
360
|
+
<img src="${screenshots.diff}" alt="Diff" loading="lazy">
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>`;
|
|
364
|
+
const screenshotSection = status === "pass" ? `<details class="screenshot-accordion"><summary>Show</summary>${imagesHtml}</details>` : imagesHtml;
|
|
365
|
+
return `
|
|
366
|
+
<div class="card ${viewport.type}">
|
|
367
|
+
<div class="card-header">
|
|
368
|
+
<h3>${page.path} - ${vpLabel}</h3>
|
|
369
|
+
<span class="badge ${status}">${status === "error" ? "ERROR" : `${status.toUpperCase()} ${comparison.diffPercentage.toFixed(2)}%`}</span>
|
|
626
370
|
</div>
|
|
627
|
-
${
|
|
371
|
+
${screenshotSection}
|
|
628
372
|
</div>`;
|
|
629
373
|
}
|
|
630
374
|
|
|
@@ -677,7 +421,6 @@ async function runVrt(options) {
|
|
|
677
421
|
afterShot.buffer,
|
|
678
422
|
options.threshold
|
|
679
423
|
);
|
|
680
|
-
const diffRegions = comparison.passed ? [] : detectDiffRegions(comparison.diffImage);
|
|
681
424
|
const filePrefix = `${beforeShot.pageName}--${beforeShot.viewportType}`;
|
|
682
425
|
const beforePath = join3("screenshots", `${filePrefix}--before.png`);
|
|
683
426
|
const afterPath = join3("screenshots", `${filePrefix}--after.png`);
|
|
@@ -711,7 +454,6 @@ async function runVrt(options) {
|
|
|
711
454
|
threshold: options.threshold,
|
|
712
455
|
dimensions: comparison.dimensions
|
|
713
456
|
},
|
|
714
|
-
diffRegions,
|
|
715
457
|
screenshots: {
|
|
716
458
|
before: beforePath,
|
|
717
459
|
after: afterPath,
|
|
@@ -793,7 +535,6 @@ function createErrorResult(pagePath, viewportType, options, error) {
|
|
|
793
535
|
threshold: options.threshold,
|
|
794
536
|
dimensions: { width: 0, height: 0, beforeHeight: 0, afterHeight: 0 }
|
|
795
537
|
},
|
|
796
|
-
diffRegions: [],
|
|
797
538
|
screenshots: { before: "", after: "", diff: "" },
|
|
798
539
|
error
|
|
799
540
|
};
|
|
@@ -863,7 +604,7 @@ ${command}
|
|
|
863
604
|
3. \`./vrt-results/\` \u5185\u306E\u6700\u65B0\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u306B\u3042\u308B \`report.json\` \u3092\u8AAD\u3080
|
|
864
605
|
4. \`status: "fail"\` \u306E\u30C6\u30B9\u30C8\u7D50\u679C\u306B\u6CE8\u76EE\u3059\u308B
|
|
865
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
|
|
866
|
-
6.
|
|
607
|
+
6. diff\u753B\u50CF\u304B\u3089\u4FEE\u6B63\u3059\u3079\u304DCSS\u3084HTML\u306E\u5834\u6240\u3092\u7279\u5B9A\u3059\u308B
|
|
867
608
|
7. \u30BD\u30FC\u30B9\u30B3\u30FC\u30C9\u3092\u4FEE\u6B63\u3059\u308B
|
|
868
609
|
8. \u518D\u5EA6VRT\u3092\u5B9F\u884C\u3057\u3066\u4FEE\u6B63\u304C\u53CD\u6620\u3055\u308C\u305F\u3053\u3068\u3092\u78BA\u8A8D\u3059\u308B
|
|
869
610
|
`;
|