web-corders-vrt 0.1.1

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.
@@ -0,0 +1,917 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/vrt.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/schemas.ts
7
+ import { z } from "zod";
8
+ var cliOptionsSchema = z.object({
9
+ before: z.string().url("--before must be a valid URL"),
10
+ after: z.string().url("--after must be a valid URL"),
11
+ paths: z.string().transform((val) => val.split(",").map((p) => p.trim())),
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)
16
+ });
17
+
18
+ // src/commands/run.ts
19
+ import { mkdir, writeFile as writeFile3 } from "fs/promises";
20
+ import { join as join3 } from "path";
21
+ import ora from "ora";
22
+ import chalk2 from "chalk";
23
+
24
+ // src/core/screenshotter.ts
25
+ import { chromium } from "playwright";
26
+
27
+ // src/constants.ts
28
+ var DEFAULT_SP_VIEWPORT = { width: 375, height: 812 };
29
+ var DEFAULT_PC_VIEWPORT = { width: 1440, height: 900 };
30
+ var DEFAULT_THRESHOLD = 0.1;
31
+ var DEFAULT_DELAY = 1e3;
32
+ var DEFAULT_CONCURRENCY = 3;
33
+ var DEFAULT_OUT_DIR = "./vrt-results";
34
+ var SP_USER_AGENT = "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";
35
+ var BLOCKED_DOMAINS = [
36
+ "googletagmanager.com",
37
+ "google-analytics.com",
38
+ "googleadservices.com",
39
+ "googlesyndication.com",
40
+ "doubleclick.net",
41
+ "facebook.net",
42
+ "fbcdn.net",
43
+ "analytics.yahoo.co.jp",
44
+ "clarity.ms",
45
+ "hotjar.com",
46
+ "newrelic.com",
47
+ "sentry.io",
48
+ "datadoghq.com"
49
+ ];
50
+ var DISABLE_ANIMATIONS_CSS = `
51
+ *, *::before, *::after {
52
+ animation-duration: 0s !important;
53
+ animation-delay: 0s !important;
54
+ transition-duration: 0s !important;
55
+ transition-delay: 0s !important;
56
+ scroll-behavior: auto !important;
57
+ caret-color: transparent !important;
58
+ }
59
+ `;
60
+
61
+ // src/core/stabilizer.ts
62
+ async function stabilizePage(page, options) {
63
+ if (options.disableAnimations !== false) {
64
+ await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });
65
+ }
66
+ if (options.hideSelectors && options.hideSelectors.length > 0) {
67
+ const hideCSS = options.hideSelectors.map((s) => `${s} { visibility: hidden !important; }`).join("\n");
68
+ await page.addStyleTag({ content: hideCSS });
69
+ }
70
+ if (options.delay && options.delay > 0) {
71
+ await page.waitForTimeout(options.delay);
72
+ }
73
+ }
74
+ function getDateMockScript() {
75
+ return `
76
+ (() => {
77
+ const fixedDate = new Date('2025-01-01T00:00:00Z');
78
+ const OrigDate = Date;
79
+ const MockDate = class extends OrigDate {
80
+ constructor(...args) {
81
+ if (args.length === 0) {
82
+ super(fixedDate.getTime());
83
+ } else {
84
+ super(...args);
85
+ }
86
+ }
87
+ static now() { return fixedDate.getTime(); }
88
+ };
89
+ globalThis.Date = MockDate;
90
+ })();
91
+ `;
92
+ }
93
+
94
+ // src/core/screenshotter.ts
95
+ var Screenshotter = class {
96
+ browser = null;
97
+ async initialize() {
98
+ this.browser = await chromium.launch();
99
+ }
100
+ /**
101
+ * 指定URLのスクリーンショットを取得する。
102
+ */
103
+ async capture(url, pagePath, viewportType, hideSelectors) {
104
+ if (!this.browser) {
105
+ throw new Error("Browser not initialized. Call initialize() first.");
106
+ }
107
+ const viewport = viewportType === "sp" ? { ...DEFAULT_SP_VIEWPORT } : { ...DEFAULT_PC_VIEWPORT };
108
+ const context = await this.browser.newContext({
109
+ viewport,
110
+ deviceScaleFactor: viewportType === "sp" ? 2 : 1,
111
+ userAgent: viewportType === "sp" ? SP_USER_AGENT : void 0
112
+ });
113
+ const page = await context.newPage();
114
+ await page.addInitScript(getDateMockScript());
115
+ await page.route("**/*", (route) => {
116
+ const reqUrl = route.request().url();
117
+ const shouldBlock = BLOCKED_DOMAINS.some(
118
+ (domain) => reqUrl.includes(domain)
119
+ );
120
+ if (shouldBlock) {
121
+ return route.abort();
122
+ }
123
+ return route.continue();
124
+ });
125
+ await page.goto(url, { waitUntil: "load", timeout: 6e4 });
126
+ await stabilizePage(page, {
127
+ hideSelectors,
128
+ delay: DEFAULT_DELAY
129
+ });
130
+ const buffer = await page.screenshot({
131
+ fullPage: true,
132
+ type: "png"
133
+ });
134
+ const width = buffer.readUInt32BE(16);
135
+ const height = buffer.readUInt32BE(20);
136
+ await context.close();
137
+ const pageName = pagePath === "/" ? "top" : pagePath.replace(/^\//, "").replace(/\//g, "-");
138
+ return {
139
+ pagePath,
140
+ pageName,
141
+ viewportType,
142
+ buffer: Buffer.from(buffer),
143
+ width,
144
+ height
145
+ };
146
+ }
147
+ /**
148
+ * 複数のページ・ビューポートのスクリーンショットを並行で取得する。
149
+ */
150
+ async captureAll(baseUrl, paths, viewports, hideSelectors) {
151
+ const tasks = [];
152
+ for (const pagePath of paths) {
153
+ const url = new URL(pagePath, baseUrl).toString();
154
+ for (const viewport of viewports) {
155
+ tasks.push({ url, path: pagePath, viewport });
156
+ }
157
+ }
158
+ const results = [];
159
+ for (let i = 0; i < tasks.length; i += DEFAULT_CONCURRENCY) {
160
+ const batch = tasks.slice(i, i + DEFAULT_CONCURRENCY);
161
+ const batchResults = await Promise.all(
162
+ batch.map(
163
+ (task) => this.capture(task.url, task.path, task.viewport, hideSelectors)
164
+ )
165
+ );
166
+ results.push(...batchResults);
167
+ }
168
+ return results;
169
+ }
170
+ async cleanup() {
171
+ await this.browser?.close();
172
+ this.browser = null;
173
+ }
174
+ };
175
+
176
+ // src/core/comparator.ts
177
+ import pixelmatch from "pixelmatch";
178
+ import { PNG } from "pngjs";
179
+ function compareImages(beforeBuffer, afterBuffer, threshold = 0.1) {
180
+ const before = PNG.sync.read(beforeBuffer);
181
+ const after = PNG.sync.read(afterBuffer);
182
+ const width = Math.max(before.width, after.width);
183
+ const height = Math.max(before.height, after.height);
184
+ const normalizedBefore = normalizeImage(before, width, height);
185
+ const normalizedAfter = normalizeImage(after, width, height);
186
+ const diff = new PNG({ width, height });
187
+ const diffCount = pixelmatch(
188
+ normalizedBefore.data,
189
+ normalizedAfter.data,
190
+ diff.data,
191
+ width,
192
+ height,
193
+ {
194
+ threshold: 0.1,
195
+ includeAA: false,
196
+ alpha: 0.1,
197
+ diffColor: [255, 0, 0],
198
+ diffColorAlt: [0, 200, 0]
199
+ }
200
+ );
201
+ const totalPixels = width * height;
202
+ const diffPercentage = diffCount / totalPixels * 100;
203
+ return {
204
+ diffCount,
205
+ totalPixels,
206
+ diffPercentage,
207
+ diffImage: PNG.sync.write(diff),
208
+ passed: diffPercentage <= threshold,
209
+ dimensions: {
210
+ width,
211
+ height,
212
+ beforeHeight: before.height,
213
+ afterHeight: after.height
214
+ }
215
+ };
216
+ }
217
+ function normalizeImage(png, targetWidth, targetHeight) {
218
+ if (png.width === targetWidth && png.height === targetHeight) {
219
+ return png;
220
+ }
221
+ const normalized = new PNG({ width: targetWidth, height: targetHeight });
222
+ for (let i = 0; i < normalized.data.length; i += 4) {
223
+ normalized.data[i] = 255;
224
+ normalized.data[i + 1] = 255;
225
+ normalized.data[i + 2] = 255;
226
+ normalized.data[i + 3] = 255;
227
+ }
228
+ for (let y = 0; y < png.height; y++) {
229
+ for (let x = 0; x < png.width; x++) {
230
+ const srcIdx = (y * png.width + x) * 4;
231
+ const dstIdx = (y * targetWidth + x) * 4;
232
+ normalized.data[dstIdx] = png.data[srcIdx];
233
+ normalized.data[dstIdx + 1] = png.data[srcIdx + 1];
234
+ normalized.data[dstIdx + 2] = png.data[srcIdx + 2];
235
+ normalized.data[dstIdx + 3] = png.data[srcIdx + 3];
236
+ }
237
+ }
238
+ return normalized;
239
+ }
240
+
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
+ // src/reporters/terminal.ts
417
+ import chalk from "chalk";
418
+ function printTerminalReport(report) {
419
+ const { meta, summary, results } = report;
420
+ console.log("");
421
+ console.log(chalk.bold(`VRT Results - ${meta.timestamp}`));
422
+ console.log("=".repeat(50));
423
+ console.log("");
424
+ console.log(
425
+ `Comparing: ${chalk.cyan(meta.beforeUrl)} vs ${chalk.cyan(meta.afterUrl)}`
426
+ );
427
+ console.log("");
428
+ const groupedByPage = /* @__PURE__ */ new Map();
429
+ for (const result of results) {
430
+ const key = result.page.path;
431
+ if (!groupedByPage.has(key)) {
432
+ groupedByPage.set(key, []);
433
+ }
434
+ groupedByPage.get(key).push(result);
435
+ }
436
+ for (const [pagePath, pageResults] of groupedByPage) {
437
+ const pageName = pageResults[0].page.name;
438
+ console.log(` Page: ${chalk.bold(pageName)} (${pagePath})`);
439
+ for (const result of pageResults) {
440
+ const vpLabel = `${result.viewport.type.toUpperCase()} (${result.viewport.width}x${result.viewport.height})`;
441
+ if (result.status === "error") {
442
+ console.log(
443
+ ` ${vpLabel} ${chalk.yellow("\u26A0 ERROR")} ${result.error || "Unknown error"}`
444
+ );
445
+ continue;
446
+ }
447
+ const diffStr = `diff: ${result.comparison.diffPercentage.toFixed(2)}%`;
448
+ if (result.status === "pass") {
449
+ console.log(
450
+ ` ${vpLabel} ${chalk.green("\u2705 PASS")} ${chalk.dim(diffStr)}`
451
+ );
452
+ } else {
453
+ const thresholdStr = `(threshold: ${result.comparison.threshold}%)`;
454
+ console.log(
455
+ ` ${vpLabel} ${chalk.red("\u274C FAIL")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`
456
+ );
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
+ }
473
+ }
474
+ console.log("");
475
+ }
476
+ const statusColor = summary.overallStatus === "pass" ? chalk.green : chalk.red;
477
+ console.log(
478
+ `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`
479
+ );
480
+ console.log(`Overall: ${statusColor(summary.overallStatus.toUpperCase())}`);
481
+ console.log(`Duration: ${(meta.duration / 1e3).toFixed(1)}s`);
482
+ console.log("");
483
+ }
484
+
485
+ // src/reporters/json.ts
486
+ import { writeFile } from "fs/promises";
487
+ import { join } from "path";
488
+ async function writeJsonReport(report, outDir) {
489
+ const filePath = join(outDir, "report.json");
490
+ await writeFile(filePath, JSON.stringify(report, null, 2), "utf-8");
491
+ return filePath;
492
+ }
493
+
494
+ // src/reporters/html.ts
495
+ import { writeFile as writeFile2 } from "fs/promises";
496
+ import { join as join2 } from "path";
497
+ async function writeHtmlReport(report, outDir) {
498
+ const html = generateHtml(report, outDir);
499
+ const filePath = join2(outDir, "report.html");
500
+ await writeFile2(filePath, html, "utf-8");
501
+ return filePath;
502
+ }
503
+ function generateHtml(report, outDir) {
504
+ const { meta, summary, results } = report;
505
+ const resultCards = results.map((r) => generateResultCard(r, outDir)).join("\n");
506
+ return `<!DOCTYPE html>
507
+ <html lang="ja">
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>`;
592
+ }
593
+ function generateResultCard(result, outDir) {
594
+ const { page, viewport, status, comparison, diffRegions, screenshots } = result;
595
+ const vpLabel = `${viewport.type.toUpperCase()} ${viewport.width}x${viewport.height}`;
596
+ const regionsHtml = diffRegions.length > 0 ? diffRegions.slice(0, 5).map(
597
+ (r) => `<div class="region">Region ${r.id}: ${r.boundingBox.width}x${r.boundingBox.height} at (${r.boundingBox.x}, ${r.boundingBox.y}) \u2014 ${r.locationHint.estimatedElement}</div>`
598
+ ).join("\n") + (diffRegions.length > 5 ? `<div class="region">...and ${diffRegions.length - 5} more</div>` : "") : "";
599
+ return `
600
+ <div class="card">
601
+ <div class="card-header">
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>
611
+ </div>
612
+ <div class="images side-by-side">
613
+ <div class="img-container img-before">
614
+ <div class="label">Before</div>
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>
625
+ </div>
626
+ </div>
627
+ ${regionsHtml ? `<div class="diff-info">${regionsHtml}</div>` : ""}
628
+ </div>`;
629
+ }
630
+
631
+ // src/commands/run.ts
632
+ var VIEWPORTS = ["sp", "pc"];
633
+ async function runVrt(options) {
634
+ const startTime = Date.now();
635
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
636
+ const runDir = join3(DEFAULT_OUT_DIR, timestamp);
637
+ const screenshotsDir = join3(runDir, "screenshots");
638
+ await mkdir(screenshotsDir, { recursive: true });
639
+ const screenshotter = new Screenshotter();
640
+ const spinner = ora("Initializing browser...").start();
641
+ try {
642
+ await screenshotter.initialize();
643
+ spinner.text = `Taking screenshots of ${chalk2.cyan(options.beforeUrl)}...`;
644
+ const beforeScreenshots = await screenshotter.captureAll(
645
+ options.beforeUrl,
646
+ options.paths,
647
+ VIEWPORTS,
648
+ options.hideSelectors
649
+ );
650
+ spinner.text = `Taking screenshots of ${chalk2.cyan(options.afterUrl)}...`;
651
+ const afterScreenshots = await screenshotter.captureAll(
652
+ options.afterUrl,
653
+ options.paths,
654
+ VIEWPORTS,
655
+ options.hideSelectors
656
+ );
657
+ spinner.text = "Comparing screenshots...";
658
+ const results = [];
659
+ for (const beforeShot of beforeScreenshots) {
660
+ const afterShot = afterScreenshots.find(
661
+ (a) => a.pagePath === beforeShot.pagePath && a.viewportType === beforeShot.viewportType
662
+ );
663
+ if (!afterShot) {
664
+ results.push(
665
+ createErrorResult(
666
+ beforeShot.pagePath,
667
+ beforeShot.viewportType,
668
+ options,
669
+ "After screenshot not found"
670
+ )
671
+ );
672
+ continue;
673
+ }
674
+ try {
675
+ const comparison = compareImages(
676
+ beforeShot.buffer,
677
+ afterShot.buffer,
678
+ options.threshold
679
+ );
680
+ const diffRegions = comparison.passed ? [] : detectDiffRegions(comparison.diffImage);
681
+ const filePrefix = `${beforeShot.pageName}--${beforeShot.viewportType}`;
682
+ const beforePath = join3("screenshots", `${filePrefix}--before.png`);
683
+ const afterPath = join3("screenshots", `${filePrefix}--after.png`);
684
+ const diffPath = join3("screenshots", `${filePrefix}--diff.png`);
685
+ await writeFile3(join3(runDir, beforePath), beforeShot.buffer);
686
+ await writeFile3(join3(runDir, afterPath), afterShot.buffer);
687
+ await writeFile3(join3(runDir, diffPath), comparison.diffImage);
688
+ const pageName = beforeShot.pagePath === "/" ? "\u30C8\u30C3\u30D7\u30DA\u30FC\u30B8" : beforeShot.pagePath.replace(/^\//, "");
689
+ const viewport = getViewportDimensions(beforeShot.viewportType);
690
+ results.push({
691
+ page: {
692
+ path: beforeShot.pagePath,
693
+ name: pageName,
694
+ url: {
695
+ before: new URL(
696
+ beforeShot.pagePath,
697
+ options.beforeUrl
698
+ ).toString(),
699
+ after: new URL(beforeShot.pagePath, options.afterUrl).toString()
700
+ }
701
+ },
702
+ viewport: {
703
+ type: beforeShot.viewportType,
704
+ ...viewport
705
+ },
706
+ status: comparison.passed ? "pass" : "fail",
707
+ comparison: {
708
+ diffPercentage: comparison.diffPercentage,
709
+ diffPixelCount: comparison.diffCount,
710
+ totalPixels: comparison.totalPixels,
711
+ threshold: options.threshold,
712
+ dimensions: comparison.dimensions
713
+ },
714
+ diffRegions,
715
+ screenshots: {
716
+ before: beforePath,
717
+ after: afterPath,
718
+ diff: diffPath
719
+ }
720
+ });
721
+ } catch (error) {
722
+ results.push(
723
+ createErrorResult(
724
+ beforeShot.pagePath,
725
+ beforeShot.viewportType,
726
+ options,
727
+ error instanceof Error ? error.message : String(error)
728
+ )
729
+ );
730
+ }
731
+ }
732
+ const duration = Date.now() - startTime;
733
+ const passed = results.filter((r) => r.status === "pass").length;
734
+ const failed = results.filter((r) => r.status === "fail").length;
735
+ const errored = results.filter((r) => r.status === "error").length;
736
+ const report = {
737
+ version: "1.0",
738
+ meta: {
739
+ timestamp,
740
+ beforeUrl: options.beforeUrl,
741
+ afterUrl: options.afterUrl,
742
+ duration,
743
+ command: buildCommandString(options)
744
+ },
745
+ summary: {
746
+ totalTests: results.length,
747
+ passed,
748
+ failed,
749
+ errored,
750
+ overallStatus: failed > 0 || errored > 0 ? "fail" : "pass"
751
+ },
752
+ results
753
+ };
754
+ spinner.stop();
755
+ printTerminalReport(report);
756
+ const jsonPath = await writeJsonReport(report, runDir);
757
+ console.log(`JSON report: ${chalk2.dim(jsonPath)}`);
758
+ if (options.html) {
759
+ const htmlPath = await writeHtmlReport(report, runDir);
760
+ console.log(`HTML report: ${chalk2.dim(htmlPath)}`);
761
+ if (options.open) {
762
+ const { exec } = await import("child_process");
763
+ exec(`open "${htmlPath}"`);
764
+ }
765
+ }
766
+ console.log(`Output dir: ${chalk2.dim(runDir)}`);
767
+ return report;
768
+ } finally {
769
+ await screenshotter.cleanup();
770
+ }
771
+ }
772
+ function getViewportDimensions(type) {
773
+ return type === "sp" ? { ...DEFAULT_SP_VIEWPORT } : { ...DEFAULT_PC_VIEWPORT };
774
+ }
775
+ function createErrorResult(pagePath, viewportType, options, error) {
776
+ const viewport = getViewportDimensions(viewportType);
777
+ const pageName = pagePath === "/" ? "\u30C8\u30C3\u30D7\u30DA\u30FC\u30B8" : pagePath.replace(/^\//, "");
778
+ return {
779
+ page: {
780
+ path: pagePath,
781
+ name: pageName,
782
+ url: {
783
+ before: new URL(pagePath, options.beforeUrl).toString(),
784
+ after: new URL(pagePath, options.afterUrl).toString()
785
+ }
786
+ },
787
+ viewport: { type: viewportType, ...viewport },
788
+ status: "error",
789
+ comparison: {
790
+ diffPercentage: 0,
791
+ diffPixelCount: 0,
792
+ totalPixels: 0,
793
+ threshold: options.threshold,
794
+ dimensions: { width: 0, height: 0, beforeHeight: 0, afterHeight: 0 }
795
+ },
796
+ diffRegions: [],
797
+ screenshots: { before: "", after: "", diff: "" },
798
+ error
799
+ };
800
+ }
801
+ function buildCommandString(options) {
802
+ const parts = ["npx vrt run"];
803
+ parts.push(`--before ${options.beforeUrl}`);
804
+ parts.push(`--after ${options.afterUrl}`);
805
+ parts.push(`--paths ${options.paths.join(",")}`);
806
+ if (options.threshold !== DEFAULT_THRESHOLD) {
807
+ parts.push(`--threshold ${options.threshold}`);
808
+ }
809
+ if (options.hideSelectors.length > 0) {
810
+ parts.push(`--hide "${options.hideSelectors.join(",")}"`);
811
+ }
812
+ return parts.join(" \\\n ");
813
+ }
814
+
815
+ // src/commands/init.ts
816
+ import { mkdir as mkdir2, writeFile as writeFile4, access } from "fs/promises";
817
+ import { join as join4 } from "path";
818
+ import chalk3 from "chalk";
819
+ async function runInit(options) {
820
+ const outDir = join4(process.cwd(), ".claude", "commands");
821
+ const outFile = join4(outDir, "vrt.md");
822
+ try {
823
+ await access(outFile);
824
+ console.log(chalk3.yellow(`\u26A0 ${outFile} already exists. Overwriting...`));
825
+ } catch {
826
+ }
827
+ await mkdir2(outDir, { recursive: true });
828
+ const content = generateSkillFile(options);
829
+ await writeFile4(outFile, content, "utf-8");
830
+ console.log(chalk3.green(`\u2705 Created ${outFile}`));
831
+ console.log("");
832
+ console.log(
833
+ "This skill file allows Claude Code to run VRT with the /vrt command."
834
+ );
835
+ console.log(
836
+ "Review and customize the file, then commit it to your repository."
837
+ );
838
+ }
839
+ function generateSkillFile(options) {
840
+ const cmdParts = ["npx vrt run"];
841
+ cmdParts.push(` --before ${options.before}`);
842
+ cmdParts.push(` --after ${options.after}`);
843
+ cmdParts.push(` --paths ${options.paths}`);
844
+ if (options.threshold !== void 0 && options.threshold !== DEFAULT_THRESHOLD) {
845
+ cmdParts.push(` --threshold ${options.threshold}`);
846
+ }
847
+ if (options.hide) {
848
+ cmdParts.push(` --hide "${options.hide}"`);
849
+ }
850
+ cmdParts.push(" --no-open");
851
+ const command = cmdParts.join(" \\\n");
852
+ 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
853
+
854
+ ## \u624B\u9806
855
+
856
+ 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
857
+ 2. \u4EE5\u4E0B\u306E\u30B3\u30DE\u30F3\u30C9\u3067VRT\u3092\u5B9F\u884C:
858
+
859
+ \`\`\`bash
860
+ ${command}
861
+ \`\`\`
862
+
863
+ 3. \`./vrt-results/\` \u5185\u306E\u6700\u65B0\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u306B\u3042\u308B \`report.json\` \u3092\u8AAD\u3080
864
+ 4. \`status: "fail"\` \u306E\u30C6\u30B9\u30C8\u7D50\u679C\u306B\u6CE8\u76EE\u3059\u308B
865
+ 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. \`diffRegions\` \u306E \`locationHint\` \u304B\u3089\u4FEE\u6B63\u3059\u3079\u304DCSS\u3084HTML\u306E\u5834\u6240\u3092\u7279\u5B9A\u3059\u308B
867
+ 7. \u30BD\u30FC\u30B9\u30B3\u30FC\u30C9\u3092\u4FEE\u6B63\u3059\u308B
868
+ 8. \u518D\u5EA6VRT\u3092\u5B9F\u884C\u3057\u3066\u4FEE\u6B63\u304C\u53CD\u6620\u3055\u308C\u305F\u3053\u3068\u3092\u78BA\u8A8D\u3059\u308B
869
+ `;
870
+ }
871
+
872
+ // bin/vrt.ts
873
+ var program = new Command();
874
+ program.name("vrt").description("Visual Regression Testing CLI - Compare web pages visually").version("0.1.0");
875
+ program.command("run").description("Run visual regression tests").requiredOption("--before <url>", "Baseline 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) => {
876
+ try {
877
+ const parsed = cliOptionsSchema.parse(rawOptions);
878
+ const options = {
879
+ beforeUrl: parsed.before,
880
+ afterUrl: parsed.after,
881
+ paths: parsed.paths,
882
+ threshold: parsed.threshold,
883
+ hideSelectors: parsed.hide,
884
+ html: parsed.html,
885
+ open: parsed.open
886
+ };
887
+ const report = await runVrt(options);
888
+ process.exit(report.summary.overallStatus === "pass" ? 0 : 1);
889
+ } catch (error) {
890
+ if (error instanceof Error) {
891
+ console.error(`Error: ${error.message}`);
892
+ } else {
893
+ console.error("An unexpected error occurred");
894
+ }
895
+ process.exit(2);
896
+ }
897
+ });
898
+ program.command("init").description(
899
+ "Generate .claude/commands/vrt.md skill file for this repository"
900
+ ).requiredOption("--before <url>", "Baseline 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 (options) => {
901
+ try {
902
+ await runInit({
903
+ before: options.before,
904
+ after: options.after,
905
+ paths: options.paths,
906
+ threshold: parseFloat(options.threshold),
907
+ hide: options.hide
908
+ });
909
+ } catch (error) {
910
+ if (error instanceof Error) {
911
+ console.error(`Error: ${error.message}`);
912
+ }
913
+ process.exit(1);
914
+ }
915
+ });
916
+ program.parse();
917
+ //# sourceMappingURL=vrt.js.map