web-corders-vrt 0.1.2 → 0.1.3

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.
@@ -7,7 +7,11 @@
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)"
11
15
  ]
12
16
  }
13
17
  }
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
- if (options.hideSelectors && options.hideSelectors.length > 0) {
67
- const hideCSS = options.hideSelectors.map((s) => `${s} { visibility: hidden !important; }`).join("\n");
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 `
@@ -494,136 +511,57 @@ async function writeJsonReport(report, outDir) {
494
511
  // src/reporters/html.ts
495
512
  import { writeFile as writeFile2 } from "fs/promises";
496
513
  import { join as join2 } from "path";
514
+
515
+ // src/templates/report.html
516
+ 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';
517
+
518
+ // src/templates/report.css
519
+ 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';
520
+
521
+ // src/reporters/html.ts
497
522
  async function writeHtmlReport(report, outDir) {
498
- const html = generateHtml(report, outDir);
523
+ const html = generateHtml(report);
499
524
  const filePath = join2(outDir, "report.html");
500
525
  await writeFile2(filePath, html, "utf-8");
501
526
  return filePath;
502
527
  }
503
- function generateHtml(report, outDir) {
528
+ function generateHtml(report) {
504
529
  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>`;
530
+ const resultCards = results.map((r) => generateResultCard(r)).join("\n");
531
+ const failedStat = summary.failed > 0 ? `<div class="stat fail">${summary.failed} Failed</div>` : "";
532
+ 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
533
  }
593
- function generateResultCard(result, outDir) {
534
+ function generateResultCard(result) {
594
535
  const { page, viewport, status, comparison, diffRegions, screenshots } = result;
595
- const vpLabel = `${viewport.type.toUpperCase()} ${viewport.width}x${viewport.height}`;
536
+ const vpLabel = viewport.type.toUpperCase();
596
537
  const regionsHtml = diffRegions.length > 0 ? diffRegions.slice(0, 5).map(
597
538
  (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
539
  ).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>
540
+ const imagesHtml = `
541
+ <div class="comparison">
542
+ <div class="images">
543
+ <div class="img-container img-before">
544
+ <div class="label">Before</div>
545
+ <img src="${screenshots.before}" alt="Before" loading="lazy">
611
546
  </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>
547
+ <div class="img-container img-after">
548
+ <div class="label">After</div>
549
+ <img src="${screenshots.after}" alt="After" loading="lazy">
625
550
  </div>
551
+ <div class="img-container img-diff">
552
+ <div class="label">Diff</div>
553
+ <img src="${screenshots.diff}" alt="Diff" loading="lazy">
554
+ </div>
555
+ </div>
556
+ </div>`;
557
+ const screenshotSection = status === "pass" ? `<details class="screenshot-accordion"><summary>Show</summary>${imagesHtml}</details>` : imagesHtml;
558
+ return `
559
+ <div class="card ${viewport.type}">
560
+ <div class="card-header">
561
+ <h3>${page.path} - ${vpLabel}</h3>
562
+ <span class="badge ${status}">${status === "error" ? "ERROR" : `${status.toUpperCase()} ${comparison.diffPercentage.toFixed(2)}%`}</span>
626
563
  </div>
564
+ ${screenshotSection}
627
565
  ${regionsHtml ? `<div class="diff-info">${regionsHtml}</div>` : ""}
628
566
  </div>`;
629
567
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../bin/vrt.ts","../../src/schemas.ts","../../src/commands/run.ts","../../src/core/screenshotter.ts","../../src/constants.ts","../../src/core/stabilizer.ts","../../src/core/comparator.ts","../../src/core/region-detector.ts","../../src/reporters/terminal.ts","../../src/reporters/json.ts","../../src/reporters/html.ts","../../src/commands/init.ts"],"sourcesContent":["import { Command } from \"commander\";\nimport { cliOptionsSchema } from \"../src/schemas.js\";\nimport { runVrt } from \"../src/commands/run.js\";\nimport { runInit } from \"../src/commands/init.js\";\nimport type { ResolvedOptions } from \"../src/types.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"vrt\")\n .description(\"Visual Regression Testing CLI - Compare web pages visually\")\n .version(\"0.1.0\");\n\nprogram\n .command(\"run\")\n .description(\"Run visual regression tests\")\n .requiredOption(\"--before <url>\", \"Baseline URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .option(\"--no-html\", \"Skip HTML report generation\")\n .option(\"--no-open\", \"Do not open HTML report in browser\")\n .action(async (rawOptions) => {\n try {\n const parsed = cliOptionsSchema.parse(rawOptions);\n\n const options: ResolvedOptions = {\n beforeUrl: parsed.before,\n afterUrl: parsed.after,\n paths: parsed.paths,\n threshold: parsed.threshold,\n hideSelectors: parsed.hide,\n html: parsed.html,\n open: parsed.open,\n };\n\n const report = await runVrt(options);\n process.exit(report.summary.overallStatus === \"pass\" ? 0 : 1);\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n } else {\n console.error(\"An unexpected error occurred\");\n }\n process.exit(2);\n }\n });\n\nprogram\n .command(\"init\")\n .description(\n \"Generate .claude/commands/vrt.md skill file for this repository\",\n )\n .requiredOption(\"--before <url>\", \"Baseline URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .action(async (options) => {\n try {\n await runInit({\n before: options.before,\n after: options.after,\n paths: options.paths,\n threshold: parseFloat(options.threshold),\n hide: options.hide,\n });\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n }\n process.exit(1);\n }\n });\n\nprogram.parse();\n","import { z } from \"zod\";\n\nexport const cliOptionsSchema = z.object({\n before: z.string().url(\"--before must be a valid URL\"),\n after: z.string().url(\"--after must be a valid URL\"),\n paths: z.string().transform((val) => val.split(\",\").map((p) => p.trim())),\n threshold: z.coerce.number().min(0).max(100).default(0.1),\n hide: z\n .string()\n .optional()\n .transform((val) => (val ? val.split(\",\").map((s) => s.trim()) : [])),\n html: z.boolean().default(true),\n open: z.boolean().default(true),\n});\n\nexport type CliOptions = z.input<typeof cliOptionsSchema>;\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport ora from \"ora\";\nimport chalk from \"chalk\";\nimport { Screenshotter } from \"../core/screenshotter.js\";\nimport { compareImages } from \"../core/comparator.js\";\nimport { detectDiffRegions } from \"../core/region-detector.js\";\nimport { printTerminalReport } from \"../reporters/terminal.js\";\nimport { writeJsonReport } from \"../reporters/json.js\";\nimport { writeHtmlReport } from \"../reporters/html.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_OUT_DIR,\n DEFAULT_THRESHOLD,\n} from \"../constants.js\";\nimport type {\n ResolvedOptions,\n VrtReport,\n VrtTestResult,\n ViewportType,\n} from \"../types.js\";\n\nconst VIEWPORTS: ViewportType[] = [\"sp\", \"pc\"];\n\n/**\n * VRTのメイン実行コマンド。\n * スクリーンショット取得 → 比較 → レポート生成を一気に行う。\n */\nexport async function runVrt(options: ResolvedOptions): Promise<VrtReport> {\n const startTime = Date.now();\n\n // 出力ディレクトリを作成\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n const runDir = join(DEFAULT_OUT_DIR, timestamp);\n const screenshotsDir = join(runDir, \"screenshots\");\n await mkdir(screenshotsDir, { recursive: true });\n\n const screenshotter = new Screenshotter();\n const spinner = ora(\"Initializing browser...\").start();\n\n try {\n await screenshotter.initialize();\n\n // Before スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.beforeUrl)}...`;\n const beforeScreenshots = await screenshotter.captureAll(\n options.beforeUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n // After スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.afterUrl)}...`;\n const afterScreenshots = await screenshotter.captureAll(\n options.afterUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n spinner.text = \"Comparing screenshots...\";\n\n // 比較を実行\n const results: VrtTestResult[] = [];\n\n for (const beforeShot of beforeScreenshots) {\n const afterShot = afterScreenshots.find(\n (a) =>\n a.pagePath === beforeShot.pagePath &&\n a.viewportType === beforeShot.viewportType,\n );\n\n if (!afterShot) {\n results.push(\n createErrorResult(\n beforeShot.pagePath,\n beforeShot.viewportType,\n options,\n \"After screenshot not found\",\n ),\n );\n continue;\n }\n\n try {\n const comparison = compareImages(\n beforeShot.buffer,\n afterShot.buffer,\n options.threshold,\n );\n const diffRegions = comparison.passed\n ? []\n : detectDiffRegions(comparison.diffImage);\n\n // スクリーンショットを保存\n const filePrefix = `${beforeShot.pageName}--${beforeShot.viewportType}`;\n const beforePath = join(\"screenshots\", `${filePrefix}--before.png`);\n const afterPath = join(\"screenshots\", `${filePrefix}--after.png`);\n const diffPath = join(\"screenshots\", `${filePrefix}--diff.png`);\n\n await writeFile(join(runDir, beforePath), beforeShot.buffer);\n await writeFile(join(runDir, afterPath), afterShot.buffer);\n await writeFile(join(runDir, diffPath), comparison.diffImage);\n\n const pageName =\n beforeShot.pagePath === \"/\"\n ? \"トップページ\"\n : beforeShot.pagePath.replace(/^\\//, \"\");\n const viewport = getViewportDimensions(beforeShot.viewportType);\n\n results.push({\n page: {\n path: beforeShot.pagePath,\n name: pageName,\n url: {\n before: new URL(\n beforeShot.pagePath,\n options.beforeUrl,\n ).toString(),\n after: new URL(beforeShot.pagePath, options.afterUrl).toString(),\n },\n },\n viewport: {\n type: beforeShot.viewportType,\n ...viewport,\n },\n status: comparison.passed ? \"pass\" : \"fail\",\n comparison: {\n diffPercentage: comparison.diffPercentage,\n diffPixelCount: comparison.diffCount,\n totalPixels: comparison.totalPixels,\n threshold: options.threshold,\n dimensions: comparison.dimensions,\n },\n diffRegions,\n screenshots: {\n before: beforePath,\n after: afterPath,\n diff: diffPath,\n },\n });\n } catch (error) {\n results.push(\n createErrorResult(\n beforeShot.pagePath,\n beforeShot.viewportType,\n options,\n error instanceof Error ? error.message : String(error),\n ),\n );\n }\n }\n\n const duration = Date.now() - startTime;\n const passed = results.filter((r) => r.status === \"pass\").length;\n const failed = results.filter((r) => r.status === \"fail\").length;\n const errored = results.filter((r) => r.status === \"error\").length;\n\n const report: VrtReport = {\n version: \"1.0\",\n meta: {\n timestamp,\n beforeUrl: options.beforeUrl,\n afterUrl: options.afterUrl,\n duration,\n command: buildCommandString(options),\n },\n summary: {\n totalTests: results.length,\n passed,\n failed,\n errored,\n overallStatus: failed > 0 || errored > 0 ? \"fail\" : \"pass\",\n },\n results,\n };\n\n spinner.stop();\n\n // レポート出力\n printTerminalReport(report);\n\n const jsonPath = await writeJsonReport(report, runDir);\n console.log(`JSON report: ${chalk.dim(jsonPath)}`);\n\n if (options.html) {\n const htmlPath = await writeHtmlReport(report, runDir);\n console.log(`HTML report: ${chalk.dim(htmlPath)}`);\n\n if (options.open) {\n const { exec } = await import(\"node:child_process\");\n exec(`open \"${htmlPath}\"`);\n }\n }\n\n console.log(`Output dir: ${chalk.dim(runDir)}`);\n\n return report;\n } finally {\n await screenshotter.cleanup();\n }\n}\n\nfunction getViewportDimensions(\n type: ViewportType,\n): { width: number; height: number } {\n return type === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n}\n\nfunction createErrorResult(\n pagePath: string,\n viewportType: ViewportType,\n options: ResolvedOptions,\n error: string,\n): VrtTestResult {\n const viewport = getViewportDimensions(viewportType);\n const pageName =\n pagePath === \"/\" ? \"トップページ\" : pagePath.replace(/^\\//, \"\");\n return {\n page: {\n path: pagePath,\n name: pageName,\n url: {\n before: new URL(pagePath, options.beforeUrl).toString(),\n after: new URL(pagePath, options.afterUrl).toString(),\n },\n },\n viewport: { type: viewportType, ...viewport },\n status: \"error\",\n comparison: {\n diffPercentage: 0,\n diffPixelCount: 0,\n totalPixels: 0,\n threshold: options.threshold,\n dimensions: { width: 0, height: 0, beforeHeight: 0, afterHeight: 0 },\n },\n diffRegions: [],\n screenshots: { before: \"\", after: \"\", diff: \"\" },\n error,\n };\n}\n\nfunction buildCommandString(options: ResolvedOptions): string {\n const parts = [\"npx web-corders-vrt run\"];\n parts.push(`--before ${options.beforeUrl}`);\n parts.push(`--after ${options.afterUrl}`);\n parts.push(`--paths ${options.paths.join(\",\")}`);\n if (options.threshold !== DEFAULT_THRESHOLD) {\n parts.push(`--threshold ${options.threshold}`);\n }\n if (options.hideSelectors.length > 0) {\n parts.push(`--hide \"${options.hideSelectors.join(\",\")}\"`);\n }\n return parts.join(\" \\\\\\n \");\n}\n","import { chromium, type Browser, type BrowserContext } from \"playwright\";\nimport type { ScreenshotResult, ViewportType } from \"../types.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_DELAY,\n DEFAULT_CONCURRENCY,\n SP_USER_AGENT,\n BLOCKED_DOMAINS,\n} from \"../constants.js\";\nimport { stabilizePage, getDateMockScript } from \"./stabilizer.js\";\n\nexport class Screenshotter {\n private browser: Browser | null = null;\n\n async initialize(): Promise<void> {\n this.browser = await chromium.launch();\n }\n\n /**\n * 指定URLのスクリーンショットを取得する。\n */\n async capture(\n url: string,\n pagePath: string,\n viewportType: ViewportType,\n hideSelectors: string[],\n ): Promise<ScreenshotResult> {\n if (!this.browser) {\n throw new Error(\"Browser not initialized. Call initialize() first.\");\n }\n\n const viewport =\n viewportType === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n\n const context: BrowserContext = await this.browser.newContext({\n viewport,\n deviceScaleFactor: viewportType === \"sp\" ? 2 : 1,\n userAgent: viewportType === \"sp\" ? SP_USER_AGENT : undefined,\n });\n\n const page = await context.newPage();\n\n // 日付をモック\n await page.addInitScript(getDateMockScript());\n\n // 広告・計測系リクエストをブロック\n await page.route(\"**/*\", (route) => {\n const reqUrl = route.request().url();\n const shouldBlock = BLOCKED_DOMAINS.some((domain) =>\n reqUrl.includes(domain),\n );\n if (shouldBlock) {\n return route.abort();\n }\n return route.continue();\n });\n\n // ページにアクセス\n await page.goto(url, { waitUntil: \"load\", timeout: 60000 });\n\n // ページの安定化\n await stabilizePage(page, {\n hideSelectors,\n delay: DEFAULT_DELAY,\n });\n\n // スクリーンショット取得\n const buffer = await page.screenshot({\n fullPage: true,\n type: \"png\",\n });\n\n // 画像サイズを取得(PNGヘッダから読む)\n const width = buffer.readUInt32BE(16);\n const height = buffer.readUInt32BE(20);\n\n await context.close();\n\n // パスからページ名を生成\n const pageName =\n pagePath === \"/\"\n ? \"top\"\n : pagePath.replace(/^\\//, \"\").replace(/\\//g, \"-\");\n\n return {\n pagePath,\n pageName,\n viewportType,\n buffer: Buffer.from(buffer),\n width,\n height,\n };\n }\n\n /**\n * 複数のページ・ビューポートのスクリーンショットを並行で取得する。\n */\n async captureAll(\n baseUrl: string,\n paths: string[],\n viewports: ViewportType[],\n hideSelectors: string[],\n ): Promise<ScreenshotResult[]> {\n const tasks: Array<{ url: string; path: string; viewport: ViewportType }> =\n [];\n\n for (const pagePath of paths) {\n const url = new URL(pagePath, baseUrl).toString();\n for (const viewport of viewports) {\n tasks.push({ url, path: pagePath, viewport });\n }\n }\n\n // 並行実行制御\n const results: ScreenshotResult[] = [];\n\n for (let i = 0; i < tasks.length; i += DEFAULT_CONCURRENCY) {\n const batch = tasks.slice(i, i + DEFAULT_CONCURRENCY);\n const batchResults = await Promise.all(\n batch.map((task) =>\n this.capture(task.url, task.path, task.viewport, hideSelectors),\n ),\n );\n results.push(...batchResults);\n }\n\n return results;\n }\n\n async cleanup(): Promise<void> {\n await this.browser?.close();\n this.browser = null;\n }\n}\n","export const DEFAULT_SP_VIEWPORT = { width: 375, height: 812 } as const;\nexport const DEFAULT_PC_VIEWPORT = { width: 1440, height: 900 } as const;\n\nexport const DEFAULT_THRESHOLD = 0.1;\nexport const DEFAULT_DELAY = 1000;\nexport const DEFAULT_CONCURRENCY = 3;\nexport const DEFAULT_OUT_DIR = \"./vrt-results\";\n\nexport const SP_USER_AGENT =\n \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1\";\n\n/** VRTに不要な広告・計測系ドメイン(常にブロック) */\nexport const BLOCKED_DOMAINS = [\n \"googletagmanager.com\",\n \"google-analytics.com\",\n \"googleadservices.com\",\n \"googlesyndication.com\",\n \"doubleclick.net\",\n \"facebook.net\",\n \"fbcdn.net\",\n \"analytics.yahoo.co.jp\",\n \"clarity.ms\",\n \"hotjar.com\",\n \"newrelic.com\",\n \"sentry.io\",\n \"datadoghq.com\",\n];\n\n/** アニメーション・トランジションを無効化するCSS */\nexport const DISABLE_ANIMATIONS_CSS = `\n*, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n scroll-behavior: auto !important;\n caret-color: transparent !important;\n}\n`;\n","import type { Page } from \"playwright\";\nimport { DISABLE_ANIMATIONS_CSS } from \"../constants.js\";\n\nexport interface StabilizeOptions {\n disableAnimations?: boolean;\n hideSelectors?: string[];\n delay?: number;\n}\n\n/**\n * ページの動的コンテンツを安定化させる。\n * アニメーション無効化、要素の非表示、日付のモックなどを行う。\n */\nexport async function stabilizePage(\n page: Page,\n options: StabilizeOptions,\n): Promise<void> {\n // アニメーション・トランジションを無効化\n if (options.disableAnimations !== false) {\n await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });\n }\n\n // 指定要素を非表示にする(visibility: hidden でレイアウトを崩さない)\n if (options.hideSelectors && options.hideSelectors.length > 0) {\n const hideCSS = options.hideSelectors\n .map((s) => `${s} { visibility: hidden !important; }`)\n .join(\"\\n\");\n await page.addStyleTag({ content: hideCSS });\n }\n\n // 追加の待機時間\n if (options.delay && options.delay > 0) {\n await page.waitForTimeout(options.delay);\n }\n}\n\n/**\n * 日付をモックして固定値にする初期化スクリプト。\n * page.goto() の前に page.addInitScript() で使う。\n */\nexport function getDateMockScript(): string {\n return `\n (() => {\n const fixedDate = new Date('2025-01-01T00:00:00Z');\n const OrigDate = Date;\n const MockDate = class extends OrigDate {\n constructor(...args) {\n if (args.length === 0) {\n super(fixedDate.getTime());\n } else {\n super(...args);\n }\n }\n static now() { return fixedDate.getTime(); }\n };\n globalThis.Date = MockDate;\n })();\n `;\n}\n","import pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\nimport type { ComparisonResult } from \"../types.js\";\n\n/**\n * 2つのPNG画像をピクセル単位で比較し、差分画像を生成する。\n * サイズが異なる場合は大きい方に合わせて白パディングで拡張する。\n */\nexport function compareImages(\n beforeBuffer: Buffer,\n afterBuffer: Buffer,\n threshold: number = 0.1,\n): ComparisonResult {\n const before = PNG.sync.read(beforeBuffer);\n const after = PNG.sync.read(afterBuffer);\n\n const width = Math.max(before.width, after.width);\n const height = Math.max(before.height, after.height);\n\n // サイズが異なる場合は正規化\n const normalizedBefore = normalizeImage(before, width, height);\n const normalizedAfter = normalizeImage(after, width, height);\n\n const diff = new PNG({ width, height });\n\n const diffCount = pixelmatch(\n normalizedBefore.data,\n normalizedAfter.data,\n diff.data,\n width,\n height,\n {\n threshold: 0.1,\n includeAA: false,\n alpha: 0.1,\n diffColor: [255, 0, 0],\n diffColorAlt: [0, 200, 0],\n },\n );\n\n const totalPixels = width * height;\n const diffPercentage = (diffCount / totalPixels) * 100;\n\n return {\n diffCount,\n totalPixels,\n diffPercentage,\n diffImage: PNG.sync.write(diff),\n passed: diffPercentage <= threshold,\n dimensions: {\n width,\n height,\n beforeHeight: before.height,\n afterHeight: after.height,\n },\n };\n}\n\n/**\n * 画像を指定サイズに正規化する。\n * 小さい場合は白(#ffffff)でパディングする。\n */\nfunction normalizeImage(\n png: PNG,\n targetWidth: number,\n targetHeight: number,\n): PNG {\n if (png.width === targetWidth && png.height === targetHeight) {\n return png;\n }\n\n const normalized = new PNG({ width: targetWidth, height: targetHeight });\n\n // 白で埋める\n for (let i = 0; i < normalized.data.length; i += 4) {\n normalized.data[i] = 255; // R\n normalized.data[i + 1] = 255; // G\n normalized.data[i + 2] = 255; // B\n normalized.data[i + 3] = 255; // A\n }\n\n // 元の画像をコピー\n for (let y = 0; y < png.height; y++) {\n for (let x = 0; x < png.width; x++) {\n const srcIdx = (y * png.width + x) * 4;\n const dstIdx = (y * targetWidth + x) * 4;\n normalized.data[dstIdx] = png.data[srcIdx];\n normalized.data[dstIdx + 1] = png.data[srcIdx + 1];\n normalized.data[dstIdx + 2] = png.data[srcIdx + 2];\n normalized.data[dstIdx + 3] = png.data[srcIdx + 3];\n }\n }\n\n return normalized;\n}\n","import { PNG } from \"pngjs\";\nimport type {\n VrtDiffRegion,\n VerticalPosition,\n HorizontalPosition,\n} from \"../types.js\";\n\nexport interface RegionDetectorOptions {\n /** この面積(px)未満の領域は無視する */\n minRegionSize?: number;\n /** この距離(px)以下の差分ピクセルを同一領域としてマージする */\n mergingDistance?: number;\n}\n\n/**\n * diff画像から差分領域を検出し、座標・位置ヒントを返す。\n * Connected Component Labeling + bounding box 方式。\n */\nexport function detectDiffRegions(\n diffImageBuffer: Buffer,\n options: RegionDetectorOptions = {},\n): VrtDiffRegion[] {\n const { minRegionSize = 10, mergingDistance = 50 } = options;\n\n const png = PNG.sync.read(diffImageBuffer);\n const { width, height, data } = png;\n\n // 1. diff ピクセル(赤色)の座標を収集\n const diffPixels: Array<{ x: number; y: number }> = [];\n for (let y = 0; y < height; y++) {\n for (let x = 0; x < width; x++) {\n const idx = (y * width + x) * 4;\n const r = data[idx];\n const g = data[idx + 1];\n const b = data[idx + 2];\n const a = data[idx + 3];\n\n // pixelmatch の diffColor [255, 0, 0] または diffColorAlt [0, 200, 0] を検出\n if (\n a > 100 &&\n ((r > 200 && g < 100 && b < 100) || (r < 100 && g > 150 && b < 100))\n ) {\n diffPixels.push({ x, y });\n }\n }\n }\n\n if (diffPixels.length === 0) {\n return [];\n }\n\n // 2. グリッドベースのクラスタリング(高速化のため)\n const clusters = clusterPixels(diffPixels, mergingDistance);\n\n // 3. 各クラスタの bounding box を計算し、フィルタリング\n const regions: VrtDiffRegion[] = clusters\n .map((cluster, idx) => {\n let minX = Infinity;\n let maxX = -Infinity;\n let minY = Infinity;\n let maxY = -Infinity;\n for (const p of cluster) {\n if (p.x < minX) minX = p.x;\n if (p.x > maxX) maxX = p.x;\n if (p.y < minY) minY = p.y;\n if (p.y > maxY) maxY = p.y;\n }\n const regionWidth = maxX - minX + 1;\n const regionHeight = maxY - minY + 1;\n\n return {\n id: idx + 1,\n boundingBox: {\n x: minX,\n y: minY,\n width: regionWidth,\n height: regionHeight,\n },\n diffPixelCount: cluster.length,\n diffPercentageInRegion:\n (cluster.length / (regionWidth * regionHeight)) * 100,\n locationHint: estimatePosition(\n minX,\n minY,\n regionWidth,\n regionHeight,\n width,\n height,\n ),\n };\n })\n .filter((r) => r.diffPixelCount >= minRegionSize)\n .sort((a, b) => b.diffPixelCount - a.diffPixelCount);\n\n // IDを振り直す\n return regions.map((r, i) => ({ ...r, id: i + 1 }));\n}\n\n/**\n * ピクセルをグリッドベースでクラスタリングする。\n * mergingDistance をセルサイズとしてグリッドに分割し、\n * 隣接セルのピクセルを同一クラスタとしてマージする。\n */\nfunction clusterPixels(\n pixels: Array<{ x: number; y: number }>,\n distance: number,\n): Array<Array<{ x: number; y: number }>> {\n const cellSize = Math.max(distance, 1);\n const grid = new Map<string, Array<{ x: number; y: number }>>();\n\n // グリッドに割り当て\n for (const p of pixels) {\n const cellX = Math.floor(p.x / cellSize);\n const cellY = Math.floor(p.y / cellSize);\n const key = `${cellX},${cellY}`;\n if (!grid.has(key)) {\n grid.set(key, []);\n }\n grid.get(key)!.push(p);\n }\n\n // Union-Find でセルをクラスタリング\n const cellKeys = Array.from(grid.keys());\n const parent = new Map<string, string>();\n\n function find(key: string): string {\n if (!parent.has(key)) parent.set(key, key);\n // 反復的にルートを探索\n let root = key;\n while (parent.get(root) !== root) {\n root = parent.get(root)!;\n }\n // パス圧縮\n let current = key;\n while (current !== root) {\n const next = parent.get(current)!;\n parent.set(current, root);\n current = next;\n }\n return root;\n }\n\n function union(a: string, b: string): void {\n const ra = find(a);\n const rb = find(b);\n if (ra !== rb) parent.set(ra, rb);\n }\n\n // 隣接セルをマージ\n for (const key of cellKeys) {\n const [cx, cy] = key.split(\",\").map(Number);\n for (let dx = -1; dx <= 1; dx++) {\n for (let dy = -1; dy <= 1; dy++) {\n if (dx === 0 && dy === 0) continue;\n const neighborKey = `${cx + dx},${cy + dy}`;\n if (grid.has(neighborKey)) {\n union(key, neighborKey);\n }\n }\n }\n }\n\n // クラスタごとにピクセルを集約\n const clusters = new Map<string, Array<{ x: number; y: number }>>();\n for (const key of cellKeys) {\n const root = find(key);\n if (!clusters.has(root)) {\n clusters.set(root, []);\n }\n clusters.get(root)!.push(...grid.get(key)!);\n }\n\n return Array.from(clusters.values());\n}\n\n/**\n * 差分領域の位置から、CSSの修正箇所を推定するヒントを生成する。\n */\nfunction estimatePosition(\n x: number,\n y: number,\n regionWidth: number,\n regionHeight: number,\n pageWidth: number,\n pageHeight: number,\n): VrtDiffRegion[\"locationHint\"] {\n // 垂直位置の推定\n const centerY = y + regionHeight / 2;\n const yRatio = centerY / pageHeight;\n let verticalPosition: VerticalPosition;\n if (yRatio < 0.1) verticalPosition = \"top\";\n else if (yRatio < 0.3) verticalPosition = \"upper\";\n else if (yRatio < 0.7) verticalPosition = \"middle\";\n else if (yRatio < 0.9) verticalPosition = \"lower\";\n else verticalPosition = \"bottom\";\n\n // 水平位置の推定\n const centerX = x + regionWidth / 2;\n const widthRatio = regionWidth / pageWidth;\n let horizontalPosition: HorizontalPosition;\n if (widthRatio > 0.8) {\n horizontalPosition = \"full-width\";\n } else if (centerX < pageWidth * 0.33) {\n horizontalPosition = \"left\";\n } else if (centerX > pageWidth * 0.67) {\n horizontalPosition = \"right\";\n } else {\n horizontalPosition = \"center\";\n }\n\n // 要素の推定\n const estimatedElement = guessElement(\n verticalPosition,\n horizontalPosition,\n regionWidth,\n regionHeight,\n pageWidth,\n y,\n );\n\n return {\n verticalPosition,\n horizontalPosition,\n fromTopPx: y,\n fromLeftPx: x,\n estimatedElement,\n };\n}\n\n/**\n * 位置とサイズから、可能性のあるUI要素を推測する。\n */\nfunction guessElement(\n vPos: VerticalPosition,\n hPos: HorizontalPosition,\n width: number,\n height: number,\n pageWidth: number,\n fromTop: number,\n): string {\n // ページ最上部の全幅要素 → ヘッダー/ナビゲーション\n if (vPos === \"top\" && hPos === \"full-width\" && fromTop < 100) {\n return \"Likely a header or navigation bar\";\n }\n\n // ページ最下部の全幅要素 → フッター\n if (vPos === \"bottom\" && hPos === \"full-width\") {\n return \"Likely a footer\";\n }\n\n // 上部の大きな要素 → ヒーローセクション\n if (vPos === \"upper\" && hPos === \"full-width\" && height > 200) {\n return \"Likely a hero section or banner\";\n }\n\n // 小さなボタンサイズ\n if (width < 200 && height < 60) {\n return \"Likely a button or small UI element\";\n }\n\n // 横長の細い要素 → テキスト行\n if (width > pageWidth * 0.5 && height < 40) {\n return \"Likely a text line or heading\";\n }\n\n // カードサイズ\n if (width > 200 && width < 500 && height > 100 && height < 400) {\n return \"Likely a card or content block\";\n }\n\n // 画像サイズ\n if (width > 100 && height > 100 && width < pageWidth * 0.8) {\n return \"Likely an image or media element\";\n }\n\n return `UI element at ~${fromTop}px from top`;\n}\n","import chalk from \"chalk\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * ターミナルにカラー出力でVRT結果を表示する。\n */\nexport function printTerminalReport(report: VrtReport): void {\n const { meta, summary, results } = report;\n\n console.log(\"\");\n console.log(chalk.bold(`VRT Results - ${meta.timestamp}`));\n console.log(\"=\".repeat(50));\n console.log(\"\");\n console.log(\n `Comparing: ${chalk.cyan(meta.beforeUrl)} vs ${chalk.cyan(meta.afterUrl)}`,\n );\n console.log(\"\");\n\n // ページごとにグループ化\n const groupedByPage = new Map<string, typeof results>();\n for (const result of results) {\n const key = result.page.path;\n if (!groupedByPage.has(key)) {\n groupedByPage.set(key, []);\n }\n groupedByPage.get(key)!.push(result);\n }\n\n for (const [pagePath, pageResults] of groupedByPage) {\n const pageName = pageResults[0].page.name;\n console.log(` Page: ${chalk.bold(pageName)} (${pagePath})`);\n\n for (const result of pageResults) {\n const vpLabel = `${result.viewport.type.toUpperCase()} (${result.viewport.width}x${result.viewport.height})`;\n\n if (result.status === \"error\") {\n console.log(\n ` ${vpLabel} ${chalk.yellow(\"⚠ ERROR\")} ${result.error || \"Unknown error\"}`,\n );\n continue;\n }\n\n const diffStr = `diff: ${result.comparison.diffPercentage.toFixed(2)}%`;\n\n if (result.status === \"pass\") {\n console.log(\n ` ${vpLabel} ${chalk.green(\"✅ PASS\")} ${chalk.dim(diffStr)}`,\n );\n } else {\n const thresholdStr = `(threshold: ${result.comparison.threshold}%)`;\n console.log(\n ` ${vpLabel} ${chalk.red(\"❌ FAIL\")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`,\n );\n\n // 差分領域の詳細\n for (const region of result.diffRegions.slice(0, 5)) {\n const { boundingBox: bb, locationHint: lh } = region;\n console.log(\n chalk.dim(\n ` → Region ${region.id}: ${bb.width}x${bb.height} at (${bb.x}, ${bb.y}) - ${lh.estimatedElement}`,\n ),\n );\n }\n if (result.diffRegions.length > 5) {\n console.log(\n chalk.dim(\n ` → ...and ${result.diffRegions.length - 5} more regions`,\n ),\n );\n }\n }\n }\n\n console.log(\"\");\n }\n\n // サマリー\n const statusColor =\n summary.overallStatus === \"pass\" ? chalk.green : chalk.red;\n console.log(\n `Summary: ${chalk.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? chalk.red(`${summary.failed} failed`) : `${summary.failed} failed`}${summary.errored > 0 ? `, ${chalk.yellow(`${summary.errored} errored`)}` : \"\"} / ${summary.totalTests} total`,\n );\n console.log(`Overall: ${statusColor(summary.overallStatus.toUpperCase())}`);\n console.log(`Duration: ${(meta.duration / 1000).toFixed(1)}s`);\n console.log(\"\");\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * VrtReport を JSON ファイルとして保存する。\n */\nexport async function writeJsonReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const filePath = join(outDir, \"report.json\");\n await writeFile(filePath, JSON.stringify(report, null, 2), \"utf-8\");\n return filePath;\n}\n","import { readFile, writeFile } from \"node:fs/promises\";\nimport { join, relative } from \"node:path\";\nimport type { VrtReport, VrtTestResult } from \"../types.js\";\n\n/**\n * 単一HTMLファイルのレポートを生成する。\n * 外部依存なし、CSSとJSはインライン。\n */\nexport async function writeHtmlReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const html = generateHtml(report, outDir);\n const filePath = join(outDir, \"report.html\");\n await writeFile(filePath, html, \"utf-8\");\n return filePath;\n}\n\nfunction generateHtml(report: VrtReport, outDir: string): string {\n const { meta, summary, results } = report;\n\n const resultCards = results\n .map((r) => generateResultCard(r, outDir))\n .join(\"\\n\");\n\n return `<!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 - ${meta.timestamp}</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }\n .header { background: #1a1a2e; color: white; padding: 24px 32px; }\n .header h1 { font-size: 20px; margin-bottom: 8px; }\n .header .meta { font-size: 13px; color: #aaa; }\n .header .meta a { color: #7eb8da; }\n .summary { display: flex; gap: 16px; padding: 16px 32px; background: white; border-bottom: 1px solid #e0e0e0; }\n .summary .stat { padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 600; }\n .stat.pass { background: #e8f5e9; color: #2e7d32; }\n .stat.fail { background: #ffebee; color: #c62828; }\n .stat.total { background: #e3f2fd; color: #1565c0; }\n .results { padding: 24px 32px; display: flex; flex-direction: column; gap: 24px; }\n .card { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }\n .card-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }\n .card-header h3 { font-size: 15px; }\n .badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }\n .badge.pass { background: #e8f5e9; color: #2e7d32; }\n .badge.fail { background: #ffebee; color: #c62828; }\n .badge.error { background: #fff3e0; color: #e65100; }\n .comparison { padding: 16px 20px; }\n .comparison-controls { display: flex; gap: 8px; margin-bottom: 12px; }\n .comparison-controls button { padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }\n .comparison-controls button.active { background: #1a1a2e; color: white; border-color: #1a1a2e; }\n .images { display: flex; gap: 8px; }\n .images.side-by-side { flex-direction: row; }\n .images.diff-only .img-before, .images.diff-only .img-after { display: none; }\n .images.before-only .img-after, .images.before-only .img-diff { display: none; }\n .images.after-only .img-before, .images.after-only .img-diff { display: none; }\n .img-container { flex: 1; min-width: 0; }\n .img-container img { width: 100%; height: auto; border: 1px solid #eee; border-radius: 4px; }\n .img-container .label { font-size: 11px; color: #888; margin-bottom: 4px; text-transform: uppercase; font-weight: 600; }\n .diff-info { padding: 8px 20px 16px; font-size: 13px; color: #666; }\n .diff-info .region { margin: 4px 0; padding-left: 16px; }\n .slider-container { position: relative; overflow: hidden; }\n .slider-container img { width: 100%; display: block; }\n .slider-overlay { position: absolute; top: 0; left: 0; height: 100%; overflow: hidden; border-right: 2px solid #ff0000; }\n .slider-overlay img { width: 100%; height: 100%; object-fit: cover; object-position: left; }\n input[type=\"range\"].slider { width: 100%; margin-top: 8px; }\n </style>\n</head>\n<body>\n <div class=\"header\">\n <h1>VRT Report</h1>\n <div class=\"meta\">\n ${meta.timestamp} | Duration: ${(meta.duration / 1000).toFixed(1)}s<br>\n Before: ${meta.beforeUrl} → After: ${meta.afterUrl}\n </div>\n </div>\n <div class=\"summary\">\n <div class=\"stat total\">${summary.totalTests} Total</div>\n <div class=\"stat pass\">${summary.passed} Passed</div>\n ${summary.failed > 0 ? `<div class=\"stat fail\">${summary.failed} Failed</div>` : \"\"}\n </div>\n <div class=\"results\">\n ${resultCards}\n </div>\n <script>\n document.querySelectorAll('.comparison-controls button').forEach(btn => {\n btn.addEventListener('click', () => {\n const card = btn.closest('.card');\n const images = card.querySelector('.images');\n const buttons = card.querySelectorAll('.comparison-controls button');\n buttons.forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n images.className = 'images ' + btn.dataset.mode;\n });\n });\n\n document.querySelectorAll('.slider').forEach(slider => {\n slider.addEventListener('input', (e) => {\n const container = e.target.closest('.comparison').querySelector('.slider-overlay');\n if (container) {\n container.style.width = e.target.value + '%';\n }\n });\n });\n </script>\n</body>\n</html>`;\n}\n\nfunction generateResultCard(result: VrtTestResult, outDir: string): string {\n const { page, viewport, status, comparison, diffRegions, screenshots } =\n result;\n const vpLabel = `${viewport.type.toUpperCase()} ${viewport.width}x${viewport.height}`;\n\n const regionsHtml =\n diffRegions.length > 0\n ? diffRegions\n .slice(0, 5)\n .map(\n (r) =>\n `<div class=\"region\">Region ${r.id}: ${r.boundingBox.width}x${r.boundingBox.height} at (${r.boundingBox.x}, ${r.boundingBox.y}) — ${r.locationHint.estimatedElement}</div>`,\n )\n .join(\"\\n\") +\n (diffRegions.length > 5\n ? `<div class=\"region\">...and ${diffRegions.length - 5} more</div>`\n : \"\")\n : \"\";\n\n return `\n <div class=\"card\">\n <div class=\"card-header\">\n <h3>${page.name} (${page.path}) — ${vpLabel}</h3>\n <span class=\"badge ${status}\">${status.toUpperCase()} ${status !== \"error\" ? `${comparison.diffPercentage.toFixed(2)}%` : \"\"}</span>\n </div>\n <div class=\"comparison\">\n <div class=\"comparison-controls\">\n <button class=\"active\" data-mode=\"side-by-side\">Side by Side</button>\n <button data-mode=\"diff-only\">Diff Only</button>\n <button data-mode=\"before-only\">Before</button>\n <button data-mode=\"after-only\">After</button>\n </div>\n <div class=\"images side-by-side\">\n <div class=\"img-container img-before\">\n <div class=\"label\">Before</div>\n <img src=\"${screenshots.before}\" alt=\"Before\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-after\">\n <div class=\"label\">After</div>\n <img src=\"${screenshots.after}\" alt=\"After\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-diff\">\n <div class=\"label\">Diff</div>\n <img src=\"${screenshots.diff}\" alt=\"Diff\" loading=\"lazy\">\n </div>\n </div>\n </div>\n ${regionsHtml ? `<div class=\"diff-info\">${regionsHtml}</div>` : \"\"}\n </div>`;\n}\n","import { mkdir, writeFile, access } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport { DEFAULT_THRESHOLD } from \"../constants.js\";\n\nexport interface InitOptions {\n before: string;\n after: string;\n paths: string;\n threshold?: number;\n hide?: string;\n}\n\n/**\n * .claude/commands/vrt.md スキルファイルを生成する。\n */\nexport async function runInit(options: InitOptions): Promise<void> {\n const outDir = join(process.cwd(), \".claude\", \"commands\");\n const outFile = join(outDir, \"vrt.md\");\n\n // 既存ファイルの確認\n try {\n await access(outFile);\n console.log(chalk.yellow(`⚠ ${outFile} already exists. Overwriting...`));\n } catch {\n // ファイルが存在しない場合は正常\n }\n\n await mkdir(outDir, { recursive: true });\n\n const content = generateSkillFile(options);\n await writeFile(outFile, content, \"utf-8\");\n\n console.log(chalk.green(`✅ Created ${outFile}`));\n console.log(\"\");\n console.log(\n \"This skill file allows Claude Code to run VRT with the /vrt command.\",\n );\n console.log(\n \"Review and customize the file, then commit it to your repository.\",\n );\n}\n\nfunction generateSkillFile(options: InitOptions): string {\n // CLI コマンドを構築\n const cmdParts = [\"npx web-corders-vrt run\"];\n cmdParts.push(` --before ${options.before}`);\n cmdParts.push(` --after ${options.after}`);\n cmdParts.push(` --paths ${options.paths}`);\n\n if (options.threshold !== undefined && options.threshold !== DEFAULT_THRESHOLD) {\n cmdParts.push(` --threshold ${options.threshold}`);\n }\n\n if (options.hide) {\n cmdParts.push(` --hide \"${options.hide}\"`);\n }\n\n // --no-open をデフォルトでつける(Claude実行時にブラウザを開かないため)\n cmdParts.push(\" --no-open\");\n\n const command = cmdParts.join(\" \\\\\\n\");\n\n return `VRTを実行して本番環境とのビジュアル差分を検出する。\n\n## 手順\n\n1. 開発サーバーが起動していなければ \\`npm run dev\\` で起動して待機する\n2. 以下のコマンドでVRTを実行:\n\n\\`\\`\\`bash\n${command}\n\\`\\`\\`\n\n3. \\`./vrt-results/\\` 内の最新ディレクトリにある \\`report.json\\` を読む\n4. \\`status: \"fail\"\\` のテスト結果に注目する\n5. 該当するdiff画像(\\`*--diff.png\\`)をReadツールで視覚的に確認する\n6. \\`diffRegions\\` の \\`locationHint\\` から修正すべきCSSやHTMLの場所を特定する\n7. ソースコードを修正する\n8. 再度VRTを実行して修正が反映されたことを確認する\n`;\n}\n"],"mappings":"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","names":["writeFile","join","chalk","PNG","writeFile","join","join","chalk","writeFile","mkdir","writeFile","join","chalk","join","chalk","mkdir","writeFile"]}
1
+ {"version":3,"sources":["../../bin/vrt.ts","../../src/schemas.ts","../../src/commands/run.ts","../../src/core/screenshotter.ts","../../src/constants.ts","../../src/core/stabilizer.ts","../../src/core/comparator.ts","../../src/core/region-detector.ts","../../src/reporters/terminal.ts","../../src/reporters/json.ts","../../src/reporters/html.ts","../../src/templates/report.html","../../src/templates/report.css","../../src/commands/init.ts"],"sourcesContent":["import { Command } from \"commander\";\nimport { cliOptionsSchema } from \"../src/schemas.js\";\nimport { runVrt } from \"../src/commands/run.js\";\nimport { runInit } from \"../src/commands/init.js\";\nimport type { ResolvedOptions } from \"../src/types.js\";\n\nconst program = new Command();\n\nprogram\n .name(\"vrt\")\n .description(\"Visual Regression Testing CLI - Compare web pages visually\")\n .version(\"0.1.0\");\n\nprogram\n .command(\"run\")\n .description(\"Run visual regression tests\")\n .requiredOption(\"--before <url>\", \"Baseline URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .option(\"--no-html\", \"Skip HTML report generation\")\n .option(\"--no-open\", \"Do not open HTML report in browser\")\n .action(async (rawOptions) => {\n try {\n const parsed = cliOptionsSchema.parse(rawOptions);\n\n const options: ResolvedOptions = {\n beforeUrl: parsed.before,\n afterUrl: parsed.after,\n paths: parsed.paths,\n threshold: parsed.threshold,\n hideSelectors: parsed.hide,\n html: parsed.html,\n open: parsed.open,\n };\n\n const report = await runVrt(options);\n process.exit(report.summary.overallStatus === \"pass\" ? 0 : 1);\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n } else {\n console.error(\"An unexpected error occurred\");\n }\n process.exit(2);\n }\n });\n\nprogram\n .command(\"init\")\n .description(\n \"Generate .claude/commands/vrt.md skill file for this repository\",\n )\n .requiredOption(\"--before <url>\", \"Baseline URL (production)\")\n .requiredOption(\"--after <url>\", \"Comparison URL (local/staging)\")\n .requiredOption(\"--paths <paths>\", \"Page paths to compare (comma-separated)\")\n .option(\"--threshold <n>\", \"Diff tolerance percentage\", \"0.1\")\n .option(\"--hide <selectors>\", \"CSS selectors to hide (comma-separated)\")\n .action(async (options) => {\n try {\n await runInit({\n before: options.before,\n after: options.after,\n paths: options.paths,\n threshold: parseFloat(options.threshold),\n hide: options.hide,\n });\n } catch (error) {\n if (error instanceof Error) {\n console.error(`Error: ${error.message}`);\n }\n process.exit(1);\n }\n });\n\nprogram.parse();\n","import { z } from \"zod\";\n\nexport const cliOptionsSchema = z.object({\n before: z.string().url(\"--before must be a valid URL\"),\n after: z.string().url(\"--after must be a valid URL\"),\n paths: z.string().transform((val) => val.split(\",\").map((p) => p.trim())),\n threshold: z.coerce.number().min(0).max(100).default(0.1),\n hide: z\n .string()\n .optional()\n .transform((val) => (val ? val.split(\",\").map((s) => s.trim()) : [])),\n html: z.boolean().default(true),\n open: z.boolean().default(true),\n});\n\nexport type CliOptions = z.input<typeof cliOptionsSchema>;\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport ora from \"ora\";\nimport chalk from \"chalk\";\nimport { Screenshotter } from \"../core/screenshotter.js\";\nimport { compareImages } from \"../core/comparator.js\";\nimport { detectDiffRegions } from \"../core/region-detector.js\";\nimport { printTerminalReport } from \"../reporters/terminal.js\";\nimport { writeJsonReport } from \"../reporters/json.js\";\nimport { writeHtmlReport } from \"../reporters/html.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_OUT_DIR,\n DEFAULT_THRESHOLD,\n} from \"../constants.js\";\nimport type {\n ResolvedOptions,\n VrtReport,\n VrtTestResult,\n ViewportType,\n} from \"../types.js\";\n\nconst VIEWPORTS: ViewportType[] = [\"sp\", \"pc\"];\n\n/**\n * VRTのメイン実行コマンド。\n * スクリーンショット取得 → 比較 → レポート生成を一気に行う。\n */\nexport async function runVrt(options: ResolvedOptions): Promise<VrtReport> {\n const startTime = Date.now();\n\n // 出力ディレクトリを作成\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n const runDir = join(DEFAULT_OUT_DIR, timestamp);\n const screenshotsDir = join(runDir, \"screenshots\");\n await mkdir(screenshotsDir, { recursive: true });\n\n const screenshotter = new Screenshotter();\n const spinner = ora(\"Initializing browser...\").start();\n\n try {\n await screenshotter.initialize();\n\n // Before スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.beforeUrl)}...`;\n const beforeScreenshots = await screenshotter.captureAll(\n options.beforeUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n // After スクリーンショット取得\n spinner.text = `Taking screenshots of ${chalk.cyan(options.afterUrl)}...`;\n const afterScreenshots = await screenshotter.captureAll(\n options.afterUrl,\n options.paths,\n VIEWPORTS,\n options.hideSelectors,\n );\n\n spinner.text = \"Comparing screenshots...\";\n\n // 比較を実行\n const results: VrtTestResult[] = [];\n\n for (const beforeShot of beforeScreenshots) {\n const afterShot = afterScreenshots.find(\n (a) =>\n a.pagePath === beforeShot.pagePath &&\n a.viewportType === beforeShot.viewportType,\n );\n\n if (!afterShot) {\n results.push(\n createErrorResult(\n beforeShot.pagePath,\n beforeShot.viewportType,\n options,\n \"After screenshot not found\",\n ),\n );\n continue;\n }\n\n try {\n const comparison = compareImages(\n beforeShot.buffer,\n afterShot.buffer,\n options.threshold,\n );\n const diffRegions = comparison.passed\n ? []\n : detectDiffRegions(comparison.diffImage);\n\n // スクリーンショットを保存\n const filePrefix = `${beforeShot.pageName}--${beforeShot.viewportType}`;\n const beforePath = join(\"screenshots\", `${filePrefix}--before.png`);\n const afterPath = join(\"screenshots\", `${filePrefix}--after.png`);\n const diffPath = join(\"screenshots\", `${filePrefix}--diff.png`);\n\n await writeFile(join(runDir, beforePath), beforeShot.buffer);\n await writeFile(join(runDir, afterPath), afterShot.buffer);\n await writeFile(join(runDir, diffPath), comparison.diffImage);\n\n const pageName =\n beforeShot.pagePath === \"/\"\n ? \"トップページ\"\n : beforeShot.pagePath.replace(/^\\//, \"\");\n const viewport = getViewportDimensions(beforeShot.viewportType);\n\n results.push({\n page: {\n path: beforeShot.pagePath,\n name: pageName,\n url: {\n before: new URL(\n beforeShot.pagePath,\n options.beforeUrl,\n ).toString(),\n after: new URL(beforeShot.pagePath, options.afterUrl).toString(),\n },\n },\n viewport: {\n type: beforeShot.viewportType,\n ...viewport,\n },\n status: comparison.passed ? \"pass\" : \"fail\",\n comparison: {\n diffPercentage: comparison.diffPercentage,\n diffPixelCount: comparison.diffCount,\n totalPixels: comparison.totalPixels,\n threshold: options.threshold,\n dimensions: comparison.dimensions,\n },\n diffRegions,\n screenshots: {\n before: beforePath,\n after: afterPath,\n diff: diffPath,\n },\n });\n } catch (error) {\n results.push(\n createErrorResult(\n beforeShot.pagePath,\n beforeShot.viewportType,\n options,\n error instanceof Error ? error.message : String(error),\n ),\n );\n }\n }\n\n const duration = Date.now() - startTime;\n const passed = results.filter((r) => r.status === \"pass\").length;\n const failed = results.filter((r) => r.status === \"fail\").length;\n const errored = results.filter((r) => r.status === \"error\").length;\n\n const report: VrtReport = {\n version: \"1.0\",\n meta: {\n timestamp,\n beforeUrl: options.beforeUrl,\n afterUrl: options.afterUrl,\n duration,\n command: buildCommandString(options),\n },\n summary: {\n totalTests: results.length,\n passed,\n failed,\n errored,\n overallStatus: failed > 0 || errored > 0 ? \"fail\" : \"pass\",\n },\n results,\n };\n\n spinner.stop();\n\n // レポート出力\n printTerminalReport(report);\n\n const jsonPath = await writeJsonReport(report, runDir);\n console.log(`JSON report: ${chalk.dim(jsonPath)}`);\n\n if (options.html) {\n const htmlPath = await writeHtmlReport(report, runDir);\n console.log(`HTML report: ${chalk.dim(htmlPath)}`);\n\n if (options.open) {\n const { exec } = await import(\"node:child_process\");\n exec(`open \"${htmlPath}\"`);\n }\n }\n\n console.log(`Output dir: ${chalk.dim(runDir)}`);\n\n return report;\n } finally {\n await screenshotter.cleanup();\n }\n}\n\nfunction getViewportDimensions(\n type: ViewportType,\n): { width: number; height: number } {\n return type === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n}\n\nfunction createErrorResult(\n pagePath: string,\n viewportType: ViewportType,\n options: ResolvedOptions,\n error: string,\n): VrtTestResult {\n const viewport = getViewportDimensions(viewportType);\n const pageName =\n pagePath === \"/\" ? \"トップページ\" : pagePath.replace(/^\\//, \"\");\n return {\n page: {\n path: pagePath,\n name: pageName,\n url: {\n before: new URL(pagePath, options.beforeUrl).toString(),\n after: new URL(pagePath, options.afterUrl).toString(),\n },\n },\n viewport: { type: viewportType, ...viewport },\n status: \"error\",\n comparison: {\n diffPercentage: 0,\n diffPixelCount: 0,\n totalPixels: 0,\n threshold: options.threshold,\n dimensions: { width: 0, height: 0, beforeHeight: 0, afterHeight: 0 },\n },\n diffRegions: [],\n screenshots: { before: \"\", after: \"\", diff: \"\" },\n error,\n };\n}\n\nfunction buildCommandString(options: ResolvedOptions): string {\n const parts = [\"npx web-corders-vrt run\"];\n parts.push(`--before ${options.beforeUrl}`);\n parts.push(`--after ${options.afterUrl}`);\n parts.push(`--paths ${options.paths.join(\",\")}`);\n if (options.threshold !== DEFAULT_THRESHOLD) {\n parts.push(`--threshold ${options.threshold}`);\n }\n if (options.hideSelectors.length > 0) {\n parts.push(`--hide \"${options.hideSelectors.join(\",\")}\"`);\n }\n return parts.join(\" \\\\\\n \");\n}\n","import { chromium, type Browser, type BrowserContext } from \"playwright\";\nimport type { ScreenshotResult, ViewportType } from \"../types.js\";\nimport {\n DEFAULT_SP_VIEWPORT,\n DEFAULT_PC_VIEWPORT,\n DEFAULT_DELAY,\n DEFAULT_CONCURRENCY,\n SP_USER_AGENT,\n BLOCKED_DOMAINS,\n} from \"../constants.js\";\nimport { stabilizePage, getDateMockScript } from \"./stabilizer.js\";\n\nexport class Screenshotter {\n private browser: Browser | null = null;\n\n async initialize(): Promise<void> {\n this.browser = await chromium.launch();\n }\n\n /**\n * 指定URLのスクリーンショットを取得する。\n */\n async capture(\n url: string,\n pagePath: string,\n viewportType: ViewportType,\n hideSelectors: string[],\n ): Promise<ScreenshotResult> {\n if (!this.browser) {\n throw new Error(\"Browser not initialized. Call initialize() first.\");\n }\n\n const viewport =\n viewportType === \"sp\"\n ? { ...DEFAULT_SP_VIEWPORT }\n : { ...DEFAULT_PC_VIEWPORT };\n\n const context: BrowserContext = await this.browser.newContext({\n viewport,\n deviceScaleFactor: viewportType === \"sp\" ? 2 : 1,\n userAgent: viewportType === \"sp\" ? SP_USER_AGENT : undefined,\n });\n\n const page = await context.newPage();\n\n // 日付をモック\n await page.addInitScript(getDateMockScript());\n\n // 広告・計測系リクエストをブロック\n await page.route(\"**/*\", (route) => {\n const reqUrl = route.request().url();\n const shouldBlock = BLOCKED_DOMAINS.some((domain) =>\n reqUrl.includes(domain),\n );\n if (shouldBlock) {\n return route.abort();\n }\n return route.continue();\n });\n\n // ページにアクセス\n await page.goto(url, { waitUntil: \"load\", timeout: 60000 });\n\n // ページの安定化\n await stabilizePage(page, {\n hideSelectors,\n delay: DEFAULT_DELAY,\n });\n\n // スクリーンショット取得\n const buffer = await page.screenshot({\n fullPage: true,\n type: \"png\",\n });\n\n // 画像サイズを取得(PNGヘッダから読む)\n const width = buffer.readUInt32BE(16);\n const height = buffer.readUInt32BE(20);\n\n await context.close();\n\n // パスからページ名を生成\n const pageName =\n pagePath === \"/\"\n ? \"top\"\n : pagePath.replace(/^\\//, \"\").replace(/\\//g, \"-\");\n\n return {\n pagePath,\n pageName,\n viewportType,\n buffer: Buffer.from(buffer),\n width,\n height,\n };\n }\n\n /**\n * 複数のページ・ビューポートのスクリーンショットを並行で取得する。\n */\n async captureAll(\n baseUrl: string,\n paths: string[],\n viewports: ViewportType[],\n hideSelectors: string[],\n ): Promise<ScreenshotResult[]> {\n const tasks: Array<{ url: string; path: string; viewport: ViewportType }> =\n [];\n\n for (const pagePath of paths) {\n const url = new URL(pagePath, baseUrl).toString();\n for (const viewport of viewports) {\n tasks.push({ url, path: pagePath, viewport });\n }\n }\n\n // 並行実行制御\n const results: ScreenshotResult[] = [];\n\n for (let i = 0; i < tasks.length; i += DEFAULT_CONCURRENCY) {\n const batch = tasks.slice(i, i + DEFAULT_CONCURRENCY);\n const batchResults = await Promise.all(\n batch.map((task) =>\n this.capture(task.url, task.path, task.viewport, hideSelectors),\n ),\n );\n results.push(...batchResults);\n }\n\n return results;\n }\n\n async cleanup(): Promise<void> {\n await this.browser?.close();\n this.browser = null;\n }\n}\n","export const DEFAULT_SP_VIEWPORT = { width: 375, height: 812 } as const;\nexport const DEFAULT_PC_VIEWPORT = { width: 1440, height: 900 } as const;\n\nexport const DEFAULT_THRESHOLD = 0.1;\nexport const DEFAULT_DELAY = 1000;\nexport const DEFAULT_CONCURRENCY = 3;\nexport const DEFAULT_OUT_DIR = \"./vrt-results\";\n\nexport const SP_USER_AGENT =\n \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1\";\n\n/** VRTに不要な広告・計測系ドメイン(常にブロック) */\nexport const BLOCKED_DOMAINS = [\n \"googletagmanager.com\",\n \"google-analytics.com\",\n \"googleadservices.com\",\n \"googlesyndication.com\",\n \"doubleclick.net\",\n \"facebook.net\",\n \"fbcdn.net\",\n \"analytics.yahoo.co.jp\",\n \"clarity.ms\",\n \"hotjar.com\",\n \"newrelic.com\",\n \"sentry.io\",\n \"datadoghq.com\",\n];\n\n/** VRTに不要な開発ツール系要素(常に非表示) */\nexport const DEFAULT_HIDE_SELECTORS = [\"#devtools-indicator\"];\n\n/** アニメーション・トランジションを無効化するCSS */\nexport const DISABLE_ANIMATIONS_CSS = `\n*, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n scroll-behavior: auto !important;\n caret-color: transparent !important;\n}\n`;\n","import type { Page } from \"playwright\";\nimport {\n DISABLE_ANIMATIONS_CSS,\n DEFAULT_HIDE_SELECTORS,\n} from \"../constants.js\";\n\nexport interface StabilizeOptions {\n disableAnimations?: boolean;\n hideSelectors?: string[];\n delay?: number;\n}\n\n/**\n * ページの動的コンテンツを安定化させる。\n * アニメーション無効化、要素の非表示、日付のモックなどを行う。\n */\nexport async function stabilizePage(\n page: Page,\n options: StabilizeOptions,\n): Promise<void> {\n // アニメーション・トランジションを無効化\n if (options.disableAnimations !== false) {\n await page.addStyleTag({ content: DISABLE_ANIMATIONS_CSS });\n }\n\n // 指定要素を非表示にする(visibility: hidden でレイアウトを崩さない)\n const allHideSelectors = [\n ...DEFAULT_HIDE_SELECTORS,\n ...(options.hideSelectors ?? []),\n ];\n if (allHideSelectors.length > 0) {\n const hideCSS = allHideSelectors\n .map((s) => `${s} { visibility: hidden !important; }`)\n .join(\"\\n\");\n await page.addStyleTag({ content: hideCSS });\n }\n\n // 追加の待機時間(hydration完了を待つ)\n if (options.delay && options.delay > 0) {\n await page.waitForTimeout(options.delay);\n }\n\n // Shadow DOM内の要素はCSSが届かないのでJSで削除(Next.js devtools等)\n if (allHideSelectors.length > 0) {\n await page.evaluate(`\n for (const sel of ${JSON.stringify(allHideSelectors)}) {\n document.querySelectorAll(sel).forEach(el => el.remove());\n document.querySelectorAll(\"*\").forEach(el => {\n if (el.shadowRoot) {\n el.shadowRoot.querySelectorAll(sel).forEach(inner => inner.remove());\n }\n });\n }\n `);\n }\n}\n\n/**\n * 日付をモックして固定値にする初期化スクリプト。\n * page.goto() の前に page.addInitScript() で使う。\n */\nexport function getDateMockScript(): string {\n return `\n (() => {\n const fixedDate = new Date('2025-01-01T00:00:00Z');\n const OrigDate = Date;\n const MockDate = class extends OrigDate {\n constructor(...args) {\n if (args.length === 0) {\n super(fixedDate.getTime());\n } else {\n super(...args);\n }\n }\n static now() { return fixedDate.getTime(); }\n };\n globalThis.Date = MockDate;\n })();\n `;\n}\n","import pixelmatch from \"pixelmatch\";\nimport { PNG } from \"pngjs\";\nimport type { ComparisonResult } from \"../types.js\";\n\n/**\n * 2つのPNG画像をピクセル単位で比較し、差分画像を生成する。\n * サイズが異なる場合は大きい方に合わせて白パディングで拡張する。\n */\nexport function compareImages(\n beforeBuffer: Buffer,\n afterBuffer: Buffer,\n threshold: number = 0.1,\n): ComparisonResult {\n const before = PNG.sync.read(beforeBuffer);\n const after = PNG.sync.read(afterBuffer);\n\n const width = Math.max(before.width, after.width);\n const height = Math.max(before.height, after.height);\n\n // サイズが異なる場合は正規化\n const normalizedBefore = normalizeImage(before, width, height);\n const normalizedAfter = normalizeImage(after, width, height);\n\n const diff = new PNG({ width, height });\n\n const diffCount = pixelmatch(\n normalizedBefore.data,\n normalizedAfter.data,\n diff.data,\n width,\n height,\n {\n threshold: 0.1,\n includeAA: false,\n alpha: 0.1,\n diffColor: [255, 0, 0],\n diffColorAlt: [0, 200, 0],\n },\n );\n\n const totalPixels = width * height;\n const diffPercentage = (diffCount / totalPixels) * 100;\n\n return {\n diffCount,\n totalPixels,\n diffPercentage,\n diffImage: PNG.sync.write(diff),\n passed: diffPercentage <= threshold,\n dimensions: {\n width,\n height,\n beforeHeight: before.height,\n afterHeight: after.height,\n },\n };\n}\n\n/**\n * 画像を指定サイズに正規化する。\n * 小さい場合は白(#ffffff)でパディングする。\n */\nfunction normalizeImage(\n png: PNG,\n targetWidth: number,\n targetHeight: number,\n): PNG {\n if (png.width === targetWidth && png.height === targetHeight) {\n return png;\n }\n\n const normalized = new PNG({ width: targetWidth, height: targetHeight });\n\n // 白で埋める\n for (let i = 0; i < normalized.data.length; i += 4) {\n normalized.data[i] = 255; // R\n normalized.data[i + 1] = 255; // G\n normalized.data[i + 2] = 255; // B\n normalized.data[i + 3] = 255; // A\n }\n\n // 元の画像をコピー\n for (let y = 0; y < png.height; y++) {\n for (let x = 0; x < png.width; x++) {\n const srcIdx = (y * png.width + x) * 4;\n const dstIdx = (y * targetWidth + x) * 4;\n normalized.data[dstIdx] = png.data[srcIdx];\n normalized.data[dstIdx + 1] = png.data[srcIdx + 1];\n normalized.data[dstIdx + 2] = png.data[srcIdx + 2];\n normalized.data[dstIdx + 3] = png.data[srcIdx + 3];\n }\n }\n\n return normalized;\n}\n","import { PNG } from \"pngjs\";\nimport type {\n VrtDiffRegion,\n VerticalPosition,\n HorizontalPosition,\n} from \"../types.js\";\n\nexport interface RegionDetectorOptions {\n /** この面積(px)未満の領域は無視する */\n minRegionSize?: number;\n /** この距離(px)以下の差分ピクセルを同一領域としてマージする */\n mergingDistance?: number;\n}\n\n/**\n * diff画像から差分領域を検出し、座標・位置ヒントを返す。\n * Connected Component Labeling + bounding box 方式。\n */\nexport function detectDiffRegions(\n diffImageBuffer: Buffer,\n options: RegionDetectorOptions = {},\n): VrtDiffRegion[] {\n const { minRegionSize = 10, mergingDistance = 50 } = options;\n\n const png = PNG.sync.read(diffImageBuffer);\n const { width, height, data } = png;\n\n // 1. diff ピクセル(赤色)の座標を収集\n const diffPixels: Array<{ x: number; y: number }> = [];\n for (let y = 0; y < height; y++) {\n for (let x = 0; x < width; x++) {\n const idx = (y * width + x) * 4;\n const r = data[idx];\n const g = data[idx + 1];\n const b = data[idx + 2];\n const a = data[idx + 3];\n\n // pixelmatch の diffColor [255, 0, 0] または diffColorAlt [0, 200, 0] を検出\n if (\n a > 100 &&\n ((r > 200 && g < 100 && b < 100) || (r < 100 && g > 150 && b < 100))\n ) {\n diffPixels.push({ x, y });\n }\n }\n }\n\n if (diffPixels.length === 0) {\n return [];\n }\n\n // 2. グリッドベースのクラスタリング(高速化のため)\n const clusters = clusterPixels(diffPixels, mergingDistance);\n\n // 3. 各クラスタの bounding box を計算し、フィルタリング\n const regions: VrtDiffRegion[] = clusters\n .map((cluster, idx) => {\n let minX = Infinity;\n let maxX = -Infinity;\n let minY = Infinity;\n let maxY = -Infinity;\n for (const p of cluster) {\n if (p.x < minX) minX = p.x;\n if (p.x > maxX) maxX = p.x;\n if (p.y < minY) minY = p.y;\n if (p.y > maxY) maxY = p.y;\n }\n const regionWidth = maxX - minX + 1;\n const regionHeight = maxY - minY + 1;\n\n return {\n id: idx + 1,\n boundingBox: {\n x: minX,\n y: minY,\n width: regionWidth,\n height: regionHeight,\n },\n diffPixelCount: cluster.length,\n diffPercentageInRegion:\n (cluster.length / (regionWidth * regionHeight)) * 100,\n locationHint: estimatePosition(\n minX,\n minY,\n regionWidth,\n regionHeight,\n width,\n height,\n ),\n };\n })\n .filter((r) => r.diffPixelCount >= minRegionSize)\n .sort((a, b) => b.diffPixelCount - a.diffPixelCount);\n\n // IDを振り直す\n return regions.map((r, i) => ({ ...r, id: i + 1 }));\n}\n\n/**\n * ピクセルをグリッドベースでクラスタリングする。\n * mergingDistance をセルサイズとしてグリッドに分割し、\n * 隣接セルのピクセルを同一クラスタとしてマージする。\n */\nfunction clusterPixels(\n pixels: Array<{ x: number; y: number }>,\n distance: number,\n): Array<Array<{ x: number; y: number }>> {\n const cellSize = Math.max(distance, 1);\n const grid = new Map<string, Array<{ x: number; y: number }>>();\n\n // グリッドに割り当て\n for (const p of pixels) {\n const cellX = Math.floor(p.x / cellSize);\n const cellY = Math.floor(p.y / cellSize);\n const key = `${cellX},${cellY}`;\n if (!grid.has(key)) {\n grid.set(key, []);\n }\n grid.get(key)!.push(p);\n }\n\n // Union-Find でセルをクラスタリング\n const cellKeys = Array.from(grid.keys());\n const parent = new Map<string, string>();\n\n function find(key: string): string {\n if (!parent.has(key)) parent.set(key, key);\n // 反復的にルートを探索\n let root = key;\n while (parent.get(root) !== root) {\n root = parent.get(root)!;\n }\n // パス圧縮\n let current = key;\n while (current !== root) {\n const next = parent.get(current)!;\n parent.set(current, root);\n current = next;\n }\n return root;\n }\n\n function union(a: string, b: string): void {\n const ra = find(a);\n const rb = find(b);\n if (ra !== rb) parent.set(ra, rb);\n }\n\n // 隣接セルをマージ\n for (const key of cellKeys) {\n const [cx, cy] = key.split(\",\").map(Number);\n for (let dx = -1; dx <= 1; dx++) {\n for (let dy = -1; dy <= 1; dy++) {\n if (dx === 0 && dy === 0) continue;\n const neighborKey = `${cx + dx},${cy + dy}`;\n if (grid.has(neighborKey)) {\n union(key, neighborKey);\n }\n }\n }\n }\n\n // クラスタごとにピクセルを集約\n const clusters = new Map<string, Array<{ x: number; y: number }>>();\n for (const key of cellKeys) {\n const root = find(key);\n if (!clusters.has(root)) {\n clusters.set(root, []);\n }\n clusters.get(root)!.push(...grid.get(key)!);\n }\n\n return Array.from(clusters.values());\n}\n\n/**\n * 差分領域の位置から、CSSの修正箇所を推定するヒントを生成する。\n */\nfunction estimatePosition(\n x: number,\n y: number,\n regionWidth: number,\n regionHeight: number,\n pageWidth: number,\n pageHeight: number,\n): VrtDiffRegion[\"locationHint\"] {\n // 垂直位置の推定\n const centerY = y + regionHeight / 2;\n const yRatio = centerY / pageHeight;\n let verticalPosition: VerticalPosition;\n if (yRatio < 0.1) verticalPosition = \"top\";\n else if (yRatio < 0.3) verticalPosition = \"upper\";\n else if (yRatio < 0.7) verticalPosition = \"middle\";\n else if (yRatio < 0.9) verticalPosition = \"lower\";\n else verticalPosition = \"bottom\";\n\n // 水平位置の推定\n const centerX = x + regionWidth / 2;\n const widthRatio = regionWidth / pageWidth;\n let horizontalPosition: HorizontalPosition;\n if (widthRatio > 0.8) {\n horizontalPosition = \"full-width\";\n } else if (centerX < pageWidth * 0.33) {\n horizontalPosition = \"left\";\n } else if (centerX > pageWidth * 0.67) {\n horizontalPosition = \"right\";\n } else {\n horizontalPosition = \"center\";\n }\n\n // 要素の推定\n const estimatedElement = guessElement(\n verticalPosition,\n horizontalPosition,\n regionWidth,\n regionHeight,\n pageWidth,\n y,\n );\n\n return {\n verticalPosition,\n horizontalPosition,\n fromTopPx: y,\n fromLeftPx: x,\n estimatedElement,\n };\n}\n\n/**\n * 位置とサイズから、可能性のあるUI要素を推測する。\n */\nfunction guessElement(\n vPos: VerticalPosition,\n hPos: HorizontalPosition,\n width: number,\n height: number,\n pageWidth: number,\n fromTop: number,\n): string {\n // ページ最上部の全幅要素 → ヘッダー/ナビゲーション\n if (vPos === \"top\" && hPos === \"full-width\" && fromTop < 100) {\n return \"Likely a header or navigation bar\";\n }\n\n // ページ最下部の全幅要素 → フッター\n if (vPos === \"bottom\" && hPos === \"full-width\") {\n return \"Likely a footer\";\n }\n\n // 上部の大きな要素 → ヒーローセクション\n if (vPos === \"upper\" && hPos === \"full-width\" && height > 200) {\n return \"Likely a hero section or banner\";\n }\n\n // 小さなボタンサイズ\n if (width < 200 && height < 60) {\n return \"Likely a button or small UI element\";\n }\n\n // 横長の細い要素 → テキスト行\n if (width > pageWidth * 0.5 && height < 40) {\n return \"Likely a text line or heading\";\n }\n\n // カードサイズ\n if (width > 200 && width < 500 && height > 100 && height < 400) {\n return \"Likely a card or content block\";\n }\n\n // 画像サイズ\n if (width > 100 && height > 100 && width < pageWidth * 0.8) {\n return \"Likely an image or media element\";\n }\n\n return `UI element at ~${fromTop}px from top`;\n}\n","import chalk from \"chalk\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * ターミナルにカラー出力でVRT結果を表示する。\n */\nexport function printTerminalReport(report: VrtReport): void {\n const { meta, summary, results } = report;\n\n console.log(\"\");\n console.log(chalk.bold(`VRT Results - ${meta.timestamp}`));\n console.log(\"=\".repeat(50));\n console.log(\"\");\n console.log(\n `Comparing: ${chalk.cyan(meta.beforeUrl)} vs ${chalk.cyan(meta.afterUrl)}`,\n );\n console.log(\"\");\n\n // ページごとにグループ化\n const groupedByPage = new Map<string, typeof results>();\n for (const result of results) {\n const key = result.page.path;\n if (!groupedByPage.has(key)) {\n groupedByPage.set(key, []);\n }\n groupedByPage.get(key)!.push(result);\n }\n\n for (const [pagePath, pageResults] of groupedByPage) {\n const pageName = pageResults[0].page.name;\n console.log(` Page: ${chalk.bold(pageName)} (${pagePath})`);\n\n for (const result of pageResults) {\n const vpLabel = `${result.viewport.type.toUpperCase()} (${result.viewport.width}x${result.viewport.height})`;\n\n if (result.status === \"error\") {\n console.log(\n ` ${vpLabel} ${chalk.yellow(\"⚠ ERROR\")} ${result.error || \"Unknown error\"}`,\n );\n continue;\n }\n\n const diffStr = `diff: ${result.comparison.diffPercentage.toFixed(2)}%`;\n\n if (result.status === \"pass\") {\n console.log(\n ` ${vpLabel} ${chalk.green(\"✅ PASS\")} ${chalk.dim(diffStr)}`,\n );\n } else {\n const thresholdStr = `(threshold: ${result.comparison.threshold}%)`;\n console.log(\n ` ${vpLabel} ${chalk.red(\"❌ FAIL\")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`,\n );\n\n // 差分領域の詳細\n for (const region of result.diffRegions.slice(0, 5)) {\n const { boundingBox: bb, locationHint: lh } = region;\n console.log(\n chalk.dim(\n ` → Region ${region.id}: ${bb.width}x${bb.height} at (${bb.x}, ${bb.y}) - ${lh.estimatedElement}`,\n ),\n );\n }\n if (result.diffRegions.length > 5) {\n console.log(\n chalk.dim(\n ` → ...and ${result.diffRegions.length - 5} more regions`,\n ),\n );\n }\n }\n }\n\n console.log(\"\");\n }\n\n // サマリー\n const statusColor =\n summary.overallStatus === \"pass\" ? chalk.green : chalk.red;\n console.log(\n `Summary: ${chalk.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? chalk.red(`${summary.failed} failed`) : `${summary.failed} failed`}${summary.errored > 0 ? `, ${chalk.yellow(`${summary.errored} errored`)}` : \"\"} / ${summary.totalTests} total`,\n );\n console.log(`Overall: ${statusColor(summary.overallStatus.toUpperCase())}`);\n console.log(`Duration: ${(meta.duration / 1000).toFixed(1)}s`);\n console.log(\"\");\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport } from \"../types.js\";\n\n/**\n * VrtReport を JSON ファイルとして保存する。\n */\nexport async function writeJsonReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const filePath = join(outDir, \"report.json\");\n await writeFile(filePath, JSON.stringify(report, null, 2), \"utf-8\");\n return filePath;\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { VrtReport, VrtTestResult } from \"../types.js\";\nimport reportTemplate from \"../templates/report.html\";\nimport reportStyles from \"../templates/report.css\";\n\n/**\n * 単一HTMLファイルのレポートを生成する。\n * 外部依存なし、CSSはインライン。\n */\nexport async function writeHtmlReport(\n report: VrtReport,\n outDir: string,\n): Promise<string> {\n const html = generateHtml(report);\n const filePath = join(outDir, \"report.html\");\n await writeFile(filePath, html, \"utf-8\");\n return filePath;\n}\n\nfunction generateHtml(report: VrtReport): string {\n const { meta, summary, results } = report;\n\n const resultCards = results.map((r) => generateResultCard(r)).join(\"\\n\");\n\n const failedStat =\n summary.failed > 0\n ? `<div class=\"stat fail\">${summary.failed} Failed</div>`\n : \"\";\n\n return (reportTemplate as string)\n .replace(\"{{CSS}}\", reportStyles as string)\n .replace(/\\{\\{TIMESTAMP\\}\\}/g, meta.timestamp)\n .replace(\"{{DURATION}}\", (meta.duration / 1000).toFixed(1))\n .replace(\"{{BEFORE_URL}}\", meta.beforeUrl)\n .replace(\"{{AFTER_URL}}\", meta.afterUrl)\n .replace(\"{{TOTAL}}\", String(summary.totalTests))\n .replace(\"{{PASSED}}\", String(summary.passed))\n .replace(\"{{FAILED_STAT}}\", failedStat)\n .replace(\"{{RESULTS}}\", resultCards);\n}\n\nfunction generateResultCard(result: VrtTestResult): string {\n const { page, viewport, status, comparison, diffRegions, screenshots } =\n result;\n const vpLabel = viewport.type.toUpperCase();\n\n const regionsHtml =\n diffRegions.length > 0\n ? diffRegions\n .slice(0, 5)\n .map(\n (r) =>\n `<div class=\"region\">Region ${r.id}: ${r.boundingBox.width}x${r.boundingBox.height} at (${r.boundingBox.x}, ${r.boundingBox.y}) — ${r.locationHint.estimatedElement}</div>`,\n )\n .join(\"\\n\") +\n (diffRegions.length > 5\n ? `<div class=\"region\">...and ${diffRegions.length - 5} more</div>`\n : \"\")\n : \"\";\n\n const imagesHtml = `\n <div class=\"comparison\">\n <div class=\"images\">\n <div class=\"img-container img-before\">\n <div class=\"label\">Before</div>\n <img src=\"${screenshots.before}\" alt=\"Before\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-after\">\n <div class=\"label\">After</div>\n <img src=\"${screenshots.after}\" alt=\"After\" loading=\"lazy\">\n </div>\n <div class=\"img-container img-diff\">\n <div class=\"label\">Diff</div>\n <img src=\"${screenshots.diff}\" alt=\"Diff\" loading=\"lazy\">\n </div>\n </div>\n </div>`;\n\n // Passしたテストはアコーディオンに格納\n const screenshotSection =\n status === \"pass\"\n ? `<details class=\"screenshot-accordion\"><summary>Show</summary>${imagesHtml}</details>`\n : imagesHtml;\n\n return `\n <div class=\"card ${viewport.type}\">\n <div class=\"card-header\">\n <h3>${page.path} - ${vpLabel}</h3>\n <span class=\"badge ${status}\">${status === \"error\" ? \"ERROR\" : `${status.toUpperCase()} ${comparison.diffPercentage.toFixed(2)}%`}</span>\n </div>\n ${screenshotSection}\n ${regionsHtml ? `<div class=\"diff-info\">${regionsHtml}</div>` : \"\"}\n </div>`;\n}\n","<!doctype html>\n<html lang=\"ja\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>VRT Report - {{TIMESTAMP}}</title>\n <style>\n {{CSS}}\n </style>\n </head>\n <body>\n <div class=\"header\">\n <h1>web-corders-vrt</h1>\n <div class=\"meta\">\n {{TIMESTAMP}} | Duration: {{DURATION}}s<br />\n Before: {{BEFORE_URL}} → After: {{AFTER_URL}}\n </div>\n </div>\n <div class=\"summary\">\n <div class=\"stat total\">{{TOTAL}} Total</div>\n <div class=\"stat pass\">{{PASSED}} Passed</div>\n {{FAILED_STAT}}\n </div>\n <div class=\"results\">\n {{RESULTS}}\n </div>\n </body>\n</html>\n","* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n background: #f5f5f5;\n color: #333;\n}\n\n.header {\n background: #1a1a2e;\n color: white;\n padding: 24px 32px;\n text-align: center;\n}\n\n.header h1 {\n font-size: 20px;\n margin-bottom: 8px;\n}\n\n.header .meta {\n font-size: 13px;\n color: #aaa;\n}\n\n.summary {\n display: flex;\n gap: 16px;\n padding: 16px 32px;\n background: white;\n border-bottom: 1px solid #e0e0e0;\n}\n\n.summary .stat {\n padding: 8px 16px;\n border-radius: 6px;\n font-size: 14px;\n font-weight: 600;\n}\n\n.stat.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.stat.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.stat.total {\n background: #e3f2fd;\n color: #1565c0;\n}\n\n.results {\n padding: 24px 32px;\n display: flex;\n flex-direction: column;\n gap: 24px;\n}\n\n.card {\n background: white;\n border-radius: 8px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n overflow: hidden;\n}\n\n.card-header {\n padding: 16px 20px;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.card-header h3 {\n font-size: 15px;\n}\n\n.badge {\n padding: 4px 10px;\n border-radius: 12px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.badge.pass {\n background: #e8f5e9;\n color: #2e7d32;\n}\n\n.badge.fail {\n background: #ffebee;\n color: #c62828;\n}\n\n.badge.error {\n background: #fff3e0;\n color: #e65100;\n}\n\n.comparison {\n padding: 16px 20px;\n}\n\n.images {\n display: flex;\n gap: 8px;\n}\n\n.card.sp .img-container {\n flex: none;\n width: 375px;\n}\n\n.card.sp .img-container img {\n width: 375px;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.card.pc .img-container {\n flex: 1;\n min-width: 0;\n}\n\n.card.pc .img-container img {\n width: 100%;\n height: auto;\n border: 1px solid #eee;\n border-radius: 4px;\n}\n\n.img-container .label {\n font-size: 11px;\n color: #888;\n margin-bottom: 4px;\n text-transform: uppercase;\n font-weight: 600;\n}\n\n.diff-info {\n padding: 8px 20px 16px;\n font-size: 13px;\n color: #666;\n}\n\n.diff-info .region {\n margin: 4px 0;\n padding-left: 16px;\n}\n\n/* Accordion for passed tests */\n.screenshot-accordion {\n border-top: 1px solid #eee;\n}\n\n.screenshot-accordion summary {\n padding: 12px 20px;\n cursor: pointer;\n font-size: 13px;\n font-weight: 600;\n color: #666;\n user-select: none;\n}\n\n.screenshot-accordion summary:hover {\n background: #fafafa;\n}\n","import { mkdir, writeFile, access } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport chalk from \"chalk\";\nimport { DEFAULT_THRESHOLD } from \"../constants.js\";\n\nexport interface InitOptions {\n before: string;\n after: string;\n paths: string;\n threshold?: number;\n hide?: string;\n}\n\n/**\n * .claude/commands/vrt.md スキルファイルを生成する。\n */\nexport async function runInit(options: InitOptions): Promise<void> {\n const outDir = join(process.cwd(), \".claude\", \"commands\");\n const outFile = join(outDir, \"vrt.md\");\n\n // 既存ファイルの確認\n try {\n await access(outFile);\n console.log(chalk.yellow(`⚠ ${outFile} already exists. Overwriting...`));\n } catch {\n // ファイルが存在しない場合は正常\n }\n\n await mkdir(outDir, { recursive: true });\n\n const content = generateSkillFile(options);\n await writeFile(outFile, content, \"utf-8\");\n\n console.log(chalk.green(`✅ Created ${outFile}`));\n console.log(\"\");\n console.log(\n \"This skill file allows Claude Code to run VRT with the /vrt command.\",\n );\n console.log(\n \"Review and customize the file, then commit it to your repository.\",\n );\n}\n\nfunction generateSkillFile(options: InitOptions): string {\n // CLI コマンドを構築\n const cmdParts = [\"npx web-corders-vrt run\"];\n cmdParts.push(` --before ${options.before}`);\n cmdParts.push(` --after ${options.after}`);\n cmdParts.push(` --paths ${options.paths}`);\n\n if (options.threshold !== undefined && options.threshold !== DEFAULT_THRESHOLD) {\n cmdParts.push(` --threshold ${options.threshold}`);\n }\n\n if (options.hide) {\n cmdParts.push(` --hide \"${options.hide}\"`);\n }\n\n // --no-open をデフォルトでつける(Claude実行時にブラウザを開かないため)\n cmdParts.push(\" --no-open\");\n\n const command = cmdParts.join(\" \\\\\\n\");\n\n return `VRTを実行して本番環境とのビジュアル差分を検出する。\n\n## 手順\n\n1. 開発サーバーが起動していなければ \\`npm run dev\\` で起動して待機する\n2. 以下のコマンドでVRTを実行:\n\n\\`\\`\\`bash\n${command}\n\\`\\`\\`\n\n3. \\`./vrt-results/\\` 内の最新ディレクトリにある \\`report.json\\` を読む\n4. \\`status: \"fail\"\\` のテスト結果に注目する\n5. 該当するdiff画像(\\`*--diff.png\\`)をReadツールで視覚的に確認する\n6. \\`diffRegions\\` の \\`locationHint\\` から修正すべきCSSやHTMLの場所を特定する\n7. ソースコードを修正する\n8. 再度VRTを実行して修正が反映されたことを確認する\n`;\n}\n"],"mappings":"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","names":["writeFile","join","chalk","PNG","writeFile","join","report_default","join","writeFile","report_default","join","chalk","writeFile","mkdir","writeFile","join","chalk","join","chalk","mkdir","writeFile"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-corders-vrt",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Visual Regression Testing CLI tool for comparing web pages",
5
5
  "type": "module",
6
6
  "bin": {
package/src/constants.ts CHANGED
@@ -26,6 +26,9 @@ export const BLOCKED_DOMAINS = [
26
26
  "datadoghq.com",
27
27
  ];
28
28
 
29
+ /** VRTに不要な開発ツール系要素(常に非表示) */
30
+ export const DEFAULT_HIDE_SELECTORS = ["#devtools-indicator"];
31
+
29
32
  /** アニメーション・トランジションを無効化するCSS */
30
33
  export const DISABLE_ANIMATIONS_CSS = `
31
34
  *, *::before, *::after {
@@ -1,5 +1,8 @@
1
1
  import type { Page } from "playwright";
2
- import { DISABLE_ANIMATIONS_CSS } from "../constants.js";
2
+ import {
3
+ DISABLE_ANIMATIONS_CSS,
4
+ DEFAULT_HIDE_SELECTORS,
5
+ } from "../constants.js";
3
6
 
4
7
  export interface StabilizeOptions {
5
8
  disableAnimations?: boolean;
@@ -21,17 +24,35 @@ export async function stabilizePage(
21
24
  }
22
25
 
23
26
  // 指定要素を非表示にする(visibility: hidden でレイアウトを崩さない)
24
- if (options.hideSelectors && options.hideSelectors.length > 0) {
25
- const hideCSS = options.hideSelectors
27
+ const allHideSelectors = [
28
+ ...DEFAULT_HIDE_SELECTORS,
29
+ ...(options.hideSelectors ?? []),
30
+ ];
31
+ if (allHideSelectors.length > 0) {
32
+ const hideCSS = allHideSelectors
26
33
  .map((s) => `${s} { visibility: hidden !important; }`)
27
34
  .join("\n");
28
35
  await page.addStyleTag({ content: hideCSS });
29
36
  }
30
37
 
31
- // 追加の待機時間
38
+ // 追加の待機時間(hydration完了を待つ)
32
39
  if (options.delay && options.delay > 0) {
33
40
  await page.waitForTimeout(options.delay);
34
41
  }
42
+
43
+ // Shadow DOM内の要素はCSSが届かないのでJSで削除(Next.js devtools等)
44
+ if (allHideSelectors.length > 0) {
45
+ await page.evaluate(`
46
+ for (const sel of ${JSON.stringify(allHideSelectors)}) {
47
+ document.querySelectorAll(sel).forEach(el => el.remove());
48
+ document.querySelectorAll("*").forEach(el => {
49
+ if (el.shadowRoot) {
50
+ el.shadowRoot.querySelectorAll(sel).forEach(inner => inner.remove());
51
+ }
52
+ });
53
+ }
54
+ `);
55
+ }
35
56
  }
36
57
 
37
58
  /**
@@ -1,120 +1,49 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
- import { join, relative } from "node:path";
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
3
  import type { VrtReport, VrtTestResult } from "../types.js";
4
+ import reportTemplate from "../templates/report.html";
5
+ import reportStyles from "../templates/report.css";
4
6
 
5
7
  /**
6
8
  * 単一HTMLファイルのレポートを生成する。
7
- * 外部依存なし、CSSとJSはインライン。
9
+ * 外部依存なし、CSSはインライン。
8
10
  */
9
11
  export async function writeHtmlReport(
10
12
  report: VrtReport,
11
13
  outDir: string,
12
14
  ): Promise<string> {
13
- const html = generateHtml(report, outDir);
15
+ const html = generateHtml(report);
14
16
  const filePath = join(outDir, "report.html");
15
17
  await writeFile(filePath, html, "utf-8");
16
18
  return filePath;
17
19
  }
18
20
 
19
- function generateHtml(report: VrtReport, outDir: string): string {
21
+ function generateHtml(report: VrtReport): string {
20
22
  const { meta, summary, results } = report;
21
23
 
22
- const resultCards = results
23
- .map((r) => generateResultCard(r, outDir))
24
- .join("\n");
24
+ const resultCards = results.map((r) => generateResultCard(r)).join("\n");
25
25
 
26
- return `<!DOCTYPE html>
27
- <html lang="ja">
28
- <head>
29
- <meta charset="UTF-8">
30
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
31
- <title>VRT Report - ${meta.timestamp}</title>
32
- <style>
33
- * { margin: 0; padding: 0; box-sizing: border-box; }
34
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #333; }
35
- .header { background: #1a1a2e; color: white; padding: 24px 32px; }
36
- .header h1 { font-size: 20px; margin-bottom: 8px; }
37
- .header .meta { font-size: 13px; color: #aaa; }
38
- .header .meta a { color: #7eb8da; }
39
- .summary { display: flex; gap: 16px; padding: 16px 32px; background: white; border-bottom: 1px solid #e0e0e0; }
40
- .summary .stat { padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 600; }
41
- .stat.pass { background: #e8f5e9; color: #2e7d32; }
42
- .stat.fail { background: #ffebee; color: #c62828; }
43
- .stat.total { background: #e3f2fd; color: #1565c0; }
44
- .results { padding: 24px 32px; display: flex; flex-direction: column; gap: 24px; }
45
- .card { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
46
- .card-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
47
- .card-header h3 { font-size: 15px; }
48
- .badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
49
- .badge.pass { background: #e8f5e9; color: #2e7d32; }
50
- .badge.fail { background: #ffebee; color: #c62828; }
51
- .badge.error { background: #fff3e0; color: #e65100; }
52
- .comparison { padding: 16px 20px; }
53
- .comparison-controls { display: flex; gap: 8px; margin-bottom: 12px; }
54
- .comparison-controls button { padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; }
55
- .comparison-controls button.active { background: #1a1a2e; color: white; border-color: #1a1a2e; }
56
- .images { display: flex; gap: 8px; }
57
- .images.side-by-side { flex-direction: row; }
58
- .images.diff-only .img-before, .images.diff-only .img-after { display: none; }
59
- .images.before-only .img-after, .images.before-only .img-diff { display: none; }
60
- .images.after-only .img-before, .images.after-only .img-diff { display: none; }
61
- .img-container { flex: 1; min-width: 0; }
62
- .img-container img { width: 100%; height: auto; border: 1px solid #eee; border-radius: 4px; }
63
- .img-container .label { font-size: 11px; color: #888; margin-bottom: 4px; text-transform: uppercase; font-weight: 600; }
64
- .diff-info { padding: 8px 20px 16px; font-size: 13px; color: #666; }
65
- .diff-info .region { margin: 4px 0; padding-left: 16px; }
66
- .slider-container { position: relative; overflow: hidden; }
67
- .slider-container img { width: 100%; display: block; }
68
- .slider-overlay { position: absolute; top: 0; left: 0; height: 100%; overflow: hidden; border-right: 2px solid #ff0000; }
69
- .slider-overlay img { width: 100%; height: 100%; object-fit: cover; object-position: left; }
70
- input[type="range"].slider { width: 100%; margin-top: 8px; }
71
- </style>
72
- </head>
73
- <body>
74
- <div class="header">
75
- <h1>VRT Report</h1>
76
- <div class="meta">
77
- ${meta.timestamp} | Duration: ${(meta.duration / 1000).toFixed(1)}s<br>
78
- Before: ${meta.beforeUrl} → After: ${meta.afterUrl}
79
- </div>
80
- </div>
81
- <div class="summary">
82
- <div class="stat total">${summary.totalTests} Total</div>
83
- <div class="stat pass">${summary.passed} Passed</div>
84
- ${summary.failed > 0 ? `<div class="stat fail">${summary.failed} Failed</div>` : ""}
85
- </div>
86
- <div class="results">
87
- ${resultCards}
88
- </div>
89
- <script>
90
- document.querySelectorAll('.comparison-controls button').forEach(btn => {
91
- btn.addEventListener('click', () => {
92
- const card = btn.closest('.card');
93
- const images = card.querySelector('.images');
94
- const buttons = card.querySelectorAll('.comparison-controls button');
95
- buttons.forEach(b => b.classList.remove('active'));
96
- btn.classList.add('active');
97
- images.className = 'images ' + btn.dataset.mode;
98
- });
99
- });
26
+ const failedStat =
27
+ summary.failed > 0
28
+ ? `<div class="stat fail">${summary.failed} Failed</div>`
29
+ : "";
100
30
 
101
- document.querySelectorAll('.slider').forEach(slider => {
102
- slider.addEventListener('input', (e) => {
103
- const container = e.target.closest('.comparison').querySelector('.slider-overlay');
104
- if (container) {
105
- container.style.width = e.target.value + '%';
106
- }
107
- });
108
- });
109
- </script>
110
- </body>
111
- </html>`;
31
+ return (reportTemplate as string)
32
+ .replace("{{CSS}}", reportStyles as string)
33
+ .replace(/\{\{TIMESTAMP\}\}/g, meta.timestamp)
34
+ .replace("{{DURATION}}", (meta.duration / 1000).toFixed(1))
35
+ .replace("{{BEFORE_URL}}", meta.beforeUrl)
36
+ .replace("{{AFTER_URL}}", meta.afterUrl)
37
+ .replace("{{TOTAL}}", String(summary.totalTests))
38
+ .replace("{{PASSED}}", String(summary.passed))
39
+ .replace("{{FAILED_STAT}}", failedStat)
40
+ .replace("{{RESULTS}}", resultCards);
112
41
  }
113
42
 
114
- function generateResultCard(result: VrtTestResult, outDir: string): string {
43
+ function generateResultCard(result: VrtTestResult): string {
115
44
  const { page, viewport, status, comparison, diffRegions, screenshots } =
116
45
  result;
117
- const vpLabel = `${viewport.type.toUpperCase()} ${viewport.width}x${viewport.height}`;
46
+ const vpLabel = viewport.type.toUpperCase();
118
47
 
119
48
  const regionsHtml =
120
49
  diffRegions.length > 0
@@ -130,34 +59,37 @@ function generateResultCard(result: VrtTestResult, outDir: string): string {
130
59
  : "")
131
60
  : "";
132
61
 
133
- return `
134
- <div class="card">
135
- <div class="card-header">
136
- <h3>${page.name} (${page.path}) — ${vpLabel}</h3>
137
- <span class="badge ${status}">${status.toUpperCase()} ${status !== "error" ? `${comparison.diffPercentage.toFixed(2)}%` : ""}</span>
138
- </div>
139
- <div class="comparison">
140
- <div class="comparison-controls">
141
- <button class="active" data-mode="side-by-side">Side by Side</button>
142
- <button data-mode="diff-only">Diff Only</button>
143
- <button data-mode="before-only">Before</button>
144
- <button data-mode="after-only">After</button>
62
+ const imagesHtml = `
63
+ <div class="comparison">
64
+ <div class="images">
65
+ <div class="img-container img-before">
66
+ <div class="label">Before</div>
67
+ <img src="${screenshots.before}" alt="Before" loading="lazy">
68
+ </div>
69
+ <div class="img-container img-after">
70
+ <div class="label">After</div>
71
+ <img src="${screenshots.after}" alt="After" loading="lazy">
145
72
  </div>
146
- <div class="images side-by-side">
147
- <div class="img-container img-before">
148
- <div class="label">Before</div>
149
- <img src="${screenshots.before}" alt="Before" loading="lazy">
150
- </div>
151
- <div class="img-container img-after">
152
- <div class="label">After</div>
153
- <img src="${screenshots.after}" alt="After" loading="lazy">
154
- </div>
155
- <div class="img-container img-diff">
156
- <div class="label">Diff</div>
157
- <img src="${screenshots.diff}" alt="Diff" loading="lazy">
158
- </div>
73
+ <div class="img-container img-diff">
74
+ <div class="label">Diff</div>
75
+ <img src="${screenshots.diff}" alt="Diff" loading="lazy">
159
76
  </div>
160
77
  </div>
78
+ </div>`;
79
+
80
+ // Passしたテストはアコーディオンに格納
81
+ const screenshotSection =
82
+ status === "pass"
83
+ ? `<details class="screenshot-accordion"><summary>Show</summary>${imagesHtml}</details>`
84
+ : imagesHtml;
85
+
86
+ return `
87
+ <div class="card ${viewport.type}">
88
+ <div class="card-header">
89
+ <h3>${page.path} - ${vpLabel}</h3>
90
+ <span class="badge ${status}">${status === "error" ? "ERROR" : `${status.toUpperCase()} ${comparison.diffPercentage.toFixed(2)}%`}</span>
91
+ </div>
92
+ ${screenshotSection}
161
93
  ${regionsHtml ? `<div class="diff-info">${regionsHtml}</div>` : ""}
162
94
  </div>`;
163
95
  }
@@ -0,0 +1,176 @@
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
9
+ background: #f5f5f5;
10
+ color: #333;
11
+ }
12
+
13
+ .header {
14
+ background: #1a1a2e;
15
+ color: white;
16
+ padding: 24px 32px;
17
+ text-align: center;
18
+ }
19
+
20
+ .header h1 {
21
+ font-size: 20px;
22
+ margin-bottom: 8px;
23
+ }
24
+
25
+ .header .meta {
26
+ font-size: 13px;
27
+ color: #aaa;
28
+ }
29
+
30
+ .summary {
31
+ display: flex;
32
+ gap: 16px;
33
+ padding: 16px 32px;
34
+ background: white;
35
+ border-bottom: 1px solid #e0e0e0;
36
+ }
37
+
38
+ .summary .stat {
39
+ padding: 8px 16px;
40
+ border-radius: 6px;
41
+ font-size: 14px;
42
+ font-weight: 600;
43
+ }
44
+
45
+ .stat.pass {
46
+ background: #e8f5e9;
47
+ color: #2e7d32;
48
+ }
49
+
50
+ .stat.fail {
51
+ background: #ffebee;
52
+ color: #c62828;
53
+ }
54
+
55
+ .stat.total {
56
+ background: #e3f2fd;
57
+ color: #1565c0;
58
+ }
59
+
60
+ .results {
61
+ padding: 24px 32px;
62
+ display: flex;
63
+ flex-direction: column;
64
+ gap: 24px;
65
+ }
66
+
67
+ .card {
68
+ background: white;
69
+ border-radius: 8px;
70
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
71
+ overflow: hidden;
72
+ }
73
+
74
+ .card-header {
75
+ padding: 16px 20px;
76
+ border-bottom: 1px solid #eee;
77
+ display: flex;
78
+ justify-content: space-between;
79
+ align-items: center;
80
+ }
81
+
82
+ .card-header h3 {
83
+ font-size: 15px;
84
+ }
85
+
86
+ .badge {
87
+ padding: 4px 10px;
88
+ border-radius: 12px;
89
+ font-size: 12px;
90
+ font-weight: 600;
91
+ }
92
+
93
+ .badge.pass {
94
+ background: #e8f5e9;
95
+ color: #2e7d32;
96
+ }
97
+
98
+ .badge.fail {
99
+ background: #ffebee;
100
+ color: #c62828;
101
+ }
102
+
103
+ .badge.error {
104
+ background: #fff3e0;
105
+ color: #e65100;
106
+ }
107
+
108
+ .comparison {
109
+ padding: 16px 20px;
110
+ }
111
+
112
+ .images {
113
+ display: flex;
114
+ gap: 8px;
115
+ }
116
+
117
+ .card.sp .img-container {
118
+ flex: none;
119
+ width: 375px;
120
+ }
121
+
122
+ .card.sp .img-container img {
123
+ width: 375px;
124
+ height: auto;
125
+ border: 1px solid #eee;
126
+ border-radius: 4px;
127
+ }
128
+
129
+ .card.pc .img-container {
130
+ flex: 1;
131
+ min-width: 0;
132
+ }
133
+
134
+ .card.pc .img-container img {
135
+ width: 100%;
136
+ height: auto;
137
+ border: 1px solid #eee;
138
+ border-radius: 4px;
139
+ }
140
+
141
+ .img-container .label {
142
+ font-size: 11px;
143
+ color: #888;
144
+ margin-bottom: 4px;
145
+ text-transform: uppercase;
146
+ font-weight: 600;
147
+ }
148
+
149
+ .diff-info {
150
+ padding: 8px 20px 16px;
151
+ font-size: 13px;
152
+ color: #666;
153
+ }
154
+
155
+ .diff-info .region {
156
+ margin: 4px 0;
157
+ padding-left: 16px;
158
+ }
159
+
160
+ /* Accordion for passed tests */
161
+ .screenshot-accordion {
162
+ border-top: 1px solid #eee;
163
+ }
164
+
165
+ .screenshot-accordion summary {
166
+ padding: 12px 20px;
167
+ cursor: pointer;
168
+ font-size: 13px;
169
+ font-weight: 600;
170
+ color: #666;
171
+ user-select: none;
172
+ }
173
+
174
+ .screenshot-accordion summary:hover {
175
+ background: #fafafa;
176
+ }
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>VRT Report - {{TIMESTAMP}}</title>
7
+ <style>
8
+ {{CSS}}
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <div class="header">
13
+ <h1>web-corders-vrt</h1>
14
+ <div class="meta">
15
+ {{TIMESTAMP}} | Duration: {{DURATION}}s<br />
16
+ Before: {{BEFORE_URL}} → After: {{AFTER_URL}}
17
+ </div>
18
+ </div>
19
+ <div class="summary">
20
+ <div class="stat total">{{TOTAL}} Total</div>
21
+ <div class="stat pass">{{PASSED}} Passed</div>
22
+ {{FAILED_STAT}}
23
+ </div>
24
+ <div class="results">
25
+ {{RESULTS}}
26
+ </div>
27
+ </body>
28
+ </html>
@@ -0,0 +1,8 @@
1
+ declare module "*.html" {
2
+ const content: string;
3
+ export default content;
4
+ }
5
+ declare module "*.css" {
6
+ const content: string;
7
+ export default content;
8
+ }
package/tsup.config.ts CHANGED
@@ -9,6 +9,10 @@ export default defineConfig({
9
9
  sourcemap: true,
10
10
  clean: true,
11
11
  splitting: false,
12
+ loader: {
13
+ ".html": "text",
14
+ ".css": "text",
15
+ },
12
16
  banner: {
13
17
  js: "#!/usr/bin/env node",
14
18
  },