afterbefore 0.1.0 → 0.1.2
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/LICENSE +143 -21
- package/README.md +4 -112
- package/dist/cli.js +1258 -266
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +74 -2
- package/dist/index.js +1209 -236
- package/dist/index.js.map +1 -1
- package/package.json +6 -7
package/dist/index.js
CHANGED
|
@@ -1,30 +1,66 @@
|
|
|
1
1
|
// src/pipeline.ts
|
|
2
|
-
import { resolve as
|
|
2
|
+
import { resolve as resolve4 } from "path";
|
|
3
|
+
import { exec } from "child_process";
|
|
4
|
+
import chalk2 from "chalk";
|
|
3
5
|
|
|
4
6
|
// src/logger.ts
|
|
5
7
|
import chalk from "chalk";
|
|
6
8
|
import ora from "ora";
|
|
9
|
+
var BAR_WIDTH = 20;
|
|
10
|
+
var BAR_SPINNER = { interval: 80, frames: [" "] };
|
|
7
11
|
var Logger = class {
|
|
8
12
|
spinner = null;
|
|
13
|
+
pipelineTotal = 0;
|
|
14
|
+
lastStep = 0;
|
|
15
|
+
lastLabel = "";
|
|
16
|
+
pipelineActive = false;
|
|
9
17
|
info(message) {
|
|
10
|
-
this.
|
|
11
|
-
|
|
18
|
+
if (this.pipelineActive) return;
|
|
19
|
+
if (this.spinner) {
|
|
20
|
+
this.spinner.clear();
|
|
21
|
+
console.log(chalk.blue("\u2139"), message);
|
|
22
|
+
this.spinner.render();
|
|
23
|
+
} else {
|
|
24
|
+
console.log(chalk.blue("\u2139"), message);
|
|
25
|
+
}
|
|
12
26
|
}
|
|
13
27
|
success(message) {
|
|
14
|
-
this.
|
|
15
|
-
|
|
28
|
+
if (this.pipelineActive) return;
|
|
29
|
+
if (this.spinner) {
|
|
30
|
+
this.spinner.clear();
|
|
31
|
+
console.log(chalk.green("\u2714"), message);
|
|
32
|
+
this.spinner.render();
|
|
33
|
+
} else {
|
|
34
|
+
console.log(chalk.green("\u2714"), message);
|
|
35
|
+
}
|
|
16
36
|
}
|
|
17
37
|
warn(message) {
|
|
18
|
-
this.
|
|
19
|
-
|
|
38
|
+
if (this.spinner) {
|
|
39
|
+
this.spinner.clear();
|
|
40
|
+
console.log(chalk.yellow("\u26A0"), message);
|
|
41
|
+
this.spinner.render();
|
|
42
|
+
} else {
|
|
43
|
+
console.log(chalk.yellow("\u26A0"), message);
|
|
44
|
+
}
|
|
20
45
|
}
|
|
21
46
|
error(message) {
|
|
22
|
-
this.
|
|
23
|
-
|
|
47
|
+
if (this.spinner) {
|
|
48
|
+
this.spinner.clear();
|
|
49
|
+
console.error(chalk.red("\u2716"), message);
|
|
50
|
+
this.spinner.render();
|
|
51
|
+
} else {
|
|
52
|
+
console.error(chalk.red("\u2716"), message);
|
|
53
|
+
}
|
|
24
54
|
}
|
|
25
55
|
dim(message) {
|
|
26
|
-
this.
|
|
27
|
-
|
|
56
|
+
if (this.pipelineActive) return;
|
|
57
|
+
if (this.spinner) {
|
|
58
|
+
this.spinner.clear();
|
|
59
|
+
console.log(chalk.dim(message));
|
|
60
|
+
this.spinner.render();
|
|
61
|
+
} else {
|
|
62
|
+
console.log(chalk.dim(message));
|
|
63
|
+
}
|
|
28
64
|
}
|
|
29
65
|
spin(message) {
|
|
30
66
|
this.clearSpinner();
|
|
@@ -34,6 +70,60 @@ var Logger = class {
|
|
|
34
70
|
stopSpinner() {
|
|
35
71
|
this.clearSpinner();
|
|
36
72
|
}
|
|
73
|
+
startPipeline(total) {
|
|
74
|
+
this.pipelineTotal = total;
|
|
75
|
+
this.lastStep = 0;
|
|
76
|
+
this.lastLabel = "";
|
|
77
|
+
this.pipelineActive = true;
|
|
78
|
+
this.clearSpinner();
|
|
79
|
+
const text = this.renderPipeline(0, "Starting...");
|
|
80
|
+
this.spinner = ora({
|
|
81
|
+
text: chalk.dim(text),
|
|
82
|
+
spinner: BAR_SPINNER
|
|
83
|
+
}).start();
|
|
84
|
+
}
|
|
85
|
+
pipeline(step, label) {
|
|
86
|
+
if (step === this.lastStep && label === this.lastLabel) return;
|
|
87
|
+
this.lastStep = step;
|
|
88
|
+
this.lastLabel = label;
|
|
89
|
+
const text = chalk.dim(this.renderPipeline(step, label));
|
|
90
|
+
if (!this.spinner) {
|
|
91
|
+
this.spinner = ora({
|
|
92
|
+
text,
|
|
93
|
+
spinner: BAR_SPINNER
|
|
94
|
+
}).start();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.spinner.text = text;
|
|
98
|
+
}
|
|
99
|
+
completePipeline(finished = false) {
|
|
100
|
+
this.pipelineActive = false;
|
|
101
|
+
if (this.spinner) {
|
|
102
|
+
if (finished) {
|
|
103
|
+
const bar = "\u2588".repeat(BAR_WIDTH);
|
|
104
|
+
this.spinner.stopAndPersist({
|
|
105
|
+
symbol: chalk.green("\u2714"),
|
|
106
|
+
text: bar
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
this.spinner.stop();
|
|
110
|
+
}
|
|
111
|
+
this.spinner = null;
|
|
112
|
+
} else if (finished) {
|
|
113
|
+
const bar = "\u2588".repeat(BAR_WIDTH);
|
|
114
|
+
console.log(`${chalk.green("\u2714")} ${bar}`);
|
|
115
|
+
}
|
|
116
|
+
this.pipelineTotal = 0;
|
|
117
|
+
this.lastStep = 0;
|
|
118
|
+
this.lastLabel = "";
|
|
119
|
+
}
|
|
120
|
+
renderPipeline(step, label) {
|
|
121
|
+
const total = this.pipelineTotal || 1;
|
|
122
|
+
const clampedStep = Math.max(0, Math.min(step, total));
|
|
123
|
+
const filled = Math.round(clampedStep / total * BAR_WIDTH);
|
|
124
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
|
|
125
|
+
return ` ${bar} ${clampedStep}/${total} ${label}`;
|
|
126
|
+
}
|
|
37
127
|
clearSpinner() {
|
|
38
128
|
if (this.spinner) {
|
|
39
129
|
this.spinner.stop();
|
|
@@ -90,6 +180,30 @@ Received ${signal}, cleaning up...`);
|
|
|
90
180
|
};
|
|
91
181
|
var cleanupRegistry = new CleanupRegistry();
|
|
92
182
|
|
|
183
|
+
// src/config.ts
|
|
184
|
+
import { resolve } from "path";
|
|
185
|
+
import { existsSync, readFileSync } from "fs";
|
|
186
|
+
import { pathToFileURL } from "url";
|
|
187
|
+
var CONFIG_FILES = [
|
|
188
|
+
"afterbefore.config.json",
|
|
189
|
+
"afterbefore.config.js",
|
|
190
|
+
"afterbefore.config.mjs"
|
|
191
|
+
];
|
|
192
|
+
async function loadConfig(cwd) {
|
|
193
|
+
for (const name of CONFIG_FILES) {
|
|
194
|
+
const filePath = resolve(cwd, name);
|
|
195
|
+
if (!existsSync(filePath)) continue;
|
|
196
|
+
logger.dim(` Config: ${name}`);
|
|
197
|
+
if (name.endsWith(".json")) {
|
|
198
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
199
|
+
return JSON.parse(raw);
|
|
200
|
+
}
|
|
201
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
202
|
+
return mod.default ?? mod;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
93
207
|
// src/utils/fs.ts
|
|
94
208
|
import { mkdir } from "fs/promises";
|
|
95
209
|
async function ensureDir(dir) {
|
|
@@ -99,7 +213,7 @@ async function ensureDir(dir) {
|
|
|
99
213
|
// src/utils/port.ts
|
|
100
214
|
import { createServer } from "net";
|
|
101
215
|
function findPort() {
|
|
102
|
-
return new Promise((
|
|
216
|
+
return new Promise((resolve5, reject) => {
|
|
103
217
|
const server = createServer();
|
|
104
218
|
server.listen(0, () => {
|
|
105
219
|
const addr = server.address();
|
|
@@ -109,7 +223,7 @@ function findPort() {
|
|
|
109
223
|
return;
|
|
110
224
|
}
|
|
111
225
|
const port = addr.port;
|
|
112
|
-
server.close(() =>
|
|
226
|
+
server.close(() => resolve5(port));
|
|
113
227
|
});
|
|
114
228
|
server.on("error", reject);
|
|
115
229
|
});
|
|
@@ -138,6 +252,112 @@ function getDiffNameStatus(base, cwd) {
|
|
|
138
252
|
const mergeBase = getMergeBase(base, cwd);
|
|
139
253
|
return git(`diff --name-status ${mergeBase}`, cwd);
|
|
140
254
|
}
|
|
255
|
+
function getGitDiff(base, cwd) {
|
|
256
|
+
const mergeBase = getMergeBase(base, cwd);
|
|
257
|
+
return git(`diff ${mergeBase}`, cwd);
|
|
258
|
+
}
|
|
259
|
+
function getCurrentBranch(cwd) {
|
|
260
|
+
return git("rev-parse --abbrev-ref HEAD", cwd);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/utils/bgcolor.ts
|
|
264
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
265
|
+
import { resolve as resolve2 } from "path";
|
|
266
|
+
var DEFAULT_BG = "#0a0a0a";
|
|
267
|
+
var GLOBAL_CSS_PATHS = [
|
|
268
|
+
"app/globals.css",
|
|
269
|
+
"src/app/globals.css",
|
|
270
|
+
"styles/globals.css",
|
|
271
|
+
"src/styles/globals.css",
|
|
272
|
+
"app/global.css",
|
|
273
|
+
"src/app/global.css"
|
|
274
|
+
];
|
|
275
|
+
function hslToHex(h, s, l) {
|
|
276
|
+
s /= 100;
|
|
277
|
+
l /= 100;
|
|
278
|
+
const a = s * Math.min(l, 1 - l);
|
|
279
|
+
const f = (n) => {
|
|
280
|
+
const k = (n + h / 30) % 12;
|
|
281
|
+
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
282
|
+
return Math.round(255 * color).toString(16).padStart(2, "0");
|
|
283
|
+
};
|
|
284
|
+
return `#${f(0)}${f(8)}${f(4)}`;
|
|
285
|
+
}
|
|
286
|
+
function parseColorValue(raw) {
|
|
287
|
+
const v = raw.trim();
|
|
288
|
+
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
289
|
+
if (v.length === 4) {
|
|
290
|
+
return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
|
|
291
|
+
}
|
|
292
|
+
return v.slice(0, 7);
|
|
293
|
+
}
|
|
294
|
+
const hslMatch = v.match(
|
|
295
|
+
/^hsl\(\s*([\d.]+)[,\s]+\s*([\d.]+)%[,\s]+\s*([\d.]+)%/
|
|
296
|
+
);
|
|
297
|
+
if (hslMatch) {
|
|
298
|
+
return hslToHex(
|
|
299
|
+
parseFloat(hslMatch[1]),
|
|
300
|
+
parseFloat(hslMatch[2]),
|
|
301
|
+
parseFloat(hslMatch[3])
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const bareHsl = v.match(/^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/);
|
|
305
|
+
if (bareHsl) {
|
|
306
|
+
return hslToHex(
|
|
307
|
+
parseFloat(bareHsl[1]),
|
|
308
|
+
parseFloat(bareHsl[2]),
|
|
309
|
+
parseFloat(bareHsl[3])
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const rgbMatch = v.match(
|
|
313
|
+
/^rgb\(\s*([\d.]+)[,\s]+\s*([\d.]+)[,\s]+\s*([\d.]+)/
|
|
314
|
+
);
|
|
315
|
+
if (rgbMatch) {
|
|
316
|
+
const toHex = (n) => Math.round(parseFloat(n)).toString(16).padStart(2, "0");
|
|
317
|
+
return `#${toHex(rgbMatch[1])}${toHex(rgbMatch[2])}${toHex(rgbMatch[3])}`;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
function detectBgColor(cwd) {
|
|
322
|
+
for (const relPath of GLOBAL_CSS_PATHS) {
|
|
323
|
+
const absPath = resolve2(cwd, relPath);
|
|
324
|
+
if (!existsSync2(absPath)) continue;
|
|
325
|
+
const css = readFileSync2(absPath, "utf-8");
|
|
326
|
+
const darkBlock = css.match(/\.dark\s*\{([^}]+)\}/);
|
|
327
|
+
if (darkBlock) {
|
|
328
|
+
const bgVar = darkBlock[1].match(/--background\s*:\s*([^;]+)/);
|
|
329
|
+
if (bgVar) {
|
|
330
|
+
const color = parseColorValue(bgVar[1]);
|
|
331
|
+
if (color) return color;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const rootBlock = css.match(/:root\s*\{([^}]+)\}/);
|
|
335
|
+
if (rootBlock) {
|
|
336
|
+
const bgVar = rootBlock[1].match(/--background\s*:\s*([^;]+)/);
|
|
337
|
+
if (bgVar) {
|
|
338
|
+
const color = parseColorValue(bgVar[1]);
|
|
339
|
+
if (color) return color;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const anyBgVar = css.match(/--background\s*:\s*([^;]+)/);
|
|
343
|
+
if (anyBgVar) {
|
|
344
|
+
const color = parseColorValue(anyBgVar[1]);
|
|
345
|
+
if (color) return color;
|
|
346
|
+
}
|
|
347
|
+
const bodyBg = css.match(
|
|
348
|
+
/body\s*\{[^}]*?background(?:-color)?\s*:\s*([^;]+)/
|
|
349
|
+
);
|
|
350
|
+
if (bodyBg) {
|
|
351
|
+
const val = bodyBg[1].trim();
|
|
352
|
+
if (!val.startsWith("var(")) {
|
|
353
|
+
const color = parseColorValue(val);
|
|
354
|
+
if (color) return color;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
return DEFAULT_BG;
|
|
360
|
+
}
|
|
141
361
|
|
|
142
362
|
// src/stages/diff.ts
|
|
143
363
|
var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
|
|
@@ -163,6 +383,17 @@ function getChangedFiles(base, cwd) {
|
|
|
163
383
|
}
|
|
164
384
|
|
|
165
385
|
// src/stages/classify.ts
|
|
386
|
+
var GLOBAL_FILES = /* @__PURE__ */ new Set([
|
|
387
|
+
"tailwind.config.ts",
|
|
388
|
+
"tailwind.config.js",
|
|
389
|
+
"postcss.config.js",
|
|
390
|
+
"postcss.config.mjs",
|
|
391
|
+
"postcss.config.cjs"
|
|
392
|
+
]);
|
|
393
|
+
function isGlobalVisualFile(filePath) {
|
|
394
|
+
const p = filePath.replace(/^src\//, "");
|
|
395
|
+
return GLOBAL_FILES.has(p) || /globals?\.(css|scss)$/.test(p);
|
|
396
|
+
}
|
|
166
397
|
var VISUAL_CATEGORIES = /* @__PURE__ */ new Set([
|
|
167
398
|
"page",
|
|
168
399
|
"component",
|
|
@@ -210,13 +441,13 @@ function filterVisuallyRelevant(files) {
|
|
|
210
441
|
}
|
|
211
442
|
|
|
212
443
|
// src/stages/graph.ts
|
|
213
|
-
import { readdirSync, readFileSync as
|
|
444
|
+
import { readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
214
445
|
import { join as join2, relative } from "path";
|
|
215
446
|
import { init, parse } from "es-module-lexer";
|
|
216
447
|
|
|
217
448
|
// src/stages/resolve.ts
|
|
218
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
219
|
-
import { resolve, dirname, join } from "path";
|
|
449
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
|
|
450
|
+
import { resolve as resolve3, dirname, join } from "path";
|
|
220
451
|
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
221
452
|
function createResolver(projectRoot) {
|
|
222
453
|
const mappings = loadPathMappings(projectRoot);
|
|
@@ -224,7 +455,7 @@ function createResolver(projectRoot) {
|
|
|
224
455
|
function cachedExists(p) {
|
|
225
456
|
const cached = existsCache.get(p);
|
|
226
457
|
if (cached !== void 0) return cached;
|
|
227
|
-
const result =
|
|
458
|
+
const result = existsSync3(p);
|
|
228
459
|
existsCache.set(p, result);
|
|
229
460
|
return result;
|
|
230
461
|
}
|
|
@@ -250,14 +481,14 @@ function createResolver(projectRoot) {
|
|
|
250
481
|
return (specifier, fromFile) => {
|
|
251
482
|
if (specifier.startsWith(".")) {
|
|
252
483
|
const dir = dirname(fromFile);
|
|
253
|
-
const candidate =
|
|
484
|
+
const candidate = resolve3(dir, specifier);
|
|
254
485
|
return tryResolve(candidate);
|
|
255
486
|
}
|
|
256
487
|
for (const mapping of mappings) {
|
|
257
488
|
if (!specifier.startsWith(mapping.prefix)) continue;
|
|
258
489
|
const rest = specifier.slice(mapping.prefix.length);
|
|
259
490
|
for (const target of mapping.targets) {
|
|
260
|
-
const candidate =
|
|
491
|
+
const candidate = resolve3(projectRoot, target + rest);
|
|
261
492
|
const result = tryResolve(candidate);
|
|
262
493
|
if (result) return result;
|
|
263
494
|
}
|
|
@@ -267,13 +498,13 @@ function createResolver(projectRoot) {
|
|
|
267
498
|
}
|
|
268
499
|
function loadPathMappings(projectRoot) {
|
|
269
500
|
const tsconfigPath = join(projectRoot, "tsconfig.json");
|
|
270
|
-
if (!
|
|
501
|
+
if (!existsSync3(tsconfigPath)) {
|
|
271
502
|
logger.dim("No tsconfig.json found, skipping path alias resolution");
|
|
272
503
|
return [];
|
|
273
504
|
}
|
|
274
505
|
try {
|
|
275
|
-
const raw =
|
|
276
|
-
const cleaned = raw
|
|
506
|
+
const raw = readFileSync3(tsconfigPath, "utf-8");
|
|
507
|
+
const cleaned = stripJsonComments(raw);
|
|
277
508
|
const config = JSON.parse(cleaned);
|
|
278
509
|
const paths = config?.compilerOptions?.paths;
|
|
279
510
|
if (!paths) return [];
|
|
@@ -295,6 +526,52 @@ function loadPathMappings(projectRoot) {
|
|
|
295
526
|
return [];
|
|
296
527
|
}
|
|
297
528
|
}
|
|
529
|
+
function stripJsonComments(input) {
|
|
530
|
+
let result = "";
|
|
531
|
+
let i = 0;
|
|
532
|
+
const len = input.length;
|
|
533
|
+
while (i < len) {
|
|
534
|
+
const ch = input[i];
|
|
535
|
+
if (ch === '"') {
|
|
536
|
+
let j = i + 1;
|
|
537
|
+
while (j < len) {
|
|
538
|
+
if (input[j] === "\\") {
|
|
539
|
+
j += 2;
|
|
540
|
+
} else if (input[j] === '"') {
|
|
541
|
+
j++;
|
|
542
|
+
break;
|
|
543
|
+
} else {
|
|
544
|
+
j++;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
result += input.slice(i, j);
|
|
548
|
+
i = j;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (ch === "/" && input[i + 1] === "/") {
|
|
552
|
+
i += 2;
|
|
553
|
+
while (i < len && input[i] !== "\n") i++;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (ch === "/" && input[i + 1] === "*") {
|
|
557
|
+
i += 2;
|
|
558
|
+
while (i < len && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
559
|
+
i += 2;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (ch === ",") {
|
|
563
|
+
let j = i + 1;
|
|
564
|
+
while (j < len && /\s/.test(input[j])) j++;
|
|
565
|
+
if (input[j] === "}" || input[j] === "]") {
|
|
566
|
+
i = j;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
result += ch;
|
|
571
|
+
i++;
|
|
572
|
+
}
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
298
575
|
|
|
299
576
|
// src/stages/graph.ts
|
|
300
577
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
@@ -326,7 +603,7 @@ function collectFiles(dir) {
|
|
|
326
603
|
function parseImports(filePath) {
|
|
327
604
|
let source;
|
|
328
605
|
try {
|
|
329
|
-
source =
|
|
606
|
+
source = readFileSync4(filePath, "utf-8");
|
|
330
607
|
} catch {
|
|
331
608
|
return [];
|
|
332
609
|
}
|
|
@@ -346,7 +623,7 @@ function parseImports(filePath) {
|
|
|
346
623
|
}
|
|
347
624
|
async function buildImportGraph(projectRoot) {
|
|
348
625
|
await init;
|
|
349
|
-
const
|
|
626
|
+
const resolve5 = createResolver(projectRoot);
|
|
350
627
|
const allFiles = [];
|
|
351
628
|
for (const dir of SOURCE_DIRS) {
|
|
352
629
|
const fullDir = join2(projectRoot, dir);
|
|
@@ -360,7 +637,7 @@ async function buildImportGraph(projectRoot) {
|
|
|
360
637
|
const specifiers = parseImports(filePath);
|
|
361
638
|
const deps = /* @__PURE__ */ new Set();
|
|
362
639
|
for (const spec of specifiers) {
|
|
363
|
-
const resolved =
|
|
640
|
+
const resolved = resolve5(spec, filePath);
|
|
364
641
|
if (!resolved) continue;
|
|
365
642
|
const relResolved = relative(projectRoot, resolved);
|
|
366
643
|
deps.add(relResolved);
|
|
@@ -415,16 +692,16 @@ function getLayoutDir(filePath) {
|
|
|
415
692
|
|
|
416
693
|
// src/stages/impact.ts
|
|
417
694
|
var MAX_DEPTH = 3;
|
|
418
|
-
function findAffectedRoutes(changedFiles, graph, projectRoot) {
|
|
695
|
+
function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
|
|
419
696
|
const routeMap = /* @__PURE__ */ new Map();
|
|
420
697
|
for (const file of changedFiles) {
|
|
421
698
|
const visited = /* @__PURE__ */ new Set();
|
|
422
699
|
const queue = [
|
|
423
|
-
{ path: file, depth: 0 }
|
|
700
|
+
{ path: file, depth: 0, chain: [file] }
|
|
424
701
|
];
|
|
425
702
|
visited.add(file);
|
|
426
703
|
while (queue.length > 0) {
|
|
427
|
-
const { path, depth } = queue.shift();
|
|
704
|
+
const { path, depth, chain } = queue.shift();
|
|
428
705
|
if (isPageFile(path)) {
|
|
429
706
|
const route = pagePathToRoute(path);
|
|
430
707
|
if (route !== null && !routeMap.has(route)) {
|
|
@@ -432,7 +709,8 @@ function findAffectedRoutes(changedFiles, graph, projectRoot) {
|
|
|
432
709
|
pagePath: path,
|
|
433
710
|
route,
|
|
434
711
|
reason: depth === 0 ? "direct" : "transitive",
|
|
435
|
-
depth
|
|
712
|
+
depth,
|
|
713
|
+
triggerChain: chain
|
|
436
714
|
});
|
|
437
715
|
}
|
|
438
716
|
}
|
|
@@ -442,7 +720,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot) {
|
|
|
442
720
|
for (const importer of importers) {
|
|
443
721
|
if (visited.has(importer)) continue;
|
|
444
722
|
visited.add(importer);
|
|
445
|
-
queue.push({ path: importer, depth: depth + 1 });
|
|
723
|
+
queue.push({ path: importer, depth: depth + 1, chain: [...chain, importer] });
|
|
446
724
|
}
|
|
447
725
|
}
|
|
448
726
|
}
|
|
@@ -457,13 +735,25 @@ function findAffectedRoutes(changedFiles, graph, projectRoot) {
|
|
|
457
735
|
pagePath: knownFile,
|
|
458
736
|
route,
|
|
459
737
|
reason: "transitive",
|
|
460
|
-
depth: 1
|
|
738
|
+
depth: 1,
|
|
739
|
+
triggerChain: [file, knownFile]
|
|
461
740
|
});
|
|
462
741
|
}
|
|
463
742
|
}
|
|
464
743
|
}
|
|
465
744
|
}
|
|
466
|
-
const routes = Array.from(routeMap.values())
|
|
745
|
+
const routes = Array.from(routeMap.values()).sort(
|
|
746
|
+
(a, b) => a.depth - b.depth
|
|
747
|
+
);
|
|
748
|
+
if (maxRoutes > 0 && routes.length > maxRoutes) {
|
|
749
|
+
const skipped = routes.length - maxRoutes;
|
|
750
|
+
logger.warn(
|
|
751
|
+
`Limiting to ${maxRoutes} route(s), skipping ${skipped} deeper route(s). Use --max-routes 0 for unlimited.`
|
|
752
|
+
);
|
|
753
|
+
const limited = routes.slice(0, maxRoutes);
|
|
754
|
+
logger.dim(`Found ${limited.length} affected route(s) (of ${routes.length} total)`);
|
|
755
|
+
return limited;
|
|
756
|
+
}
|
|
467
757
|
logger.dim(`Found ${routes.length} affected route(s)`);
|
|
468
758
|
return routes;
|
|
469
759
|
}
|
|
@@ -474,14 +764,23 @@ import { rm, mkdtemp } from "fs/promises";
|
|
|
474
764
|
import { join as join4 } from "path";
|
|
475
765
|
import { tmpdir } from "os";
|
|
476
766
|
|
|
767
|
+
// src/errors.ts
|
|
768
|
+
var AfterbeforeError = class extends Error {
|
|
769
|
+
constructor(message, suggestion) {
|
|
770
|
+
super(message);
|
|
771
|
+
this.suggestion = suggestion;
|
|
772
|
+
this.name = "AfterbeforeError";
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
477
776
|
// src/utils/pm.ts
|
|
478
|
-
import { existsSync as
|
|
777
|
+
import { existsSync as existsSync4 } from "fs";
|
|
479
778
|
import { join as join3 } from "path";
|
|
480
779
|
function detectPackageManager(dir) {
|
|
481
|
-
if (
|
|
780
|
+
if (existsSync4(join3(dir, "bun.lockb")) || existsSync4(join3(dir, "bun.lock")))
|
|
482
781
|
return "bun";
|
|
483
|
-
if (
|
|
484
|
-
if (
|
|
782
|
+
if (existsSync4(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
783
|
+
if (existsSync4(join3(dir, "yarn.lock"))) return "yarn";
|
|
485
784
|
return "npm";
|
|
486
785
|
}
|
|
487
786
|
function pmExec(pm) {
|
|
@@ -500,13 +799,20 @@ function pmExec(pm) {
|
|
|
500
799
|
// src/stages/worktree.ts
|
|
501
800
|
async function createWorktree(base, cwd) {
|
|
502
801
|
const worktreeDir = await mkdtemp(join4(tmpdir(), "afterbefore-wt-"));
|
|
503
|
-
logger.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
802
|
+
logger.dim(`Creating worktree for ${base} at ${worktreeDir}`);
|
|
803
|
+
try {
|
|
804
|
+
execSync2(`git worktree add "${worktreeDir}" "${base}"`, {
|
|
805
|
+
cwd,
|
|
806
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
807
|
+
});
|
|
808
|
+
} catch (err) {
|
|
809
|
+
throw new AfterbeforeError(
|
|
810
|
+
`Failed to create worktree for ref "${base}".`,
|
|
811
|
+
`Make sure the branch/ref "${base}" exists. Run "git branch -a" to see available refs.`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
508
814
|
const pm = detectPackageManager(cwd);
|
|
509
|
-
logger.
|
|
815
|
+
logger.dim(`Installing dependencies with ${pm}`);
|
|
510
816
|
execSync2(`${pm} install`, {
|
|
511
817
|
cwd: worktreeDir,
|
|
512
818
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -536,21 +842,24 @@ async function createWorktree(base, cwd) {
|
|
|
536
842
|
|
|
537
843
|
// src/stages/server.ts
|
|
538
844
|
import { spawn } from "child_process";
|
|
845
|
+
import { existsSync as existsSync5 } from "fs";
|
|
846
|
+
import { join as join5 } from "path";
|
|
539
847
|
function waitForServer(url, timeoutMs) {
|
|
540
848
|
const start = Date.now();
|
|
541
|
-
return new Promise((
|
|
849
|
+
return new Promise((resolve5, reject) => {
|
|
542
850
|
const poll = async () => {
|
|
543
851
|
if (Date.now() - start > timeoutMs) {
|
|
544
852
|
reject(
|
|
545
|
-
new
|
|
546
|
-
`Server at ${url} did not respond within ${timeoutMs / 1e3}s
|
|
853
|
+
new AfterbeforeError(
|
|
854
|
+
`Server at ${url} did not respond within ${timeoutMs / 1e3}s.`,
|
|
855
|
+
`Try running "next dev" manually to check for errors. Also check if port ${url.split(":").pop()} is already in use.`
|
|
547
856
|
)
|
|
548
857
|
);
|
|
549
858
|
return;
|
|
550
859
|
}
|
|
551
860
|
try {
|
|
552
861
|
await fetch(url);
|
|
553
|
-
|
|
862
|
+
resolve5();
|
|
554
863
|
} catch {
|
|
555
864
|
setTimeout(poll, 500);
|
|
556
865
|
}
|
|
@@ -561,13 +870,20 @@ function waitForServer(url, timeoutMs) {
|
|
|
561
870
|
async function startServer(projectDir, port) {
|
|
562
871
|
const url = `http://localhost:${port}`;
|
|
563
872
|
const pm = detectPackageManager(projectDir);
|
|
564
|
-
const
|
|
565
|
-
const [cmd, ...baseArgs] =
|
|
873
|
+
const exec2 = pmExec(pm);
|
|
874
|
+
const [cmd, ...baseArgs] = exec2.split(" ");
|
|
875
|
+
const lockFile = join5(projectDir, ".next", "dev", "lock");
|
|
876
|
+
if (existsSync5(lockFile)) {
|
|
877
|
+
throw new AfterbeforeError(
|
|
878
|
+
`Another Next.js dev server is running in ${projectDir} (.next/dev/lock exists).`,
|
|
879
|
+
`Stop the other dev server first, or delete .next/dev/lock if it's stale.`
|
|
880
|
+
);
|
|
881
|
+
}
|
|
566
882
|
logger.info(`Starting Next.js dev server on ${url} (using ${pm})`);
|
|
567
883
|
const child = spawn(cmd, [...baseArgs, "next", "dev", "-p", String(port)], {
|
|
568
884
|
cwd: projectDir,
|
|
569
885
|
stdio: ["pipe", "pipe", "pipe"],
|
|
570
|
-
detached:
|
|
886
|
+
detached: true
|
|
571
887
|
});
|
|
572
888
|
child.stderr?.on("data", (data) => {
|
|
573
889
|
const msg = data.toString().trim();
|
|
@@ -579,70 +895,582 @@ async function startServer(projectDir, port) {
|
|
|
579
895
|
}
|
|
580
896
|
async function stopServer(server) {
|
|
581
897
|
logger.dim(`Stopping server on port ${server.port}`);
|
|
582
|
-
server.process.
|
|
583
|
-
|
|
898
|
+
const pid = server.process.pid;
|
|
899
|
+
try {
|
|
900
|
+
process.kill(-pid, "SIGTERM");
|
|
901
|
+
} catch {
|
|
902
|
+
}
|
|
903
|
+
await new Promise((resolve5) => {
|
|
584
904
|
const timeout = setTimeout(() => {
|
|
585
|
-
|
|
586
|
-
|
|
905
|
+
try {
|
|
906
|
+
process.kill(-pid, "SIGKILL");
|
|
907
|
+
} catch {
|
|
908
|
+
}
|
|
909
|
+
resolve5();
|
|
587
910
|
}, 5e3);
|
|
588
911
|
server.process.on("exit", () => {
|
|
589
912
|
clearTimeout(timeout);
|
|
590
|
-
|
|
913
|
+
resolve5();
|
|
591
914
|
});
|
|
592
915
|
});
|
|
593
916
|
}
|
|
594
917
|
|
|
595
918
|
// src/stages/capture.ts
|
|
596
|
-
import { join as
|
|
597
|
-
import {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
919
|
+
import { join as join6 } from "path";
|
|
920
|
+
import { execSync as execSync3 } from "child_process";
|
|
921
|
+
import { chromium, devices } from "playwright";
|
|
922
|
+
|
|
923
|
+
// src/utils/tabs.ts
|
|
924
|
+
async function detectTabs(page, maxTabs) {
|
|
925
|
+
const allTabs = await page.evaluate(() => {
|
|
926
|
+
const tablists = document.querySelectorAll('[role="tablist"]');
|
|
927
|
+
const results = [];
|
|
928
|
+
for (const tablist of tablists) {
|
|
929
|
+
if (tablist.closest('[role="tabpanel"]')) continue;
|
|
930
|
+
const tabs = tablist.querySelectorAll('[role="tab"]');
|
|
931
|
+
for (const tab of tabs) {
|
|
932
|
+
const label = (tab.textContent ?? "").trim();
|
|
933
|
+
if (!label) continue;
|
|
934
|
+
const selected = tab.getAttribute("aria-selected") === "true" || tab.getAttribute("data-state") === "active";
|
|
935
|
+
results.push({ label, selected });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return results;
|
|
939
|
+
});
|
|
940
|
+
return allTabs.filter((t) => !t.selected).slice(0, maxTabs);
|
|
601
941
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
942
|
+
function sanitizeTabLabel(label) {
|
|
943
|
+
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/utils/sections.ts
|
|
947
|
+
async function detectSections(page, maxSections) {
|
|
948
|
+
const sections = await page.evaluate((max) => {
|
|
949
|
+
const headings = Array.from(document.querySelectorAll("h2, h3"));
|
|
950
|
+
const valid = headings.filter((h) => (h.textContent ?? "").trim().length > 0);
|
|
951
|
+
if (valid.length < 3) return [];
|
|
952
|
+
const results = [];
|
|
953
|
+
const tagged = /* @__PURE__ */ new Set();
|
|
954
|
+
let idx = 0;
|
|
955
|
+
for (const heading of valid) {
|
|
956
|
+
if (idx >= max) break;
|
|
957
|
+
const container = findSectionContainer(heading);
|
|
958
|
+
if (!container || tagged.has(container)) continue;
|
|
959
|
+
if (container.scrollHeight > document.documentElement.scrollHeight * 0.9) continue;
|
|
960
|
+
container.setAttribute("data-ab-section", String(idx));
|
|
961
|
+
tagged.add(container);
|
|
962
|
+
results.push({
|
|
963
|
+
label: (heading.textContent ?? "").trim(),
|
|
964
|
+
index: idx
|
|
965
|
+
});
|
|
966
|
+
idx++;
|
|
967
|
+
}
|
|
968
|
+
return results;
|
|
969
|
+
function findSectionContainer(heading) {
|
|
970
|
+
let el = heading;
|
|
971
|
+
const headingTag = heading.tagName.toLowerCase();
|
|
972
|
+
while (el && el !== document.body) {
|
|
973
|
+
const parentEl = el.parentElement;
|
|
974
|
+
if (!parentEl) return el;
|
|
975
|
+
const parentTag = parentEl.tagName.toLowerCase();
|
|
976
|
+
if (parentTag === "section" || parentTag === "article") return parentEl;
|
|
977
|
+
if (parentEl.getAttribute("role") === "region") return parentEl;
|
|
978
|
+
if (parentTag === "body" || parentTag === "main") return el;
|
|
979
|
+
const siblingHeadings = parentEl.querySelectorAll(`:scope > * ${headingTag}, :scope > ${headingTag}`);
|
|
980
|
+
if (siblingHeadings.length > 1) return el;
|
|
981
|
+
el = parentEl;
|
|
982
|
+
}
|
|
983
|
+
return el;
|
|
984
|
+
}
|
|
985
|
+
}, maxSections);
|
|
986
|
+
return sections;
|
|
987
|
+
}
|
|
988
|
+
async function tagSectionOnPage(page, headingText, index) {
|
|
989
|
+
return page.evaluate(
|
|
990
|
+
({ text, idx }) => {
|
|
991
|
+
const headings = Array.from(document.querySelectorAll("h2, h3"));
|
|
992
|
+
const match = headings.find(
|
|
993
|
+
(h) => (h.textContent ?? "").trim() === text
|
|
994
|
+
);
|
|
995
|
+
if (!match) return false;
|
|
996
|
+
let el = match;
|
|
997
|
+
const headingTag = match.tagName.toLowerCase();
|
|
998
|
+
while (el && el !== document.body) {
|
|
999
|
+
const parentEl = el.parentElement;
|
|
1000
|
+
if (!parentEl) break;
|
|
1001
|
+
const parentTag = parentEl.tagName.toLowerCase();
|
|
1002
|
+
if (parentTag === "section" || parentTag === "article") {
|
|
1003
|
+
el = parentEl;
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
if (parentEl.getAttribute("role") === "region") {
|
|
1007
|
+
el = parentEl;
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
if (parentTag === "body" || parentTag === "main") break;
|
|
1011
|
+
const siblingHeadings = parentEl.querySelectorAll(
|
|
1012
|
+
`:scope > * ${headingTag}, :scope > ${headingTag}`
|
|
1013
|
+
);
|
|
1014
|
+
if (siblingHeadings.length > 1) break;
|
|
1015
|
+
el = parentEl;
|
|
1016
|
+
}
|
|
1017
|
+
if (el) {
|
|
1018
|
+
el.setAttribute("data-ab-section", String(idx));
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
return false;
|
|
1022
|
+
},
|
|
1023
|
+
{ text: headingText, idx: index }
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
async function cleanupSectionTags(page) {
|
|
1027
|
+
await page.evaluate(() => {
|
|
1028
|
+
const tagged = document.querySelectorAll("[data-ab-section]");
|
|
1029
|
+
for (const el of tagged) {
|
|
1030
|
+
el.removeAttribute("data-ab-section");
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
function sanitizeSectionLabel(label) {
|
|
1035
|
+
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// src/stages/capture.ts
|
|
1039
|
+
async function launchBrowser() {
|
|
606
1040
|
try {
|
|
607
|
-
|
|
608
|
-
|
|
1041
|
+
return await chromium.launch();
|
|
1042
|
+
} catch {
|
|
1043
|
+
logger.dim("Chromium not found, installing...");
|
|
1044
|
+
execSync3("npx playwright install chromium", {
|
|
1045
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
609
1046
|
});
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1047
|
+
return await chromium.launch();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
var MAX_COMPONENT_INSTANCES_PER_SOURCE = 20;
|
|
1051
|
+
function normalizePath(filePath) {
|
|
1052
|
+
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1053
|
+
}
|
|
1054
|
+
function sanitizeComponentLabel(label) {
|
|
1055
|
+
return label.toLowerCase().replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
|
|
1056
|
+
}
|
|
1057
|
+
function groupBySource(instances) {
|
|
1058
|
+
const map = /* @__PURE__ */ new Map();
|
|
1059
|
+
for (const instance of instances) {
|
|
1060
|
+
const list = map.get(instance.source) ?? [];
|
|
1061
|
+
list.push(instance);
|
|
1062
|
+
map.set(instance.source, list);
|
|
1063
|
+
}
|
|
1064
|
+
for (const [source, list] of map.entries()) {
|
|
1065
|
+
list.sort((a, b) => a.index - b.index);
|
|
1066
|
+
map.set(source, list);
|
|
1067
|
+
}
|
|
1068
|
+
return map;
|
|
1069
|
+
}
|
|
1070
|
+
async function captureByAttr(page, attrName, attrValue, outPath) {
|
|
1071
|
+
try {
|
|
1072
|
+
const locator = page.locator(`[${attrName}="${attrValue}"]`).first();
|
|
1073
|
+
if (await locator.count() === 0) return false;
|
|
1074
|
+
await locator.screenshot({ path: outPath });
|
|
1075
|
+
return true;
|
|
1076
|
+
} catch {
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function tagChangedComponentInstances(page, changedComponents, maxPerSource = MAX_COMPONENT_INSTANCES_PER_SOURCE) {
|
|
1081
|
+
const normalized = changedComponents.map(normalizePath);
|
|
1082
|
+
return page.evaluate(
|
|
1083
|
+
({ changed, maxPerSource: maxPerSource2 }) => {
|
|
1084
|
+
const normalize = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1085
|
+
const targets = changed.map((original) => {
|
|
1086
|
+
const norm = normalize(original);
|
|
1087
|
+
const noSrc = norm.replace(/^src\//, "");
|
|
1088
|
+
return {
|
|
1089
|
+
original: norm,
|
|
1090
|
+
variants: Array.from(/* @__PURE__ */ new Set([norm, noSrc, `src/${noSrc}`]))
|
|
1091
|
+
};
|
|
1092
|
+
});
|
|
1093
|
+
for (const el of document.querySelectorAll("[data-ab-comp-key]")) {
|
|
1094
|
+
el.removeAttribute("data-ab-comp-key");
|
|
1095
|
+
}
|
|
1096
|
+
for (const el of document.querySelectorAll("[data-ab-parent-key]")) {
|
|
1097
|
+
el.removeAttribute("data-ab-parent-key");
|
|
1098
|
+
}
|
|
1099
|
+
const getFiber = (el) => {
|
|
1100
|
+
const anyEl = el;
|
|
1101
|
+
for (const key in anyEl) {
|
|
1102
|
+
if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
|
|
1103
|
+
return anyEl[key];
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return null;
|
|
1107
|
+
};
|
|
1108
|
+
const matchSource = (rawSource) => {
|
|
1109
|
+
const normalizedSource = normalize(rawSource);
|
|
1110
|
+
for (const target of targets) {
|
|
1111
|
+
for (const variant of target.variants) {
|
|
1112
|
+
if (normalizedSource === variant || normalizedSource.endsWith(`/${variant}`)) {
|
|
1113
|
+
return target.original;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return null;
|
|
1118
|
+
};
|
|
1119
|
+
const getComponentMatch = (el) => {
|
|
1120
|
+
const fiber = getFiber(el);
|
|
1121
|
+
if (!fiber) return null;
|
|
1122
|
+
let current = fiber;
|
|
1123
|
+
while (current) {
|
|
1124
|
+
const sourceFile = current?._debugSource?.fileName ?? current?.elementType?._debugSource?.fileName ?? current?.type?._debugSource?.fileName;
|
|
1125
|
+
if (typeof sourceFile === "string") {
|
|
1126
|
+
const source = matchSource(sourceFile);
|
|
1127
|
+
if (source) {
|
|
1128
|
+
const type = current?.elementType ?? current?.type;
|
|
1129
|
+
const name = typeof type === "function" && (type.displayName || type.name) || (typeof type === "string" ? type : void 0) || source.split("/").pop() || source;
|
|
1130
|
+
return { source, name };
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
current = current.return;
|
|
1134
|
+
}
|
|
1135
|
+
return null;
|
|
1136
|
+
};
|
|
1137
|
+
const pickParentContainer = (el) => {
|
|
1138
|
+
const ownRect = el.getBoundingClientRect();
|
|
1139
|
+
let parent = el.parentElement;
|
|
1140
|
+
while (parent && parent !== document.body) {
|
|
1141
|
+
const rect = parent.getBoundingClientRect();
|
|
1142
|
+
if (rect.width >= ownRect.width * 1.15 || rect.height >= ownRect.height * 1.15) {
|
|
1143
|
+
return parent;
|
|
1144
|
+
}
|
|
1145
|
+
parent = parent.parentElement;
|
|
1146
|
+
}
|
|
1147
|
+
return el.parentElement ?? el;
|
|
1148
|
+
};
|
|
1149
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
1150
|
+
const elements = Array.from(document.querySelectorAll("body *"));
|
|
1151
|
+
for (const el of elements) {
|
|
1152
|
+
const rect = el.getBoundingClientRect();
|
|
1153
|
+
if (rect.width < 4 || rect.height < 4) continue;
|
|
1154
|
+
if (rect.bottom < 0 || rect.right < 0) continue;
|
|
1155
|
+
const match = getComponentMatch(el);
|
|
1156
|
+
if (!match) continue;
|
|
1157
|
+
const parent = el.parentElement;
|
|
1158
|
+
if (parent) {
|
|
1159
|
+
const parentMatch = getComponentMatch(parent);
|
|
1160
|
+
if (parentMatch && parentMatch.source === match.source) {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const list = bySource.get(match.source) ?? [];
|
|
1165
|
+
list.push({
|
|
1166
|
+
el,
|
|
1167
|
+
parent: pickParentContainer(el),
|
|
1168
|
+
name: match.name,
|
|
1169
|
+
top: rect.top + window.scrollY,
|
|
1170
|
+
left: rect.left + window.scrollX
|
|
1171
|
+
});
|
|
1172
|
+
bySource.set(match.source, list);
|
|
1173
|
+
}
|
|
1174
|
+
const tagged = [];
|
|
1175
|
+
for (let sourceIndex = 0; sourceIndex < targets.length; sourceIndex++) {
|
|
1176
|
+
const source = targets[sourceIndex].original;
|
|
1177
|
+
const list = bySource.get(source) ?? [];
|
|
1178
|
+
list.sort((a, b) => a.top === b.top ? a.left - b.left : a.top - b.top);
|
|
1179
|
+
const limit = Math.min(list.length, maxPerSource2);
|
|
1180
|
+
for (let i = 0; i < limit; i++) {
|
|
1181
|
+
const instance = list[i];
|
|
1182
|
+
const componentKey = `ab-comp-${sourceIndex}-${i}`;
|
|
1183
|
+
const parentKey = `ab-parent-${sourceIndex}-${i}`;
|
|
1184
|
+
instance.el.setAttribute("data-ab-comp-key", componentKey);
|
|
1185
|
+
instance.parent.setAttribute("data-ab-parent-key", parentKey);
|
|
1186
|
+
tagged.push({
|
|
1187
|
+
source,
|
|
1188
|
+
name: instance.name,
|
|
1189
|
+
index: i,
|
|
1190
|
+
componentKey,
|
|
1191
|
+
parentKey
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return tagged;
|
|
1196
|
+
},
|
|
1197
|
+
{ changed: normalized, maxPerSource }
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLabel, outputDir, options, settle, results) {
|
|
1201
|
+
const sections = await detectSections(afterPage, options.maxSectionsPerRoute);
|
|
1202
|
+
if (sections.length === 0) return;
|
|
1203
|
+
const usedSlugs = /* @__PURE__ */ new Set();
|
|
1204
|
+
for (const section of sections) {
|
|
1205
|
+
let slug = sanitizeSectionLabel(section.label);
|
|
1206
|
+
if (!slug) continue;
|
|
1207
|
+
if (usedSlugs.has(slug)) {
|
|
1208
|
+
let suffix = 2;
|
|
1209
|
+
while (usedSlugs.has(`${slug}-${suffix}`)) suffix++;
|
|
1210
|
+
slug = `${slug}-${suffix}`;
|
|
627
1211
|
}
|
|
628
|
-
|
|
1212
|
+
usedSlugs.add(slug);
|
|
1213
|
+
const sectionPrefix = `${parentPrefix}~s.${slug}`;
|
|
1214
|
+
const sectionLabel = `${parentLabel} [${section.label}]`;
|
|
1215
|
+
const sectionAfterPath = join6(outputDir, `${sectionPrefix}-after.png`);
|
|
1216
|
+
const sectionBeforePath = join6(outputDir, `${sectionPrefix}-before.png`);
|
|
1217
|
+
try {
|
|
1218
|
+
const afterEl = afterPage.locator(`[data-ab-section="${section.index}"]`).first();
|
|
1219
|
+
await afterEl.screenshot({ path: sectionAfterPath });
|
|
1220
|
+
const found = await tagSectionOnPage(beforePage, section.label, section.index);
|
|
1221
|
+
if (found) {
|
|
1222
|
+
const beforeEl = beforePage.locator(`[data-ab-section="${section.index}"]`).first();
|
|
1223
|
+
await beforeEl.screenshot({ path: sectionBeforePath });
|
|
1224
|
+
} else {
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
results.push({
|
|
1228
|
+
route: sectionLabel,
|
|
1229
|
+
prefix: sectionPrefix,
|
|
1230
|
+
beforePath: sectionBeforePath,
|
|
1231
|
+
afterPath: sectionAfterPath
|
|
1232
|
+
});
|
|
1233
|
+
} catch {
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
await cleanupSectionTags(afterPage);
|
|
1237
|
+
await cleanupSectionTags(beforePage);
|
|
1238
|
+
}
|
|
1239
|
+
async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
1240
|
+
const browser = options.browser ?? await launchBrowser();
|
|
1241
|
+
const ownsBrowser = !options.browser;
|
|
1242
|
+
const results = [];
|
|
1243
|
+
try {
|
|
1244
|
+
const device = options.device ? devices[options.device] : void 0;
|
|
1245
|
+
const contextOpts = device ? { ...device } : {
|
|
1246
|
+
viewport: { width: options.width, height: options.height },
|
|
1247
|
+
deviceScaleFactor: 2
|
|
1248
|
+
};
|
|
1249
|
+
const [beforeCtx, afterCtx] = await Promise.all([
|
|
1250
|
+
browser.newContext(contextOpts),
|
|
1251
|
+
browser.newContext(contextOpts)
|
|
1252
|
+
]);
|
|
1253
|
+
const [beforePage, afterPage] = await Promise.all([
|
|
1254
|
+
beforeCtx.newPage(),
|
|
1255
|
+
afterCtx.newPage()
|
|
1256
|
+
]);
|
|
1257
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
1258
|
+
const task = tasks[i];
|
|
1259
|
+
options.onProgress?.(i + 1, task.label);
|
|
1260
|
+
const beforePath = join6(outputDir, `${task.prefix}-before.png`);
|
|
1261
|
+
const afterPath = join6(outputDir, `${task.prefix}-after.png`);
|
|
1262
|
+
const settle = async (page) => {
|
|
1263
|
+
await page.evaluate("document.fonts.ready");
|
|
1264
|
+
if (options.delay > 0) {
|
|
1265
|
+
await page.waitForTimeout(options.delay);
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
const performActions = async (page, actions) => {
|
|
1269
|
+
for (const action of actions) {
|
|
1270
|
+
if (action.click) {
|
|
1271
|
+
await page.locator(action.click).first().click();
|
|
1272
|
+
}
|
|
1273
|
+
if (action.scroll) {
|
|
1274
|
+
await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
|
|
1275
|
+
}
|
|
1276
|
+
if (action.wait && action.wait > 0) {
|
|
1277
|
+
await page.waitForTimeout(action.wait);
|
|
1278
|
+
}
|
|
1279
|
+
await settle(page);
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
const screenshot = async (page, path) => {
|
|
1283
|
+
if (task.selector) {
|
|
1284
|
+
const el = page.locator(task.selector).first();
|
|
1285
|
+
await el.screenshot({ path });
|
|
1286
|
+
} else {
|
|
1287
|
+
await page.screenshot({ path, fullPage: true });
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
await Promise.all([
|
|
1291
|
+
beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
|
|
1292
|
+
afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
|
|
1293
|
+
]);
|
|
1294
|
+
results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
|
|
1295
|
+
if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
|
|
1296
|
+
const changedComponents = Array.from(
|
|
1297
|
+
new Set(task.changedComponents.map(normalizePath))
|
|
1298
|
+
);
|
|
1299
|
+
const [afterInstances, beforeInstances] = await Promise.all([
|
|
1300
|
+
tagChangedComponentInstances(afterPage, changedComponents),
|
|
1301
|
+
tagChangedComponentInstances(beforePage, changedComponents)
|
|
1302
|
+
]);
|
|
1303
|
+
const afterBySource = groupBySource(afterInstances);
|
|
1304
|
+
const beforeBySource = groupBySource(beforeInstances);
|
|
1305
|
+
for (const source of changedComponents) {
|
|
1306
|
+
const afterList = afterBySource.get(source) ?? [];
|
|
1307
|
+
const beforeList = beforeBySource.get(source) ?? [];
|
|
1308
|
+
const pairCount = Math.min(afterList.length, beforeList.length);
|
|
1309
|
+
if (pairCount === 0) continue;
|
|
1310
|
+
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1311
|
+
const afterInstance = afterList[pairIndex];
|
|
1312
|
+
const beforeInstance = beforeList[pairIndex];
|
|
1313
|
+
const sourceSlug = sanitizeComponentLabel(source) || "component";
|
|
1314
|
+
const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
|
|
1315
|
+
const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
|
|
1316
|
+
const baseLabel = `${task.label} [${componentName} #${pairIndex + 1}]`;
|
|
1317
|
+
const contextPrefix = `${task.prefix}~cmp.${itemSlug}~context`;
|
|
1318
|
+
results.push({
|
|
1319
|
+
route: `${baseLabel} [context]`,
|
|
1320
|
+
prefix: contextPrefix,
|
|
1321
|
+
beforePath,
|
|
1322
|
+
afterPath
|
|
1323
|
+
});
|
|
1324
|
+
const parentPrefix = `${task.prefix}~cmp.${itemSlug}~parent`;
|
|
1325
|
+
const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
|
|
1326
|
+
const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
|
|
1327
|
+
const [parentBeforeOk, parentAfterOk] = await Promise.all([
|
|
1328
|
+
captureByAttr(
|
|
1329
|
+
beforePage,
|
|
1330
|
+
"data-ab-parent-key",
|
|
1331
|
+
beforeInstance.parentKey,
|
|
1332
|
+
parentBeforePath
|
|
1333
|
+
),
|
|
1334
|
+
captureByAttr(
|
|
1335
|
+
afterPage,
|
|
1336
|
+
"data-ab-parent-key",
|
|
1337
|
+
afterInstance.parentKey,
|
|
1338
|
+
parentAfterPath
|
|
1339
|
+
)
|
|
1340
|
+
]);
|
|
1341
|
+
if (parentBeforeOk && parentAfterOk) {
|
|
1342
|
+
results.push({
|
|
1343
|
+
route: `${baseLabel} [parent]`,
|
|
1344
|
+
prefix: parentPrefix,
|
|
1345
|
+
beforePath: parentBeforePath,
|
|
1346
|
+
afterPath: parentAfterPath
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
const componentPrefix = `${task.prefix}~cmp.${itemSlug}~component`;
|
|
1350
|
+
const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
|
|
1351
|
+
const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
|
|
1352
|
+
const [componentBeforeOk, componentAfterOk] = await Promise.all([
|
|
1353
|
+
captureByAttr(
|
|
1354
|
+
beforePage,
|
|
1355
|
+
"data-ab-comp-key",
|
|
1356
|
+
beforeInstance.componentKey,
|
|
1357
|
+
componentBeforePath
|
|
1358
|
+
),
|
|
1359
|
+
captureByAttr(
|
|
1360
|
+
afterPage,
|
|
1361
|
+
"data-ab-comp-key",
|
|
1362
|
+
afterInstance.componentKey,
|
|
1363
|
+
componentAfterPath
|
|
1364
|
+
)
|
|
1365
|
+
]);
|
|
1366
|
+
if (componentBeforeOk && componentAfterOk) {
|
|
1367
|
+
results.push({
|
|
1368
|
+
route: `${baseLabel} [component]`,
|
|
1369
|
+
prefix: componentPrefix,
|
|
1370
|
+
beforePath: componentBeforePath,
|
|
1371
|
+
afterPath: componentAfterPath
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
|
|
1378
|
+
await captureAutoSections(
|
|
1379
|
+
afterPage,
|
|
1380
|
+
beforePage,
|
|
1381
|
+
task.prefix,
|
|
1382
|
+
task.label,
|
|
1383
|
+
outputDir,
|
|
1384
|
+
options,
|
|
1385
|
+
settle,
|
|
1386
|
+
results
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
|
|
1390
|
+
const tabs = await detectTabs(afterPage, options.maxTabsPerRoute);
|
|
1391
|
+
const usedPrefixes = /* @__PURE__ */ new Set();
|
|
1392
|
+
for (const tab of tabs) {
|
|
1393
|
+
let slug = sanitizeTabLabel(tab.label);
|
|
1394
|
+
if (!slug) continue;
|
|
1395
|
+
if (usedPrefixes.has(slug)) {
|
|
1396
|
+
let suffix = 2;
|
|
1397
|
+
while (usedPrefixes.has(`${slug}-${suffix}`)) suffix++;
|
|
1398
|
+
slug = `${slug}-${suffix}`;
|
|
1399
|
+
}
|
|
1400
|
+
usedPrefixes.add(slug);
|
|
1401
|
+
const tabPrefix = `${task.prefix}~${slug}`;
|
|
1402
|
+
const tabLabel = `${task.label} [${tab.label}]`;
|
|
1403
|
+
const tabBeforePath = join6(outputDir, `${tabPrefix}-before.png`);
|
|
1404
|
+
const tabAfterPath = join6(outputDir, `${tabPrefix}-after.png`);
|
|
1405
|
+
try {
|
|
1406
|
+
const afterUrlBefore = afterPage.url();
|
|
1407
|
+
await afterPage.getByRole("tab", { name: tab.label }).first().click();
|
|
1408
|
+
await settle(afterPage);
|
|
1409
|
+
if (afterPage.url() !== afterUrlBefore) {
|
|
1410
|
+
await afterPage.goBack({ waitUntil: "networkidle" });
|
|
1411
|
+
await settle(afterPage);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
await afterPage.screenshot({ path: tabAfterPath, fullPage: true });
|
|
1415
|
+
try {
|
|
1416
|
+
const beforeUrlBefore = beforePage.url();
|
|
1417
|
+
await beforePage.getByRole("tab", { name: tab.label }).first().click({ timeout: 2e3 });
|
|
1418
|
+
await settle(beforePage);
|
|
1419
|
+
if (beforePage.url() !== beforeUrlBefore) {
|
|
1420
|
+
await beforePage.goBack({ waitUntil: "networkidle" });
|
|
1421
|
+
await settle(beforePage);
|
|
1422
|
+
await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
|
|
1423
|
+
} else {
|
|
1424
|
+
await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
|
|
1425
|
+
}
|
|
1426
|
+
} catch {
|
|
1427
|
+
await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
|
|
1428
|
+
}
|
|
1429
|
+
results.push({
|
|
1430
|
+
route: tabLabel,
|
|
1431
|
+
prefix: tabPrefix,
|
|
1432
|
+
beforePath: tabBeforePath,
|
|
1433
|
+
afterPath: tabAfterPath
|
|
1434
|
+
});
|
|
1435
|
+
if (options.autoSections && !task.skipAutoSections) {
|
|
1436
|
+
await captureAutoSections(
|
|
1437
|
+
afterPage,
|
|
1438
|
+
beforePage,
|
|
1439
|
+
tabPrefix,
|
|
1440
|
+
tabLabel,
|
|
1441
|
+
outputDir,
|
|
1442
|
+
options,
|
|
1443
|
+
settle,
|
|
1444
|
+
results
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
} catch {
|
|
1448
|
+
logger.dim(` Skipped tab "${tab.label}" on ${task.route}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
if (tabs.length > 0) {
|
|
1452
|
+
await Promise.all([
|
|
1453
|
+
beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }),
|
|
1454
|
+
afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" })
|
|
1455
|
+
]);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
await Promise.all([beforeCtx.close(), afterCtx.close()]);
|
|
629
1460
|
} finally {
|
|
630
|
-
|
|
1461
|
+
if (ownsBrowser) {
|
|
1462
|
+
await browser.close();
|
|
1463
|
+
}
|
|
631
1464
|
}
|
|
632
1465
|
return results;
|
|
633
1466
|
}
|
|
634
1467
|
|
|
635
1468
|
// src/stages/compare.ts
|
|
636
|
-
import { readFileSync as
|
|
1469
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
637
1470
|
import { writeFileSync } from "fs";
|
|
638
|
-
import { join as
|
|
1471
|
+
import { join as join7, dirname as dirname2 } from "path";
|
|
639
1472
|
import { PNG } from "pngjs";
|
|
640
1473
|
import pixelmatch from "pixelmatch";
|
|
641
|
-
import sharp from "sharp";
|
|
642
|
-
function readPng(filePath) {
|
|
643
|
-
const buffer = readFileSync3(filePath);
|
|
644
|
-
return PNG.sync.read(buffer);
|
|
645
|
-
}
|
|
646
1474
|
function normalizeDimensions(img1, img2) {
|
|
647
1475
|
const width = Math.max(img1.width, img2.width);
|
|
648
1476
|
const height = Math.max(img1.height, img2.height);
|
|
@@ -660,135 +1488,106 @@ function normalizeDimensions(img1, img2) {
|
|
|
660
1488
|
};
|
|
661
1489
|
return [pad(img1), pad(img2)];
|
|
662
1490
|
}
|
|
663
|
-
async function
|
|
664
|
-
const
|
|
665
|
-
const
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
);
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const beforeWithLabel = await sharp({
|
|
688
|
-
create: {
|
|
689
|
-
width: bw,
|
|
690
|
-
height: labelHeight + bh,
|
|
691
|
-
channels: 4,
|
|
692
|
-
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
693
|
-
}
|
|
694
|
-
}).composite([
|
|
695
|
-
{ input: beforeLabel, top: 0, left: 0 },
|
|
696
|
-
{ input: await sharp(beforePath).png().toBuffer(), top: labelHeight, left: 0 }
|
|
697
|
-
]).png().toBuffer();
|
|
698
|
-
const afterWithLabel = await sharp({
|
|
699
|
-
create: {
|
|
700
|
-
width: aw,
|
|
701
|
-
height: labelHeight + ah,
|
|
702
|
-
channels: 4,
|
|
703
|
-
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
704
|
-
}
|
|
705
|
-
}).composite([
|
|
706
|
-
{ input: afterLabel, top: 0, left: 0 },
|
|
707
|
-
{ input: await sharp(afterPath).png().toBuffer(), top: labelHeight, left: 0 }
|
|
708
|
-
]).png().toBuffer();
|
|
709
|
-
await sharp({
|
|
710
|
-
create: {
|
|
711
|
-
width: totalWidth,
|
|
712
|
-
height: totalHeight,
|
|
713
|
-
channels: 4,
|
|
714
|
-
background: { r: 229, g: 231, b: 235, alpha: 1 }
|
|
715
|
-
}
|
|
716
|
-
}).composite([
|
|
717
|
-
{ input: beforeWithLabel, top: 0, left: 0 },
|
|
718
|
-
{ input: afterWithLabel, top: 0, left: bw + gap }
|
|
719
|
-
]).png().toFile(outputPath);
|
|
1491
|
+
async function generateComposite(beforePath, afterPath, outputPath, browser, bgColor) {
|
|
1492
|
+
const beforeUri = `data:image/png;base64,${readFileSync5(beforePath).toString("base64")}`;
|
|
1493
|
+
const afterUri = `data:image/png;base64,${readFileSync5(afterPath).toString("base64")}`;
|
|
1494
|
+
const page = await browser.newPage({
|
|
1495
|
+
viewport: { width: 2400, height: 1600 },
|
|
1496
|
+
deviceScaleFactor: 1
|
|
1497
|
+
});
|
|
1498
|
+
const html = `<!DOCTYPE html>
|
|
1499
|
+
<html><head><style>
|
|
1500
|
+
* { margin: 0; box-sizing: border-box; }
|
|
1501
|
+
body { background: ${bgColor}; display: flex; justify-content: center; align-items: center; width: 2400px; height: 1600px; padding: 120px; gap: 80px; overflow: hidden; }
|
|
1502
|
+
.col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; max-height: 100%; }
|
|
1503
|
+
img { width: 100%; max-height: 1280px; object-fit: contain; }
|
|
1504
|
+
.label { margin-top: 40px; font: 500 30px/1 system-ui, sans-serif; flex-shrink: 0; }
|
|
1505
|
+
.before { color: #888; }
|
|
1506
|
+
.after { color: #22c55e; }
|
|
1507
|
+
</style></head><body>
|
|
1508
|
+
<div class="col"><img src="${beforeUri}"><div class="label before">Before</div></div>
|
|
1509
|
+
<div class="col"><img src="${afterUri}"><div class="label after">After</div></div>
|
|
1510
|
+
</body></html>`;
|
|
1511
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
1512
|
+
await page.waitForTimeout(200);
|
|
1513
|
+
await page.screenshot({ path: outputPath });
|
|
1514
|
+
await page.close();
|
|
720
1515
|
}
|
|
721
|
-
async function
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1516
|
+
async function compareOne(capture, outputDir, threshold, options) {
|
|
1517
|
+
const dir = dirname2(capture.beforePath);
|
|
1518
|
+
const diffPath = join7(dir, `${capture.prefix}-diff.png`);
|
|
1519
|
+
const comparePath = join7(dir, `${capture.prefix}-compare.png`);
|
|
1520
|
+
const sliderPath = join7(dir, `${capture.prefix}-slider.html`);
|
|
1521
|
+
const beforeBuffer = readFileSync5(capture.beforePath);
|
|
1522
|
+
const afterBuffer = readFileSync5(capture.afterPath);
|
|
1523
|
+
const beforeImg = PNG.sync.read(beforeBuffer);
|
|
1524
|
+
const afterImg = PNG.sync.read(afterBuffer);
|
|
1525
|
+
const [normBefore, normAfter] = normalizeDimensions(beforeImg, afterImg);
|
|
1526
|
+
const { width, height } = normBefore;
|
|
1527
|
+
const totalPixels = width * height;
|
|
1528
|
+
const diffImg = new PNG({ width, height });
|
|
1529
|
+
const diffPixels = pixelmatch(
|
|
1530
|
+
normBefore.data,
|
|
1531
|
+
normAfter.data,
|
|
1532
|
+
diffImg.data,
|
|
1533
|
+
width,
|
|
1534
|
+
height,
|
|
1535
|
+
{ threshold: 0.1 }
|
|
1536
|
+
);
|
|
1537
|
+
const diffPercentage = diffPixels / totalPixels * 100;
|
|
1538
|
+
const changed = diffPercentage > threshold;
|
|
1539
|
+
writeFileSync(diffPath, PNG.sync.write(diffImg));
|
|
1540
|
+
if (changed) {
|
|
1541
|
+
await generateComposite(
|
|
746
1542
|
capture.beforePath,
|
|
747
1543
|
capture.afterPath,
|
|
748
|
-
|
|
1544
|
+
comparePath,
|
|
1545
|
+
options.browser,
|
|
1546
|
+
options.bgColor
|
|
749
1547
|
);
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
changed
|
|
770
|
-
});
|
|
1548
|
+
}
|
|
1549
|
+
return {
|
|
1550
|
+
route: capture.route,
|
|
1551
|
+
prefix: capture.prefix,
|
|
1552
|
+
beforePath: capture.beforePath,
|
|
1553
|
+
afterPath: capture.afterPath,
|
|
1554
|
+
diffPath,
|
|
1555
|
+
comparePath,
|
|
1556
|
+
sliderPath,
|
|
1557
|
+
diffPixels,
|
|
1558
|
+
totalPixels,
|
|
1559
|
+
diffPercentage,
|
|
1560
|
+
changed
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
async function compareScreenshots(captures, outputDir, threshold = 0.1, options) {
|
|
1564
|
+
const results = [];
|
|
1565
|
+
for (const capture of captures) {
|
|
1566
|
+
results.push(await compareOne(capture, outputDir, threshold, options));
|
|
771
1567
|
}
|
|
772
1568
|
return results;
|
|
773
1569
|
}
|
|
774
1570
|
|
|
775
1571
|
// src/stages/report.ts
|
|
776
1572
|
import { writeFileSync as writeFileSync2 } from "fs";
|
|
777
|
-
import { join as
|
|
778
|
-
import { execSync as
|
|
1573
|
+
import { join as join8 } from "path";
|
|
1574
|
+
import { execSync as execSync4 } from "child_process";
|
|
779
1575
|
|
|
780
1576
|
// src/templates/report.html.ts
|
|
781
|
-
import { readFileSync as
|
|
1577
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
1578
|
+
import { relative as relative2 } from "path";
|
|
782
1579
|
function toBase64(filePath) {
|
|
783
|
-
return
|
|
1580
|
+
return readFileSync6(filePath).toString("base64");
|
|
784
1581
|
}
|
|
785
1582
|
function imgSrc(filePath) {
|
|
786
1583
|
return `data:image/png;base64,${toBase64(filePath)}`;
|
|
787
1584
|
}
|
|
788
|
-
function generateReportHtml(results) {
|
|
1585
|
+
function generateReportHtml(results, outputDir) {
|
|
789
1586
|
const changed = results.filter((r) => r.changed);
|
|
790
1587
|
const unchanged = results.filter((r) => !r.changed);
|
|
791
|
-
const card = (r) =>
|
|
1588
|
+
const card = (r) => {
|
|
1589
|
+
const sliderHref = outputDir ? relative2(outputDir, r.sliderPath) : r.sliderPath;
|
|
1590
|
+
return `
|
|
792
1591
|
<div class="card ${r.changed ? "changed" : "unchanged"}">
|
|
793
1592
|
<div class="card-header">
|
|
794
1593
|
<span class="route">${r.route}</span>
|
|
@@ -810,8 +1609,9 @@ function generateReportHtml(results) {
|
|
|
810
1609
|
<img src="${imgSrc(r.diffPath)}" alt="Diff" />
|
|
811
1610
|
</div>
|
|
812
1611
|
</div>
|
|
813
|
-
${r.changed ? `<a class="slider-link" href="${
|
|
1612
|
+
${r.changed ? `<a class="slider-link" href="${sliderHref}">Open slider view</a>` : ""}
|
|
814
1613
|
</div>`;
|
|
1614
|
+
};
|
|
815
1615
|
return `<!DOCTYPE html>
|
|
816
1616
|
<html lang="en">
|
|
817
1617
|
<head>
|
|
@@ -852,9 +1652,9 @@ ${unchanged.length > 0 ? `<h2 class="section-title">Unchanged (${unchanged.lengt
|
|
|
852
1652
|
}
|
|
853
1653
|
|
|
854
1654
|
// src/templates/slider.html.ts
|
|
855
|
-
import { readFileSync as
|
|
1655
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
856
1656
|
function toBase642(filePath) {
|
|
857
|
-
return
|
|
1657
|
+
return readFileSync7(filePath).toString("base64");
|
|
858
1658
|
}
|
|
859
1659
|
function imgSrc2(filePath) {
|
|
860
1660
|
return `data:image/png;base64,${toBase642(filePath)}`;
|
|
@@ -929,7 +1729,8 @@ function generateSliderHtml(result) {
|
|
|
929
1729
|
}
|
|
930
1730
|
|
|
931
1731
|
// src/templates/summary.md.ts
|
|
932
|
-
function generateSummaryMd(results) {
|
|
1732
|
+
function generateSummaryMd(results, gitDiff, options) {
|
|
1733
|
+
const includeFilePaths = options?.includeFilePaths ?? true;
|
|
933
1734
|
const changed = results.filter((r) => r.changed);
|
|
934
1735
|
const lines = [];
|
|
935
1736
|
lines.push("<!-- afterbefore -->");
|
|
@@ -953,6 +1754,24 @@ function generateSummaryMd(results) {
|
|
|
953
1754
|
const pct = r.changed ? `${r.diffPercentage.toFixed(2)}%` : "0%";
|
|
954
1755
|
lines.push(`| \`${r.route}\` | ${pct} | ${status} |`);
|
|
955
1756
|
}
|
|
1757
|
+
if (gitDiff) {
|
|
1758
|
+
lines.push("");
|
|
1759
|
+
lines.push("### Changed files");
|
|
1760
|
+
lines.push("```diff");
|
|
1761
|
+
lines.push(gitDiff);
|
|
1762
|
+
lines.push("```");
|
|
1763
|
+
}
|
|
1764
|
+
if (includeFilePaths) {
|
|
1765
|
+
lines.push("");
|
|
1766
|
+
lines.push("### Screenshots");
|
|
1767
|
+
lines.push("| Route | Before | After |");
|
|
1768
|
+
lines.push("|-------|--------|-------|");
|
|
1769
|
+
for (const r of changed) {
|
|
1770
|
+
lines.push(`| \`${r.route}\` | \`${r.beforePath}\` | \`${r.afterPath}\` |`);
|
|
1771
|
+
}
|
|
1772
|
+
lines.push("");
|
|
1773
|
+
lines.push("Review the before/after screenshots above to verify the visual changes match the code diff.");
|
|
1774
|
+
}
|
|
956
1775
|
return lines.join("\n");
|
|
957
1776
|
}
|
|
958
1777
|
|
|
@@ -960,7 +1779,7 @@ function generateSummaryMd(results) {
|
|
|
960
1779
|
var COMMENT_MARKER = "<!-- afterbefore -->";
|
|
961
1780
|
function findPrNumber() {
|
|
962
1781
|
try {
|
|
963
|
-
const output =
|
|
1782
|
+
const output = execSync4("gh pr view --json number -q .number", {
|
|
964
1783
|
encoding: "utf-8",
|
|
965
1784
|
stdio: ["pipe", "pipe", "pipe"]
|
|
966
1785
|
}).trim();
|
|
@@ -971,7 +1790,7 @@ function findPrNumber() {
|
|
|
971
1790
|
}
|
|
972
1791
|
function findExistingCommentId(prNumber) {
|
|
973
1792
|
try {
|
|
974
|
-
const output =
|
|
1793
|
+
const output = execSync4(
|
|
975
1794
|
`gh api repos/{owner}/{repo}/issues/${prNumber}/comments --jq '.[] | select(.body | contains("${COMMENT_MARKER}")) | .id'`,
|
|
976
1795
|
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
977
1796
|
).trim();
|
|
@@ -985,7 +1804,7 @@ function postOrUpdateComment(prNumber, body) {
|
|
|
985
1804
|
const existingId = findExistingCommentId(prNumber);
|
|
986
1805
|
if (existingId) {
|
|
987
1806
|
logger.info(`Updating existing PR comment (id: ${existingId})`);
|
|
988
|
-
|
|
1807
|
+
execSync4(
|
|
989
1808
|
`gh api repos/{owner}/{repo}/issues/comments/${existingId} -X PATCH -f body=@-`,
|
|
990
1809
|
{
|
|
991
1810
|
input: body,
|
|
@@ -995,7 +1814,7 @@ function postOrUpdateComment(prNumber, body) {
|
|
|
995
1814
|
);
|
|
996
1815
|
} else {
|
|
997
1816
|
logger.info(`Creating new PR comment`);
|
|
998
|
-
|
|
1817
|
+
execSync4(
|
|
999
1818
|
`gh api repos/{owner}/{repo}/issues/${prNumber}/comments -f body=@-`,
|
|
1000
1819
|
{
|
|
1001
1820
|
input: body,
|
|
@@ -1008,11 +1827,11 @@ function postOrUpdateComment(prNumber, body) {
|
|
|
1008
1827
|
async function generateReport(results, outputDir, options) {
|
|
1009
1828
|
await ensureDir(outputDir);
|
|
1010
1829
|
const summaryMd = generateSummaryMd(results);
|
|
1011
|
-
const summaryPath =
|
|
1830
|
+
const summaryPath = join8(outputDir, "summary.md");
|
|
1012
1831
|
writeFileSync2(summaryPath, summaryMd, "utf-8");
|
|
1013
1832
|
logger.success(`Written summary to ${summaryPath}`);
|
|
1014
|
-
const reportHtml = generateReportHtml(results);
|
|
1015
|
-
const indexPath =
|
|
1833
|
+
const reportHtml = generateReportHtml(results, outputDir);
|
|
1834
|
+
const indexPath = join8(outputDir, "index.html");
|
|
1016
1835
|
writeFileSync2(indexPath, reportHtml, "utf-8");
|
|
1017
1836
|
logger.success(`Written report to ${indexPath}`);
|
|
1018
1837
|
for (const result of results) {
|
|
@@ -1029,80 +1848,234 @@ async function generateReport(results, outputDir, options) {
|
|
|
1029
1848
|
);
|
|
1030
1849
|
return;
|
|
1031
1850
|
}
|
|
1032
|
-
|
|
1851
|
+
const prSummary = generateSummaryMd(results, void 0, { includeFilePaths: false });
|
|
1852
|
+
postOrUpdateComment(prNumber, prSummary);
|
|
1033
1853
|
logger.success(`Posted results to PR #${prNumber}`);
|
|
1034
1854
|
}
|
|
1035
1855
|
}
|
|
1036
1856
|
|
|
1037
1857
|
// src/pipeline.ts
|
|
1858
|
+
function formatTriggerChain(chain) {
|
|
1859
|
+
if (chain.length <= 1) return chain[0] ?? "";
|
|
1860
|
+
return chain.join(" \u2192 ");
|
|
1861
|
+
}
|
|
1862
|
+
function generateSessionName(cwd) {
|
|
1863
|
+
const branch = getCurrentBranch(cwd);
|
|
1864
|
+
const name = branch.replace(/^(feat|fix|perf|chore|refactor|docs|style|test|ci|build)\//, "");
|
|
1865
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1866
|
+
return `${name}_${date}`;
|
|
1867
|
+
}
|
|
1868
|
+
function routeToPrefix(route) {
|
|
1869
|
+
if (route === "/") return "_root";
|
|
1870
|
+
return route.replace(/^\//, "").replace(/\//g, "-");
|
|
1871
|
+
}
|
|
1872
|
+
var ROUTE_IMPACT_MAX_DEPTH = 3;
|
|
1873
|
+
function normalizePath2(filePath) {
|
|
1874
|
+
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1875
|
+
}
|
|
1876
|
+
function findRoutesForChangedFile(changedFile, graph) {
|
|
1877
|
+
const routes = /* @__PURE__ */ new Set();
|
|
1878
|
+
const start = normalizePath2(changedFile);
|
|
1879
|
+
const queue = [{ file: start, depth: 0 }];
|
|
1880
|
+
const visited = /* @__PURE__ */ new Set([start]);
|
|
1881
|
+
while (queue.length > 0) {
|
|
1882
|
+
const { file, depth } = queue.shift();
|
|
1883
|
+
if (isPageFile(file)) {
|
|
1884
|
+
const route = pagePathToRoute(file);
|
|
1885
|
+
if (route) routes.add(route);
|
|
1886
|
+
}
|
|
1887
|
+
if (depth >= ROUTE_IMPACT_MAX_DEPTH) continue;
|
|
1888
|
+
const importers = graph.reverse.get(file);
|
|
1889
|
+
if (!importers) continue;
|
|
1890
|
+
for (const importer of importers) {
|
|
1891
|
+
if (visited.has(importer)) continue;
|
|
1892
|
+
visited.add(importer);
|
|
1893
|
+
queue.push({ file: importer, depth: depth + 1 });
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return Array.from(routes);
|
|
1897
|
+
}
|
|
1898
|
+
function mapRouteToChangedComponents(changedComponentFiles, graph) {
|
|
1899
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
1900
|
+
for (const componentPath of changedComponentFiles) {
|
|
1901
|
+
const routes = findRoutesForChangedFile(componentPath, graph);
|
|
1902
|
+
for (const route of routes) {
|
|
1903
|
+
const next = routeMap.get(route) ?? [];
|
|
1904
|
+
next.push(componentPath);
|
|
1905
|
+
routeMap.set(route, next);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
for (const [route, files] of routeMap.entries()) {
|
|
1909
|
+
routeMap.set(route, Array.from(new Set(files)).sort((a, b) => a.localeCompare(b)));
|
|
1910
|
+
}
|
|
1911
|
+
return routeMap;
|
|
1912
|
+
}
|
|
1913
|
+
function expandRoutes(routes, config, routeComponentMap) {
|
|
1914
|
+
const tasks = [];
|
|
1915
|
+
for (const r of routes) {
|
|
1916
|
+
const scenarios = config?.scenarios?.[r.route] ?? [];
|
|
1917
|
+
const changedComponents = routeComponentMap.get(r.route) ?? [];
|
|
1918
|
+
tasks.push({
|
|
1919
|
+
route: r.route,
|
|
1920
|
+
label: r.route,
|
|
1921
|
+
prefix: routeToPrefix(r.route),
|
|
1922
|
+
skipAutoTabs: scenarios.length > 0,
|
|
1923
|
+
skipAutoSections: scenarios.length > 0,
|
|
1924
|
+
changedComponents
|
|
1925
|
+
});
|
|
1926
|
+
for (const s of scenarios) {
|
|
1927
|
+
tasks.push({
|
|
1928
|
+
route: r.route,
|
|
1929
|
+
label: `${r.route} [${s.name}]`,
|
|
1930
|
+
prefix: `${routeToPrefix(r.route)}~${s.name}`,
|
|
1931
|
+
actions: s.actions,
|
|
1932
|
+
selector: s.selector
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return tasks;
|
|
1937
|
+
}
|
|
1938
|
+
function openInBrowser(filePath) {
|
|
1939
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1940
|
+
exec(`${cmd} "${filePath}"`);
|
|
1941
|
+
}
|
|
1038
1942
|
async function runPipeline(options) {
|
|
1039
1943
|
const { base, output, post, cwd } = options;
|
|
1040
|
-
const
|
|
1944
|
+
const sessionName = generateSessionName(cwd);
|
|
1945
|
+
const outputDir = resolve4(cwd, output, sessionName);
|
|
1946
|
+
const startTime = Date.now();
|
|
1041
1947
|
try {
|
|
1042
|
-
const
|
|
1948
|
+
const version = true ? "0.1.2" : "dev";
|
|
1949
|
+
console.log(`
|
|
1950
|
+
afterbefore v${version} \xB7 Comparing against ${base}
|
|
1951
|
+
`);
|
|
1952
|
+
const config = await loadConfig(cwd);
|
|
1953
|
+
logger.startPipeline(8);
|
|
1954
|
+
logger.pipeline(1, "Analyzing diff...");
|
|
1043
1955
|
const diffFiles = getChangedFiles(base, cwd);
|
|
1044
|
-
|
|
1956
|
+
const gitDiff = getGitDiff(base, cwd);
|
|
1045
1957
|
if (diffFiles.length === 0) {
|
|
1958
|
+
logger.completePipeline();
|
|
1046
1959
|
logger.success("No changed files detected. Nothing to do.");
|
|
1047
1960
|
return;
|
|
1048
1961
|
}
|
|
1049
|
-
logger.info(`Found ${diffFiles.length} changed file(s)`);
|
|
1050
1962
|
const classified = classifyFiles(diffFiles);
|
|
1051
1963
|
const visualFiles = filterVisuallyRelevant(classified);
|
|
1052
1964
|
const impactfulFiles = classified.filter(
|
|
1053
1965
|
(f) => f.category !== "test" && f.category !== "other"
|
|
1054
1966
|
);
|
|
1055
1967
|
if (impactfulFiles.length === 0) {
|
|
1968
|
+
logger.completePipeline();
|
|
1056
1969
|
logger.success(
|
|
1057
1970
|
"No visually relevant changes detected (only test/other files changed)."
|
|
1058
1971
|
);
|
|
1059
1972
|
return;
|
|
1060
1973
|
}
|
|
1061
|
-
logger.
|
|
1062
|
-
|
|
1063
|
-
);
|
|
1064
|
-
const graphSpinner = logger.spin("Building import graph...");
|
|
1974
|
+
logger.pipeline(2, "Building import graph...");
|
|
1975
|
+
const worktreePromise = createWorktree(base, cwd);
|
|
1065
1976
|
const graph = await buildImportGraph(cwd);
|
|
1066
|
-
|
|
1977
|
+
logger.pipeline(3, "Finding affected routes...");
|
|
1067
1978
|
const changedPaths = impactfulFiles.map((f) => f.path);
|
|
1068
|
-
|
|
1979
|
+
let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes);
|
|
1980
|
+
const changedComponentFiles = impactfulFiles.filter((f) => f.category === "component").map((f) => f.path);
|
|
1981
|
+
const routeComponentMap = mapRouteToChangedComponents(changedComponentFiles, graph);
|
|
1982
|
+
if (affectedRoutes.length === 0) {
|
|
1983
|
+
const hasGlobalChanges = impactfulFiles.some((f) => isGlobalVisualFile(f.path));
|
|
1984
|
+
if (hasGlobalChanges) {
|
|
1985
|
+
const allRoutes = [];
|
|
1986
|
+
for (const file of graph.forward.keys()) {
|
|
1987
|
+
if (!isPageFile(file)) continue;
|
|
1988
|
+
const route = pagePathToRoute(file);
|
|
1989
|
+
if (route === null) continue;
|
|
1990
|
+
allRoutes.push({
|
|
1991
|
+
pagePath: file,
|
|
1992
|
+
route,
|
|
1993
|
+
reason: "transitive",
|
|
1994
|
+
depth: 0,
|
|
1995
|
+
triggerChain: [file]
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
allRoutes.sort((a, b) => a.route.localeCompare(b.route));
|
|
1999
|
+
if (options.maxRoutes > 0 && allRoutes.length > options.maxRoutes) {
|
|
2000
|
+
affectedRoutes = allRoutes.slice(0, options.maxRoutes);
|
|
2001
|
+
} else {
|
|
2002
|
+
affectedRoutes = allRoutes;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
1069
2006
|
if (affectedRoutes.length === 0) {
|
|
2007
|
+
worktreePromise.then((w) => w.cleanup()).catch(() => {
|
|
2008
|
+
});
|
|
2009
|
+
logger.completePipeline();
|
|
1070
2010
|
logger.success(
|
|
1071
2011
|
"No affected routes found. Changed files don't impact any pages."
|
|
1072
2012
|
);
|
|
1073
2013
|
return;
|
|
1074
2014
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
2015
|
+
console.log();
|
|
2016
|
+
for (const r of affectedRoutes) {
|
|
2017
|
+
const chain = formatTriggerChain(r.triggerChain);
|
|
2018
|
+
const label = r.reason === "direct" ? "(direct)" : `\u2190 ${chain}`;
|
|
2019
|
+
console.log(chalk2.dim(` ${r.route} ${label}`));
|
|
2020
|
+
}
|
|
2021
|
+
console.log();
|
|
2022
|
+
logger.pipeline(4, "Setting up worktree...");
|
|
2023
|
+
const worktree = await worktreePromise;
|
|
2024
|
+
logger.pipeline(5, "Starting servers...");
|
|
1078
2025
|
await ensureDir(outputDir);
|
|
1079
|
-
const worktree = await createWorktree(base, cwd);
|
|
1080
2026
|
const beforePort = await findAvailablePort();
|
|
1081
2027
|
const afterPort = await findAvailablePort(/* @__PURE__ */ new Set([beforePort]));
|
|
1082
|
-
const beforeServer = await
|
|
2028
|
+
const [beforeServer, afterServer, browser] = await Promise.all([
|
|
2029
|
+
startServer(worktree.path, beforePort),
|
|
2030
|
+
startServer(cwd, afterPort),
|
|
2031
|
+
launchBrowser()
|
|
2032
|
+
]);
|
|
1083
2033
|
cleanupRegistry.register(() => stopServer(beforeServer));
|
|
1084
|
-
const afterServer = await startServer(cwd, afterPort);
|
|
1085
2034
|
cleanupRegistry.register(() => stopServer(afterServer));
|
|
1086
|
-
|
|
2035
|
+
cleanupRegistry.register(() => browser.close());
|
|
2036
|
+
logger.pipeline(6, "Capturing screenshots...");
|
|
2037
|
+
const tasks = expandRoutes(affectedRoutes, config, routeComponentMap);
|
|
1087
2038
|
const captures = await captureRoutes(
|
|
1088
|
-
|
|
2039
|
+
tasks,
|
|
1089
2040
|
beforeServer.url,
|
|
1090
2041
|
afterServer.url,
|
|
1091
|
-
outputDir
|
|
2042
|
+
outputDir,
|
|
2043
|
+
{
|
|
2044
|
+
browser,
|
|
2045
|
+
width: options.width,
|
|
2046
|
+
height: options.height,
|
|
2047
|
+
device: options.device,
|
|
2048
|
+
delay: options.delay,
|
|
2049
|
+
autoTabs: options.autoTabs,
|
|
2050
|
+
maxTabsPerRoute: options.maxTabsPerRoute,
|
|
2051
|
+
autoSections: options.autoSections,
|
|
2052
|
+
maxSectionsPerRoute: options.maxSectionsPerRoute,
|
|
2053
|
+
onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
|
|
2054
|
+
}
|
|
1092
2055
|
);
|
|
1093
|
-
|
|
2056
|
+
logger.pipeline(7, "Comparing screenshots...");
|
|
2057
|
+
const bgColor = detectBgColor(cwd);
|
|
2058
|
+
const results = await compareScreenshots(captures, outputDir, options.threshold, { browser, bgColor });
|
|
2059
|
+
logger.pipeline(8, "Generating report...");
|
|
1094
2060
|
await generateReport(results, outputDir, { post });
|
|
2061
|
+
const summary = generateSummaryMd(results, gitDiff);
|
|
2062
|
+
logger.completePipeline(true);
|
|
2063
|
+
console.log("\n" + summary);
|
|
2064
|
+
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
1095
2065
|
const changedCount = results.filter((r) => r.changed).length;
|
|
1096
2066
|
logger.success(
|
|
1097
|
-
`Done
|
|
2067
|
+
`Done in ${elapsed}s \u2014 ${results.length} route(s) captured, ${changedCount} with visual changes`
|
|
1098
2068
|
);
|
|
1099
|
-
logger.
|
|
1100
|
-
|
|
2069
|
+
logger.dim(` Report: ${outputDir}/index.html`);
|
|
2070
|
+
if (options.open) {
|
|
2071
|
+
openInBrowser(resolve4(outputDir, "index.html"));
|
|
2072
|
+
}
|
|
1101
2073
|
} finally {
|
|
1102
2074
|
await cleanupRegistry.runAll();
|
|
1103
2075
|
}
|
|
1104
2076
|
}
|
|
1105
2077
|
export {
|
|
2078
|
+
loadConfig,
|
|
1106
2079
|
runPipeline
|
|
1107
2080
|
};
|
|
1108
2081
|
//# sourceMappingURL=index.js.map
|