afterbefore 0.1.7 → 0.1.9

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
@@ -109,6 +109,7 @@ var Logger = class {
109
109
  return;
110
110
  }
111
111
  this.spinner.text = text;
112
+ this.spinner.render();
112
113
  }
113
114
  completePipeline(finished = false) {
114
115
  this.pipelineActive = false;
@@ -432,12 +433,6 @@ function isGlobalVisualFile(filePath) {
432
433
  const p = filePath.replace(/^src\//, "");
433
434
  return GLOBAL_FILES.has(p) || /globals?\.(css|scss)$/.test(p);
434
435
  }
435
- var VISUAL_CATEGORIES = /* @__PURE__ */ new Set([
436
- "page",
437
- "component",
438
- "style",
439
- "layout"
440
- ]);
441
436
  function classifyFile(filePath) {
442
437
  const p = filePath.replace(/^src\//, "");
443
438
  if (/\.(test|spec)\.[tj]sx?$/.test(p) || /^tests?\//.test(p) || /\/__tests__\//.test(p) || p.includes(".test.") || p.includes(".spec.")) {
@@ -474,9 +469,6 @@ function classifyFiles(files) {
474
469
  category: classifyFile(f.path)
475
470
  }));
476
471
  }
477
- function filterVisuallyRelevant(files) {
478
- return files.filter((f) => VISUAL_CATEGORIES.has(f.category));
479
- }
480
472
 
481
473
  // src/stages/graph.ts
482
474
  import { readdirSync, readFileSync as readFileSync4 } from "fs";
@@ -728,6 +720,14 @@ function getLayoutDir(filePath) {
728
720
  return match[1];
729
721
  }
730
722
 
723
+ // src/utils/path.ts
724
+ function normalizePath(filePath) {
725
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
726
+ }
727
+ function sanitizeLabel(label, maxLength = 40) {
728
+ return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, maxLength);
729
+ }
730
+
731
731
  // src/stages/impact.ts
732
732
  var MAX_DEPTH = 3;
733
733
  function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
@@ -795,6 +795,28 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
795
795
  logger.dim(`Found ${routes.length} affected route(s)`);
796
796
  return routes;
797
797
  }
798
+ function findRoutesForFile(file, graph) {
799
+ const routes = /* @__PURE__ */ new Set();
800
+ const start = normalizePath(file);
801
+ const queue = [{ path: start, depth: 0 }];
802
+ const visited = /* @__PURE__ */ new Set([start]);
803
+ while (queue.length > 0) {
804
+ const { path, depth } = queue.shift();
805
+ if (isPageFile(path)) {
806
+ const route = pagePathToRoute(path);
807
+ if (route) routes.add(route);
808
+ }
809
+ if (depth >= MAX_DEPTH) continue;
810
+ const importers = graph.reverse.get(path);
811
+ if (!importers) continue;
812
+ for (const importer of importers) {
813
+ if (visited.has(importer)) continue;
814
+ visited.add(importer);
815
+ queue.push({ path: importer, depth: depth + 1 });
816
+ }
817
+ }
818
+ return Array.from(routes);
819
+ }
798
820
 
799
821
  // src/stages/worktree.ts
800
822
  import { execSync as execSync2 } from "child_process";
@@ -969,7 +991,7 @@ async function detectTabs(page, maxTabs) {
969
991
  return allTabs.filter((t) => !t.selected).slice(0, maxTabs);
970
992
  }
971
993
  function sanitizeTabLabel(label) {
972
- return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
994
+ return sanitizeLabel(label);
973
995
  }
974
996
 
975
997
  // src/utils/sections.ts
@@ -1061,7 +1083,7 @@ async function cleanupSectionTags(page) {
1061
1083
  });
1062
1084
  }
1063
1085
  function sanitizeSectionLabel(label) {
1064
- return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
1086
+ return sanitizeLabel(label);
1065
1087
  }
1066
1088
 
1067
1089
  // src/stages/capture.ts
@@ -1077,11 +1099,9 @@ async function launchBrowser() {
1077
1099
  }
1078
1100
  }
1079
1101
  var MAX_COMPONENT_INSTANCES_PER_SOURCE = 20;
1080
- function normalizePath(filePath) {
1081
- return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
1082
- }
1083
1102
  function sanitizeComponentLabel(label) {
1084
- return label.toLowerCase().replace(/\.[a-z0-9]+$/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
1103
+ const noExt = label.replace(/\.[a-z0-9]+$/i, "");
1104
+ return sanitizeLabel(noExt, 60);
1085
1105
  }
1086
1106
  function groupBySource(instances) {
1087
1107
  const map = /* @__PURE__ */ new Map();
@@ -1396,6 +1416,88 @@ async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLa
1396
1416
  await cleanupSectionTags(afterPage);
1397
1417
  await cleanupSectionTags(beforePage);
1398
1418
  }
1419
+ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl, outputDir, options, settle, results) {
1420
+ const tabs = await detectTabs(afterPage, options.maxTabsPerRoute);
1421
+ const usedPrefixes = /* @__PURE__ */ new Set();
1422
+ for (const tab of tabs) {
1423
+ let slug = sanitizeTabLabel(tab.label);
1424
+ if (!slug) continue;
1425
+ if (usedPrefixes.has(slug)) {
1426
+ let suffix = 2;
1427
+ while (usedPrefixes.has(`${slug}-${suffix}`)) suffix++;
1428
+ slug = `${slug}-${suffix}`;
1429
+ }
1430
+ usedPrefixes.add(slug);
1431
+ const tabPrefix = `${task.prefix}~${slug}`;
1432
+ const tabLabel = `${task.label} [${tab.label}]`;
1433
+ const tabBeforePath = join6(outputDir, `${tabPrefix}-before.png`);
1434
+ const tabAfterPath = join6(outputDir, `${tabPrefix}-after.png`);
1435
+ try {
1436
+ const afterUrlBefore = afterPage.url();
1437
+ await afterPage.getByRole("tab", { name: tab.label }).first().click();
1438
+ await settle(afterPage);
1439
+ if (afterPage.url() !== afterUrlBefore) {
1440
+ await afterPage.goBack({ waitUntil: "networkidle" });
1441
+ await settle(afterPage);
1442
+ continue;
1443
+ }
1444
+ await afterPage.screenshot({ path: tabAfterPath, fullPage: true });
1445
+ try {
1446
+ const beforeUrlBefore = beforePage.url();
1447
+ await beforePage.getByRole("tab", { name: tab.label }).first().click({ timeout: 2e3 });
1448
+ await settle(beforePage);
1449
+ if (beforePage.url() !== beforeUrlBefore) {
1450
+ await beforePage.goBack({ waitUntil: "networkidle" });
1451
+ await settle(beforePage);
1452
+ await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
1453
+ } else {
1454
+ await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
1455
+ }
1456
+ } catch {
1457
+ await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
1458
+ }
1459
+ results.push({
1460
+ route: tabLabel,
1461
+ prefix: tabPrefix,
1462
+ beforePath: tabBeforePath,
1463
+ afterPath: tabAfterPath
1464
+ });
1465
+ if ((task.changedComponents?.length ?? 0) > 0) {
1466
+ await captureComponentInstances(
1467
+ afterPage,
1468
+ beforePage,
1469
+ task.changedComponents,
1470
+ tabPrefix,
1471
+ tabLabel,
1472
+ outputDir,
1473
+ tabBeforePath,
1474
+ tabAfterPath,
1475
+ results
1476
+ );
1477
+ }
1478
+ if (options.autoSections && !task.skipAutoSections) {
1479
+ await captureAutoSections(
1480
+ afterPage,
1481
+ beforePage,
1482
+ tabPrefix,
1483
+ tabLabel,
1484
+ outputDir,
1485
+ options,
1486
+ settle,
1487
+ results
1488
+ );
1489
+ }
1490
+ } catch {
1491
+ logger.dim(` Skipped tab "${tab.label}" on ${task.route}`);
1492
+ }
1493
+ }
1494
+ if (tabs.length > 0) {
1495
+ await Promise.all([
1496
+ beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }),
1497
+ afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" })
1498
+ ]);
1499
+ }
1500
+ }
1399
1501
  async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1400
1502
  const browser = options.browser ?? await launchBrowser();
1401
1503
  const ownsBrowser = !options.browser;
@@ -1478,86 +1580,17 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1478
1580
  );
1479
1581
  }
1480
1582
  if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
1481
- const tabs = await detectTabs(afterPage, options.maxTabsPerRoute);
1482
- const usedPrefixes = /* @__PURE__ */ new Set();
1483
- for (const tab of tabs) {
1484
- let slug = sanitizeTabLabel(tab.label);
1485
- if (!slug) continue;
1486
- if (usedPrefixes.has(slug)) {
1487
- let suffix = 2;
1488
- while (usedPrefixes.has(`${slug}-${suffix}`)) suffix++;
1489
- slug = `${slug}-${suffix}`;
1490
- }
1491
- usedPrefixes.add(slug);
1492
- const tabPrefix = `${task.prefix}~${slug}`;
1493
- const tabLabel = `${task.label} [${tab.label}]`;
1494
- const tabBeforePath = join6(outputDir, `${tabPrefix}-before.png`);
1495
- const tabAfterPath = join6(outputDir, `${tabPrefix}-after.png`);
1496
- try {
1497
- const afterUrlBefore = afterPage.url();
1498
- await afterPage.getByRole("tab", { name: tab.label }).first().click();
1499
- await settle(afterPage);
1500
- if (afterPage.url() !== afterUrlBefore) {
1501
- await afterPage.goBack({ waitUntil: "networkidle" });
1502
- await settle(afterPage);
1503
- continue;
1504
- }
1505
- await afterPage.screenshot({ path: tabAfterPath, fullPage: true });
1506
- try {
1507
- const beforeUrlBefore = beforePage.url();
1508
- await beforePage.getByRole("tab", { name: tab.label }).first().click({ timeout: 2e3 });
1509
- await settle(beforePage);
1510
- if (beforePage.url() !== beforeUrlBefore) {
1511
- await beforePage.goBack({ waitUntil: "networkidle" });
1512
- await settle(beforePage);
1513
- await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
1514
- } else {
1515
- await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
1516
- }
1517
- } catch {
1518
- await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
1519
- }
1520
- results.push({
1521
- route: tabLabel,
1522
- prefix: tabPrefix,
1523
- beforePath: tabBeforePath,
1524
- afterPath: tabAfterPath
1525
- });
1526
- if ((task.changedComponents?.length ?? 0) > 0) {
1527
- await captureComponentInstances(
1528
- afterPage,
1529
- beforePage,
1530
- task.changedComponents,
1531
- tabPrefix,
1532
- tabLabel,
1533
- outputDir,
1534
- tabBeforePath,
1535
- tabAfterPath,
1536
- results
1537
- );
1538
- }
1539
- if (options.autoSections && !task.skipAutoSections) {
1540
- await captureAutoSections(
1541
- afterPage,
1542
- beforePage,
1543
- tabPrefix,
1544
- tabLabel,
1545
- outputDir,
1546
- options,
1547
- settle,
1548
- results
1549
- );
1550
- }
1551
- } catch {
1552
- logger.dim(` Skipped tab "${tab.label}" on ${task.route}`);
1553
- }
1554
- }
1555
- if (tabs.length > 0) {
1556
- await Promise.all([
1557
- beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }),
1558
- afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" })
1559
- ]);
1560
- }
1583
+ await captureAutoTabs(
1584
+ afterPage,
1585
+ beforePage,
1586
+ task,
1587
+ beforeUrl,
1588
+ afterUrl,
1589
+ outputDir,
1590
+ options,
1591
+ settle,
1592
+ results
1593
+ );
1561
1594
  }
1562
1595
  }
1563
1596
  await Promise.all([beforeCtx.close(), afterCtx.close()]);
@@ -1592,18 +1625,30 @@ function normalizeDimensions(img1, img2) {
1592
1625
  return [pad(img1), pad(img2)];
1593
1626
  }
1594
1627
  async function generateComposite(beforePath, afterPath, outputPath, browser, bgColor) {
1595
- const beforeUri = `data:image/png;base64,${readFileSync5(beforePath).toString("base64")}`;
1596
- const afterUri = `data:image/png;base64,${readFileSync5(afterPath).toString("base64")}`;
1628
+ const beforeBuf = readFileSync5(beforePath);
1629
+ const afterBuf = readFileSync5(afterPath);
1630
+ const beforeUri = `data:image/png;base64,${beforeBuf.toString("base64")}`;
1631
+ const afterUri = `data:image/png;base64,${afterBuf.toString("base64")}`;
1632
+ const beforePng = PNG.sync.read(beforeBuf);
1633
+ const afterPng = PNG.sync.read(afterBuf);
1634
+ const imgW = Math.max(beforePng.width, afterPng.width);
1635
+ const imgH = Math.max(beforePng.height, afterPng.height);
1636
+ const PADDING = 120;
1637
+ const GAP = 80;
1638
+ const LABEL_H = 70;
1639
+ const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
1640
+ const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
1641
+ const maxImgH = canvasH - PADDING * 2 - LABEL_H;
1597
1642
  const page = await browser.newPage({
1598
- viewport: { width: 2400, height: 1600 },
1643
+ viewport: { width: canvasW, height: canvasH },
1599
1644
  deviceScaleFactor: 1
1600
1645
  });
1601
1646
  const html = `<!DOCTYPE html>
1602
1647
  <html><head><style>
1603
1648
  * { margin: 0; box-sizing: border-box; }
1604
- body { background: ${bgColor}; display: flex; justify-content: center; align-items: center; width: 2400px; height: 1600px; padding: 120px; gap: 80px; overflow: hidden; }
1649
+ 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; }
1605
1650
  .col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; max-height: 100%; }
1606
- img { width: 100%; max-height: 1280px; object-fit: contain; }
1651
+ img { width: 100%; max-height: ${maxImgH}px; object-fit: contain; }
1607
1652
  .label { margin-top: 40px; font: 500 30px/1 system-ui, sans-serif; flex-shrink: 0; }
1608
1653
  .before { color: #888; }
1609
1654
  .after { color: #22c55e; }
@@ -1788,36 +1833,10 @@ function routeToPrefix(route) {
1788
1833
  if (route === "/") return "_root";
1789
1834
  return route.replace(/^\//, "").replace(/\//g, "-");
1790
1835
  }
1791
- var ROUTE_IMPACT_MAX_DEPTH = 3;
1792
- function normalizePath2(filePath) {
1793
- return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
1794
- }
1795
- function findRoutesForChangedFile(changedFile, graph) {
1796
- const routes = /* @__PURE__ */ new Set();
1797
- const start = normalizePath2(changedFile);
1798
- const queue = [{ file: start, depth: 0 }];
1799
- const visited = /* @__PURE__ */ new Set([start]);
1800
- while (queue.length > 0) {
1801
- const { file, depth } = queue.shift();
1802
- if (isPageFile(file)) {
1803
- const route = pagePathToRoute(file);
1804
- if (route) routes.add(route);
1805
- }
1806
- if (depth >= ROUTE_IMPACT_MAX_DEPTH) continue;
1807
- const importers = graph.reverse.get(file);
1808
- if (!importers) continue;
1809
- for (const importer of importers) {
1810
- if (visited.has(importer)) continue;
1811
- visited.add(importer);
1812
- queue.push({ file: importer, depth: depth + 1 });
1813
- }
1814
- }
1815
- return Array.from(routes);
1816
- }
1817
1836
  function mapRouteToChangedComponents(changedComponentFiles, graph) {
1818
1837
  const routeMap = /* @__PURE__ */ new Map();
1819
1838
  for (const componentPath of changedComponentFiles) {
1820
- const routes = findRoutesForChangedFile(componentPath, graph);
1839
+ const routes = findRoutesForFile(componentPath, graph);
1821
1840
  for (const route of routes) {
1822
1841
  const next = routeMap.get(route) ?? [];
1823
1842
  next.push(componentPath);
@@ -1860,7 +1879,7 @@ async function runPipeline(options) {
1860
1879
  const outputDir = resolve4(cwd, output, sessionName);
1861
1880
  const startTime = Date.now();
1862
1881
  try {
1863
- const version = true ? "0.1.7" : "dev";
1882
+ const version = true ? "0.1.9" : "dev";
1864
1883
  console.log(`
1865
1884
  afterbefore v${version} \xB7 Comparing against ${base}
1866
1885
  `);
@@ -1875,7 +1894,6 @@ afterbefore v${version} \xB7 Comparing against ${base}
1875
1894
  return;
1876
1895
  }
1877
1896
  const classified = classifyFiles(diffFiles);
1878
- const visualFiles = filterVisuallyRelevant(classified);
1879
1897
  const impactfulFiles = classified.filter(
1880
1898
  (f) => f.category !== "test" && f.category !== "other"
1881
1899
  );
@@ -2006,7 +2024,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
2006
2024
  var program = new Command();
2007
2025
  program.name("afterbefore").description(
2008
2026
  "Automatic before/after screenshot capture for PRs. Git diff is the config."
2009
- ).version("0.1.7").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(
2027
+ ).version("0.1.9").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(
2010
2028
  "--threshold <percent>",
2011
2029
  "Diff threshold percentage (changes below this are ignored)",
2012
2030
  "0.1"
@@ -2014,7 +2032,7 @@ program.name("afterbefore").description(
2014
2032
  "--max-routes <count>",
2015
2033
  "Maximum routes to capture (0 = unlimited)",
2016
2034
  "6"
2017
- ).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) => {
2035
+ ).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("--no-auto-sections", "Disable auto-detection of heading-labeled sections").option("--max-sections <count>", "Max auto-detected sections per page state", "10").action(async (opts) => {
2018
2036
  const cwd = process.cwd();
2019
2037
  if (!isGitRepo(cwd)) {
2020
2038
  logger.error("Not a git repository. Run this from inside a git repo.");
@@ -2032,7 +2050,7 @@ program.name("afterbefore").description(
2032
2050
  delay: parseInt(opts.delay, 10),
2033
2051
  autoTabs: opts.autoTabs,
2034
2052
  maxTabsPerRoute: parseInt(opts.maxTabs, 10),
2035
- autoSections: opts.autoSections ?? false,
2053
+ autoSections: opts.autoSections,
2036
2054
  maxSectionsPerRoute: parseInt(opts.maxSections, 10),
2037
2055
  cwd
2038
2056
  };