snapdiff-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export declare function approveCommand(name: string): Promise<void>;
@@ -0,0 +1,49 @@
1
+ import pc from "picocolors";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { baselineImagePath, ensureDirs, captureSnapshot, saveBaselineMeta, loadConfig } from "../core/index.js";
5
+ export async function approveCommand(name) {
6
+ const cwd = process.cwd();
7
+ const { baselineExists } = await import("@snapdiff/core");
8
+ if (!(await baselineExists(cwd, name))) {
9
+ console.log(pc.yellow(`⚠ "${name}" 没有基线截图,无法接受。`));
10
+ console.log(` 请先运行: ${pc.bold(`npx snapdiff capture <url> --name ${name}`)}`);
11
+ return;
12
+ }
13
+ // Try loading config, fallback to minimal inline config
14
+ let config = await loadConfig(cwd);
15
+ let snap = config?.snaps.find((s) => s.name === name);
16
+ if (!snap) {
17
+ // Try reading the baseline meta json for URL info
18
+ const metaPath = join(cwd, ".snapdiff", "baselines", `${name}.json`);
19
+ let url = "";
20
+ try {
21
+ const meta = JSON.parse(await readFile(metaPath, "utf-8"));
22
+ url = meta.url || "";
23
+ }
24
+ catch { }
25
+ if (!url) {
26
+ console.log(pc.yellow(`⚠ 未找到 "${name}" 的配置,也无法从基线元数据中恢复 URL。`));
27
+ console.log(` 请先运行: ${pc.bold(`npx snapdiff capture <url> --name ${name}`)}`);
28
+ return;
29
+ }
30
+ // Reconstruct a minimal snap config from metadata
31
+ console.log(pc.dim(` 从基线元数据恢复配置: ${url}`));
32
+ snap = { name, url, viewport: { width: 1440, height: 900 }, threshold: 0.1 };
33
+ }
34
+ await ensureDirs(cwd);
35
+ const viewport = snap.viewport ?? { width: 1440, height: 900 };
36
+ console.log(pc.cyan(`\n 正在将 "${name}" 的当前状态接受为新基线...`));
37
+ try {
38
+ const { imagePath, meta } = await captureSnapshot({
39
+ config: { ...snap, viewport },
40
+ outputPath: baselineImagePath(cwd, name),
41
+ });
42
+ await saveBaselineMeta(cwd, name, meta);
43
+ console.log(pc.green(` ✔ 基线已更新: ${snap.url}`));
44
+ }
45
+ catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ console.log(pc.red(` ✗ 接受失败: ${msg}`));
48
+ }
49
+ }
@@ -0,0 +1,6 @@
1
+ export declare function captureCommand(url: string | undefined, options: {
2
+ name?: string;
3
+ selector?: string;
4
+ width?: string;
5
+ height?: string;
6
+ }): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { ensureDirs, captureSnapshotsParallel, baselineImagePath, saveBaselineMeta, loadConfig, } from "../core/index.js";
2
+ import pc from "picocolors";
3
+ export async function captureCommand(url, options) {
4
+ const cwd = process.cwd();
5
+ const viewport = {
6
+ width: parseInt(options.width || "1440", 10),
7
+ height: parseInt(options.height || "900", 10),
8
+ };
9
+ // No args → use config file (parallel)
10
+ if (!url && !options.name) {
11
+ const config = await loadConfig(cwd);
12
+ if (!config || config.snaps.length === 0) {
13
+ console.log(pc.yellow("⚠ 未找到配置,请先运行 snapdiff init 或提供 URL 参数"));
14
+ console.log(` 用法: ${pc.bold("npx snapdiff capture <url> --name <name>")}`);
15
+ return;
16
+ }
17
+ await ensureDirs(cwd);
18
+ console.log(pc.cyan(`\n 正在并行截取 ${config.snaps.length} 个页面...`));
19
+ const tasks = config.snaps.map((snap) => ({
20
+ config: { ...snap, viewport: snap.viewport ?? viewport, threshold: snap.threshold ?? 0.1 },
21
+ outputPath: baselineImagePath(cwd, snap.name),
22
+ }));
23
+ const results = await captureSnapshotsParallel(tasks, 3);
24
+ for (let i = 0; i < results.length; i++) {
25
+ await saveBaselineMeta(cwd, config.snaps[i].name, results[i].meta);
26
+ console.log(pc.green(` ✔ ${config.snaps[i].url} ── 基线已保存`));
27
+ }
28
+ return;
29
+ }
30
+ // Single URL mode
31
+ if (!url || !options.name) {
32
+ console.log(pc.yellow("请提供 URL 和名称:"));
33
+ console.log(` ${pc.bold("npx snapdiff capture <url> --name <name>")}`);
34
+ return;
35
+ }
36
+ await ensureDirs(cwd);
37
+ const { captureSnapshot } = await import("@snapdiff/core");
38
+ const snap = {
39
+ name: options.name,
40
+ url,
41
+ selector: options.selector,
42
+ viewport,
43
+ threshold: 0.1,
44
+ };
45
+ console.log(pc.cyan(`\n 正在截取 ${snap.name}...`));
46
+ const { imagePath, meta } = await captureSnapshot({
47
+ config: snap,
48
+ outputPath: baselineImagePath(cwd, snap.name),
49
+ });
50
+ await saveBaselineMeta(cwd, snap.name, meta);
51
+ console.log(pc.green(` ✔ ${url} ── 基线已保存`));
52
+ }
@@ -0,0 +1,4 @@
1
+ export declare function diffCommand(url: string | undefined, options: {
2
+ name?: string;
3
+ threshold?: string;
4
+ }): Promise<void>;
@@ -0,0 +1,130 @@
1
+ import { ensureDirs, captureSnapshot, captureSnapshotsParallel, baselineImagePath, diffImagePath, compareSnapshots, loadConfig, generateReportSummary, generateHtmlReport, baselineExists, } from "../core/index.js";
2
+ import pc from "picocolors";
3
+ import { unlink } from "node:fs/promises";
4
+ export async function diffCommand(url, options) {
5
+ const cwd = process.cwd();
6
+ await ensureDirs(cwd);
7
+ const threshold = parseFloat(options.threshold || "0.1");
8
+ // No args → run all snaps from config (parallel capture)
9
+ if (!url && !options.name) {
10
+ const config = await loadConfig(cwd);
11
+ if (!config || config.snaps.length === 0) {
12
+ console.log(pc.yellow("⚠ 未找到配置,请先运行 snapdiff init 或提供参数"));
13
+ console.log(` ${pc.bold("npx snapdiff diff <url> --name <name>")}`);
14
+ return;
15
+ }
16
+ // Check which snaps have baselines
17
+ const validSnaps = [];
18
+ for (const snap of config.snaps) {
19
+ if (await baselineExists(cwd, snap.name)) {
20
+ validSnaps.push(snap);
21
+ }
22
+ else {
23
+ console.log(pc.yellow(`\n ⚠ "${snap.name}" 还没有基线截图。`));
24
+ console.log(` ${pc.bold("npx snapdiff capture " + snap.url + " --name " + snap.name)}`);
25
+ }
26
+ }
27
+ if (validSnaps.length === 0) {
28
+ console.log(pc.dim("\n 没有需要对比的页面。"));
29
+ return;
30
+ }
31
+ console.log(pc.cyan(`\n 正在并行截取 ${validSnaps.length} 个页面...`));
32
+ const timestamp = String(Math.floor(Date.now() / 1000));
33
+ // Phase 1: capture all current states in parallel
34
+ const captureTasks = validSnaps.map((snap) => ({
35
+ config: {
36
+ ...snap,
37
+ viewport: snap.viewport ?? { width: 1440, height: 900 },
38
+ threshold: snap.threshold ?? 0.1,
39
+ },
40
+ outputPath: baselineImagePath(cwd, "current-" + snap.name),
41
+ }));
42
+ const captureResults = await captureSnapshotsParallel(captureTasks, 3);
43
+ // Phase 2: compare all diffs (sequential, fast operations)
44
+ const results = [];
45
+ for (let i = 0; i < validSnaps.length; i++) {
46
+ const snap = validSnaps[i];
47
+ const currentPath = captureResults[i].imagePath;
48
+ const diffOut = diffImagePath(cwd, snap.name, timestamp);
49
+ const result = await compareSnapshots({
50
+ baselinePath: baselineImagePath(cwd, snap.name),
51
+ currentPath,
52
+ diffOutputPath: diffOut,
53
+ threshold: threshold / 100,
54
+ });
55
+ result.name = snap.name;
56
+ result.url = snap.url;
57
+ results.push(result);
58
+ await unlink(currentPath).catch(() => { });
59
+ if (result.error) {
60
+ console.log(" ⚠ " + snap.name + ": " + result.error);
61
+ }
62
+ else if (result.passed) {
63
+ console.log(pc.green(" ✅ " + snap.name + " ── 无变化 (差异 " + result.diffPercent + "%)"));
64
+ }
65
+ else {
66
+ const bar = makeBar(result.diffPercent, 20);
67
+ console.log(" ❌ " + snap.name);
68
+ console.log(" " + bar + " " + result.diffPercent + "% (" + result.diffPixels + " 像素)");
69
+ console.log(pc.dim(" 📄 diff 图: " + result.diffImagePath));
70
+ console.log(pc.cyan(" 如果这是预期的变更: " + pc.bold("npx snapdiff approve " + snap.name)));
71
+ }
72
+ }
73
+ if (results.length > 0) {
74
+ console.log(generateReportSummary(results));
75
+ const htmlPath = await generateHtmlReport(results, cwd);
76
+ console.log(pc.cyan("\n 📊 HTML 报告: " + htmlPath));
77
+ }
78
+ return;
79
+ }
80
+ // Single URL mode
81
+ if (!url || !options.name) {
82
+ console.log(pc.yellow("请提供 URL 和名称:"));
83
+ console.log(" " + pc.bold("npx snapdiff diff <url> --name <name>"));
84
+ return;
85
+ }
86
+ const name = options.name;
87
+ if (!(await baselineExists(cwd, name))) {
88
+ console.log(pc.yellow('\n ⚠ "' + name + '" 还没有基线截图。'));
89
+ console.log(" 请先运行: " + pc.bold("npx snapdiff capture " + url + " --name " + name));
90
+ return;
91
+ }
92
+ console.log(pc.cyan("正在对比 " + name + "..."));
93
+ const viewport = { width: 1440, height: 900 };
94
+ const t = String(Math.floor(Date.now() / 1000));
95
+ const currentPath = baselineImagePath(cwd, "current-" + name);
96
+ const { imagePath: curPath } = await captureSnapshot({
97
+ config: { name, url, viewport, threshold: threshold / 100 },
98
+ outputPath: currentPath,
99
+ });
100
+ const diffOut = diffImagePath(cwd, name, t);
101
+ const result = await compareSnapshots({
102
+ baselinePath: baselineImagePath(cwd, name),
103
+ currentPath: curPath,
104
+ diffOutputPath: diffOut,
105
+ threshold: threshold / 100,
106
+ });
107
+ result.name = name;
108
+ result.url = url;
109
+ await unlink(curPath).catch(() => { });
110
+ if (result.error) {
111
+ console.log(" ⚠ " + result.error);
112
+ }
113
+ else if (result.passed) {
114
+ console.log(pc.green(" ✅ 无变化 (差异 " + result.diffPercent + "%)"));
115
+ }
116
+ else {
117
+ const bar = makeBar(result.diffPercent, 20);
118
+ console.log(" ❌ " + name);
119
+ console.log(" " + bar + " " + result.diffPercent + "% (" + result.diffPixels + " 像素)");
120
+ console.log(pc.dim(" 📄 diff 图: " + result.diffImagePath));
121
+ console.log(pc.cyan(" 如果这是预期的变更: " + pc.bold("npx snapdiff approve " + name)));
122
+ }
123
+ const htmlPath = await generateHtmlReport([result], cwd);
124
+ console.log(pc.cyan("\n 📊 HTML 报告: " + htmlPath));
125
+ }
126
+ function makeBar(percent, width) {
127
+ const filled = Math.min(Math.round((percent / 100) * width), width);
128
+ const empty = width - filled;
129
+ return pc.red("█".repeat(filled)) + pc.dim("░".repeat(empty));
130
+ }
@@ -0,0 +1,4 @@
1
+ export declare function initCommand(options: {
2
+ ci?: boolean;
3
+ yes?: boolean;
4
+ }): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import path from "node:path";
2
+ import { writeFile, mkdir, readFile, appendFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import pc from "picocolors";
5
+ export async function initCommand(options) {
6
+ const cwd = process.cwd();
7
+ const configPath = path.join(cwd, "snapdiff.config.json");
8
+ if (existsSync(configPath)) {
9
+ console.log(pc.yellow("⚠ snapdiff.config.json 已存在,跳过初始化。"));
10
+ return;
11
+ }
12
+ const projectName = path.basename(cwd);
13
+ if (options.yes) {
14
+ // Non-interactive mode: use defaults
15
+ await createConfig(cwd, configPath, {
16
+ name: projectName,
17
+ url: "http://localhost:3000",
18
+ selector: "",
19
+ });
20
+ await setupGitignore(cwd);
21
+ await takeFirstBaseline(cwd, configPath);
22
+ await maybeSetupCi(cwd, options.ci);
23
+ printNextSteps(cwd);
24
+ return;
25
+ }
26
+ // Interactive mode
27
+ console.log(pc.cyan("\n ⚡ snapdiff 初始化向导\n"));
28
+ const answers = await askQuestions([
29
+ { key: "name", question: "? 项目名称", default: projectName },
30
+ { key: "url", question: "? 要监控的页面 URL", default: "http://localhost:3000" },
31
+ { key: "selector", question: "? 页面加载完成后的等待元素(可选,留空则等待网络空闲)", default: "" },
32
+ ]);
33
+ await createConfig(cwd, configPath, answers);
34
+ await setupGitignore(cwd);
35
+ await takeFirstBaseline(cwd, configPath);
36
+ if (options.ci) {
37
+ await maybeSetupCi(cwd, true);
38
+ }
39
+ printNextSteps(cwd);
40
+ }
41
+ async function askQuestions(questions) {
42
+ const answers = {};
43
+ const readline = await import("node:readline/promises");
44
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
45
+ for (const q of questions) {
46
+ const prompt = q.default ? `${pc.dim(q.question)} (${q.default}): ` : `${q.question}: `;
47
+ const answer = await rl.question(prompt);
48
+ answers[q.key] = answer || q.default;
49
+ }
50
+ rl.close();
51
+ return answers;
52
+ }
53
+ async function createConfig(cwd, configPath, answers) {
54
+ const config = {
55
+ snaps: [
56
+ {
57
+ name: answers.name,
58
+ url: answers.url,
59
+ ...(answers.selector ? { selector: answers.selector } : {}),
60
+ viewport: { width: 1440, height: 900 },
61
+ threshold: 0.1,
62
+ },
63
+ ],
64
+ };
65
+ await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
66
+ console.log(pc.green(` ✔ 已创建 ${configPath}`));
67
+ }
68
+ async function setupGitignore(cwd) {
69
+ const gitignorePath = path.join(cwd, ".gitignore");
70
+ const diffDirEntry = "\n# snapdiff: 临时 diff 截图(不看 git)\n.snapdiff/diffs/\n.snapdiff/reports/\n";
71
+ try {
72
+ if (existsSync(gitignorePath)) {
73
+ const existing = await readFile(gitignorePath, "utf-8");
74
+ if (!existing.includes(".snapdiff/diffs/")) {
75
+ await appendFile(gitignorePath, diffDirEntry);
76
+ console.log(pc.green(" ✔ 已将 .snapdiff/diffs/ 和 .snapdiff/reports/ 添加到 .gitignore"));
77
+ }
78
+ }
79
+ else {
80
+ await writeFile(gitignorePath, diffDirEntry, "utf-8");
81
+ console.log(pc.green(" ✔ 已创建 .gitignore"));
82
+ }
83
+ }
84
+ catch {
85
+ // .gitignore 不是关键功能,忽略错误
86
+ }
87
+ }
88
+ async function takeFirstBaseline(cwd, configPath) {
89
+ const { ensureDirs, captureSnapshot, baselineImagePath, saveBaselineMeta } = await import("@snapdiff/core");
90
+ const config = JSON.parse(await readFile(configPath, "utf-8"));
91
+ await ensureDirs(cwd);
92
+ console.log(pc.cyan("\n 正在截取基线..."));
93
+ try {
94
+ const { imagePath, meta } = await captureSnapshot({
95
+ config: config.snaps[0],
96
+ outputPath: baselineImagePath(cwd, config.snaps[0].name),
97
+ });
98
+ await saveBaselineMeta(cwd, config.snaps[0].name, meta);
99
+ console.log(pc.green(` ✔ ${config.snaps[0].url} ── 基线已保存`));
100
+ }
101
+ catch (err) {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ console.log(pc.yellow(` ⚠ 首次截图失败: ${msg}`));
104
+ console.log(` 稍后可以手动运行 ${pc.bold("npx snapdiff capture")}`);
105
+ }
106
+ }
107
+ async function maybeSetupCi(cwd, ci) {
108
+ if (!ci)
109
+ return;
110
+ const ciDir = path.join(cwd, ".github", "workflows");
111
+ if (!existsSync(ciDir)) {
112
+ await mkdir(ciDir, { recursive: true });
113
+ }
114
+ const ciYaml = `name: Visual Regression Test
115
+ on: [pull_request]
116
+ jobs:
117
+ snapdiff:
118
+ runs-on: ubuntu-latest
119
+ steps:
120
+ - uses: actions/checkout@v4
121
+ - uses: actions/setup-node@v4
122
+ with:
123
+ node-version: 20
124
+ - run: npx snapdiff diff
125
+ `;
126
+ await writeFile(path.join(ciDir, "snapdiff.yml"), ciYaml, "utf-8");
127
+ console.log(pc.green(" ✔ 已创建 .github/workflows/snapdiff.yml"));
128
+ }
129
+ function printNextSteps(cwd) {
130
+ console.log(pc.cyan("\n 下一步:"));
131
+ console.log(` ▸ 修改代码后运行 ${pc.bold("npx snapdiff diff")}`);
132
+ console.log(` ▸ 查看基线状态 ${pc.bold("npx snapdiff status")}`);
133
+ console.log(` ▸ 非交互式初始化 ${pc.bold("npx snapdiff init --yes")}`);
134
+ console.log();
135
+ }
@@ -0,0 +1 @@
1
+ export declare function statusCommand(): Promise<void>;
@@ -0,0 +1,49 @@
1
+ import pc from "picocolors";
2
+ import { listBaselines, loadConfig } from "../core/index.js";
3
+ export async function statusCommand() {
4
+ const cwd = process.cwd();
5
+ const baselines = await listBaselines(cwd);
6
+ const config = await loadConfig(cwd);
7
+ console.log(pc.cyan("\n 📸 snapdiff 基线状态\n"));
8
+ if (baselines.length === 0 && (!config || config.snaps.length === 0)) {
9
+ console.log(" 还没有任何基线截图。");
10
+ console.log(` 请先运行: ${pc.bold("npx snapdiff init")}`);
11
+ console.log(` 或: ${pc.bold("npx snapdiff capture <url> --name <name>")}`);
12
+ console.log();
13
+ return;
14
+ }
15
+ // Build a set of configured snap names
16
+ const configuredSnaps = new Set(config?.snaps.map((s) => s.name) ?? []);
17
+ // Header
18
+ const header = ` ${"名称".padEnd(22)} ${"URL".padEnd(40)} ${"基线时间".padEnd(22)} 状态`;
19
+ console.log(pc.dim(header));
20
+ console.log(pc.dim(" " + "─".repeat(90)));
21
+ // Show configured snaps first
22
+ if (config) {
23
+ for (const snap of config.snaps) {
24
+ const baseline = baselines.find((b) => b.name === snap.name);
25
+ const timeStr = baseline?.meta
26
+ ? new Date(baseline.meta.capturedAt).toLocaleString("zh-CN")
27
+ : "—";
28
+ const status = baseline
29
+ ? pc.green("✅ 正常")
30
+ : pc.yellow("⚠ 未截取");
31
+ console.log(` ${snap.name.padEnd(22)} ${snap.url.padEnd(40)} ${timeStr.padEnd(22)} ${status}`);
32
+ }
33
+ }
34
+ // Show uncatalogued baselines
35
+ const uncatalogued = baselines.filter((b) => !configuredSnaps.has(b.name));
36
+ if (uncatalogued.length > 0) {
37
+ console.log();
38
+ console.log(pc.dim(" 未纳入配置的基线:"));
39
+ for (const b of uncatalogued) {
40
+ const timeStr = b.meta
41
+ ? new Date(b.meta.capturedAt).toLocaleString("zh-CN")
42
+ : "—";
43
+ console.log(` ${b.name.padEnd(22)} ${timeStr.padEnd(22)} ${pc.dim("(无对应配置)")}`);
44
+ }
45
+ }
46
+ console.log();
47
+ console.log(pc.dim(" 提示: 修改代码后运行 npx snapdiff diff 进行对比"));
48
+ console.log();
49
+ }
@@ -0,0 +1,14 @@
1
+ import { SnapConfig, BaselineMeta } from "./types.js";
2
+ export interface CaptureOptions {
3
+ config: SnapConfig;
4
+ outputPath: string;
5
+ }
6
+ export interface CaptureResult {
7
+ imagePath: string;
8
+ meta: BaselineMeta;
9
+ }
10
+ export declare function captureSnapshot(options: CaptureOptions): Promise<CaptureResult>;
11
+ export declare function captureSnapshotsParallel(snaps: Array<{
12
+ config: SnapConfig;
13
+ outputPath: string;
14
+ }>, concurrency?: number): Promise<CaptureResult[]>;
@@ -0,0 +1,83 @@
1
+ import { chromium } from "playwright";
2
+ function prefixLines(msg) {
3
+ return msg.replace(/^/gm, " ");
4
+ }
5
+ export async function captureSnapshot(options) {
6
+ const { config, outputPath } = options;
7
+ const viewport = config.viewport ?? { width: 1440, height: 900 };
8
+ let browser = null;
9
+ try {
10
+ browser = await chromium.launch({ headless: true });
11
+ const page = await browser.newPage({ viewport });
12
+ await page.goto(config.url, {
13
+ waitUntil: "networkidle",
14
+ timeout: 30_000,
15
+ });
16
+ if (config.selector) {
17
+ await page.waitForSelector(config.selector, { timeout: 15_000 });
18
+ }
19
+ await page.waitForTimeout(500);
20
+ const screenshotBuffer = await page.screenshot({ fullPage: false, type: "png" });
21
+ const { writeFile } = await import("node:fs/promises");
22
+ await writeFile(outputPath, screenshotBuffer);
23
+ const meta = {
24
+ name: config.name,
25
+ url: config.url,
26
+ viewport,
27
+ selector: config.selector,
28
+ capturedAt: new Date().toISOString(),
29
+ contentHash: simpleHash(screenshotBuffer),
30
+ };
31
+ return { imagePath: outputPath, meta };
32
+ }
33
+ catch (err) {
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ if (msg.includes("ERR_CONNECTION_REFUSED") || msg.includes("ENOTFOUND") || msg.includes("ERR_NAME_NOT_RESOLVED")) {
36
+ throw new Error(`无法访问 ${config.url},请检查页面是否已启动或 URL 是否正确`);
37
+ }
38
+ if (msg.includes("ERR_CONNECTION_TIMEOUT") || msg.includes("ERR_TIMED_OUT") || msg.includes("timeout")) {
39
+ throw new Error(`访问 ${config.url} 超时(30 秒),页面可能加载过慢`);
40
+ }
41
+ if (msg.includes("ERR_ABORTED")) {
42
+ throw new Error(`对 ${config.url} 的请求被中断,请检查页面是否被重定向或拦截`);
43
+ }
44
+ if (msg.includes("waitForSelector") || (msg.includes("Timeout") && config.selector)) {
45
+ throw new Error(`等待选择器 "${config.selector}" 超时,该元素在页面中未找到`);
46
+ }
47
+ if (msg.includes("spawn EACCES") || msg.includes("spawn EPERM") || msg.includes("spawn ENOTDIR")) {
48
+ throw new Error(`浏览器进程启动失败(权限不足),请重新安装 Chromium:\n npx playwright install chromium`);
49
+ }
50
+ throw new Error(`截图失败: ${msg}`);
51
+ }
52
+ finally {
53
+ if (browser)
54
+ await browser.close().catch(() => { });
55
+ }
56
+ }
57
+ export async function captureSnapshotsParallel(snaps, concurrency = 3) {
58
+ const results = [];
59
+ const queue = [...snaps];
60
+ async function worker() {
61
+ while (queue.length > 0) {
62
+ const item = queue.shift();
63
+ try {
64
+ const result = await captureSnapshot(item);
65
+ results.push(result);
66
+ }
67
+ catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ console.error(` ✗ ${item.config.name}: ${msg}`);
70
+ }
71
+ }
72
+ }
73
+ const workers = Array.from({ length: Math.min(concurrency, snaps.length) }, () => worker());
74
+ await Promise.all(workers);
75
+ return results;
76
+ }
77
+ function simpleHash(buffer) {
78
+ let hash = 0;
79
+ for (let i = 0; i < buffer.length; i += 1000) {
80
+ hash = ((hash << 5) - hash + buffer[i]) | 0;
81
+ }
82
+ return Math.abs(hash).toString(16).padStart(8, "0");
83
+ }
@@ -0,0 +1,9 @@
1
+ import { SnapConfig } from "./types.js";
2
+ export interface ProjectConfig {
3
+ snaps: SnapConfig[];
4
+ ci?: {
5
+ mode: "strict" | "auto-capture";
6
+ };
7
+ }
8
+ export declare function loadConfig(cwd: string): Promise<ProjectConfig | null>;
9
+ export declare function defaultConfig(): ProjectConfig;
@@ -0,0 +1,41 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ const CONFIG_FILES = [
5
+ "snapdiff.config.ts",
6
+ "snapdiff.config.js",
7
+ "snapdiff.config.json",
8
+ ".snapdiffrc",
9
+ ".snapdiffrc.json",
10
+ ];
11
+ function stripBom(raw) {
12
+ return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
13
+ }
14
+ export async function loadConfig(cwd) {
15
+ for (const file of CONFIG_FILES) {
16
+ const path = join(cwd, file);
17
+ if (!existsSync(path))
18
+ continue;
19
+ if (file.endsWith(".json") || file === ".snapdiffrc") {
20
+ try {
21
+ const raw = await readFile(path, "utf-8");
22
+ return JSON.parse(stripBom(raw));
23
+ }
24
+ catch (err) {
25
+ const msg = err instanceof SyntaxError
26
+ ? `配置文件 ${file} 格式错误,请检查 JSON 语法`
27
+ : `读取配置文件 ${file} 失败: ${err instanceof Error ? err.message : err}`;
28
+ console.error(` ⚠ ${msg}`);
29
+ return null;
30
+ }
31
+ }
32
+ if (file.endsWith(".ts") || file.endsWith(".js")) {
33
+ console.warn(" ⚠ TypeScript/JS 配置文件当前版本暂不支持,请使用 snapdiff.config.json");
34
+ continue;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ export function defaultConfig() {
40
+ return { snaps: [] };
41
+ }
@@ -0,0 +1,8 @@
1
+ import { DiffResult } from "./types.js";
2
+ export interface DiffOptions {
3
+ baselinePath: string;
4
+ currentPath: string;
5
+ diffOutputPath: string;
6
+ threshold: number;
7
+ }
8
+ export declare function compareSnapshots(options: DiffOptions): Promise<DiffResult>;
@@ -0,0 +1,35 @@
1
+ import pixelmatch from "pixelmatch";
2
+ import { PNG } from "pngjs";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ export async function compareSnapshots(options) {
5
+ const { baselinePath, currentPath, diffOutputPath, threshold } = options;
6
+ const baselinePng = PNG.sync.read(await readFile(baselinePath));
7
+ const currentPng = PNG.sync.read(await readFile(currentPath));
8
+ if (baselinePng.width !== currentPng.width ||
9
+ baselinePng.height !== currentPng.height) {
10
+ return {
11
+ name: "",
12
+ url: "",
13
+ diffPixels: 0,
14
+ totalPixels: 0,
15
+ diffPercent: 0,
16
+ passed: false,
17
+ error: `尺寸不匹配: baseline ${baselinePng.width}x${baselinePng.height}, current ${currentPng.width}x${currentPng.height}`,
18
+ };
19
+ }
20
+ const diff = new PNG({ width: baselinePng.width, height: baselinePng.height });
21
+ const diffPixels = pixelmatch(baselinePng.data, currentPng.data, diff.data, baselinePng.width, baselinePng.height, { threshold: 0.1 });
22
+ const totalPixels = baselinePng.width * baselinePng.height;
23
+ const diffPercent = (diffPixels / totalPixels) * 100;
24
+ const passed = diffPercent <= threshold * 100;
25
+ await writeFile(diffOutputPath, PNG.sync.write(diff));
26
+ return {
27
+ name: "",
28
+ url: "",
29
+ diffPixels,
30
+ totalPixels,
31
+ diffPercent: Math.round(diffPercent * 100) / 100,
32
+ passed,
33
+ diffImagePath: diffOutputPath,
34
+ };
35
+ }
@@ -0,0 +1,7 @@
1
+ export type { SnapConfig, BaselineMeta, DiffResult } from "./types.js";
2
+ export type { ProjectConfig } from "./config.js";
3
+ export { loadConfig, defaultConfig } from "./config.js";
4
+ export { captureSnapshot, captureSnapshotsParallel, type CaptureOptions, type CaptureResult } from "./capture.js";
5
+ export { compareSnapshots, type DiffOptions } from "./diff.js";
6
+ export { ensureDirs, baselineImagePath, baselineMetaPath, diffImagePath, saveBaselineMeta, loadBaselineMeta, baselineExists, listBaselines, } from "./storage.js";
7
+ export { generateTextReport, generateReportSummary, generateHtmlReport } from "./reporter.js";
@@ -0,0 +1,5 @@
1
+ export { loadConfig, defaultConfig } from "./config.js";
2
+ export { captureSnapshot, captureSnapshotsParallel } from "./capture.js";
3
+ export { compareSnapshots } from "./diff.js";
4
+ export { ensureDirs, baselineImagePath, baselineMetaPath, diffImagePath, saveBaselineMeta, loadBaselineMeta, baselineExists, listBaselines, } from "./storage.js";
5
+ export { generateTextReport, generateReportSummary, generateHtmlReport } from "./reporter.js";
@@ -0,0 +1,8 @@
1
+ import { DiffResult } from "./types.js";
2
+ export interface ReportOptions {
3
+ results: DiffResult[];
4
+ verbose?: boolean;
5
+ }
6
+ export declare function generateTextReport(options: ReportOptions): string;
7
+ export declare function generateReportSummary(results: DiffResult[]): string;
8
+ export declare function generateHtmlReport(results: DiffResult[], cwd: string): Promise<string>;
@@ -0,0 +1,164 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ export function generateTextReport(options) {
4
+ const { results } = options;
5
+ const lines = [];
6
+ for (const result of results) {
7
+ if (result.error) {
8
+ lines.push(`\n ⚠ ${result.error}`);
9
+ continue;
10
+ }
11
+ if (result.passed) {
12
+ lines.push(` ✅ ${result.name} ── 无变化 (差异 ${result.diffPercent}%)`);
13
+ }
14
+ else {
15
+ const bar = makePercentBar(result.diffPercent, 20);
16
+ lines.push(` ❌ ${result.name}`);
17
+ lines.push(` ${bar} ${result.diffPercent}% (${result.diffPixels} 像素)`);
18
+ if (result.diffImagePath) {
19
+ lines.push(` 📄 diff 图: ${result.diffImagePath}`);
20
+ }
21
+ lines.push(` 如果这是预期的变更, 请运行: npx snapdiff approve ${result.name}`);
22
+ }
23
+ }
24
+ return lines.join("\n");
25
+ }
26
+ export function generateReportSummary(results) {
27
+ if (results.length === 0) {
28
+ return "\n ℹ 没有页面需要对比。";
29
+ }
30
+ const total = results.length;
31
+ const passed = results.filter((r) => r.passed && !r.error).length;
32
+ const failed = results.filter((r) => !r.passed && !r.error).length;
33
+ const errored = results.filter((r) => r.error).length;
34
+ const summary = `\n 📊 摘要: ${total} 个页面, ${passed} 通过, ${failed} 失败, ${errored} 错误`;
35
+ return summary + (failed > 0
36
+ ? `\n 失败页面:\n${results.filter(r => !r.passed && !r.error).map(r => ` ❌ ${r.name} (${r.diffPercent}%)`).join("\n")}`
37
+ : "");
38
+ }
39
+ export async function generateHtmlReport(results, cwd) {
40
+ const reportDir = ".snapdiff/reports";
41
+ const absReportDir = reportDir; // relative to cwd
42
+ if (!existsSync(absReportDir)) {
43
+ await mkdir(absReportDir, { recursive: true });
44
+ }
45
+ const timestamp = Math.floor(Date.now() / 1000);
46
+ const reportPath = `${absReportDir}/report-${timestamp}.html`;
47
+ // Edge case: no results
48
+ if (results.length === 0) {
49
+ const html = `<!DOCTYPE html>
50
+ <html lang="zh-CN">
51
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
52
+ <title>snapdiff 报告</title>
53
+ <style>body{font-family:-apple-system,sans-serif;background:#f5f5f5;padding:40px;text-align:center;color:#666;}
54
+ h1{font-size:24px;color:#333;}p{font-size:14px;margin-top:8px;}
55
+ </style></head>
56
+ <body><h1>无对比结果</h1><p>没有页面需要对比,请确认基线截图是否存在。</p></body></html>`;
57
+ await writeFile(reportPath, html, "utf-8");
58
+ return reportPath;
59
+ }
60
+ let cardsHtml = "";
61
+ let passedCount = 0;
62
+ let failedCount = 0;
63
+ for (const r of results) {
64
+ if (r.error) {
65
+ cardsHtml += `<div class="card error"><h3>⚠ ${r.name}</h3><p>${r.error}</p></div>`;
66
+ continue;
67
+ }
68
+ const status = r.passed ? "passed" : "failed";
69
+ if (r.passed)
70
+ passedCount++;
71
+ else
72
+ failedCount++;
73
+ let diffImgHtml = "";
74
+ if (r.diffImagePath && !r.passed) {
75
+ try {
76
+ const diffBuf = await readFile(r.diffImagePath);
77
+ const b64 = diffBuf.toString("base64");
78
+ diffImgHtml = `<img src="data:image/png;base64,${b64}" alt="diff" class="diff-img" />`;
79
+ }
80
+ catch {
81
+ diffImgHtml = `<p class="dim">diff 图不可用</p>`;
82
+ }
83
+ }
84
+ const bar = makePercentBar(r.diffPercent, 30);
85
+ cardsHtml += `
86
+ <div class="card ${status}">
87
+ <div class="card-header">
88
+ <span class="status-badge ${status}">${r.passed ? "✅" : "❌"}</span>
89
+ <span class="name">${r.name}</span>
90
+ <span class="url">${r.url}</span>
91
+ </div>
92
+ <div class="card-body">
93
+ <div class="stat-row">
94
+ <span>差异比例</span>
95
+ <span class="stat-value">${r.diffPercent}%</span>
96
+ </div>
97
+ <div class="stat-row">
98
+ <span>差异像素</span>
99
+ <span class="stat-value">${r.diffPixels.toLocaleString()} / ${r.totalPixels.toLocaleString()}</span>
100
+ </div>
101
+ <div class="bar-container">${bar}</div>
102
+ ${diffImgHtml}
103
+ </div>
104
+ </div>`;
105
+ }
106
+ const html = `<!DOCTYPE html>
107
+ <html lang="zh-CN">
108
+ <head>
109
+ <meta charset="UTF-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
111
+ <title>snapdiff 视觉回归报告</title>
112
+ <style>
113
+ *{margin:0;padding:0;box-sizing:border-box;}
114
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f5f5;color:#1a1a1a;padding:24px;}
115
+ .header{max-width:800px;margin:0 auto 24px;}
116
+ .header h1{font-size:24px;font-weight:600;}
117
+ .header .meta{color:#666;font-size:14px;margin-top:4px;}
118
+ .summary{display:flex;gap:16px;max-width:800px;margin:0 auto 24px;}
119
+ .summary-item{flex:1;background:white;border-radius:8px;padding:16px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.08);}
120
+ .summary-item .num{font-size:32px;font-weight:700;}
121
+ .summary-item .label{font-size:13px;color:#666;margin-top:4px;}
122
+ .summary-item.total .num{color:#333;}
123
+ .summary-item.passed .num{color:#22c55e;}
124
+ .summary-item.failed .num{color:#ef4444;}
125
+ .cards{max-width:800px;margin:0 auto;}
126
+ .card{background:white;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;}
127
+ .card.passed{border-left:4px solid #22c55e;}
128
+ .card.failed{border-left:4px solid #ef4444;}
129
+ .card.error{border-left:4px solid #f59e0b;}
130
+ .card-header{display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid #f0f0f0;}
131
+ .status-badge{font-size:18px;}
132
+ .name{font-weight:600;font-size:15px;}
133
+ .url{color:#666;font-size:13px;margin-left:auto;}
134
+ .card-body{padding:16px;}
135
+ .stat-row{display:flex;justify-content:space-between;padding:4px 0;font-size:14px;}
136
+ .stat-value{font-weight:600;}
137
+ .bar-container{margin:8px 0;font-family:"SF Mono","Fira Code",monospace;font-size:13px;color:#666;}
138
+ .diff-img{width:100%;max-width:600px;margin-top:12px;border:1px solid #e0e0e0;border-radius:4px;}
139
+ .dim{color:#999;font-size:13px;}
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <div class="header">
144
+ <h1>snapdiff 视觉回归报告</h1>
145
+ <div class="meta">${new Date().toLocaleString("zh-CN")} · 共 ${results.length} 个页面</div>
146
+ </div>
147
+ <div class="summary">
148
+ <div class="summary-item total"><div class="num">${results.length}</div><div class="label">总页面</div></div>
149
+ <div class="summary-item passed"><div class="num">${passedCount}</div><div class="label">通过</div></div>
150
+ <div class="summary-item failed"><div class="num">${failedCount}</div><div class="label">失败</div></div>
151
+ </div>
152
+ <div class="cards">${cardsHtml}</div>
153
+ </body>
154
+ </html>`;
155
+ await writeFile(reportPath, html, "utf-8");
156
+ return reportPath;
157
+ }
158
+ function makePercentBar(percent, width) {
159
+ const filled = Math.round((percent / 100) * width);
160
+ const empty = width - filled;
161
+ const fillChar = "█";
162
+ const emptyChar = "░";
163
+ return fillChar.repeat(Math.min(filled, width)) + emptyChar.repeat(Math.max(empty, 0));
164
+ }
@@ -0,0 +1,13 @@
1
+ export { existsSync, mkdirSync } from "node:fs";
2
+ import { BaselineMeta } from "./types.js";
3
+ export declare function ensureDirs(cwd: string): Promise<void>;
4
+ export declare function baselineImagePath(cwd: string, name: string): string;
5
+ export declare function baselineMetaPath(cwd: string, name: string): string;
6
+ export declare function diffImagePath(cwd: string, name: string, timestamp: string): string;
7
+ export declare function saveBaselineMeta(cwd: string, name: string, meta: BaselineMeta): Promise<void>;
8
+ export declare function loadBaselineMeta(cwd: string, name: string): Promise<BaselineMeta | null>;
9
+ export declare function baselineExists(cwd: string, name: string): Promise<boolean>;
10
+ export declare function listBaselines(cwd: string): Promise<Array<{
11
+ name: string;
12
+ meta: BaselineMeta | null;
13
+ }>>;
@@ -0,0 +1,69 @@
1
+ export { existsSync, mkdirSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ const BASELINE_DIR = ".snapdiff/baselines";
6
+ const DIFF_DIR = ".snapdiff/diffs";
7
+ const CONFIG_DIR = ".snapdiff";
8
+ export async function ensureDirs(cwd) {
9
+ const dirs = [
10
+ join(cwd, CONFIG_DIR),
11
+ join(cwd, BASELINE_DIR),
12
+ join(cwd, DIFF_DIR),
13
+ ];
14
+ for (const dir of dirs) {
15
+ if (!existsSync(dir)) {
16
+ await mkdir(dir, { recursive: true });
17
+ }
18
+ }
19
+ }
20
+ export function baselineImagePath(cwd, name) {
21
+ return join(cwd, BASELINE_DIR, `${name}.png`);
22
+ }
23
+ export function baselineMetaPath(cwd, name) {
24
+ return join(cwd, BASELINE_DIR, `${name}.json`);
25
+ }
26
+ export function diffImagePath(cwd, name, timestamp) {
27
+ return join(cwd, DIFF_DIR, `${name}-${timestamp}-diff.png`);
28
+ }
29
+ export async function saveBaselineMeta(cwd, name, meta) {
30
+ const dir = join(cwd, BASELINE_DIR);
31
+ if (!existsSync(dir)) {
32
+ await mkdir(dir, { recursive: true });
33
+ }
34
+ await writeFile(baselineMetaPath(cwd, name), JSON.stringify(meta, null, 2));
35
+ }
36
+ export async function loadBaselineMeta(cwd, name) {
37
+ const path = baselineMetaPath(cwd, name);
38
+ if (!existsSync(path))
39
+ return null;
40
+ try {
41
+ const raw = await readFile(path, "utf-8");
42
+ return JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export async function baselineExists(cwd, name) {
49
+ return existsSync(baselineImagePath(cwd, name));
50
+ }
51
+ export async function listBaselines(cwd) {
52
+ const dir = join(cwd, BASELINE_DIR);
53
+ if (!existsSync(dir))
54
+ return [];
55
+ try {
56
+ const files = await readdir(dir);
57
+ const pngFiles = files.filter((f) => f.endsWith(".png"));
58
+ const results = [];
59
+ for (const file of pngFiles) {
60
+ const name = file.replace(/\.png$/, "");
61
+ const meta = await loadBaselineMeta(cwd, name);
62
+ results.push({ name, meta });
63
+ }
64
+ return results;
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ }
@@ -0,0 +1,37 @@
1
+ export interface SnapConfig {
2
+ name: string;
3
+ url: string;
4
+ selector?: string;
5
+ viewport?: {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ threshold?: number;
10
+ maskRegions?: Array<{
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }>;
16
+ }
17
+ export interface BaselineMeta {
18
+ name: string;
19
+ url: string;
20
+ viewport: {
21
+ width: number;
22
+ height: number;
23
+ };
24
+ selector?: string;
25
+ capturedAt: string;
26
+ contentHash: string;
27
+ }
28
+ export interface DiffResult {
29
+ name: string;
30
+ url: string;
31
+ diffPixels: number;
32
+ totalPixels: number;
33
+ diffPercent: number;
34
+ passed: boolean;
35
+ diffImagePath?: string;
36
+ error?: string;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import { initCommand } from "./commands/init.js";
5
+ import { captureCommand } from "./commands/capture.js";
6
+ import { diffCommand } from "./commands/diff.js";
7
+ import { approveCommand } from "./commands/approve.js";
8
+ import { statusCommand } from "./commands/status.js";
9
+ // Global error handler — prevent raw stack traces from leaking to users
10
+ process.on("unhandledRejection", (err) => {
11
+ const msg = err instanceof Error ? err.message : String(err);
12
+ console.error(pc.red(`\n ⚠ 意外错误: ${msg}`));
13
+ console.error(pc.dim(` 如需帮助,请提供以上完整输出来排查问题。`));
14
+ process.exit(1);
15
+ });
16
+ process.on("uncaughtException", (err) => {
17
+ console.error(pc.red(`\n ⚠ 意外错误: ${err.message}`));
18
+ console.error(pc.dim(` 如需帮助,请提供以上完整输出来排查问题。`));
19
+ process.exit(1);
20
+ });
21
+ // Detect non-TTY stdin early — e.g. piped input in CI
22
+ const isInteractive = process.stdin.isTTY;
23
+ const program = new Command();
24
+ program
25
+ .name("snapdiff")
26
+ .description(pc.cyan("一行命令的视觉回归测试工具"))
27
+ .version("0.1.0");
28
+ program
29
+ .command("init")
30
+ .description("初始化 snapdiff 配置(交互式向导,自动完成首次截图)")
31
+ .option("--ci", "同时生成 GitHub Action 配置文件")
32
+ .option("--yes", "跳过交互提问,使用默认值")
33
+ .addHelpText("after", pc.dim(`\n示例:\n $ snapdiff init\n $ snapdiff init --yes 非交互式,快速初始化\n $ snapdiff init --yes --ci 非交互式 + CI 配置\n`))
34
+ .action((opts) => {
35
+ // If stdin is not a TTY and --yes not given, auto-enable --yes to avoid hanging
36
+ if (!isInteractive && !opts.yes) {
37
+ opts.yes = true;
38
+ }
39
+ initCommand(opts);
40
+ });
41
+ program
42
+ .command("capture")
43
+ .description("截取当前页面作为基线截图")
44
+ .argument("[url]", "页面 URL")
45
+ .option("-n, --name <name>", "截图名称")
46
+ .option("-s, --selector <selector>", "等页面中该元素出现后再截图,如 #app-root")
47
+ .option("-w, --width <width>", "视口宽度", "1440")
48
+ .option("-h, --height <height>", "视口高度", "900")
49
+ .addHelpText("after", pc.dim(`\n示例:\n $ snapdiff capture 从配置文件批量截取\n $ snapdiff capture https://ex.com -n my-page 截取单个页面\n $ snapdiff capture https://ex.com -n home -s #main\n`))
50
+ .action(captureCommand);
51
+ program
52
+ .command("diff")
53
+ .description("对比当前页面与基线截图")
54
+ .argument("[url]", "页面 URL")
55
+ .option("-n, --name <name>", "截图名称")
56
+ .option("-t, --threshold <threshold>", "允许的差异阈值百分比,超出即判定为失败", "0.1")
57
+ .addHelpText("after", pc.dim(`\n示例:\n $ snapdiff diff 对比配置文件中的所有页面\n $ snapdiff diff https://ex.com -n my-page 对比单个页面\n $ snapdiff diff -t 0.5 设置更宽松的阈值\n`))
58
+ .action(diffCommand);
59
+ program
60
+ .command("approve")
61
+ .description("接受当前差异为新基线(覆盖旧基线)")
62
+ .argument("<name>", "截图名称(必填)")
63
+ .addHelpText("after", pc.dim(`\n示例:\n $ snapdiff approve my-page 将我页面的新状态设为基线\n`))
64
+ .action(approveCommand);
65
+ program
66
+ .command("status")
67
+ .description("查看所有基线状态(表格展示)")
68
+ .addHelpText("after", pc.dim(`\n示例:\n $ snapdiff status 查看哪些页面有基线,哪些还未截取\n`))
69
+ .action(statusCommand);
70
+ program.addHelpText("afterAll", `
71
+ ${pc.bold("快速开始")}:
72
+ $ snapdiff init 首次运行,走一遍向导
73
+ $ snapdiff diff 改完代码后对比变化
74
+
75
+ ${pc.bold("典型工作流")}:
76
+ 1. ${pc.dim("snapdiff init")} 初始化项目,自动截取首张基线
77
+ 2. ${pc.dim("修改代码")} 改你的 CSS/组件/页面
78
+ 3. ${pc.dim("snapdiff diff")} 对比变化,查看差异
79
+ 4. ${pc.dim("snapdiff approve <name>")} 确认变更,更新基线
80
+
81
+ ${pc.dim("完整文档: https://github.com/your-org/snapdiff")}
82
+ `);
83
+ program.parse(process.argv);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import { baselineImagePath } from "@snapdiff/core";
2
+ // Just test the type
3
+ const x = baselineImagePath("/tmp", "test");
4
+ console.log(x);
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "snapdiff-cli",
3
+ "version": "0.1.0",
4
+ "description": "?????????????",
5
+ "type": "module",
6
+ "bin": {
7
+ "snapdiff": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "dependencies": {
19
+ "playwright": "^1.52.0",
20
+ "pixelmatch": "^6.0.0",
21
+ "pngjs": "^7.0.0",
22
+ "commander": "^13.0.0",
23
+ "picocolors": "^1.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "@types/pngjs": "^6.0.0",
28
+ "typescript": "^5.7.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ }
36
+ }