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