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/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.clearSpinner();
13
- console.log(chalk.blue("\u2139"), message);
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.clearSpinner();
17
- console.log(chalk.green("\u2714"), message);
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.clearSpinner();
21
- console.log(chalk.yellow("\u26A0"), message);
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.clearSpinner();
25
- console.error(chalk.red("\u2716"), message);
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.clearSpinner();
29
- console.log(chalk.dim(message));
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/utils/git.ts
49
- import { execSync } from "child_process";
50
- function git(args, cwd) {
51
- return execSync(`git ${args}`, {
52
- cwd,
53
- encoding: "utf-8",
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((resolve3, reject) => {
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(() => resolve3(port));
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 readFileSync2 } from "fs";
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 = existsSync(p);
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 = resolve(dir, specifier);
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 = resolve(projectRoot, target + rest);
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 (!existsSync(tsconfigPath)) {
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 = readFileSync(tsconfigPath, "utf-8");
289
- const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
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 = readFileSync2(filePath, "utf-8");
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 resolve3 = createResolver(projectRoot);
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 = resolve3(spec, filePath);
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 existsSync2 } from "fs";
790
+ import { existsSync as existsSync4 } from "fs";
492
791
  import { join as join3 } from "path";
493
792
  function detectPackageManager(dir) {
494
- if (existsSync2(join3(dir, "bun.lockb")) || existsSync2(join3(dir, "bun.lock")))
793
+ if (existsSync4(join3(dir, "bun.lockb")) || existsSync4(join3(dir, "bun.lock")))
495
794
  return "bun";
496
- if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
497
- if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
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.info(`Creating worktree for ${base} at ${worktreeDir}`);
517
- execSync2(`git worktree add "${worktreeDir}" "${base}"`, {
518
- cwd,
519
- stdio: ["pipe", "pipe", "pipe"]
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.info(`Installing dependencies with ${pm}`);
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((resolve3, reject) => {
862
+ return new Promise((resolve5, reject) => {
555
863
  const poll = async () => {
556
864
  if (Date.now() - start > timeoutMs) {
557
865
  reject(
558
- new Error(
559
- `Server at ${url} did not respond within ${timeoutMs / 1e3}s. Make sure 'next dev' starts correctly in your project.`
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
- resolve3();
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 exec = pmExec(pm);
578
- const [cmd, ...baseArgs] = exec.split(" ");
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: false
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.kill("SIGTERM");
596
- await new Promise((resolve3) => {
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
- server.process.kill("SIGKILL");
599
- resolve3();
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
- resolve3();
926
+ resolve5();
604
927
  });
605
928
  });
606
929
  }
607
930
 
608
931
  // src/stages/capture.ts
609
- import { join as join5 } from "path";
610
- import { chromium } from "playwright";
611
- function routeToDir(route) {
612
- if (route === "/") return "_root";
613
- return route.replace(/^\//, "").replace(/\//g, "_");
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
- async function captureRoutes(routes, beforeUrl, afterUrl, outputDir) {
616
- logger.info(`Capturing ${routes.length} route(s)`);
617
- const browser = await chromium.launch();
618
- const results = [];
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
- const context = await browser.newContext({
621
- viewport: { width: 1280, height: 720 }
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
- const page = await context.newPage();
624
- for (const route of routes) {
625
- const dirName = routeToDir(route);
626
- const routeDir = join5(outputDir, dirName);
627
- await ensureDir(routeDir);
628
- const beforePath = join5(routeDir, "before.png");
629
- const afterPath = join5(routeDir, "after.png");
630
- logger.dim(`Capturing before: ${route}`);
631
- await page.goto(`${beforeUrl}${route}`, { waitUntil: "networkidle" });
632
- await page.waitForTimeout(500);
633
- await page.screenshot({ path: beforePath, fullPage: true });
634
- logger.dim(`Capturing after: ${route}`);
635
- await page.goto(`${afterUrl}${route}`, { waitUntil: "networkidle" });
636
- await page.waitForTimeout(500);
637
- await page.screenshot({ path: afterPath, fullPage: true });
638
- results.push({ route, beforePath, afterPath });
639
- logger.success(`Captured ${route}`);
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
- await context.close();
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
- await browser.close();
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 readFileSync3 } from "fs";
1482
+ import { readFileSync as readFileSync5 } from "fs";
650
1483
  import { writeFileSync } from "fs";
651
- import { join as join6, dirname as dirname2 } from "path";
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 generateSideBySide(beforePath, afterPath, outputPath) {
677
- const labelHeight = 32;
678
- const gap = 4;
679
- const beforeImg = sharp(beforePath);
680
- const afterImg = sharp(afterPath);
681
- const [beforeMeta, afterMeta] = await Promise.all([
682
- beforeImg.metadata(),
683
- afterImg.metadata()
684
- ]);
685
- const bw = beforeMeta.width;
686
- const bh = beforeMeta.height;
687
- const aw = afterMeta.width;
688
- const ah = afterMeta.height;
689
- const totalWidth = bw + gap + aw;
690
- const totalHeight = labelHeight + Math.max(bh, ah);
691
- const labelSvg = (text, w) => Buffer.from(
692
- `<svg width="${w}" height="${labelHeight}">
693
- <rect width="${w}" height="${labelHeight}" fill="#f3f4f6"/>
694
- <text x="${w / 2}" y="${labelHeight / 2 + 5}" text-anchor="middle"
695
- font-family="sans-serif" font-size="14" font-weight="bold" fill="#374151">${text}</text>
696
- </svg>`
697
- );
698
- const beforeLabel = await sharp(labelSvg("Before", bw)).png().toBuffer();
699
- const afterLabel = await sharp(labelSvg("After", aw)).png().toBuffer();
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 compareScreenshots(captures, outputDir, threshold = 0.1) {
735
- logger.info(`Comparing ${captures.length} route(s)`);
736
- const results = [];
737
- for (const capture of captures) {
738
- const routeDir = dirname2(capture.beforePath);
739
- const diffPath = join6(routeDir, "diff.png");
740
- const sideBySidePath = join6(routeDir, "side-by-side.png");
741
- const sliderPath = join6(routeDir, "slider.html");
742
- await ensureDir(routeDir);
743
- const beforeImg = readPng(capture.beforePath);
744
- const afterImg = readPng(capture.afterPath);
745
- const [normBefore, normAfter] = normalizeDimensions(beforeImg, afterImg);
746
- const { width, height } = normBefore;
747
- const totalPixels = width * height;
748
- const diffImg = new PNG({ width, height });
749
- const diffPixels = pixelmatch(
750
- normBefore.data,
751
- normAfter.data,
752
- diffImg.data,
753
- width,
754
- height,
755
- { threshold: 0.1 }
756
- );
757
- writeFileSync(diffPath, PNG.sync.write(diffImg));
758
- await generateSideBySide(
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
- sideBySidePath
1557
+ comparePath,
1558
+ options.browser,
1559
+ options.bgColor
762
1560
  );
763
- const diffPercentage = diffPixels / totalPixels * 100;
764
- const changed = diffPercentage > threshold;
765
- if (changed) {
766
- logger.warn(
767
- `${capture.route}: ${diffPercentage.toFixed(2)}% changed (${diffPixels} pixels)`
768
- );
769
- } else {
770
- logger.success(`${capture.route}: no visual changes`);
771
- }
772
- results.push({
773
- route: capture.route,
774
- beforePath: capture.beforePath,
775
- afterPath: capture.afterPath,
776
- diffPath,
777
- sideBySidePath,
778
- sliderPath,
779
- diffPixels,
780
- totalPixels,
781
- diffPercentage,
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 join7 } from "path";
791
- import { execSync as execSync3 } from "child_process";
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 readFileSync4 } from "fs";
1590
+ import { readFileSync as readFileSync6 } from "fs";
1591
+ import { relative as relative2 } from "path";
795
1592
  function toBase64(filePath) {
796
- return readFileSync4(filePath).toString("base64");
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="${r.sliderPath}">Open slider view</a>` : ""}
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 readFileSync5 } from "fs";
1668
+ import { readFileSync as readFileSync7 } from "fs";
869
1669
  function toBase642(filePath) {
870
- return readFileSync5(filePath).toString("base64");
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 = execSync3("gh pr view --json number -q .number", {
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 = execSync3(
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
- execSync3(
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
- execSync3(
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 = join7(outputDir, "summary.md");
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 = join7(outputDir, "index.html");
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
- postOrUpdateComment(prNumber, summaryMd);
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 outputDir = resolve2(cwd, output);
1957
+ const sessionName = generateSessionName(cwd);
1958
+ const outputDir = resolve4(cwd, output, sessionName);
1959
+ const startTime = Date.now();
1054
1960
  try {
1055
- const spinner = logger.spin("Analyzing git diff...");
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
- spinner.stop();
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.info(
1075
- `${visualFiles.length} visually relevant file(s), ${impactfulFiles.length} potentially impactful`
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
- graphSpinner.stop();
1990
+ logger.pipeline(3, "Finding affected routes...");
1080
1991
  const changedPaths = impactfulFiles.map((f) => f.path);
1081
- const affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd);
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
- logger.info(
1089
- `Affected routes: ${affectedRoutes.map((r) => r.route).join(", ")}`
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 startServer(worktree.path, beforePort);
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
- const routes = affectedRoutes.map((r) => r.route);
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
- routes,
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
- const results = await compareScreenshots(captures, outputDir, options.threshold);
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! ${results.length} route(s) captured, ${changedCount} with visual changes.`
2080
+ `Done in ${elapsed}s \u2014 ${results.length} route(s) captured, ${changedCount} with visual changes`
1111
2081
  );
1112
- logger.info(`Output: ${outputDir}`);
1113
- logger.dim(`Open ${resolve2(outputDir, "index.html")} in your browser to view the report.`);
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.0").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(
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
- ).action(async (opts) => {
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
- logger.error(
1144
- err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
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
  });