afterbefore 0.1.11 → 0.1.12

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
@@ -240,8 +240,8 @@ function getCurrentBranch(cwd) {
240
240
  }
241
241
 
242
242
  // src/pipeline.ts
243
- import { resolve as resolve4 } from "path";
244
- import { unlinkSync } from "fs";
243
+ import { resolve as resolve3 } from "path";
244
+ import { unlinkSync as unlinkSync2 } from "fs";
245
245
 
246
246
  // src/config.ts
247
247
  import { resolve } from "path";
@@ -276,7 +276,7 @@ async function ensureDir(dir) {
276
276
  // src/utils/port.ts
277
277
  import { createServer } from "net";
278
278
  function findPort() {
279
- return new Promise((resolve5, reject) => {
279
+ return new Promise((resolve4, reject) => {
280
280
  const server = createServer();
281
281
  server.listen(0, () => {
282
282
  const addr = server.address();
@@ -286,7 +286,7 @@ function findPort() {
286
286
  return;
287
287
  }
288
288
  const port = addr.port;
289
- server.close(() => resolve5(port));
289
+ server.close(() => resolve4(port));
290
290
  });
291
291
  server.on("error", reject);
292
292
  });
@@ -299,105 +299,6 @@ async function findAvailablePort(exclude) {
299
299
  throw new Error("Failed to find available port after 5 attempts");
300
300
  }
301
301
 
302
- // src/utils/bgcolor.ts
303
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
304
- import { resolve as resolve2 } from "path";
305
- var DEFAULT_BG = "#0a0a0a";
306
- var GLOBAL_CSS_PATHS = [
307
- "app/globals.css",
308
- "src/app/globals.css",
309
- "styles/globals.css",
310
- "src/styles/globals.css",
311
- "app/global.css",
312
- "src/app/global.css"
313
- ];
314
- function hslToHex(h, s, l) {
315
- s /= 100;
316
- l /= 100;
317
- const a = s * Math.min(l, 1 - l);
318
- const f = (n) => {
319
- const k = (n + h / 30) % 12;
320
- const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
321
- return Math.round(255 * color).toString(16).padStart(2, "0");
322
- };
323
- return `#${f(0)}${f(8)}${f(4)}`;
324
- }
325
- function parseColorValue(raw) {
326
- const v = raw.trim();
327
- if (/^#[0-9a-fA-F]{3,8}$/.test(v)) {
328
- if (v.length === 4) {
329
- return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
330
- }
331
- return v.slice(0, 7);
332
- }
333
- const hslMatch = v.match(
334
- /^hsl\(\s*([\d.]+)[,\s]+\s*([\d.]+)%[,\s]+\s*([\d.]+)%/
335
- );
336
- if (hslMatch) {
337
- return hslToHex(
338
- parseFloat(hslMatch[1]),
339
- parseFloat(hslMatch[2]),
340
- parseFloat(hslMatch[3])
341
- );
342
- }
343
- const bareHsl = v.match(/^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/);
344
- if (bareHsl) {
345
- return hslToHex(
346
- parseFloat(bareHsl[1]),
347
- parseFloat(bareHsl[2]),
348
- parseFloat(bareHsl[3])
349
- );
350
- }
351
- const rgbMatch = v.match(
352
- /^rgb\(\s*([\d.]+)[,\s]+\s*([\d.]+)[,\s]+\s*([\d.]+)/
353
- );
354
- if (rgbMatch) {
355
- const toHex = (n) => Math.round(parseFloat(n)).toString(16).padStart(2, "0");
356
- return `#${toHex(rgbMatch[1])}${toHex(rgbMatch[2])}${toHex(rgbMatch[3])}`;
357
- }
358
- return null;
359
- }
360
- function detectBgColor(cwd) {
361
- for (const relPath of GLOBAL_CSS_PATHS) {
362
- const absPath = resolve2(cwd, relPath);
363
- if (!existsSync2(absPath)) continue;
364
- const css = readFileSync2(absPath, "utf-8");
365
- const darkBlock = css.match(/\.dark\s*\{([^}]+)\}/);
366
- if (darkBlock) {
367
- const bgVar = darkBlock[1].match(/--background\s*:\s*([^;]+)/);
368
- if (bgVar) {
369
- const color = parseColorValue(bgVar[1]);
370
- if (color) return color;
371
- }
372
- }
373
- const rootBlock = css.match(/:root\s*\{([^}]+)\}/);
374
- if (rootBlock) {
375
- const bgVar = rootBlock[1].match(/--background\s*:\s*([^;]+)/);
376
- if (bgVar) {
377
- const color = parseColorValue(bgVar[1]);
378
- if (color) return color;
379
- }
380
- }
381
- const anyBgVar = css.match(/--background\s*:\s*([^;]+)/);
382
- if (anyBgVar) {
383
- const color = parseColorValue(anyBgVar[1]);
384
- if (color) return color;
385
- }
386
- const bodyBg = css.match(
387
- /body\s*\{[^}]*?background(?:-color)?\s*:\s*([^;]+)/
388
- );
389
- if (bodyBg) {
390
- const val = bodyBg[1].trim();
391
- if (!val.startsWith("var(")) {
392
- const color = parseColorValue(val);
393
- if (color) return color;
394
- }
395
- }
396
- break;
397
- }
398
- return DEFAULT_BG;
399
- }
400
-
401
302
  // src/stages/diff.ts
402
303
  var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
403
304
  function parseDiffOutput(raw) {
@@ -471,13 +372,13 @@ function classifyFiles(files) {
471
372
  }
472
373
 
473
374
  // src/stages/graph.ts
474
- import { readdirSync, readFileSync as readFileSync4 } from "fs";
375
+ import { readdirSync, readFileSync as readFileSync3 } from "fs";
475
376
  import { join as join2, relative } from "path";
476
377
  import { init, parse } from "es-module-lexer";
477
378
 
478
379
  // src/stages/resolve.ts
479
- import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
480
- import { resolve as resolve3, dirname, join } from "path";
380
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
381
+ import { resolve as resolve2, dirname, join } from "path";
481
382
  var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
482
383
  function createResolver(projectRoot) {
483
384
  const mappings = loadPathMappings(projectRoot);
@@ -485,7 +386,7 @@ function createResolver(projectRoot) {
485
386
  function cachedExists(p) {
486
387
  const cached = existsCache.get(p);
487
388
  if (cached !== void 0) return cached;
488
- const result = existsSync3(p);
389
+ const result = existsSync2(p);
489
390
  existsCache.set(p, result);
490
391
  return result;
491
392
  }
@@ -511,14 +412,14 @@ function createResolver(projectRoot) {
511
412
  return (specifier, fromFile) => {
512
413
  if (specifier.startsWith(".")) {
513
414
  const dir = dirname(fromFile);
514
- const candidate = resolve3(dir, specifier);
415
+ const candidate = resolve2(dir, specifier);
515
416
  return tryResolve(candidate);
516
417
  }
517
418
  for (const mapping of mappings) {
518
419
  if (!specifier.startsWith(mapping.prefix)) continue;
519
420
  const rest = specifier.slice(mapping.prefix.length);
520
421
  for (const target of mapping.targets) {
521
- const candidate = resolve3(projectRoot, target + rest);
422
+ const candidate = resolve2(projectRoot, target + rest);
522
423
  const result = tryResolve(candidate);
523
424
  if (result) return result;
524
425
  }
@@ -528,12 +429,12 @@ function createResolver(projectRoot) {
528
429
  }
529
430
  function loadPathMappings(projectRoot) {
530
431
  const tsconfigPath = join(projectRoot, "tsconfig.json");
531
- if (!existsSync3(tsconfigPath)) {
432
+ if (!existsSync2(tsconfigPath)) {
532
433
  logger.dim("No tsconfig.json found, skipping path alias resolution");
533
434
  return [];
534
435
  }
535
436
  try {
536
- const raw = readFileSync3(tsconfigPath, "utf-8");
437
+ const raw = readFileSync2(tsconfigPath, "utf-8");
537
438
  const cleaned = stripJsonComments(raw);
538
439
  const config = JSON.parse(cleaned);
539
440
  const paths = config?.compilerOptions?.paths;
@@ -633,7 +534,7 @@ function collectFiles(dir) {
633
534
  function parseImports(filePath) {
634
535
  let source;
635
536
  try {
636
- source = readFileSync4(filePath, "utf-8");
537
+ source = readFileSync3(filePath, "utf-8");
637
538
  } catch {
638
539
  return [];
639
540
  }
@@ -653,7 +554,7 @@ function parseImports(filePath) {
653
554
  }
654
555
  async function buildImportGraph(projectRoot) {
655
556
  await init;
656
- const resolve5 = createResolver(projectRoot);
557
+ const resolve4 = createResolver(projectRoot);
657
558
  const allFiles = [];
658
559
  for (const dir of SOURCE_DIRS) {
659
560
  const fullDir = join2(projectRoot, dir);
@@ -667,7 +568,7 @@ async function buildImportGraph(projectRoot) {
667
568
  const specifiers = parseImports(filePath);
668
569
  const deps = /* @__PURE__ */ new Set();
669
570
  for (const spec of specifiers) {
670
- const resolved = resolve5(spec, filePath);
571
+ const resolved = resolve4(spec, filePath);
671
572
  if (!resolved) continue;
672
573
  const relResolved = relative(projectRoot, resolved);
673
574
  deps.add(relResolved);
@@ -826,13 +727,13 @@ import { join as join4 } from "path";
826
727
  import { tmpdir } from "os";
827
728
 
828
729
  // src/utils/pm.ts
829
- import { existsSync as existsSync4 } from "fs";
730
+ import { existsSync as existsSync3 } from "fs";
830
731
  import { join as join3 } from "path";
831
732
  function detectPackageManager(dir) {
832
- if (existsSync4(join3(dir, "bun.lockb")) || existsSync4(join3(dir, "bun.lock")))
733
+ if (existsSync3(join3(dir, "bun.lockb")) || existsSync3(join3(dir, "bun.lock")))
833
734
  return "bun";
834
- if (existsSync4(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
835
- if (existsSync4(join3(dir, "yarn.lock"))) return "yarn";
735
+ if (existsSync3(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
736
+ if (existsSync3(join3(dir, "yarn.lock"))) return "yarn";
836
737
  return "npm";
837
738
  }
838
739
  function pmExec(pm) {
@@ -889,11 +790,11 @@ async function createWorktree(base, cwd) {
889
790
 
890
791
  // src/stages/server.ts
891
792
  import { spawn } from "child_process";
892
- import { existsSync as existsSync5 } from "fs";
793
+ import { existsSync as existsSync4 } from "fs";
893
794
  import { join as join5 } from "path";
894
795
  function waitForServer(url, timeoutMs) {
895
796
  const start = Date.now();
896
- return new Promise((resolve5, reject) => {
797
+ return new Promise((resolve4, reject) => {
897
798
  const poll = async () => {
898
799
  if (Date.now() - start > timeoutMs) {
899
800
  reject(
@@ -906,7 +807,7 @@ function waitForServer(url, timeoutMs) {
906
807
  }
907
808
  try {
908
809
  await fetch(url);
909
- resolve5();
810
+ resolve4();
910
811
  } catch {
911
812
  setTimeout(poll, 150);
912
813
  }
@@ -920,7 +821,7 @@ async function startServer(projectDir, port) {
920
821
  const exec2 = pmExec(pm);
921
822
  const [cmd, ...baseArgs] = exec2.split(" ");
922
823
  const lockFile = join5(projectDir, ".next", "dev", "lock");
923
- if (existsSync5(lockFile)) {
824
+ if (existsSync4(lockFile)) {
924
825
  throw new AfterbeforeError(
925
826
  `Another Next.js dev server is running in ${projectDir} (.next/dev/lock exists).`,
926
827
  `Stop the other dev server first, or delete .next/dev/lock if it's stale.`
@@ -947,17 +848,17 @@ async function stopServer(server) {
947
848
  process.kill(-pid, "SIGTERM");
948
849
  } catch {
949
850
  }
950
- await new Promise((resolve5) => {
851
+ await new Promise((resolve4) => {
951
852
  const timeout = setTimeout(() => {
952
853
  try {
953
854
  process.kill(-pid, "SIGKILL");
954
855
  } catch {
955
856
  }
956
- resolve5();
857
+ resolve4();
957
858
  }, 5e3);
958
859
  server.process.on("exit", () => {
959
860
  clearTimeout(timeout);
960
- resolve5();
861
+ resolve4();
961
862
  });
962
863
  });
963
864
  }
@@ -1117,7 +1018,7 @@ async function launchBrowser() {
1117
1018
  return await chromium.launch();
1118
1019
  }
1119
1020
  }
1120
- var MAX_COMPONENT_INSTANCES_PER_SOURCE = 20;
1021
+ var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
1121
1022
  function sanitizeComponentLabel(label) {
1122
1023
  const noExt = label.replace(/\.[a-z0-9]+$/i, "");
1123
1024
  return sanitizeLabel(noExt, 60);
@@ -1287,9 +1188,21 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
1287
1188
  const source = targets[sourceIndex].original;
1288
1189
  const list = bySource.get(source) ?? [];
1289
1190
  list.sort((a, b) => a.top === b.top ? a.left - b.left : a.top - b.top);
1290
- const limit = Math.min(list.length, maxPerSource2);
1191
+ const deduped = [];
1192
+ const seenDims = [];
1193
+ for (const candidate of list) {
1194
+ const rect = candidate.el.getBoundingClientRect();
1195
+ const isDuplicate = seenDims.some(
1196
+ (d) => Math.abs(d.w - rect.width) <= 2 && Math.abs(d.h - rect.height) <= 2
1197
+ );
1198
+ if (!isDuplicate) {
1199
+ seenDims.push({ w: rect.width, h: rect.height });
1200
+ deduped.push(candidate);
1201
+ }
1202
+ }
1203
+ const limit = Math.min(deduped.length, maxPerSource2);
1291
1204
  for (let i = 0; i < limit; i++) {
1292
- const instance = list[i];
1205
+ const instance = deduped[i];
1293
1206
  const componentKey = `ab-comp-${sourceIndex}-${i}`;
1294
1207
  const parentKey = `ab-parent-${sourceIndex}-${i}`;
1295
1208
  instance.el.setAttribute("data-ab-comp-key", componentKey);
@@ -1308,7 +1221,7 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
1308
1221
  { changed: normalized, maxPerSource }
1309
1222
  );
1310
1223
  }
1311
- async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir, contextBeforePath, contextAfterPath, results) {
1224
+ async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir, results) {
1312
1225
  const deduped = Array.from(new Set(changedComponents.map(normalizePath)));
1313
1226
  const [afterInstances, beforeInstances] = await Promise.all([
1314
1227
  tagChangedComponentInstances(afterPage, deduped),
@@ -1356,7 +1269,11 @@ async function captureComponentInstances(afterPage, beforePage, changedComponent
1356
1269
  route: `${baseLabel} [parent]`,
1357
1270
  prefix: parentPrefix,
1358
1271
  beforePath: parentBeforePath,
1359
- afterPath: parentAfterPath
1272
+ afterPath: parentAfterPath,
1273
+ level: "parent",
1274
+ parentPrefix: capturePrefix,
1275
+ componentSource: source,
1276
+ componentName
1360
1277
  });
1361
1278
  }
1362
1279
  const componentPrefix = `${capturePrefix}~cmp.${itemSlug}~component`;
@@ -1381,16 +1298,11 @@ async function captureComponentInstances(afterPage, beforePage, changedComponent
1381
1298
  route: `${baseLabel} [component]`,
1382
1299
  prefix: componentPrefix,
1383
1300
  beforePath: componentBeforePath,
1384
- afterPath: componentAfterPath
1385
- });
1386
- }
1387
- if (parentBeforeOk && parentAfterOk || componentBeforeOk && componentAfterOk) {
1388
- const contextPrefix = `${capturePrefix}~cmp.${itemSlug}~context`;
1389
- results.push({
1390
- route: `${baseLabel} [context]`,
1391
- prefix: contextPrefix,
1392
- beforePath: contextBeforePath,
1393
- afterPath: contextAfterPath
1301
+ afterPath: componentAfterPath,
1302
+ level: "component",
1303
+ parentPrefix: capturePrefix,
1304
+ componentSource: source,
1305
+ componentName
1394
1306
  });
1395
1307
  }
1396
1308
  }
@@ -1431,7 +1343,8 @@ async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLa
1431
1343
  route: sectionLabel,
1432
1344
  prefix: sectionPrefix,
1433
1345
  beforePath: sectionBeforePath,
1434
- afterPath: sectionAfterPath
1346
+ afterPath: sectionAfterPath,
1347
+ level: "section"
1435
1348
  });
1436
1349
  } catch {
1437
1350
  }
@@ -1483,7 +1396,8 @@ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl,
1483
1396
  route: tabLabel,
1484
1397
  prefix: tabPrefix,
1485
1398
  beforePath: tabBeforePath,
1486
- afterPath: tabAfterPath
1399
+ afterPath: tabAfterPath,
1400
+ level: "tab"
1487
1401
  });
1488
1402
  if ((task.changedComponents?.length ?? 0) > 0) {
1489
1403
  await captureComponentInstances(
@@ -1493,8 +1407,6 @@ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl,
1493
1407
  tabPrefix,
1494
1408
  tabLabel,
1495
1409
  outputDir,
1496
- tabBeforePath,
1497
- tabAfterPath,
1498
1410
  results
1499
1411
  );
1500
1412
  }
@@ -1521,6 +1433,90 @@ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl,
1521
1433
  ]);
1522
1434
  }
1523
1435
  }
1436
+ async function captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options) {
1437
+ const results = [];
1438
+ const [beforePage, afterPage] = await Promise.all([
1439
+ beforeCtx.newPage(),
1440
+ afterCtx.newPage()
1441
+ ]);
1442
+ try {
1443
+ const beforePath = join6(outputDir, `${task.prefix}-before.png`);
1444
+ const afterPath = join6(outputDir, `${task.prefix}-after.png`);
1445
+ const settle = async (page) => {
1446
+ await page.evaluate("document.fonts.ready");
1447
+ if (options.delay > 0) {
1448
+ await page.waitForTimeout(options.delay);
1449
+ }
1450
+ };
1451
+ const performActions = async (page, actions) => {
1452
+ for (const action of actions) {
1453
+ if (action.click) {
1454
+ await page.locator(action.click).first().click();
1455
+ }
1456
+ if (action.scroll) {
1457
+ await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
1458
+ }
1459
+ if (action.wait && action.wait > 0) {
1460
+ await page.waitForTimeout(action.wait);
1461
+ }
1462
+ await settle(page);
1463
+ }
1464
+ };
1465
+ const screenshot = async (page, path) => {
1466
+ if (task.selector) {
1467
+ const el = page.locator(task.selector).first();
1468
+ await el.screenshot({ path });
1469
+ } else {
1470
+ await page.screenshot({ path, fullPage: true });
1471
+ }
1472
+ };
1473
+ await Promise.all([
1474
+ beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
1475
+ afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
1476
+ ]);
1477
+ results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath, level: "page" });
1478
+ if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
1479
+ await captureComponentInstances(
1480
+ afterPage,
1481
+ beforePage,
1482
+ task.changedComponents,
1483
+ task.prefix,
1484
+ task.label,
1485
+ outputDir,
1486
+ results
1487
+ );
1488
+ }
1489
+ if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
1490
+ await captureAutoSections(
1491
+ afterPage,
1492
+ beforePage,
1493
+ task.prefix,
1494
+ task.label,
1495
+ outputDir,
1496
+ options,
1497
+ settle,
1498
+ results
1499
+ );
1500
+ }
1501
+ if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
1502
+ await captureAutoTabs(
1503
+ afterPage,
1504
+ beforePage,
1505
+ task,
1506
+ beforeUrl,
1507
+ afterUrl,
1508
+ outputDir,
1509
+ options,
1510
+ settle,
1511
+ results
1512
+ );
1513
+ }
1514
+ } finally {
1515
+ await Promise.all([beforePage.close(), afterPage.close()]);
1516
+ }
1517
+ return results;
1518
+ }
1519
+ var BATCH_SIZE = 3;
1524
1520
  async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1525
1521
  const browser = options.browser ?? await launchBrowser();
1526
1522
  const ownsBrowser = !options.browser;
@@ -1535,85 +1531,20 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1535
1531
  browser.newContext(contextOpts),
1536
1532
  browser.newContext(contextOpts)
1537
1533
  ]);
1538
- const [beforePage, afterPage] = await Promise.all([
1539
- beforeCtx.newPage(),
1540
- afterCtx.newPage()
1541
- ]);
1542
- for (let i = 0; i < tasks.length; i++) {
1543
- const task = tasks[i];
1544
- options.onProgress?.(i + 1, task.label);
1545
- const beforePath = join6(outputDir, `${task.prefix}-before.png`);
1546
- const afterPath = join6(outputDir, `${task.prefix}-after.png`);
1547
- const settle = async (page) => {
1548
- await page.evaluate("document.fonts.ready");
1549
- if (options.delay > 0) {
1550
- await page.waitForTimeout(options.delay);
1551
- }
1552
- };
1553
- const performActions = async (page, actions) => {
1554
- for (const action of actions) {
1555
- if (action.click) {
1556
- await page.locator(action.click).first().click();
1557
- }
1558
- if (action.scroll) {
1559
- await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
1560
- }
1561
- if (action.wait && action.wait > 0) {
1562
- await page.waitForTimeout(action.wait);
1563
- }
1564
- await settle(page);
1565
- }
1566
- };
1567
- const screenshot = async (page, path) => {
1568
- if (task.selector) {
1569
- const el = page.locator(task.selector).first();
1570
- await el.screenshot({ path });
1534
+ for (let batchStart = 0; batchStart < tasks.length; batchStart += BATCH_SIZE) {
1535
+ const batch = tasks.slice(batchStart, batchStart + BATCH_SIZE);
1536
+ const batchResults = await Promise.allSettled(
1537
+ batch.map((task, idx) => {
1538
+ options.onProgress?.(batchStart + idx + 1, task.label);
1539
+ return captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options);
1540
+ })
1541
+ );
1542
+ for (const result of batchResults) {
1543
+ if (result.status === "fulfilled") {
1544
+ results.push(...result.value);
1571
1545
  } else {
1572
- await page.screenshot({ path, fullPage: true });
1546
+ logger.dim(`Capture failed: ${result.reason}`);
1573
1547
  }
1574
- };
1575
- await Promise.all([
1576
- beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
1577
- afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
1578
- ]);
1579
- results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
1580
- if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
1581
- await captureComponentInstances(
1582
- afterPage,
1583
- beforePage,
1584
- task.changedComponents,
1585
- task.prefix,
1586
- task.label,
1587
- outputDir,
1588
- beforePath,
1589
- afterPath,
1590
- results
1591
- );
1592
- }
1593
- if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
1594
- await captureAutoSections(
1595
- afterPage,
1596
- beforePage,
1597
- task.prefix,
1598
- task.label,
1599
- outputDir,
1600
- options,
1601
- settle,
1602
- results
1603
- );
1604
- }
1605
- if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
1606
- await captureAutoTabs(
1607
- afterPage,
1608
- beforePage,
1609
- task,
1610
- beforeUrl,
1611
- afterUrl,
1612
- outputDir,
1613
- options,
1614
- settle,
1615
- results
1616
- );
1617
1548
  }
1618
1549
  }
1619
1550
  await Promise.all([beforeCtx.close(), afterCtx.close()]);
@@ -1626,109 +1557,70 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1626
1557
  }
1627
1558
 
1628
1559
  // src/stages/compare.ts
1629
- import { readFileSync as readFileSync5 } from "fs";
1630
- import { join as join7, dirname as dirname2 } from "path";
1631
- import { PNG } from "pngjs";
1632
- import pixelmatch from "pixelmatch";
1633
- function normalizeDimensions(img1, img2) {
1634
- const width = Math.max(img1.width, img2.width);
1635
- const height = Math.max(img1.height, img2.height);
1636
- const pad = (src) => {
1637
- if (src.width === width && src.height === height) return src;
1638
- const padded = new PNG({ width, height });
1639
- for (let i = 0; i < padded.data.length; i += 4) {
1640
- padded.data[i] = 255;
1641
- padded.data[i + 1] = 255;
1642
- padded.data[i + 2] = 255;
1643
- padded.data[i + 3] = 255;
1644
- }
1645
- PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);
1646
- return padded;
1647
- };
1648
- return [pad(img1), pad(img2)];
1649
- }
1650
- async function generateComposite(beforePath, afterPath, outputPath, browser, bgColor) {
1651
- const beforeBuf = readFileSync5(beforePath);
1652
- const afterBuf = readFileSync5(afterPath);
1653
- const beforeUri = `data:image/png;base64,${beforeBuf.toString("base64")}`;
1654
- const afterUri = `data:image/png;base64,${afterBuf.toString("base64")}`;
1655
- const beforePng = PNG.sync.read(beforeBuf);
1656
- const afterPng = PNG.sync.read(afterBuf);
1657
- const imgW = Math.max(beforePng.width, afterPng.width);
1658
- const imgH = Math.max(beforePng.height, afterPng.height);
1659
- const PADDING = 120;
1660
- const GAP = 80;
1661
- const LABEL_H = 70;
1662
- const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
1663
- const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
1664
- const maxImgH = canvasH - PADDING * 2 - LABEL_H;
1665
- const page = await browser.newPage({
1666
- viewport: { width: canvasW, height: canvasH },
1667
- deviceScaleFactor: 1
1668
- });
1669
- const html = `<!DOCTYPE html>
1670
- <html><head><style>
1671
- * { margin: 0; box-sizing: border-box; }
1672
- body { background: ${bgColor}; display: flex; justify-content: center; align-items: center; width: ${canvasW}px; height: ${canvasH}px; padding: ${PADDING}px; gap: ${GAP}px; overflow: hidden; }
1673
- .col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; max-height: 100%; }
1674
- img { width: 100%; max-height: ${maxImgH}px; object-fit: contain; }
1675
- .label { margin-top: 40px; font: 500 30px/1 system-ui, sans-serif; flex-shrink: 0; }
1676
- .before { color: #888; }
1677
- .after { color: #22c55e; }
1678
- </style></head><body>
1679
- <div class="col"><img src="${beforeUri}"><div class="label before">Before</div></div>
1680
- <div class="col"><img src="${afterUri}"><div class="label after">After</div></div>
1681
- </body></html>`;
1682
- await page.setContent(html, { waitUntil: "load" });
1683
- await page.waitForTimeout(200);
1684
- await page.screenshot({ path: outputPath });
1685
- await page.close();
1686
- }
1687
- async function compareOne(capture, outputDir, threshold, options) {
1688
- const dir = dirname2(capture.beforePath);
1689
- const comparePath = join7(dir, `${capture.prefix}-compare.png`);
1690
- const beforeBuffer = readFileSync5(capture.beforePath);
1691
- const afterBuffer = readFileSync5(capture.afterPath);
1692
- const beforeImg = PNG.sync.read(beforeBuffer);
1693
- const afterImg = PNG.sync.read(afterBuffer);
1694
- const [normBefore, normAfter] = normalizeDimensions(beforeImg, afterImg);
1695
- const { width, height } = normBefore;
1696
- const totalPixels = width * height;
1697
- const diffPixels = pixelmatch(
1698
- normBefore.data,
1699
- normAfter.data,
1700
- new Uint8Array(width * height * 4),
1701
- width,
1702
- height,
1703
- { threshold: 0.1 }
1704
- );
1705
- const diffPercentage = diffPixels / totalPixels * 100;
1706
- const changed = diffPercentage > threshold;
1707
- await generateComposite(
1560
+ import { join as join7 } from "path";
1561
+ import { unlinkSync } from "fs";
1562
+ import { ODiffServer } from "odiff-bin";
1563
+ async function compareOne(capture, outputDir, threshold, server) {
1564
+ const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
1565
+ const result = await server.compare(
1708
1566
  capture.beforePath,
1709
1567
  capture.afterPath,
1710
- comparePath,
1711
- options.browser,
1712
- options.bgColor
1568
+ diffPath,
1569
+ { threshold: 0.1, antialiasing: true }
1713
1570
  );
1571
+ if (result.match) {
1572
+ return {
1573
+ route: capture.route,
1574
+ prefix: capture.prefix,
1575
+ beforePath: capture.beforePath,
1576
+ afterPath: capture.afterPath,
1577
+ diffPixels: 0,
1578
+ totalPixels: 0,
1579
+ diffPercentage: 0,
1580
+ changed: false
1581
+ };
1582
+ }
1583
+ if (result.reason === "pixel-diff") {
1584
+ const changed = result.diffPercentage > threshold;
1585
+ if (!changed) {
1586
+ try {
1587
+ unlinkSync(diffPath);
1588
+ } catch {
1589
+ }
1590
+ }
1591
+ return {
1592
+ route: capture.route,
1593
+ prefix: capture.prefix,
1594
+ beforePath: capture.beforePath,
1595
+ afterPath: capture.afterPath,
1596
+ diffPixels: result.diffCount,
1597
+ totalPixels: 0,
1598
+ diffPercentage: result.diffPercentage,
1599
+ changed
1600
+ };
1601
+ }
1714
1602
  return {
1715
1603
  route: capture.route,
1716
1604
  prefix: capture.prefix,
1717
1605
  beforePath: capture.beforePath,
1718
1606
  afterPath: capture.afterPath,
1719
- comparePath,
1720
- diffPixels,
1721
- totalPixels,
1722
- diffPercentage,
1723
- changed
1607
+ diffPixels: 0,
1608
+ totalPixels: 0,
1609
+ diffPercentage: 100,
1610
+ changed: true
1724
1611
  };
1725
1612
  }
1726
- async function compareScreenshots(captures, outputDir, threshold = 0.1, options) {
1727
- const results = [];
1728
- for (const capture of captures) {
1729
- results.push(await compareOne(capture, outputDir, threshold, options));
1613
+ async function compareScreenshots(captures, outputDir, threshold = 0.1) {
1614
+ const server = new ODiffServer();
1615
+ try {
1616
+ const results = [];
1617
+ for (const capture of captures) {
1618
+ results.push(await compareOne(capture, outputDir, threshold, server));
1619
+ }
1620
+ return results;
1621
+ } finally {
1622
+ server.stop();
1730
1623
  }
1731
- return results;
1732
1624
  }
1733
1625
 
1734
1626
  // src/stages/report.ts
@@ -1899,10 +1791,10 @@ function expandRoutes(routes, config, routeComponentMap) {
1899
1791
  async function runPipeline(options) {
1900
1792
  const { base, output, post, cwd } = options;
1901
1793
  const sessionName = generateSessionName(cwd);
1902
- const outputDir = resolve4(cwd, output, sessionName);
1794
+ const outputDir = resolve3(cwd, output, sessionName);
1903
1795
  const startTime = Date.now();
1904
1796
  try {
1905
- const version = true ? "0.1.11" : "dev";
1797
+ const version = true ? "0.1.12" : "dev";
1906
1798
  console.log(`
1907
1799
  afterbefore v${version} \xB7 Comparing against ${base}
1908
1800
  `);
@@ -2003,21 +1895,16 @@ afterbefore v${version} \xB7 Comparing against ${base}
2003
1895
  }
2004
1896
  );
2005
1897
  logger.pipeline(7, "Comparing screenshots...");
2006
- const bgColor = detectBgColor(cwd);
2007
- const allResults = await compareScreenshots(captures, outputDir, options.threshold, { browser, bgColor });
1898
+ const allResults = await compareScreenshots(captures, outputDir, options.threshold);
2008
1899
  const results = allResults.filter((r) => {
2009
1900
  const isSubCapture = r.prefix.includes("~");
2010
1901
  if (isSubCapture && !r.changed) {
2011
1902
  try {
2012
- unlinkSync(r.beforePath);
2013
- } catch {
2014
- }
2015
- try {
2016
- unlinkSync(r.afterPath);
1903
+ unlinkSync2(r.beforePath);
2017
1904
  } catch {
2018
1905
  }
2019
1906
  try {
2020
- unlinkSync(r.comparePath);
1907
+ unlinkSync2(r.afterPath);
2021
1908
  } catch {
2022
1909
  }
2023
1910
  return false;
@@ -2036,7 +1923,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
2036
1923
  );
2037
1924
  } finally {
2038
1925
  try {
2039
- logger.writeLogFile(resolve4(outputDir, "debug.log"));
1926
+ logger.writeLogFile(resolve3(outputDir, "debug.log"));
2040
1927
  } catch {
2041
1928
  }
2042
1929
  await cleanupRegistry.runAll();
@@ -2047,7 +1934,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
2047
1934
  var program = new Command();
2048
1935
  program.name("afterbefore").description(
2049
1936
  "Automatic before/after screenshot capture for PRs. Git diff is the config."
2050
- ).version("0.1.11").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(
1937
+ ).version("0.1.12").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(
2051
1938
  "--threshold <percent>",
2052
1939
  "Diff threshold percentage (changes below this are ignored)",
2053
1940
  "0.1"