afterbefore 0.1.17 → 0.1.18
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 +600 -76
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +602 -83
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
+
import chalk3 from "chalk";
|
|
5
6
|
|
|
6
7
|
// src/logger.ts
|
|
7
8
|
import { writeFileSync } from "fs";
|
|
@@ -119,6 +120,18 @@ var Logger = class {
|
|
|
119
120
|
this.spinner.text = text;
|
|
120
121
|
this.spinner.render();
|
|
121
122
|
}
|
|
123
|
+
stageComplete(name, detail, durationMs) {
|
|
124
|
+
const duration = durationMs < 1e3 ? `${Math.round(durationMs)}ms` : `${(durationMs / 1e3).toFixed(1)}s`;
|
|
125
|
+
const line = ` ${chalk.green("\u2713")} ${name.padEnd(12)} ${chalk.dim(detail.padEnd(50))} ${chalk.dim(duration)}`;
|
|
126
|
+
this.log("stage", `${name}: ${detail} (${duration})`);
|
|
127
|
+
if (this.isTTY && this.spinner) {
|
|
128
|
+
this.spinner.clear();
|
|
129
|
+
console.log(line);
|
|
130
|
+
this.spinner.render();
|
|
131
|
+
} else {
|
|
132
|
+
console.log(line);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
122
135
|
completePipeline(finished = false) {
|
|
123
136
|
this.pipelineActive = false;
|
|
124
137
|
this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
|
|
@@ -217,11 +230,22 @@ var cleanupRegistry = new CleanupRegistry();
|
|
|
217
230
|
// src/utils/git.ts
|
|
218
231
|
import { execSync } from "child_process";
|
|
219
232
|
function git(args, cwd) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
233
|
+
try {
|
|
234
|
+
return execSync(`git ${args}`, {
|
|
235
|
+
cwd,
|
|
236
|
+
encoding: "utf-8",
|
|
237
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
238
|
+
}).trim();
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
241
|
+
if (message.includes("ENOENT") || message.includes("not found")) {
|
|
242
|
+
throw new AfterbeforeError(
|
|
243
|
+
"Git is not installed or not in PATH.",
|
|
244
|
+
"Install git: https://git-scm.com/downloads"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
225
249
|
}
|
|
226
250
|
function isGitRepo(cwd) {
|
|
227
251
|
try {
|
|
@@ -232,7 +256,14 @@ function isGitRepo(cwd) {
|
|
|
232
256
|
}
|
|
233
257
|
}
|
|
234
258
|
function getMergeBase(base, cwd) {
|
|
235
|
-
|
|
259
|
+
try {
|
|
260
|
+
return git(`merge-base ${base} HEAD`, cwd);
|
|
261
|
+
} catch {
|
|
262
|
+
throw new AfterbeforeError(
|
|
263
|
+
`Could not find merge base for "${base}". The branch or ref may not exist.`,
|
|
264
|
+
`Run "git branch -a" to see available branches. Did you mean "master" instead of "main"?`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
236
267
|
}
|
|
237
268
|
function getDiffNameStatus(base, cwd) {
|
|
238
269
|
const mergeBase = getMergeBase(base, cwd);
|
|
@@ -247,8 +278,10 @@ function getCurrentBranch(cwd) {
|
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
// src/pipeline.ts
|
|
250
|
-
import { resolve as resolve4 } from "path";
|
|
251
|
-
import { unlinkSync as unlinkSync2 } from "fs";
|
|
281
|
+
import { resolve as resolve4, basename } from "path";
|
|
282
|
+
import { writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
283
|
+
import { execSync as execSync5 } from "child_process";
|
|
284
|
+
import chalk2 from "chalk";
|
|
252
285
|
|
|
253
286
|
// src/config.ts
|
|
254
287
|
import { resolve } from "path";
|
|
@@ -736,8 +769,8 @@ function sanitizeLabel(label, maxLength = 40) {
|
|
|
736
769
|
}
|
|
737
770
|
|
|
738
771
|
// src/stages/impact.ts
|
|
739
|
-
var
|
|
740
|
-
function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
|
|
772
|
+
var DEFAULT_MAX_DEPTH = 10;
|
|
773
|
+
function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
741
774
|
const routeMap = /* @__PURE__ */ new Map();
|
|
742
775
|
for (const file of changedFiles) {
|
|
743
776
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -759,7 +792,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
|
|
|
759
792
|
});
|
|
760
793
|
}
|
|
761
794
|
}
|
|
762
|
-
if (depth >=
|
|
795
|
+
if (depth >= maxDepth) continue;
|
|
763
796
|
const importers = graph.reverse.get(path);
|
|
764
797
|
if (!importers) continue;
|
|
765
798
|
for (const importer of importers) {
|
|
@@ -802,7 +835,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
|
|
|
802
835
|
logger.dim(`Found ${routes.length} affected route(s)`);
|
|
803
836
|
return routes;
|
|
804
837
|
}
|
|
805
|
-
function findRoutesForFile(file, graph) {
|
|
838
|
+
function findRoutesForFile(file, graph, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
806
839
|
const routes = /* @__PURE__ */ new Set();
|
|
807
840
|
const start = normalizePath(file);
|
|
808
841
|
const queue = [{ path: start, depth: 0 }];
|
|
@@ -813,7 +846,7 @@ function findRoutesForFile(file, graph) {
|
|
|
813
846
|
const route = pagePathToRoute(path);
|
|
814
847
|
if (route) routes.add(route);
|
|
815
848
|
}
|
|
816
|
-
if (depth >=
|
|
849
|
+
if (depth >= maxDepth) continue;
|
|
817
850
|
const importers = graph.reverse.get(path);
|
|
818
851
|
if (!importers) continue;
|
|
819
852
|
for (const importer of importers) {
|
|
@@ -1118,10 +1151,17 @@ async function launchBrowser() {
|
|
|
1118
1151
|
return await chromium.launch();
|
|
1119
1152
|
} catch {
|
|
1120
1153
|
logger.dim("Chromium not found, installing...");
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1154
|
+
try {
|
|
1155
|
+
execSync3("npx playwright install chromium", {
|
|
1156
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1157
|
+
});
|
|
1158
|
+
return await chromium.launch();
|
|
1159
|
+
} catch {
|
|
1160
|
+
throw new AfterbeforeError(
|
|
1161
|
+
"Could not install or launch Playwright Chromium.",
|
|
1162
|
+
'Run "npx playwright install chromium" manually, then try again.'
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1125
1165
|
}
|
|
1126
1166
|
}
|
|
1127
1167
|
var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
|
|
@@ -1671,55 +1711,101 @@ async function trimImage(path) {
|
|
|
1671
1711
|
const { data, info } = await sharp(path).trim({ threshold: 50 }).toBuffer({ resolveWithObject: true });
|
|
1672
1712
|
return { data, width: info.width, height: info.height };
|
|
1673
1713
|
}
|
|
1674
|
-
|
|
1714
|
+
function roundCornersSvg(w, h, r) {
|
|
1715
|
+
return Buffer.from(
|
|
1716
|
+
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
async function roundImage(buf, width, height, radius) {
|
|
1720
|
+
const mask = roundCornersSvg(width, height, radius);
|
|
1721
|
+
return sharp(buf).resize(width, height, { fit: "fill" }).composite([{ input: mask, blend: "dest-in" }]).png().toBuffer();
|
|
1722
|
+
}
|
|
1723
|
+
async function generateComposite(beforePath, afterPath, outputPath, bgColor, metadata) {
|
|
1675
1724
|
const [beforeTrimmed, afterTrimmed] = await Promise.all([
|
|
1676
1725
|
trimImage(beforePath),
|
|
1677
1726
|
trimImage(afterPath)
|
|
1678
1727
|
]);
|
|
1728
|
+
const CANVAS_W = 1200;
|
|
1729
|
+
const PADDING = 32;
|
|
1730
|
+
const GAP = 24;
|
|
1731
|
+
const HEADER_H = 48;
|
|
1732
|
+
const BADGE_H = 40;
|
|
1733
|
+
const FOOTER_H = 24;
|
|
1734
|
+
const CORNER_R = 12;
|
|
1735
|
+
const contentW = CANVAS_W - PADDING * 2;
|
|
1736
|
+
const colW = Math.floor((contentW - GAP) / 2);
|
|
1679
1737
|
const imgW = Math.max(beforeTrimmed.width, afterTrimmed.width);
|
|
1680
1738
|
const imgH = Math.max(beforeTrimmed.height, afterTrimmed.height);
|
|
1681
|
-
const
|
|
1682
|
-
const
|
|
1683
|
-
const
|
|
1684
|
-
const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
|
|
1685
|
-
const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
|
|
1686
|
-
const maxImgH = canvasH - PADDING * 2 - LABEL_H;
|
|
1687
|
-
const colW = Math.floor((canvasW - PADDING * 2 - GAP) / 2);
|
|
1739
|
+
const imgAspect = imgH / (imgW || 1);
|
|
1740
|
+
const scaledH = Math.min(Math.round(colW * imgAspect), 520);
|
|
1741
|
+
const CANVAS_H = PADDING + HEADER_H + scaledH + BADGE_H + FOOTER_H + PADDING;
|
|
1688
1742
|
const [beforeBuf, afterBuf] = await Promise.all(
|
|
1689
1743
|
[beforeTrimmed, afterTrimmed].map(async (trimmed) => {
|
|
1690
|
-
|
|
1744
|
+
const resized = await sharp(trimmed.data).resize(colW, scaledH, { fit: "contain", background: bgColor }).toBuffer({ resolveWithObject: true });
|
|
1745
|
+
return {
|
|
1746
|
+
data: await roundImage(resized.data, resized.info.width, resized.info.height, CORNER_R),
|
|
1747
|
+
width: resized.info.width,
|
|
1748
|
+
height: resized.info.height
|
|
1749
|
+
};
|
|
1691
1750
|
})
|
|
1692
1751
|
);
|
|
1693
|
-
const
|
|
1694
|
-
const
|
|
1695
|
-
const afterLeft = PADDING + colW + GAP
|
|
1696
|
-
const
|
|
1697
|
-
const
|
|
1698
|
-
const
|
|
1699
|
-
const
|
|
1700
|
-
const
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1752
|
+
const imgTop = PADDING + HEADER_H;
|
|
1753
|
+
const beforeLeft = PADDING;
|
|
1754
|
+
const afterLeft = PADDING + colW + GAP;
|
|
1755
|
+
const badgeY = imgTop + scaledH + 8;
|
|
1756
|
+
const beforeBadgeCX = PADDING + Math.floor(colW / 2);
|
|
1757
|
+
const afterBadgeCX = PADDING + colW + GAP + Math.floor(colW / 2);
|
|
1758
|
+
const diffPct = metadata.diffPercentage.toFixed(1);
|
|
1759
|
+
const diffBadgeW = Math.max(80, diffPct.length * 12 + 40);
|
|
1760
|
+
const overlaySvg = Buffer.from(
|
|
1761
|
+
`<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
|
|
1762
|
+
<!-- Header: route name + diff badge -->
|
|
1763
|
+
<text x="${PADDING}" y="${PADDING + 28}"
|
|
1764
|
+
font-family="'SF Mono', 'Fira Code', 'Consolas', monospace" font-size="18" font-weight="600"
|
|
1765
|
+
fill="#e0e0e0">${escapeXml(metadata.route)}</text>
|
|
1766
|
+
|
|
1767
|
+
<rect x="${CANVAS_W - PADDING - diffBadgeW}" y="${PADDING + 6}" width="${diffBadgeW}" height="28" rx="14"
|
|
1768
|
+
fill="#f59e0b" fill-opacity="0.2"/>
|
|
1769
|
+
<text x="${CANVAS_W - PADDING - diffBadgeW / 2}" y="${PADDING + 25}"
|
|
1770
|
+
font-family="system-ui, sans-serif" font-size="13" font-weight="700" text-anchor="middle"
|
|
1771
|
+
fill="#f59e0b">${diffPct}% changed</text>
|
|
1772
|
+
|
|
1773
|
+
<!-- "Before" pill badge -->
|
|
1774
|
+
<rect x="${beforeBadgeCX - 44}" y="${badgeY}" width="88" height="28" rx="14"
|
|
1775
|
+
fill="#555" fill-opacity="0.5"/>
|
|
1776
|
+
<text x="${beforeBadgeCX}" y="${badgeY + 19}"
|
|
1777
|
+
font-family="system-ui, sans-serif" font-size="13" font-weight="600" text-anchor="middle"
|
|
1778
|
+
fill="#ccc">Before</text>
|
|
1779
|
+
|
|
1780
|
+
<!-- "After" pill badge -->
|
|
1781
|
+
<rect x="${afterBadgeCX - 40}" y="${badgeY}" width="80" height="28" rx="14"
|
|
1782
|
+
fill="#22c55e" fill-opacity="0.25"/>
|
|
1783
|
+
<text x="${afterBadgeCX}" y="${badgeY + 19}"
|
|
1784
|
+
font-family="system-ui, sans-serif" font-size="13" font-weight="600" text-anchor="middle"
|
|
1707
1785
|
fill="#22c55e">After</text>
|
|
1786
|
+
|
|
1787
|
+
<!-- Branding -->
|
|
1788
|
+
<text x="${CANVAS_W - PADDING}" y="${CANVAS_H - 10}"
|
|
1789
|
+
font-family="system-ui, sans-serif" font-size="10" text-anchor="end"
|
|
1790
|
+
fill="#444">afterbefore</text>
|
|
1708
1791
|
</svg>`
|
|
1709
1792
|
);
|
|
1710
1793
|
await sharp({
|
|
1711
1794
|
create: {
|
|
1712
|
-
width:
|
|
1713
|
-
height:
|
|
1795
|
+
width: CANVAS_W,
|
|
1796
|
+
height: CANVAS_H,
|
|
1714
1797
|
channels: 4,
|
|
1715
|
-
background:
|
|
1798
|
+
background: "#1a1a2e"
|
|
1716
1799
|
}
|
|
1717
1800
|
}).composite([
|
|
1718
|
-
{ input: beforeBuf.data, left: beforeLeft, top:
|
|
1719
|
-
{ input: afterBuf.data, left: afterLeft, top:
|
|
1720
|
-
{ input:
|
|
1801
|
+
{ input: beforeBuf.data, left: beforeLeft, top: imgTop },
|
|
1802
|
+
{ input: afterBuf.data, left: afterLeft, top: imgTop },
|
|
1803
|
+
{ input: overlaySvg, left: 0, top: 0 }
|
|
1721
1804
|
]).png().toFile(outputPath);
|
|
1722
1805
|
}
|
|
1806
|
+
function escapeXml(str) {
|
|
1807
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1808
|
+
}
|
|
1723
1809
|
async function compareOne(capture, outputDir, threshold, server, options) {
|
|
1724
1810
|
const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
|
|
1725
1811
|
const dir = dirname2(capture.beforePath);
|
|
@@ -1747,18 +1833,30 @@ async function compareOne(capture, outputDir, threshold, server, options) {
|
|
|
1747
1833
|
diffPercentage = 100;
|
|
1748
1834
|
changed = true;
|
|
1749
1835
|
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1836
|
+
if (changed) {
|
|
1837
|
+
await generateComposite(
|
|
1838
|
+
capture.beforePath,
|
|
1839
|
+
capture.afterPath,
|
|
1840
|
+
comparePath,
|
|
1841
|
+
options.bgColor,
|
|
1842
|
+
{ route: capture.route, diffPercentage }
|
|
1843
|
+
);
|
|
1844
|
+
} else {
|
|
1845
|
+
try {
|
|
1846
|
+
unlinkSync(capture.beforePath);
|
|
1847
|
+
} catch {
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
unlinkSync(capture.afterPath);
|
|
1851
|
+
} catch {
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1756
1854
|
return {
|
|
1757
1855
|
route: capture.route,
|
|
1758
1856
|
prefix: capture.prefix,
|
|
1759
1857
|
beforePath: capture.beforePath,
|
|
1760
1858
|
afterPath: capture.afterPath,
|
|
1761
|
-
comparePath,
|
|
1859
|
+
comparePath: changed ? comparePath : "",
|
|
1762
1860
|
diffPixels,
|
|
1763
1861
|
totalPixels,
|
|
1764
1862
|
diffPercentage,
|
|
@@ -1830,7 +1928,21 @@ function generateSummaryMd(results, gitDiff, options) {
|
|
|
1830
1928
|
|
|
1831
1929
|
// src/stages/report.ts
|
|
1832
1930
|
var COMMENT_MARKER = "<!-- afterbefore -->";
|
|
1931
|
+
function isGhInstalled() {
|
|
1932
|
+
try {
|
|
1933
|
+
execSync4("gh --version", { stdio: ["pipe", "pipe", "pipe"] });
|
|
1934
|
+
return true;
|
|
1935
|
+
} catch {
|
|
1936
|
+
return false;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1833
1939
|
function findPrNumber() {
|
|
1940
|
+
if (!isGhInstalled()) {
|
|
1941
|
+
logger.warn(
|
|
1942
|
+
"GitHub CLI (gh) is not installed. Install it from https://cli.github.com"
|
|
1943
|
+
);
|
|
1944
|
+
return null;
|
|
1945
|
+
}
|
|
1834
1946
|
try {
|
|
1835
1947
|
const output = execSync4("gh pr view --json number -q .number", {
|
|
1836
1948
|
encoding: "utf-8",
|
|
@@ -1882,7 +1994,7 @@ async function generateReport(results, outputDir, options) {
|
|
|
1882
1994
|
const prNumber = findPrNumber();
|
|
1883
1995
|
if (!prNumber) {
|
|
1884
1996
|
logger.warn(
|
|
1885
|
-
"
|
|
1997
|
+
"No open PR found for this branch. Push your branch and open a PR first, then run `npx afterbefore --post` again."
|
|
1886
1998
|
);
|
|
1887
1999
|
return;
|
|
1888
2000
|
}
|
|
@@ -1892,6 +2004,317 @@ async function generateReport(results, outputDir, options) {
|
|
|
1892
2004
|
}
|
|
1893
2005
|
}
|
|
1894
2006
|
|
|
2007
|
+
// src/templates/report.html.ts
|
|
2008
|
+
function escapeHtml(str) {
|
|
2009
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2010
|
+
}
|
|
2011
|
+
function generateReportHtml(results, sessionName) {
|
|
2012
|
+
const changed = results.filter((r) => r.changed);
|
|
2013
|
+
const unchanged = results.filter((r) => !r.changed);
|
|
2014
|
+
const totalChecked = results.length;
|
|
2015
|
+
const changedCount = changed.length;
|
|
2016
|
+
const changedCards = changed.map((r) => {
|
|
2017
|
+
const diffPct = r.diffPercentage.toFixed(2);
|
|
2018
|
+
const beforeFile = r.beforePath.split("/").pop() || "";
|
|
2019
|
+
const afterFile = r.afterPath.split("/").pop() || "";
|
|
2020
|
+
const compareFile = r.comparePath ? r.comparePath.split("/").pop() || "" : "";
|
|
2021
|
+
return `
|
|
2022
|
+
<div class="card">
|
|
2023
|
+
<div class="card-header">
|
|
2024
|
+
<span class="route">${escapeHtml(r.route)}</span>
|
|
2025
|
+
<span class="badge">${diffPct}% changed</span>
|
|
2026
|
+
</div>
|
|
2027
|
+
${compareFile ? `
|
|
2028
|
+
<div class="compare-wrap">
|
|
2029
|
+
<img src="${escapeHtml(compareFile)}" alt="Side-by-side comparison of ${escapeHtml(r.route)}" class="compare-img" loading="lazy">
|
|
2030
|
+
</div>
|
|
2031
|
+
` : ""}
|
|
2032
|
+
<div class="slider-section">
|
|
2033
|
+
<p class="slider-label">Interactive comparison \u2014 drag the divider</p>
|
|
2034
|
+
<div class="slider" style="--pos: 50%">
|
|
2035
|
+
<img src="${escapeHtml(beforeFile)}" alt="Before" class="slider-img">
|
|
2036
|
+
<img src="${escapeHtml(afterFile)}" alt="After" class="slider-img slider-after" style="clip-path: inset(0 0 0 var(--pos))">
|
|
2037
|
+
<input type="range" min="0" max="100" value="50" class="slider-range"
|
|
2038
|
+
oninput="this.parentElement.style.setProperty('--pos', this.value + '%')">
|
|
2039
|
+
<div class="slider-labels">
|
|
2040
|
+
<span>Before</span>
|
|
2041
|
+
<span>After</span>
|
|
2042
|
+
</div>
|
|
2043
|
+
</div>
|
|
2044
|
+
</div>
|
|
2045
|
+
</div>`;
|
|
2046
|
+
}).join("\n");
|
|
2047
|
+
const unchangedSection = unchanged.length > 0 ? `
|
|
2048
|
+
<details class="unchanged-section">
|
|
2049
|
+
<summary>${unchanged.length} route${unchanged.length === 1 ? "" : "s"} unchanged</summary>
|
|
2050
|
+
<ul>
|
|
2051
|
+
${unchanged.map((r) => `<li><code>${escapeHtml(r.route)}</code></li>`).join("\n ")}
|
|
2052
|
+
</ul>
|
|
2053
|
+
</details>` : "";
|
|
2054
|
+
return `<!DOCTYPE html>
|
|
2055
|
+
<html lang="en">
|
|
2056
|
+
<head>
|
|
2057
|
+
<meta charset="UTF-8">
|
|
2058
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2059
|
+
<title>afterbefore report \u2014 ${escapeHtml(sessionName)}</title>
|
|
2060
|
+
<style>
|
|
2061
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2062
|
+
|
|
2063
|
+
body {
|
|
2064
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2065
|
+
background: #0f0f17;
|
|
2066
|
+
color: #e0e0e0;
|
|
2067
|
+
padding: 2rem;
|
|
2068
|
+
max-width: 1400px;
|
|
2069
|
+
margin: 0 auto;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
h1 {
|
|
2073
|
+
font-size: 1.5rem;
|
|
2074
|
+
font-weight: 600;
|
|
2075
|
+
margin-bottom: 0.25rem;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
.subtitle {
|
|
2079
|
+
color: #888;
|
|
2080
|
+
font-size: 0.9rem;
|
|
2081
|
+
margin-bottom: 2rem;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
.summary {
|
|
2085
|
+
display: flex;
|
|
2086
|
+
gap: 1.5rem;
|
|
2087
|
+
margin-bottom: 2rem;
|
|
2088
|
+
flex-wrap: wrap;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
.stat {
|
|
2092
|
+
background: #1a1a2e;
|
|
2093
|
+
border-radius: 8px;
|
|
2094
|
+
padding: 1rem 1.5rem;
|
|
2095
|
+
min-width: 140px;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
.stat-value {
|
|
2099
|
+
font-size: 1.8rem;
|
|
2100
|
+
font-weight: 700;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
.stat-value.changed { color: #f59e0b; }
|
|
2104
|
+
.stat-value.total { color: #3b82f6; }
|
|
2105
|
+
.stat-value.unchanged { color: #22c55e; }
|
|
2106
|
+
|
|
2107
|
+
.stat-label {
|
|
2108
|
+
color: #888;
|
|
2109
|
+
font-size: 0.8rem;
|
|
2110
|
+
margin-top: 0.25rem;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
.card {
|
|
2114
|
+
background: #1a1a2e;
|
|
2115
|
+
border-radius: 12px;
|
|
2116
|
+
margin-bottom: 2rem;
|
|
2117
|
+
overflow: hidden;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
.card-header {
|
|
2121
|
+
display: flex;
|
|
2122
|
+
align-items: center;
|
|
2123
|
+
justify-content: space-between;
|
|
2124
|
+
padding: 1rem 1.5rem;
|
|
2125
|
+
border-bottom: 1px solid #2a2a3e;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
.route {
|
|
2129
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
2130
|
+
font-size: 1.1rem;
|
|
2131
|
+
font-weight: 600;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
.badge {
|
|
2135
|
+
background: #f59e0b20;
|
|
2136
|
+
color: #f59e0b;
|
|
2137
|
+
padding: 0.25rem 0.75rem;
|
|
2138
|
+
border-radius: 999px;
|
|
2139
|
+
font-size: 0.8rem;
|
|
2140
|
+
font-weight: 600;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
.compare-wrap {
|
|
2144
|
+
padding: 1rem;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
.compare-img {
|
|
2148
|
+
width: 100%;
|
|
2149
|
+
border-radius: 8px;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
.slider-section {
|
|
2153
|
+
padding: 1rem 1.5rem 1.5rem;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
.slider-label {
|
|
2157
|
+
color: #666;
|
|
2158
|
+
font-size: 0.75rem;
|
|
2159
|
+
margin-bottom: 0.75rem;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
.slider {
|
|
2163
|
+
position: relative;
|
|
2164
|
+
overflow: hidden;
|
|
2165
|
+
border-radius: 8px;
|
|
2166
|
+
background: #111;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
.slider-img {
|
|
2170
|
+
display: block;
|
|
2171
|
+
width: 100%;
|
|
2172
|
+
height: auto;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
.slider-after {
|
|
2176
|
+
position: absolute;
|
|
2177
|
+
top: 0;
|
|
2178
|
+
left: 0;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
.slider-range {
|
|
2182
|
+
position: absolute;
|
|
2183
|
+
top: 0;
|
|
2184
|
+
left: 0;
|
|
2185
|
+
width: 100%;
|
|
2186
|
+
height: 100%;
|
|
2187
|
+
opacity: 0;
|
|
2188
|
+
cursor: col-resize;
|
|
2189
|
+
z-index: 2;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
.slider-labels {
|
|
2193
|
+
display: flex;
|
|
2194
|
+
justify-content: space-between;
|
|
2195
|
+
padding: 0.5rem 0;
|
|
2196
|
+
color: #666;
|
|
2197
|
+
font-size: 0.75rem;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
.unchanged-section {
|
|
2201
|
+
background: #1a1a2e;
|
|
2202
|
+
border-radius: 12px;
|
|
2203
|
+
padding: 1rem 1.5rem;
|
|
2204
|
+
margin-bottom: 2rem;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
.unchanged-section summary {
|
|
2208
|
+
cursor: pointer;
|
|
2209
|
+
color: #888;
|
|
2210
|
+
font-size: 0.9rem;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
.unchanged-section ul {
|
|
2214
|
+
margin-top: 0.75rem;
|
|
2215
|
+
padding-left: 1.5rem;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
.unchanged-section li {
|
|
2219
|
+
color: #666;
|
|
2220
|
+
margin-bottom: 0.25rem;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
.unchanged-section code {
|
|
2224
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
2225
|
+
font-size: 0.85rem;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
.summary-table {
|
|
2229
|
+
width: 100%;
|
|
2230
|
+
border-collapse: collapse;
|
|
2231
|
+
margin-bottom: 2rem;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
.summary-table th {
|
|
2235
|
+
text-align: left;
|
|
2236
|
+
padding: 0.5rem 1rem;
|
|
2237
|
+
border-bottom: 2px solid #2a2a3e;
|
|
2238
|
+
color: #888;
|
|
2239
|
+
font-size: 0.8rem;
|
|
2240
|
+
font-weight: 600;
|
|
2241
|
+
text-transform: uppercase;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
.summary-table td {
|
|
2245
|
+
padding: 0.5rem 1rem;
|
|
2246
|
+
border-bottom: 1px solid #1a1a2e;
|
|
2247
|
+
font-size: 0.9rem;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
.summary-table code {
|
|
2251
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
2252
|
+
font-size: 0.85rem;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
.status-changed { color: #f59e0b; }
|
|
2256
|
+
.status-unchanged { color: #22c55e; }
|
|
2257
|
+
|
|
2258
|
+
footer {
|
|
2259
|
+
text-align: center;
|
|
2260
|
+
padding: 2rem 0;
|
|
2261
|
+
color: #444;
|
|
2262
|
+
font-size: 0.75rem;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
footer a {
|
|
2266
|
+
color: #555;
|
|
2267
|
+
text-decoration: none;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
footer a:hover {
|
|
2271
|
+
color: #888;
|
|
2272
|
+
}
|
|
2273
|
+
</style>
|
|
2274
|
+
</head>
|
|
2275
|
+
<body>
|
|
2276
|
+
<h1>afterbefore report</h1>
|
|
2277
|
+
<p class="subtitle">${escapeHtml(sessionName)}</p>
|
|
2278
|
+
|
|
2279
|
+
<div class="summary">
|
|
2280
|
+
<div class="stat">
|
|
2281
|
+
<div class="stat-value total">${totalChecked}</div>
|
|
2282
|
+
<div class="stat-label">Routes checked</div>
|
|
2283
|
+
</div>
|
|
2284
|
+
<div class="stat">
|
|
2285
|
+
<div class="stat-value changed">${changedCount}</div>
|
|
2286
|
+
<div class="stat-label">With visual changes</div>
|
|
2287
|
+
</div>
|
|
2288
|
+
<div class="stat">
|
|
2289
|
+
<div class="stat-value unchanged">${unchanged.length}</div>
|
|
2290
|
+
<div class="stat-label">Unchanged</div>
|
|
2291
|
+
</div>
|
|
2292
|
+
</div>
|
|
2293
|
+
|
|
2294
|
+
<table class="summary-table">
|
|
2295
|
+
<thead>
|
|
2296
|
+
<tr><th>Route</th><th>Diff %</th><th>Status</th></tr>
|
|
2297
|
+
</thead>
|
|
2298
|
+
<tbody>
|
|
2299
|
+
${results.map((r) => {
|
|
2300
|
+
const status = r.changed ? "Changed" : "Unchanged";
|
|
2301
|
+
const statusClass = r.changed ? "status-changed" : "status-unchanged";
|
|
2302
|
+
const pct = r.changed ? `${r.diffPercentage.toFixed(2)}%` : "0%";
|
|
2303
|
+
return `<tr><td><code>${escapeHtml(r.route)}</code></td><td>${pct}</td><td class="${statusClass}">${status}</td></tr>`;
|
|
2304
|
+
}).join("\n ")}
|
|
2305
|
+
</tbody>
|
|
2306
|
+
</table>
|
|
2307
|
+
|
|
2308
|
+
${changedCards}
|
|
2309
|
+
${unchangedSection}
|
|
2310
|
+
|
|
2311
|
+
<footer>
|
|
2312
|
+
Generated by <a href="https://github.com/kairevicius/afterbefore">afterbefore</a>
|
|
2313
|
+
</footer>
|
|
2314
|
+
</body>
|
|
2315
|
+
</html>`;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
1895
2318
|
// src/pipeline.ts
|
|
1896
2319
|
function generateSessionName(cwd) {
|
|
1897
2320
|
const branch = getCurrentBranch(cwd);
|
|
@@ -1943,26 +2366,60 @@ function expandRoutes(routes, config, routeComponentMap) {
|
|
|
1943
2366
|
}
|
|
1944
2367
|
return tasks;
|
|
1945
2368
|
}
|
|
2369
|
+
function applyConfigDefaults(options, config) {
|
|
2370
|
+
if (!config?.defaults) return;
|
|
2371
|
+
const defaults = config.defaults;
|
|
2372
|
+
const cliDefaults = {
|
|
2373
|
+
base: "main",
|
|
2374
|
+
output: ".afterbefore",
|
|
2375
|
+
post: false,
|
|
2376
|
+
threshold: 0.1,
|
|
2377
|
+
maxRoutes: 6,
|
|
2378
|
+
width: 1280,
|
|
2379
|
+
height: 720,
|
|
2380
|
+
delay: 0,
|
|
2381
|
+
autoTabs: true,
|
|
2382
|
+
maxTabsPerRoute: 5,
|
|
2383
|
+
autoSections: true,
|
|
2384
|
+
maxSectionsPerRoute: 10,
|
|
2385
|
+
maxDepth: 10,
|
|
2386
|
+
dryRun: false,
|
|
2387
|
+
verbose: false,
|
|
2388
|
+
open: false
|
|
2389
|
+
};
|
|
2390
|
+
const opts = options;
|
|
2391
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
2392
|
+
if (key === "cwd" || value === void 0) continue;
|
|
2393
|
+
if (key in cliDefaults && opts[key] === cliDefaults[key]) {
|
|
2394
|
+
opts[key] = value;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
1946
2398
|
async function runPipeline(options) {
|
|
1947
2399
|
const { base, output, post, cwd } = options;
|
|
1948
2400
|
const sessionName = generateSessionName(cwd);
|
|
1949
2401
|
const outputDir = resolve4(cwd, output, sessionName);
|
|
1950
2402
|
const startTime = Date.now();
|
|
1951
2403
|
try {
|
|
1952
|
-
const version = true ? "0.1.
|
|
2404
|
+
const version = true ? "0.1.18" : "dev";
|
|
2405
|
+
const mode = options.dryRun ? "Dry run" : "Comparing";
|
|
1953
2406
|
console.log(`
|
|
1954
|
-
afterbefore v${version} \xB7
|
|
2407
|
+
afterbefore v${version} \xB7 ${mode} against ${base}
|
|
1955
2408
|
`);
|
|
1956
2409
|
const config = await loadConfig(cwd);
|
|
1957
|
-
|
|
2410
|
+
applyConfigDefaults(options, config);
|
|
2411
|
+
logger.startPipeline(options.dryRun ? 3 : 8);
|
|
2412
|
+
const t1 = Date.now();
|
|
1958
2413
|
logger.pipeline(1, "Analyzing diff...");
|
|
1959
2414
|
const diffFiles = getChangedFiles(base, cwd);
|
|
1960
|
-
const gitDiff = getGitDiff(base, cwd);
|
|
2415
|
+
const gitDiff = options.dryRun ? "" : getGitDiff(base, cwd);
|
|
2416
|
+
logger.stageComplete("Diff", `${diffFiles.length} files changed`, Date.now() - t1);
|
|
1961
2417
|
if (diffFiles.length === 0) {
|
|
1962
2418
|
logger.completePipeline();
|
|
1963
2419
|
logger.success("No changed files detected. Nothing to do.");
|
|
1964
2420
|
return;
|
|
1965
2421
|
}
|
|
2422
|
+
const t2 = Date.now();
|
|
1966
2423
|
const classified = classifyFiles(diffFiles);
|
|
1967
2424
|
const impactfulFiles = classified.filter(
|
|
1968
2425
|
(f) => f.category !== "test" && f.category !== "other"
|
|
@@ -1975,11 +2432,14 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
1975
2432
|
return;
|
|
1976
2433
|
}
|
|
1977
2434
|
logger.pipeline(2, "Building import graph...");
|
|
1978
|
-
const worktreePromise = createWorktree(base, cwd);
|
|
2435
|
+
const worktreePromise = options.dryRun ? null : createWorktree(base, cwd);
|
|
1979
2436
|
const graph = await buildImportGraph(cwd);
|
|
2437
|
+
const graphEdges = Array.from(graph.forward.values()).reduce((sum, deps) => sum + deps.size, 0);
|
|
2438
|
+
logger.stageComplete("Graph", `${graph.forward.size} modules, ${graphEdges} edges`, Date.now() - t2);
|
|
2439
|
+
const t3 = Date.now();
|
|
1980
2440
|
logger.pipeline(3, "Finding affected routes...");
|
|
1981
2441
|
const changedPaths = impactfulFiles.map((f) => f.path);
|
|
1982
|
-
let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes);
|
|
2442
|
+
let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes, options.maxDepth);
|
|
1983
2443
|
const changedComponentFiles = impactfulFiles.filter((f) => f.category === "component").map((f) => f.path);
|
|
1984
2444
|
const routeComponentMap = mapRouteToChangedComponents(changedComponentFiles, graph);
|
|
1985
2445
|
if (affectedRoutes.length === 0) {
|
|
@@ -2006,8 +2466,21 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2006
2466
|
}
|
|
2007
2467
|
}
|
|
2008
2468
|
}
|
|
2469
|
+
const directCount = affectedRoutes.filter((r) => r.reason === "direct").length;
|
|
2470
|
+
const transitiveCount = affectedRoutes.length - directCount;
|
|
2471
|
+
const impactDetail = directCount > 0 && transitiveCount > 0 ? `${affectedRoutes.length} routes (${directCount} direct, ${transitiveCount} transitive)` : `${affectedRoutes.length} routes`;
|
|
2472
|
+
logger.stageComplete("Impact", impactDetail, Date.now() - t3);
|
|
2473
|
+
if (options.verbose) {
|
|
2474
|
+
console.log("");
|
|
2475
|
+
for (const r of affectedRoutes) {
|
|
2476
|
+
const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
|
|
2477
|
+
const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
|
|
2478
|
+
console.log(chalk2.dim(` ${r.route.padEnd(24)} ${depthLabel.padEnd(10)} ${chain}`));
|
|
2479
|
+
}
|
|
2480
|
+
console.log("");
|
|
2481
|
+
}
|
|
2009
2482
|
if (affectedRoutes.length === 0) {
|
|
2010
|
-
worktreePromise
|
|
2483
|
+
worktreePromise?.then((w) => w.cleanup()).catch(() => {
|
|
2011
2484
|
});
|
|
2012
2485
|
logger.completePipeline();
|
|
2013
2486
|
logger.success(
|
|
@@ -2015,8 +2488,29 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2015
2488
|
);
|
|
2016
2489
|
return;
|
|
2017
2490
|
}
|
|
2491
|
+
if (options.dryRun) {
|
|
2492
|
+
worktreePromise?.then((w) => w.cleanup()).catch(() => {
|
|
2493
|
+
});
|
|
2494
|
+
logger.completePipeline();
|
|
2495
|
+
console.log(`
|
|
2496
|
+
${affectedRoutes.length} route(s) would be captured:
|
|
2497
|
+
`);
|
|
2498
|
+
for (const r of affectedRoutes) {
|
|
2499
|
+
const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
|
|
2500
|
+
const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
|
|
2501
|
+
console.log(` ${r.route.padEnd(24)} ${chalk2.dim(depthLabel.padEnd(10))} ${chalk2.dim(chain)}`);
|
|
2502
|
+
}
|
|
2503
|
+
const elapsed2 = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2504
|
+
console.log(chalk2.dim(`
|
|
2505
|
+
Completed in ${elapsed2}s (dry run \u2014 no screenshots captured)
|
|
2506
|
+
`));
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
const t4 = Date.now();
|
|
2018
2510
|
logger.pipeline(4, "Setting up worktree...");
|
|
2019
2511
|
const worktree = await worktreePromise;
|
|
2512
|
+
logger.stageComplete("Worktree", "created + dependencies installed", Date.now() - t4);
|
|
2513
|
+
const t5 = Date.now();
|
|
2020
2514
|
logger.pipeline(5, "Starting servers...");
|
|
2021
2515
|
await ensureDir(outputDir);
|
|
2022
2516
|
const beforePort = await findAvailablePort();
|
|
@@ -2029,6 +2523,8 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2029
2523
|
cleanupRegistry.register(() => stopServer(beforeServer));
|
|
2030
2524
|
cleanupRegistry.register(() => stopServer(afterServer));
|
|
2031
2525
|
cleanupRegistry.register(() => browser.close());
|
|
2526
|
+
logger.stageComplete("Servers", `ready on :${beforePort} and :${afterPort}`, Date.now() - t5);
|
|
2527
|
+
const t6 = Date.now();
|
|
2032
2528
|
logger.pipeline(6, "Capturing screenshots...");
|
|
2033
2529
|
const tasks = expandRoutes(affectedRoutes, config, routeComponentMap);
|
|
2034
2530
|
const captures = await captureRoutes(
|
|
@@ -2049,38 +2545,60 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2049
2545
|
onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
|
|
2050
2546
|
}
|
|
2051
2547
|
);
|
|
2548
|
+
logger.stageComplete("Capture", `${captures.length} screenshots from ${tasks.length} routes`, Date.now() - t6);
|
|
2549
|
+
const t7 = Date.now();
|
|
2052
2550
|
logger.pipeline(7, "Comparing screenshots...");
|
|
2053
2551
|
const bgColor = detectBgColor(cwd);
|
|
2054
2552
|
const allResults = await compareScreenshots(captures, outputDir, options.threshold, { bgColor });
|
|
2055
2553
|
const results = allResults.filter((r) => {
|
|
2056
2554
|
const isSubCapture = r.prefix.includes("~");
|
|
2057
2555
|
if (isSubCapture && !r.changed) {
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
unlinkSync2(r.afterPath);
|
|
2064
|
-
} catch {
|
|
2065
|
-
}
|
|
2066
|
-
try {
|
|
2067
|
-
unlinkSync2(r.comparePath);
|
|
2068
|
-
} catch {
|
|
2556
|
+
if (r.comparePath) {
|
|
2557
|
+
try {
|
|
2558
|
+
unlinkSync2(r.comparePath);
|
|
2559
|
+
} catch {
|
|
2560
|
+
}
|
|
2069
2561
|
}
|
|
2070
2562
|
return false;
|
|
2071
2563
|
}
|
|
2072
2564
|
return true;
|
|
2073
2565
|
});
|
|
2566
|
+
const changedCount = results.filter((r) => r.changed).length;
|
|
2567
|
+
const unchangedCount = results.length - changedCount;
|
|
2568
|
+
logger.stageComplete("Compare", `${changedCount} changed, ${unchangedCount} unchanged`, Date.now() - t7);
|
|
2569
|
+
const t8 = Date.now();
|
|
2074
2570
|
logger.pipeline(8, "Generating report...");
|
|
2075
2571
|
await generateReport(results, outputDir, { post });
|
|
2572
|
+
const reportHtml = generateReportHtml(results, sessionName);
|
|
2573
|
+
const reportPath = resolve4(outputDir, "report.html");
|
|
2574
|
+
writeFileSync2(reportPath, reportHtml, "utf-8");
|
|
2575
|
+
logger.stageComplete("Report", reportPath.replace(cwd + "/", ""), Date.now() - t8);
|
|
2076
2576
|
const summary = generateSummaryMd(results, gitDiff);
|
|
2077
2577
|
logger.completePipeline(true);
|
|
2078
2578
|
console.log("\n" + summary);
|
|
2079
2579
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2080
|
-
const changedCount = results.filter((r) => r.changed).length;
|
|
2081
2580
|
logger.success(
|
|
2082
|
-
`Done in ${elapsed}s \u2014 ${results.length} route(s)
|
|
2581
|
+
`Done in ${elapsed}s \u2014 ${results.length} route(s) checked, ${changedCount} with visual changes`
|
|
2083
2582
|
);
|
|
2583
|
+
const changedResults = results.filter((r) => r.changed);
|
|
2584
|
+
if (changedResults.length > 0) {
|
|
2585
|
+
const hero = changedResults.reduce((best, r) => r.diffPercentage > best.diffPercentage ? r : best);
|
|
2586
|
+
if (hero.comparePath) {
|
|
2587
|
+
console.log(chalk2.dim(`
|
|
2588
|
+
Biggest change: ${hero.route} (${hero.diffPercentage.toFixed(1)}%) \u2014 ${hero.comparePath.replace(cwd + "/", "")}`));
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (!post) {
|
|
2592
|
+
console.log(chalk2.dim(` View report: open ${reportPath.replace(cwd + "/", "")}`));
|
|
2593
|
+
console.log(chalk2.dim(" Post to your PR: npx afterbefore --post\n"));
|
|
2594
|
+
}
|
|
2595
|
+
if (options.open) {
|
|
2596
|
+
try {
|
|
2597
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
2598
|
+
execSync5(`${openCmd} "${reportPath}"`, { stdio: "ignore" });
|
|
2599
|
+
} catch {
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2084
2602
|
} finally {
|
|
2085
2603
|
try {
|
|
2086
2604
|
logger.writeLogFile(resolve4(outputDir, "debug.log"));
|
|
@@ -2094,7 +2612,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2094
2612
|
var program = new Command();
|
|
2095
2613
|
program.name("afterbefore").description(
|
|
2096
2614
|
"Automatic before/after screenshot capture for PRs. Git diff is the config."
|
|
2097
|
-
).version("0.1.
|
|
2615
|
+
).version("0.1.18").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(
|
|
2098
2616
|
"--threshold <percent>",
|
|
2099
2617
|
"Diff threshold percentage (changes below this are ignored)",
|
|
2100
2618
|
"0.1"
|
|
@@ -2102,7 +2620,7 @@ program.name("afterbefore").description(
|
|
|
2102
2620
|
"--max-routes <count>",
|
|
2103
2621
|
"Maximum routes to capture (0 = unlimited)",
|
|
2104
2622
|
"6"
|
|
2105
|
-
).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) => {
|
|
2623
|
+
).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").option("--max-depth <n>", "Max import graph traversal depth", "10").option("--dry-run", "Show affected routes without capturing", false).option("--verbose", "Show detailed import graph traversal", false).option("--open", "Open HTML report in browser after capture", false).action(async (opts) => {
|
|
2106
2624
|
const cwd = process.cwd();
|
|
2107
2625
|
if (!isGitRepo(cwd)) {
|
|
2108
2626
|
logger.error("Not a git repository. Run this from inside a git repo.");
|
|
@@ -2122,6 +2640,10 @@ program.name("afterbefore").description(
|
|
|
2122
2640
|
maxTabsPerRoute: parseInt(opts.maxTabs, 10),
|
|
2123
2641
|
autoSections: opts.autoSections,
|
|
2124
2642
|
maxSectionsPerRoute: parseInt(opts.maxSections, 10),
|
|
2643
|
+
maxDepth: parseInt(opts.maxDepth, 10),
|
|
2644
|
+
dryRun: opts.dryRun,
|
|
2645
|
+
verbose: opts.verbose,
|
|
2646
|
+
open: opts.open,
|
|
2125
2647
|
cwd
|
|
2126
2648
|
};
|
|
2127
2649
|
try {
|
|
@@ -2134,6 +2656,8 @@ program.name("afterbefore").description(
|
|
|
2134
2656
|
logger.error(
|
|
2135
2657
|
err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
|
|
2136
2658
|
);
|
|
2659
|
+
console.error(chalk3.dim("\n Help us fix this: https://github.com/kairevicius/afterbefore/issues/new"));
|
|
2660
|
+
console.error(chalk3.dim(" Include the debug.log file from your output directory.\n"));
|
|
2137
2661
|
}
|
|
2138
2662
|
await cleanupRegistry.runAll();
|
|
2139
2663
|
process.exit(1);
|