afterbefore 0.1.12 → 0.1.13

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,7 +240,7 @@ function getCurrentBranch(cwd) {
240
240
  }
241
241
 
242
242
  // src/pipeline.ts
243
- import { resolve as resolve3 } from "path";
243
+ import { resolve as resolve4 } from "path";
244
244
  import { unlinkSync as unlinkSync2 } from "fs";
245
245
 
246
246
  // src/config.ts
@@ -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((resolve4, reject) => {
279
+ return new Promise((resolve5, 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(() => resolve4(port));
289
+ server.close(() => resolve5(port));
290
290
  });
291
291
  server.on("error", reject);
292
292
  });
@@ -299,6 +299,105 @@ 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
+
302
401
  // src/stages/diff.ts
303
402
  var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
304
403
  function parseDiffOutput(raw) {
@@ -372,13 +471,13 @@ function classifyFiles(files) {
372
471
  }
373
472
 
374
473
  // src/stages/graph.ts
375
- import { readdirSync, readFileSync as readFileSync3 } from "fs";
474
+ import { readdirSync, readFileSync as readFileSync4 } from "fs";
376
475
  import { join as join2, relative } from "path";
377
476
  import { init, parse } from "es-module-lexer";
378
477
 
379
478
  // src/stages/resolve.ts
380
- import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
381
- import { resolve as resolve2, dirname, join } from "path";
479
+ import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
480
+ import { resolve as resolve3, dirname, join } from "path";
382
481
  var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
383
482
  function createResolver(projectRoot) {
384
483
  const mappings = loadPathMappings(projectRoot);
@@ -386,7 +485,7 @@ function createResolver(projectRoot) {
386
485
  function cachedExists(p) {
387
486
  const cached = existsCache.get(p);
388
487
  if (cached !== void 0) return cached;
389
- const result = existsSync2(p);
488
+ const result = existsSync3(p);
390
489
  existsCache.set(p, result);
391
490
  return result;
392
491
  }
@@ -412,14 +511,14 @@ function createResolver(projectRoot) {
412
511
  return (specifier, fromFile) => {
413
512
  if (specifier.startsWith(".")) {
414
513
  const dir = dirname(fromFile);
415
- const candidate = resolve2(dir, specifier);
514
+ const candidate = resolve3(dir, specifier);
416
515
  return tryResolve(candidate);
417
516
  }
418
517
  for (const mapping of mappings) {
419
518
  if (!specifier.startsWith(mapping.prefix)) continue;
420
519
  const rest = specifier.slice(mapping.prefix.length);
421
520
  for (const target of mapping.targets) {
422
- const candidate = resolve2(projectRoot, target + rest);
521
+ const candidate = resolve3(projectRoot, target + rest);
423
522
  const result = tryResolve(candidate);
424
523
  if (result) return result;
425
524
  }
@@ -429,12 +528,12 @@ function createResolver(projectRoot) {
429
528
  }
430
529
  function loadPathMappings(projectRoot) {
431
530
  const tsconfigPath = join(projectRoot, "tsconfig.json");
432
- if (!existsSync2(tsconfigPath)) {
531
+ if (!existsSync3(tsconfigPath)) {
433
532
  logger.dim("No tsconfig.json found, skipping path alias resolution");
434
533
  return [];
435
534
  }
436
535
  try {
437
- const raw = readFileSync2(tsconfigPath, "utf-8");
536
+ const raw = readFileSync3(tsconfigPath, "utf-8");
438
537
  const cleaned = stripJsonComments(raw);
439
538
  const config = JSON.parse(cleaned);
440
539
  const paths = config?.compilerOptions?.paths;
@@ -534,7 +633,7 @@ function collectFiles(dir) {
534
633
  function parseImports(filePath) {
535
634
  let source;
536
635
  try {
537
- source = readFileSync3(filePath, "utf-8");
636
+ source = readFileSync4(filePath, "utf-8");
538
637
  } catch {
539
638
  return [];
540
639
  }
@@ -554,7 +653,7 @@ function parseImports(filePath) {
554
653
  }
555
654
  async function buildImportGraph(projectRoot) {
556
655
  await init;
557
- const resolve4 = createResolver(projectRoot);
656
+ const resolve5 = createResolver(projectRoot);
558
657
  const allFiles = [];
559
658
  for (const dir of SOURCE_DIRS) {
560
659
  const fullDir = join2(projectRoot, dir);
@@ -568,7 +667,7 @@ async function buildImportGraph(projectRoot) {
568
667
  const specifiers = parseImports(filePath);
569
668
  const deps = /* @__PURE__ */ new Set();
570
669
  for (const spec of specifiers) {
571
- const resolved = resolve4(spec, filePath);
670
+ const resolved = resolve5(spec, filePath);
572
671
  if (!resolved) continue;
573
672
  const relResolved = relative(projectRoot, resolved);
574
673
  deps.add(relResolved);
@@ -727,13 +826,13 @@ import { join as join4 } from "path";
727
826
  import { tmpdir } from "os";
728
827
 
729
828
  // src/utils/pm.ts
730
- import { existsSync as existsSync3 } from "fs";
829
+ import { existsSync as existsSync4 } from "fs";
731
830
  import { join as join3 } from "path";
732
831
  function detectPackageManager(dir) {
733
- if (existsSync3(join3(dir, "bun.lockb")) || existsSync3(join3(dir, "bun.lock")))
832
+ if (existsSync4(join3(dir, "bun.lockb")) || existsSync4(join3(dir, "bun.lock")))
734
833
  return "bun";
735
- if (existsSync3(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
736
- if (existsSync3(join3(dir, "yarn.lock"))) return "yarn";
834
+ if (existsSync4(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
835
+ if (existsSync4(join3(dir, "yarn.lock"))) return "yarn";
737
836
  return "npm";
738
837
  }
739
838
  function pmExec(pm) {
@@ -790,11 +889,11 @@ async function createWorktree(base, cwd) {
790
889
 
791
890
  // src/stages/server.ts
792
891
  import { spawn } from "child_process";
793
- import { existsSync as existsSync4 } from "fs";
892
+ import { existsSync as existsSync5 } from "fs";
794
893
  import { join as join5 } from "path";
795
894
  function waitForServer(url, timeoutMs) {
796
895
  const start = Date.now();
797
- return new Promise((resolve4, reject) => {
896
+ return new Promise((resolve5, reject) => {
798
897
  const poll = async () => {
799
898
  if (Date.now() - start > timeoutMs) {
800
899
  reject(
@@ -807,7 +906,7 @@ function waitForServer(url, timeoutMs) {
807
906
  }
808
907
  try {
809
908
  await fetch(url);
810
- resolve4();
909
+ resolve5();
811
910
  } catch {
812
911
  setTimeout(poll, 150);
813
912
  }
@@ -821,7 +920,7 @@ async function startServer(projectDir, port) {
821
920
  const exec2 = pmExec(pm);
822
921
  const [cmd, ...baseArgs] = exec2.split(" ");
823
922
  const lockFile = join5(projectDir, ".next", "dev", "lock");
824
- if (existsSync4(lockFile)) {
923
+ if (existsSync5(lockFile)) {
825
924
  throw new AfterbeforeError(
826
925
  `Another Next.js dev server is running in ${projectDir} (.next/dev/lock exists).`,
827
926
  `Stop the other dev server first, or delete .next/dev/lock if it's stale.`
@@ -848,17 +947,17 @@ async function stopServer(server) {
848
947
  process.kill(-pid, "SIGTERM");
849
948
  } catch {
850
949
  }
851
- await new Promise((resolve4) => {
950
+ await new Promise((resolve5) => {
852
951
  const timeout = setTimeout(() => {
853
952
  try {
854
953
  process.kill(-pid, "SIGKILL");
855
954
  } catch {
856
955
  }
857
- resolve4();
956
+ resolve5();
858
957
  }, 5e3);
859
958
  server.process.on("exit", () => {
860
959
  clearTimeout(timeout);
861
- resolve4();
960
+ resolve5();
862
961
  });
863
962
  });
864
963
  }
@@ -1557,65 +1656,111 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1557
1656
  }
1558
1657
 
1559
1658
  // src/stages/compare.ts
1560
- import { join as join7 } from "path";
1561
- import { unlinkSync } from "fs";
1659
+ import { join as join7, dirname as dirname2 } from "path";
1660
+ import { readFileSync as readFileSync5, unlinkSync } from "fs";
1562
1661
  import { ODiffServer } from "odiff-bin";
1563
- async function compareOne(capture, outputDir, threshold, server) {
1662
+ import sharp from "sharp";
1663
+ function readPngSize(path) {
1664
+ const buf = readFileSync5(path);
1665
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
1666
+ }
1667
+ async function generateComposite(beforePath, afterPath, outputPath, bgColor) {
1668
+ const beforeSize = readPngSize(beforePath);
1669
+ const afterSize = readPngSize(afterPath);
1670
+ const imgW = Math.max(beforeSize.width, afterSize.width);
1671
+ const imgH = Math.max(beforeSize.height, afterSize.height);
1672
+ const PADDING = 120;
1673
+ const GAP = 80;
1674
+ const LABEL_H = 70;
1675
+ const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
1676
+ const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
1677
+ const maxImgH = canvasH - PADDING * 2 - LABEL_H;
1678
+ const colW = Math.floor((canvasW - PADDING * 2 - GAP) / 2);
1679
+ const [beforeBuf, afterBuf] = await Promise.all([
1680
+ sharp(beforePath).resize(colW, maxImgH, { fit: "inside" }).toBuffer({ resolveWithObject: true }),
1681
+ sharp(afterPath).resize(colW, maxImgH, { fit: "inside" }).toBuffer({ resolveWithObject: true })
1682
+ ]);
1683
+ const beforeLeft = PADDING + Math.floor((colW - beforeBuf.info.width) / 2);
1684
+ const beforeTop = PADDING + Math.floor((maxImgH - beforeBuf.info.height) / 2);
1685
+ const afterLeft = PADDING + colW + GAP + Math.floor((colW - afterBuf.info.width) / 2);
1686
+ const afterTop = PADDING + Math.floor((maxImgH - afterBuf.info.height) / 2);
1687
+ const labelY = PADDING + maxImgH + 50;
1688
+ const beforeLabelX = PADDING + Math.floor(colW / 2);
1689
+ const afterLabelX = PADDING + colW + GAP + Math.floor(colW / 2);
1690
+ const labelSvg = Buffer.from(
1691
+ `<svg width="${canvasW}" height="${canvasH}">
1692
+ <text x="${beforeLabelX}" y="${labelY}" text-anchor="middle"
1693
+ font-family="system-ui, sans-serif" font-size="30" font-weight="500"
1694
+ fill="#888">Before</text>
1695
+ <text x="${afterLabelX}" y="${labelY}" text-anchor="middle"
1696
+ font-family="system-ui, sans-serif" font-size="30" font-weight="500"
1697
+ fill="#22c55e">After</text>
1698
+ </svg>`
1699
+ );
1700
+ await sharp({
1701
+ create: {
1702
+ width: canvasW,
1703
+ height: canvasH,
1704
+ channels: 4,
1705
+ background: bgColor
1706
+ }
1707
+ }).composite([
1708
+ { input: beforeBuf.data, left: beforeLeft, top: beforeTop },
1709
+ { input: afterBuf.data, left: afterLeft, top: afterTop },
1710
+ { input: labelSvg, left: 0, top: 0 }
1711
+ ]).png().toFile(outputPath);
1712
+ }
1713
+ async function compareOne(capture, outputDir, threshold, server, options) {
1564
1714
  const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
1715
+ const dir = dirname2(capture.beforePath);
1716
+ const comparePath = join7(dir, `${capture.prefix}-compare.png`);
1565
1717
  const result = await server.compare(
1566
1718
  capture.beforePath,
1567
1719
  capture.afterPath,
1568
1720
  diffPath,
1569
1721
  { threshold: 0.1, antialiasing: true }
1570
1722
  );
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
- };
1723
+ try {
1724
+ unlinkSync(diffPath);
1725
+ } catch {
1582
1726
  }
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
- };
1727
+ let diffPixels = 0;
1728
+ let totalPixels = 0;
1729
+ let diffPercentage = 0;
1730
+ let changed = false;
1731
+ if (result.match) {
1732
+ } else if (result.reason === "pixel-diff") {
1733
+ diffPixels = result.diffCount;
1734
+ diffPercentage = result.diffPercentage;
1735
+ changed = diffPercentage > threshold;
1736
+ } else {
1737
+ diffPercentage = 100;
1738
+ changed = true;
1601
1739
  }
1740
+ await generateComposite(
1741
+ capture.beforePath,
1742
+ capture.afterPath,
1743
+ comparePath,
1744
+ options.bgColor
1745
+ );
1602
1746
  return {
1603
1747
  route: capture.route,
1604
1748
  prefix: capture.prefix,
1605
1749
  beforePath: capture.beforePath,
1606
1750
  afterPath: capture.afterPath,
1607
- diffPixels: 0,
1608
- totalPixels: 0,
1609
- diffPercentage: 100,
1610
- changed: true
1751
+ comparePath,
1752
+ diffPixels,
1753
+ totalPixels,
1754
+ diffPercentage,
1755
+ changed
1611
1756
  };
1612
1757
  }
1613
- async function compareScreenshots(captures, outputDir, threshold = 0.1) {
1758
+ async function compareScreenshots(captures, outputDir, threshold = 0.1, options) {
1614
1759
  const server = new ODiffServer();
1615
1760
  try {
1616
1761
  const results = [];
1617
1762
  for (const capture of captures) {
1618
- results.push(await compareOne(capture, outputDir, threshold, server));
1763
+ results.push(await compareOne(capture, outputDir, threshold, server, options));
1619
1764
  }
1620
1765
  return results;
1621
1766
  } finally {
@@ -1662,10 +1807,10 @@ function generateSummaryMd(results, gitDiff, options) {
1662
1807
  if (includeFilePaths) {
1663
1808
  lines.push("");
1664
1809
  lines.push("### Screenshots");
1665
- lines.push("| Route | Before | After |");
1666
- lines.push("|-------|--------|-------|");
1810
+ lines.push("| Route | Before | After | Compare |");
1811
+ lines.push("|-------|--------|-------|---------|");
1667
1812
  for (const r of changed) {
1668
- lines.push(`| \`${r.route}\` | \`${r.beforePath}\` | \`${r.afterPath}\` |`);
1813
+ lines.push(`| \`${r.route}\` | \`${r.beforePath}\` | \`${r.afterPath}\` | \`${r.comparePath}\` |`);
1669
1814
  }
1670
1815
  lines.push("");
1671
1816
  lines.push("Review the before/after screenshots above to verify the visual changes match the code diff.");
@@ -1791,10 +1936,10 @@ function expandRoutes(routes, config, routeComponentMap) {
1791
1936
  async function runPipeline(options) {
1792
1937
  const { base, output, post, cwd } = options;
1793
1938
  const sessionName = generateSessionName(cwd);
1794
- const outputDir = resolve3(cwd, output, sessionName);
1939
+ const outputDir = resolve4(cwd, output, sessionName);
1795
1940
  const startTime = Date.now();
1796
1941
  try {
1797
- const version = true ? "0.1.12" : "dev";
1942
+ const version = true ? "0.1.13" : "dev";
1798
1943
  console.log(`
1799
1944
  afterbefore v${version} \xB7 Comparing against ${base}
1800
1945
  `);
@@ -1895,7 +2040,8 @@ afterbefore v${version} \xB7 Comparing against ${base}
1895
2040
  }
1896
2041
  );
1897
2042
  logger.pipeline(7, "Comparing screenshots...");
1898
- const allResults = await compareScreenshots(captures, outputDir, options.threshold);
2043
+ const bgColor = detectBgColor(cwd);
2044
+ const allResults = await compareScreenshots(captures, outputDir, options.threshold, { bgColor });
1899
2045
  const results = allResults.filter((r) => {
1900
2046
  const isSubCapture = r.prefix.includes("~");
1901
2047
  if (isSubCapture && !r.changed) {
@@ -1907,6 +2053,10 @@ afterbefore v${version} \xB7 Comparing against ${base}
1907
2053
  unlinkSync2(r.afterPath);
1908
2054
  } catch {
1909
2055
  }
2056
+ try {
2057
+ unlinkSync2(r.comparePath);
2058
+ } catch {
2059
+ }
1910
2060
  return false;
1911
2061
  }
1912
2062
  return true;
@@ -1923,7 +2073,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
1923
2073
  );
1924
2074
  } finally {
1925
2075
  try {
1926
- logger.writeLogFile(resolve3(outputDir, "debug.log"));
2076
+ logger.writeLogFile(resolve4(outputDir, "debug.log"));
1927
2077
  } catch {
1928
2078
  }
1929
2079
  await cleanupRegistry.runAll();
@@ -1934,7 +2084,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
1934
2084
  var program = new Command();
1935
2085
  program.name("afterbefore").description(
1936
2086
  "Automatic before/after screenshot capture for PRs. Git diff is the config."
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(
2087
+ ).version("0.1.13").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(
1938
2088
  "--threshold <percent>",
1939
2089
  "Diff threshold percentage (changes below this are ignored)",
1940
2090
  "0.1"