afterbefore 0.1.16 → 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 -77
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +602 -84
- 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";
|
|
@@ -107,7 +108,6 @@ var Logger = class {
|
|
|
107
108
|
this.log("step", `${step}/${this.pipelineTotal} ${label}`);
|
|
108
109
|
const text = chalk.dim(this.renderPipeline(step, label));
|
|
109
110
|
if (!this.isTTY) {
|
|
110
|
-
console.error(text);
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
if (!this.spinner) {
|
|
@@ -120,6 +120,18 @@ var Logger = class {
|
|
|
120
120
|
this.spinner.text = text;
|
|
121
121
|
this.spinner.render();
|
|
122
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
|
+
}
|
|
123
135
|
completePipeline(finished = false) {
|
|
124
136
|
this.pipelineActive = false;
|
|
125
137
|
this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
|
|
@@ -218,11 +230,22 @@ var cleanupRegistry = new CleanupRegistry();
|
|
|
218
230
|
// src/utils/git.ts
|
|
219
231
|
import { execSync } from "child_process";
|
|
220
232
|
function git(args, cwd) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|
|
226
249
|
}
|
|
227
250
|
function isGitRepo(cwd) {
|
|
228
251
|
try {
|
|
@@ -233,7 +256,14 @@ function isGitRepo(cwd) {
|
|
|
233
256
|
}
|
|
234
257
|
}
|
|
235
258
|
function getMergeBase(base, cwd) {
|
|
236
|
-
|
|
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
|
+
}
|
|
237
267
|
}
|
|
238
268
|
function getDiffNameStatus(base, cwd) {
|
|
239
269
|
const mergeBase = getMergeBase(base, cwd);
|
|
@@ -248,8 +278,10 @@ function getCurrentBranch(cwd) {
|
|
|
248
278
|
}
|
|
249
279
|
|
|
250
280
|
// src/pipeline.ts
|
|
251
|
-
import { resolve as resolve4 } from "path";
|
|
252
|
-
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";
|
|
253
285
|
|
|
254
286
|
// src/config.ts
|
|
255
287
|
import { resolve } from "path";
|
|
@@ -737,8 +769,8 @@ function sanitizeLabel(label, maxLength = 40) {
|
|
|
737
769
|
}
|
|
738
770
|
|
|
739
771
|
// src/stages/impact.ts
|
|
740
|
-
var
|
|
741
|
-
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) {
|
|
742
774
|
const routeMap = /* @__PURE__ */ new Map();
|
|
743
775
|
for (const file of changedFiles) {
|
|
744
776
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -760,7 +792,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
|
|
|
760
792
|
});
|
|
761
793
|
}
|
|
762
794
|
}
|
|
763
|
-
if (depth >=
|
|
795
|
+
if (depth >= maxDepth) continue;
|
|
764
796
|
const importers = graph.reverse.get(path);
|
|
765
797
|
if (!importers) continue;
|
|
766
798
|
for (const importer of importers) {
|
|
@@ -803,7 +835,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
|
|
|
803
835
|
logger.dim(`Found ${routes.length} affected route(s)`);
|
|
804
836
|
return routes;
|
|
805
837
|
}
|
|
806
|
-
function findRoutesForFile(file, graph) {
|
|
838
|
+
function findRoutesForFile(file, graph, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
807
839
|
const routes = /* @__PURE__ */ new Set();
|
|
808
840
|
const start = normalizePath(file);
|
|
809
841
|
const queue = [{ path: start, depth: 0 }];
|
|
@@ -814,7 +846,7 @@ function findRoutesForFile(file, graph) {
|
|
|
814
846
|
const route = pagePathToRoute(path);
|
|
815
847
|
if (route) routes.add(route);
|
|
816
848
|
}
|
|
817
|
-
if (depth >=
|
|
849
|
+
if (depth >= maxDepth) continue;
|
|
818
850
|
const importers = graph.reverse.get(path);
|
|
819
851
|
if (!importers) continue;
|
|
820
852
|
for (const importer of importers) {
|
|
@@ -1119,10 +1151,17 @@ async function launchBrowser() {
|
|
|
1119
1151
|
return await chromium.launch();
|
|
1120
1152
|
} catch {
|
|
1121
1153
|
logger.dim("Chromium not found, installing...");
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
+
}
|
|
1126
1165
|
}
|
|
1127
1166
|
}
|
|
1128
1167
|
var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
|
|
@@ -1672,55 +1711,101 @@ async function trimImage(path) {
|
|
|
1672
1711
|
const { data, info } = await sharp(path).trim({ threshold: 50 }).toBuffer({ resolveWithObject: true });
|
|
1673
1712
|
return { data, width: info.width, height: info.height };
|
|
1674
1713
|
}
|
|
1675
|
-
|
|
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) {
|
|
1676
1724
|
const [beforeTrimmed, afterTrimmed] = await Promise.all([
|
|
1677
1725
|
trimImage(beforePath),
|
|
1678
1726
|
trimImage(afterPath)
|
|
1679
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);
|
|
1680
1737
|
const imgW = Math.max(beforeTrimmed.width, afterTrimmed.width);
|
|
1681
1738
|
const imgH = Math.max(beforeTrimmed.height, afterTrimmed.height);
|
|
1682
|
-
const
|
|
1683
|
-
const
|
|
1684
|
-
const
|
|
1685
|
-
const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
|
|
1686
|
-
const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
|
|
1687
|
-
const maxImgH = canvasH - PADDING * 2 - LABEL_H;
|
|
1688
|
-
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;
|
|
1689
1742
|
const [beforeBuf, afterBuf] = await Promise.all(
|
|
1690
1743
|
[beforeTrimmed, afterTrimmed].map(async (trimmed) => {
|
|
1691
|
-
|
|
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
|
+
};
|
|
1692
1750
|
})
|
|
1693
1751
|
);
|
|
1694
|
-
const
|
|
1695
|
-
const
|
|
1696
|
-
const afterLeft = PADDING + colW + GAP
|
|
1697
|
-
const
|
|
1698
|
-
const
|
|
1699
|
-
const
|
|
1700
|
-
const
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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"
|
|
1708
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>
|
|
1709
1791
|
</svg>`
|
|
1710
1792
|
);
|
|
1711
1793
|
await sharp({
|
|
1712
1794
|
create: {
|
|
1713
|
-
width:
|
|
1714
|
-
height:
|
|
1795
|
+
width: CANVAS_W,
|
|
1796
|
+
height: CANVAS_H,
|
|
1715
1797
|
channels: 4,
|
|
1716
|
-
background:
|
|
1798
|
+
background: "#1a1a2e"
|
|
1717
1799
|
}
|
|
1718
1800
|
}).composite([
|
|
1719
|
-
{ input: beforeBuf.data, left: beforeLeft, top:
|
|
1720
|
-
{ input: afterBuf.data, left: afterLeft, top:
|
|
1721
|
-
{ input:
|
|
1801
|
+
{ input: beforeBuf.data, left: beforeLeft, top: imgTop },
|
|
1802
|
+
{ input: afterBuf.data, left: afterLeft, top: imgTop },
|
|
1803
|
+
{ input: overlaySvg, left: 0, top: 0 }
|
|
1722
1804
|
]).png().toFile(outputPath);
|
|
1723
1805
|
}
|
|
1806
|
+
function escapeXml(str) {
|
|
1807
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1808
|
+
}
|
|
1724
1809
|
async function compareOne(capture, outputDir, threshold, server, options) {
|
|
1725
1810
|
const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
|
|
1726
1811
|
const dir = dirname2(capture.beforePath);
|
|
@@ -1748,18 +1833,30 @@ async function compareOne(capture, outputDir, threshold, server, options) {
|
|
|
1748
1833
|
diffPercentage = 100;
|
|
1749
1834
|
changed = true;
|
|
1750
1835
|
}
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
+
}
|
|
1757
1854
|
return {
|
|
1758
1855
|
route: capture.route,
|
|
1759
1856
|
prefix: capture.prefix,
|
|
1760
1857
|
beforePath: capture.beforePath,
|
|
1761
1858
|
afterPath: capture.afterPath,
|
|
1762
|
-
comparePath,
|
|
1859
|
+
comparePath: changed ? comparePath : "",
|
|
1763
1860
|
diffPixels,
|
|
1764
1861
|
totalPixels,
|
|
1765
1862
|
diffPercentage,
|
|
@@ -1831,7 +1928,21 @@ function generateSummaryMd(results, gitDiff, options) {
|
|
|
1831
1928
|
|
|
1832
1929
|
// src/stages/report.ts
|
|
1833
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
|
+
}
|
|
1834
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
|
+
}
|
|
1835
1946
|
try {
|
|
1836
1947
|
const output = execSync4("gh pr view --json number -q .number", {
|
|
1837
1948
|
encoding: "utf-8",
|
|
@@ -1883,7 +1994,7 @@ async function generateReport(results, outputDir, options) {
|
|
|
1883
1994
|
const prNumber = findPrNumber();
|
|
1884
1995
|
if (!prNumber) {
|
|
1885
1996
|
logger.warn(
|
|
1886
|
-
"
|
|
1997
|
+
"No open PR found for this branch. Push your branch and open a PR first, then run `npx afterbefore --post` again."
|
|
1887
1998
|
);
|
|
1888
1999
|
return;
|
|
1889
2000
|
}
|
|
@@ -1893,6 +2004,317 @@ async function generateReport(results, outputDir, options) {
|
|
|
1893
2004
|
}
|
|
1894
2005
|
}
|
|
1895
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
|
+
|
|
1896
2318
|
// src/pipeline.ts
|
|
1897
2319
|
function generateSessionName(cwd) {
|
|
1898
2320
|
const branch = getCurrentBranch(cwd);
|
|
@@ -1944,26 +2366,60 @@ function expandRoutes(routes, config, routeComponentMap) {
|
|
|
1944
2366
|
}
|
|
1945
2367
|
return tasks;
|
|
1946
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
|
+
}
|
|
1947
2398
|
async function runPipeline(options) {
|
|
1948
2399
|
const { base, output, post, cwd } = options;
|
|
1949
2400
|
const sessionName = generateSessionName(cwd);
|
|
1950
2401
|
const outputDir = resolve4(cwd, output, sessionName);
|
|
1951
2402
|
const startTime = Date.now();
|
|
1952
2403
|
try {
|
|
1953
|
-
const version = true ? "0.1.
|
|
2404
|
+
const version = true ? "0.1.18" : "dev";
|
|
2405
|
+
const mode = options.dryRun ? "Dry run" : "Comparing";
|
|
1954
2406
|
console.log(`
|
|
1955
|
-
afterbefore v${version} \xB7
|
|
2407
|
+
afterbefore v${version} \xB7 ${mode} against ${base}
|
|
1956
2408
|
`);
|
|
1957
2409
|
const config = await loadConfig(cwd);
|
|
1958
|
-
|
|
2410
|
+
applyConfigDefaults(options, config);
|
|
2411
|
+
logger.startPipeline(options.dryRun ? 3 : 8);
|
|
2412
|
+
const t1 = Date.now();
|
|
1959
2413
|
logger.pipeline(1, "Analyzing diff...");
|
|
1960
2414
|
const diffFiles = getChangedFiles(base, cwd);
|
|
1961
|
-
const gitDiff = getGitDiff(base, cwd);
|
|
2415
|
+
const gitDiff = options.dryRun ? "" : getGitDiff(base, cwd);
|
|
2416
|
+
logger.stageComplete("Diff", `${diffFiles.length} files changed`, Date.now() - t1);
|
|
1962
2417
|
if (diffFiles.length === 0) {
|
|
1963
2418
|
logger.completePipeline();
|
|
1964
2419
|
logger.success("No changed files detected. Nothing to do.");
|
|
1965
2420
|
return;
|
|
1966
2421
|
}
|
|
2422
|
+
const t2 = Date.now();
|
|
1967
2423
|
const classified = classifyFiles(diffFiles);
|
|
1968
2424
|
const impactfulFiles = classified.filter(
|
|
1969
2425
|
(f) => f.category !== "test" && f.category !== "other"
|
|
@@ -1976,11 +2432,14 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
1976
2432
|
return;
|
|
1977
2433
|
}
|
|
1978
2434
|
logger.pipeline(2, "Building import graph...");
|
|
1979
|
-
const worktreePromise = createWorktree(base, cwd);
|
|
2435
|
+
const worktreePromise = options.dryRun ? null : createWorktree(base, cwd);
|
|
1980
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();
|
|
1981
2440
|
logger.pipeline(3, "Finding affected routes...");
|
|
1982
2441
|
const changedPaths = impactfulFiles.map((f) => f.path);
|
|
1983
|
-
let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes);
|
|
2442
|
+
let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes, options.maxDepth);
|
|
1984
2443
|
const changedComponentFiles = impactfulFiles.filter((f) => f.category === "component").map((f) => f.path);
|
|
1985
2444
|
const routeComponentMap = mapRouteToChangedComponents(changedComponentFiles, graph);
|
|
1986
2445
|
if (affectedRoutes.length === 0) {
|
|
@@ -2007,8 +2466,21 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2007
2466
|
}
|
|
2008
2467
|
}
|
|
2009
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
|
+
}
|
|
2010
2482
|
if (affectedRoutes.length === 0) {
|
|
2011
|
-
worktreePromise
|
|
2483
|
+
worktreePromise?.then((w) => w.cleanup()).catch(() => {
|
|
2012
2484
|
});
|
|
2013
2485
|
logger.completePipeline();
|
|
2014
2486
|
logger.success(
|
|
@@ -2016,8 +2488,29 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2016
2488
|
);
|
|
2017
2489
|
return;
|
|
2018
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();
|
|
2019
2510
|
logger.pipeline(4, "Setting up worktree...");
|
|
2020
2511
|
const worktree = await worktreePromise;
|
|
2512
|
+
logger.stageComplete("Worktree", "created + dependencies installed", Date.now() - t4);
|
|
2513
|
+
const t5 = Date.now();
|
|
2021
2514
|
logger.pipeline(5, "Starting servers...");
|
|
2022
2515
|
await ensureDir(outputDir);
|
|
2023
2516
|
const beforePort = await findAvailablePort();
|
|
@@ -2030,6 +2523,8 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2030
2523
|
cleanupRegistry.register(() => stopServer(beforeServer));
|
|
2031
2524
|
cleanupRegistry.register(() => stopServer(afterServer));
|
|
2032
2525
|
cleanupRegistry.register(() => browser.close());
|
|
2526
|
+
logger.stageComplete("Servers", `ready on :${beforePort} and :${afterPort}`, Date.now() - t5);
|
|
2527
|
+
const t6 = Date.now();
|
|
2033
2528
|
logger.pipeline(6, "Capturing screenshots...");
|
|
2034
2529
|
const tasks = expandRoutes(affectedRoutes, config, routeComponentMap);
|
|
2035
2530
|
const captures = await captureRoutes(
|
|
@@ -2050,38 +2545,60 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2050
2545
|
onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
|
|
2051
2546
|
}
|
|
2052
2547
|
);
|
|
2548
|
+
logger.stageComplete("Capture", `${captures.length} screenshots from ${tasks.length} routes`, Date.now() - t6);
|
|
2549
|
+
const t7 = Date.now();
|
|
2053
2550
|
logger.pipeline(7, "Comparing screenshots...");
|
|
2054
2551
|
const bgColor = detectBgColor(cwd);
|
|
2055
2552
|
const allResults = await compareScreenshots(captures, outputDir, options.threshold, { bgColor });
|
|
2056
2553
|
const results = allResults.filter((r) => {
|
|
2057
2554
|
const isSubCapture = r.prefix.includes("~");
|
|
2058
2555
|
if (isSubCapture && !r.changed) {
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
unlinkSync2(r.afterPath);
|
|
2065
|
-
} catch {
|
|
2066
|
-
}
|
|
2067
|
-
try {
|
|
2068
|
-
unlinkSync2(r.comparePath);
|
|
2069
|
-
} catch {
|
|
2556
|
+
if (r.comparePath) {
|
|
2557
|
+
try {
|
|
2558
|
+
unlinkSync2(r.comparePath);
|
|
2559
|
+
} catch {
|
|
2560
|
+
}
|
|
2070
2561
|
}
|
|
2071
2562
|
return false;
|
|
2072
2563
|
}
|
|
2073
2564
|
return true;
|
|
2074
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();
|
|
2075
2570
|
logger.pipeline(8, "Generating report...");
|
|
2076
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);
|
|
2077
2576
|
const summary = generateSummaryMd(results, gitDiff);
|
|
2078
2577
|
logger.completePipeline(true);
|
|
2079
2578
|
console.log("\n" + summary);
|
|
2080
2579
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2081
|
-
const changedCount = results.filter((r) => r.changed).length;
|
|
2082
2580
|
logger.success(
|
|
2083
|
-
`Done in ${elapsed}s \u2014 ${results.length} route(s)
|
|
2581
|
+
`Done in ${elapsed}s \u2014 ${results.length} route(s) checked, ${changedCount} with visual changes`
|
|
2084
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
|
+
}
|
|
2085
2602
|
} finally {
|
|
2086
2603
|
try {
|
|
2087
2604
|
logger.writeLogFile(resolve4(outputDir, "debug.log"));
|
|
@@ -2095,7 +2612,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2095
2612
|
var program = new Command();
|
|
2096
2613
|
program.name("afterbefore").description(
|
|
2097
2614
|
"Automatic before/after screenshot capture for PRs. Git diff is the config."
|
|
2098
|
-
).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(
|
|
2099
2616
|
"--threshold <percent>",
|
|
2100
2617
|
"Diff threshold percentage (changes below this are ignored)",
|
|
2101
2618
|
"0.1"
|
|
@@ -2103,7 +2620,7 @@ program.name("afterbefore").description(
|
|
|
2103
2620
|
"--max-routes <count>",
|
|
2104
2621
|
"Maximum routes to capture (0 = unlimited)",
|
|
2105
2622
|
"6"
|
|
2106
|
-
).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) => {
|
|
2107
2624
|
const cwd = process.cwd();
|
|
2108
2625
|
if (!isGitRepo(cwd)) {
|
|
2109
2626
|
logger.error("Not a git repository. Run this from inside a git repo.");
|
|
@@ -2123,6 +2640,10 @@ program.name("afterbefore").description(
|
|
|
2123
2640
|
maxTabsPerRoute: parseInt(opts.maxTabs, 10),
|
|
2124
2641
|
autoSections: opts.autoSections,
|
|
2125
2642
|
maxSectionsPerRoute: parseInt(opts.maxSections, 10),
|
|
2643
|
+
maxDepth: parseInt(opts.maxDepth, 10),
|
|
2644
|
+
dryRun: opts.dryRun,
|
|
2645
|
+
verbose: opts.verbose,
|
|
2646
|
+
open: opts.open,
|
|
2126
2647
|
cwd
|
|
2127
2648
|
};
|
|
2128
2649
|
try {
|
|
@@ -2135,6 +2656,8 @@ program.name("afterbefore").description(
|
|
|
2135
2656
|
logger.error(
|
|
2136
2657
|
err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
|
|
2137
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"));
|
|
2138
2661
|
}
|
|
2139
2662
|
await cleanupRegistry.runAll();
|
|
2140
2663
|
process.exit(1);
|