web-corders-vrt 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +13 -0
- package/README.md +169 -0
- package/bin/vrt.ts +77 -0
- package/dist/bin/vrt.js +917 -0
- package/dist/bin/vrt.js.map +1 -0
- package/package.json +46 -0
- package/src/commands/init.ts +82 -0
- package/src/commands/run.ts +259 -0
- package/src/constants.ts +39 -0
- package/src/core/comparator.ts +95 -0
- package/src/core/region-detector.ts +277 -0
- package/src/core/screenshotter.ts +137 -0
- package/src/core/stabilizer.ts +59 -0
- package/src/reporters/html.ts +163 -0
- package/src/reporters/json.ts +15 -0
- package/src/reporters/terminal.ts +86 -0
- package/src/schemas.ts +16 -0
- package/src/types/pixelmatch.d.ts +22 -0
- package/src/types.ts +117 -0
- package/test/comparator.test.ts +153 -0
- package/test/region-detector.test.ts +147 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +15 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { VrtReport, VrtTestResult } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 単一HTMLファイルのレポートを生成する。
|
|
7
|
+
* 外部依存なし、CSSとJSはインライン。
|
|
8
|
+
*/
|
|
9
|
+
export async function writeHtmlReport(
|
|
10
|
+
report: VrtReport,
|
|
11
|
+
outDir: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const html = generateHtml(report, outDir);
|
|
14
|
+
const filePath = join(outDir, "report.html");
|
|
15
|
+
await writeFile(filePath, html, "utf-8");
|
|
16
|
+
return filePath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function generateHtml(report: VrtReport, outDir: string): string {
|
|
20
|
+
const { meta, summary, results } = report;
|
|
21
|
+
|
|
22
|
+
const resultCards = results
|
|
23
|
+
.map((r) => generateResultCard(r, outDir))
|
|
24
|
+
.join("\n");
|
|
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
|
+
});
|
|
100
|
+
|
|
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>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function generateResultCard(result: VrtTestResult, outDir: string): string {
|
|
115
|
+
const { page, viewport, status, comparison, diffRegions, screenshots } =
|
|
116
|
+
result;
|
|
117
|
+
const vpLabel = `${viewport.type.toUpperCase()} ${viewport.width}x${viewport.height}`;
|
|
118
|
+
|
|
119
|
+
const regionsHtml =
|
|
120
|
+
diffRegions.length > 0
|
|
121
|
+
? diffRegions
|
|
122
|
+
.slice(0, 5)
|
|
123
|
+
.map(
|
|
124
|
+
(r) =>
|
|
125
|
+
`<div class="region">Region ${r.id}: ${r.boundingBox.width}x${r.boundingBox.height} at (${r.boundingBox.x}, ${r.boundingBox.y}) — ${r.locationHint.estimatedElement}</div>`,
|
|
126
|
+
)
|
|
127
|
+
.join("\n") +
|
|
128
|
+
(diffRegions.length > 5
|
|
129
|
+
? `<div class="region">...and ${diffRegions.length - 5} more</div>`
|
|
130
|
+
: "")
|
|
131
|
+
: "";
|
|
132
|
+
|
|
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>
|
|
145
|
+
</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>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
${regionsHtml ? `<div class="diff-info">${regionsHtml}</div>` : ""}
|
|
162
|
+
</div>`;
|
|
163
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { VrtReport } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* VrtReport を JSON ファイルとして保存する。
|
|
7
|
+
*/
|
|
8
|
+
export async function writeJsonReport(
|
|
9
|
+
report: VrtReport,
|
|
10
|
+
outDir: string,
|
|
11
|
+
): Promise<string> {
|
|
12
|
+
const filePath = join(outDir, "report.json");
|
|
13
|
+
await writeFile(filePath, JSON.stringify(report, null, 2), "utf-8");
|
|
14
|
+
return filePath;
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import type { VrtReport } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ターミナルにカラー出力でVRT結果を表示する。
|
|
6
|
+
*/
|
|
7
|
+
export function printTerminalReport(report: VrtReport): void {
|
|
8
|
+
const { meta, summary, results } = report;
|
|
9
|
+
|
|
10
|
+
console.log("");
|
|
11
|
+
console.log(chalk.bold(`VRT Results - ${meta.timestamp}`));
|
|
12
|
+
console.log("=".repeat(50));
|
|
13
|
+
console.log("");
|
|
14
|
+
console.log(
|
|
15
|
+
`Comparing: ${chalk.cyan(meta.beforeUrl)} vs ${chalk.cyan(meta.afterUrl)}`,
|
|
16
|
+
);
|
|
17
|
+
console.log("");
|
|
18
|
+
|
|
19
|
+
// ページごとにグループ化
|
|
20
|
+
const groupedByPage = new Map<string, typeof results>();
|
|
21
|
+
for (const result of results) {
|
|
22
|
+
const key = result.page.path;
|
|
23
|
+
if (!groupedByPage.has(key)) {
|
|
24
|
+
groupedByPage.set(key, []);
|
|
25
|
+
}
|
|
26
|
+
groupedByPage.get(key)!.push(result);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [pagePath, pageResults] of groupedByPage) {
|
|
30
|
+
const pageName = pageResults[0].page.name;
|
|
31
|
+
console.log(` Page: ${chalk.bold(pageName)} (${pagePath})`);
|
|
32
|
+
|
|
33
|
+
for (const result of pageResults) {
|
|
34
|
+
const vpLabel = `${result.viewport.type.toUpperCase()} (${result.viewport.width}x${result.viewport.height})`;
|
|
35
|
+
|
|
36
|
+
if (result.status === "error") {
|
|
37
|
+
console.log(
|
|
38
|
+
` ${vpLabel} ${chalk.yellow("⚠ ERROR")} ${result.error || "Unknown error"}`,
|
|
39
|
+
);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const diffStr = `diff: ${result.comparison.diffPercentage.toFixed(2)}%`;
|
|
44
|
+
|
|
45
|
+
if (result.status === "pass") {
|
|
46
|
+
console.log(
|
|
47
|
+
` ${vpLabel} ${chalk.green("✅ PASS")} ${chalk.dim(diffStr)}`,
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
const thresholdStr = `(threshold: ${result.comparison.threshold}%)`;
|
|
51
|
+
console.log(
|
|
52
|
+
` ${vpLabel} ${chalk.red("❌ FAIL")} ${chalk.red(diffStr)} ${chalk.dim(thresholdStr)}`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// 差分領域の詳細
|
|
56
|
+
for (const region of result.diffRegions.slice(0, 5)) {
|
|
57
|
+
const { boundingBox: bb, locationHint: lh } = region;
|
|
58
|
+
console.log(
|
|
59
|
+
chalk.dim(
|
|
60
|
+
` → Region ${region.id}: ${bb.width}x${bb.height} at (${bb.x}, ${bb.y}) - ${lh.estimatedElement}`,
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (result.diffRegions.length > 5) {
|
|
65
|
+
console.log(
|
|
66
|
+
chalk.dim(
|
|
67
|
+
` → ...and ${result.diffRegions.length - 5} more regions`,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// サマリー
|
|
78
|
+
const statusColor =
|
|
79
|
+
summary.overallStatus === "pass" ? chalk.green : chalk.red;
|
|
80
|
+
console.log(
|
|
81
|
+
`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`,
|
|
82
|
+
);
|
|
83
|
+
console.log(`Overall: ${statusColor(summary.overallStatus.toUpperCase())}`);
|
|
84
|
+
console.log(`Duration: ${(meta.duration / 1000).toFixed(1)}s`);
|
|
85
|
+
console.log("");
|
|
86
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const cliOptionsSchema = z.object({
|
|
4
|
+
before: z.string().url("--before must be a valid URL"),
|
|
5
|
+
after: z.string().url("--after must be a valid URL"),
|
|
6
|
+
paths: z.string().transform((val) => val.split(",").map((p) => p.trim())),
|
|
7
|
+
threshold: z.coerce.number().min(0).max(100).default(0.1),
|
|
8
|
+
hide: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.transform((val) => (val ? val.split(",").map((s) => s.trim()) : [])),
|
|
12
|
+
html: z.boolean().default(true),
|
|
13
|
+
open: z.boolean().default(true),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type CliOptions = z.input<typeof cliOptionsSchema>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
declare module "pixelmatch" {
|
|
2
|
+
interface PixelmatchOptions {
|
|
3
|
+
threshold?: number;
|
|
4
|
+
includeAA?: boolean;
|
|
5
|
+
alpha?: number;
|
|
6
|
+
aaColor?: [number, number, number];
|
|
7
|
+
diffColor?: [number, number, number];
|
|
8
|
+
diffColorAlt?: [number, number, number];
|
|
9
|
+
diffMask?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function pixelmatch(
|
|
13
|
+
img1: Buffer | Uint8Array | Uint8ClampedArray,
|
|
14
|
+
img2: Buffer | Uint8Array | Uint8ClampedArray,
|
|
15
|
+
output: Buffer | Uint8Array | Uint8ClampedArray | null,
|
|
16
|
+
width: number,
|
|
17
|
+
height: number,
|
|
18
|
+
options?: PixelmatchOptions,
|
|
19
|
+
): number;
|
|
20
|
+
|
|
21
|
+
export default pixelmatch;
|
|
22
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** VRT全体のレポート */
|
|
2
|
+
export interface VrtReport {
|
|
3
|
+
version: "1.0";
|
|
4
|
+
meta: {
|
|
5
|
+
timestamp: string;
|
|
6
|
+
beforeUrl: string;
|
|
7
|
+
afterUrl: string;
|
|
8
|
+
duration: number;
|
|
9
|
+
command: string;
|
|
10
|
+
};
|
|
11
|
+
summary: {
|
|
12
|
+
totalTests: number;
|
|
13
|
+
passed: number;
|
|
14
|
+
failed: number;
|
|
15
|
+
errored: number;
|
|
16
|
+
overallStatus: "pass" | "fail";
|
|
17
|
+
};
|
|
18
|
+
results: VrtTestResult[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ページ × ビューポート ごとのテスト結果 */
|
|
22
|
+
export interface VrtTestResult {
|
|
23
|
+
page: {
|
|
24
|
+
path: string;
|
|
25
|
+
name: string;
|
|
26
|
+
url: {
|
|
27
|
+
before: string;
|
|
28
|
+
after: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
viewport: {
|
|
32
|
+
type: ViewportType;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
};
|
|
36
|
+
status: TestStatus;
|
|
37
|
+
comparison: {
|
|
38
|
+
diffPercentage: number;
|
|
39
|
+
diffPixelCount: number;
|
|
40
|
+
totalPixels: number;
|
|
41
|
+
threshold: number;
|
|
42
|
+
dimensions: {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
beforeHeight: number;
|
|
46
|
+
afterHeight: number;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
diffRegions: VrtDiffRegion[];
|
|
50
|
+
screenshots: {
|
|
51
|
+
before: string;
|
|
52
|
+
after: string;
|
|
53
|
+
diff: string;
|
|
54
|
+
};
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 差分が検出された領域 */
|
|
59
|
+
export interface VrtDiffRegion {
|
|
60
|
+
id: number;
|
|
61
|
+
boundingBox: {
|
|
62
|
+
x: number;
|
|
63
|
+
y: number;
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
};
|
|
67
|
+
diffPixelCount: number;
|
|
68
|
+
diffPercentageInRegion: number;
|
|
69
|
+
locationHint: {
|
|
70
|
+
verticalPosition: VerticalPosition;
|
|
71
|
+
horizontalPosition: HorizontalPosition;
|
|
72
|
+
fromTopPx: number;
|
|
73
|
+
fromLeftPx: number;
|
|
74
|
+
estimatedElement: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ViewportType = "sp" | "pc";
|
|
79
|
+
export type TestStatus = "pass" | "fail" | "error";
|
|
80
|
+
export type VerticalPosition = "top" | "upper" | "middle" | "lower" | "bottom";
|
|
81
|
+
export type HorizontalPosition = "left" | "center" | "right" | "full-width";
|
|
82
|
+
|
|
83
|
+
/** CLI実行時の解決済みオプション */
|
|
84
|
+
export interface ResolvedOptions {
|
|
85
|
+
beforeUrl: string;
|
|
86
|
+
afterUrl: string;
|
|
87
|
+
paths: string[];
|
|
88
|
+
threshold: number;
|
|
89
|
+
hideSelectors: string[];
|
|
90
|
+
html: boolean;
|
|
91
|
+
open: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** スクリーンショット結果 */
|
|
95
|
+
export interface ScreenshotResult {
|
|
96
|
+
pagePath: string;
|
|
97
|
+
pageName: string;
|
|
98
|
+
viewportType: ViewportType;
|
|
99
|
+
buffer: Buffer;
|
|
100
|
+
width: number;
|
|
101
|
+
height: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 画像比較結果 */
|
|
105
|
+
export interface ComparisonResult {
|
|
106
|
+
diffCount: number;
|
|
107
|
+
totalPixels: number;
|
|
108
|
+
diffPercentage: number;
|
|
109
|
+
diffImage: Buffer;
|
|
110
|
+
passed: boolean;
|
|
111
|
+
dimensions: {
|
|
112
|
+
width: number;
|
|
113
|
+
height: number;
|
|
114
|
+
beforeHeight: number;
|
|
115
|
+
afterHeight: number;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { PNG } from "pngjs";
|
|
3
|
+
import { compareImages } from "../src/core/comparator.js";
|
|
4
|
+
|
|
5
|
+
/** テスト用のソリッドカラーPNG画像を生成する */
|
|
6
|
+
function createSolidPng(
|
|
7
|
+
width: number,
|
|
8
|
+
height: number,
|
|
9
|
+
r: number,
|
|
10
|
+
g: number,
|
|
11
|
+
b: number,
|
|
12
|
+
): Buffer {
|
|
13
|
+
const png = new PNG({ width, height });
|
|
14
|
+
for (let y = 0; y < height; y++) {
|
|
15
|
+
for (let x = 0; x < width; x++) {
|
|
16
|
+
const idx = (y * width + x) * 4;
|
|
17
|
+
png.data[idx] = r;
|
|
18
|
+
png.data[idx + 1] = g;
|
|
19
|
+
png.data[idx + 2] = b;
|
|
20
|
+
png.data[idx + 3] = 255;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return PNG.sync.write(png);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** テスト用のPNG画像を生成する(一部の領域を別の色にする) */
|
|
27
|
+
function createPngWithRegion(
|
|
28
|
+
width: number,
|
|
29
|
+
height: number,
|
|
30
|
+
bgR: number,
|
|
31
|
+
bgG: number,
|
|
32
|
+
bgB: number,
|
|
33
|
+
region: {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
w: number;
|
|
37
|
+
h: number;
|
|
38
|
+
r: number;
|
|
39
|
+
g: number;
|
|
40
|
+
b: number;
|
|
41
|
+
},
|
|
42
|
+
): Buffer {
|
|
43
|
+
const png = new PNG({ width, height });
|
|
44
|
+
for (let y = 0; y < height; y++) {
|
|
45
|
+
for (let x = 0; x < width; x++) {
|
|
46
|
+
const idx = (y * width + x) * 4;
|
|
47
|
+
const inRegion =
|
|
48
|
+
x >= region.x &&
|
|
49
|
+
x < region.x + region.w &&
|
|
50
|
+
y >= region.y &&
|
|
51
|
+
y < region.y + region.h;
|
|
52
|
+
if (inRegion) {
|
|
53
|
+
png.data[idx] = region.r;
|
|
54
|
+
png.data[idx + 1] = region.g;
|
|
55
|
+
png.data[idx + 2] = region.b;
|
|
56
|
+
} else {
|
|
57
|
+
png.data[idx] = bgR;
|
|
58
|
+
png.data[idx + 1] = bgG;
|
|
59
|
+
png.data[idx + 2] = bgB;
|
|
60
|
+
}
|
|
61
|
+
png.data[idx + 3] = 255;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return PNG.sync.write(png);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("compareImages", () => {
|
|
68
|
+
it("同一画像の場合、差分は0でpassになる", () => {
|
|
69
|
+
const img = createSolidPng(100, 100, 255, 255, 255);
|
|
70
|
+
const result = compareImages(img, img);
|
|
71
|
+
|
|
72
|
+
expect(result.diffCount).toBe(0);
|
|
73
|
+
expect(result.diffPercentage).toBe(0);
|
|
74
|
+
expect(result.passed).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("完全に異なる画像の場合、大きな差分でfailになる", () => {
|
|
78
|
+
const white = createSolidPng(100, 100, 255, 255, 255);
|
|
79
|
+
const black = createSolidPng(100, 100, 0, 0, 0);
|
|
80
|
+
const result = compareImages(white, black);
|
|
81
|
+
|
|
82
|
+
expect(result.diffCount).toBeGreaterThan(0);
|
|
83
|
+
expect(result.diffPercentage).toBeGreaterThan(0.1);
|
|
84
|
+
expect(result.passed).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("一部だけ異なる画像の場合、差分率が正しく計算される", () => {
|
|
88
|
+
const before = createSolidPng(100, 100, 255, 255, 255);
|
|
89
|
+
// 右下10x10だけ赤にする
|
|
90
|
+
const after = createPngWithRegion(100, 100, 255, 255, 255, {
|
|
91
|
+
x: 90,
|
|
92
|
+
y: 90,
|
|
93
|
+
w: 10,
|
|
94
|
+
h: 10,
|
|
95
|
+
r: 255,
|
|
96
|
+
g: 0,
|
|
97
|
+
b: 0,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = compareImages(before, after);
|
|
101
|
+
|
|
102
|
+
expect(result.diffCount).toBeGreaterThan(0);
|
|
103
|
+
// 10*10=100 / 100*100=10000 = 1%
|
|
104
|
+
expect(result.diffPercentage).toBeLessThan(5);
|
|
105
|
+
expect(result.diffPercentage).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("サイズが異なる画像でも比較できる", () => {
|
|
109
|
+
const small = createSolidPng(50, 50, 255, 255, 255);
|
|
110
|
+
const large = createSolidPng(100, 100, 255, 255, 255);
|
|
111
|
+
const result = compareImages(small, large);
|
|
112
|
+
|
|
113
|
+
expect(result.dimensions.width).toBe(100);
|
|
114
|
+
expect(result.dimensions.height).toBe(100);
|
|
115
|
+
expect(result.dimensions.beforeHeight).toBe(50);
|
|
116
|
+
expect(result.dimensions.afterHeight).toBe(100);
|
|
117
|
+
// 白でパディングされるので一部は同じ、拡張部分も白=白で差分なし
|
|
118
|
+
expect(result.diffCount).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("threshold以下の差分はpassになる", () => {
|
|
122
|
+
const before = createSolidPng(100, 100, 255, 255, 255);
|
|
123
|
+
// 1ピクセルだけ微妙に違う
|
|
124
|
+
const afterPng = new PNG({ width: 100, height: 100 });
|
|
125
|
+
for (let i = 0; i < afterPng.data.length; i += 4) {
|
|
126
|
+
afterPng.data[i] = 255;
|
|
127
|
+
afterPng.data[i + 1] = 255;
|
|
128
|
+
afterPng.data[i + 2] = 255;
|
|
129
|
+
afterPng.data[i + 3] = 255;
|
|
130
|
+
}
|
|
131
|
+
// 1ピクセルだけ変更
|
|
132
|
+
afterPng.data[0] = 200;
|
|
133
|
+
const after = PNG.sync.write(afterPng);
|
|
134
|
+
|
|
135
|
+
const result = compareImages(before, after, 1.0); // threshold 1%
|
|
136
|
+
// 1px / 10000px = 0.01% < 1%
|
|
137
|
+
expect(result.passed).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("diff画像がBufferとして返される", () => {
|
|
141
|
+
const white = createSolidPng(100, 100, 255, 255, 255);
|
|
142
|
+
const black = createSolidPng(100, 100, 0, 0, 0);
|
|
143
|
+
const result = compareImages(white, black);
|
|
144
|
+
|
|
145
|
+
expect(result.diffImage).toBeInstanceOf(Buffer);
|
|
146
|
+
expect(result.diffImage.length).toBeGreaterThan(0);
|
|
147
|
+
|
|
148
|
+
// 有効なPNGかどうか確認
|
|
149
|
+
const parsed = PNG.sync.read(result.diffImage);
|
|
150
|
+
expect(parsed.width).toBe(100);
|
|
151
|
+
expect(parsed.height).toBe(100);
|
|
152
|
+
});
|
|
153
|
+
});
|