afterbefore 0.1.5 → 0.1.7
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/README.md +2 -12
- package/dist/cli.js +182 -174
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +180 -171
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# afterbefore
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/afterbefore)
|
|
4
|
-
[](https://www.npmjs.com/package/afterbefore)
|
|
5
5
|
|
|
6
6
|
Automatic before/after screenshot capture for Next.js pull requests.
|
|
7
7
|
|
|
@@ -17,15 +17,13 @@ Run it from a feature branch in any Next.js app router project:
|
|
|
17
17
|
npx afterbefore
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
It spins up two dev servers (your branch and the base branch), captures screenshots of affected routes, diffs them pixel by pixel
|
|
20
|
+
It spins up two dev servers (your branch and the base branch), captures screenshots of affected routes, and diffs them pixel by pixel.
|
|
21
21
|
|
|
22
22
|
Output lands in `.afterbefore/`:
|
|
23
23
|
|
|
24
24
|
```
|
|
25
25
|
.afterbefore/
|
|
26
26
|
└── feature-branch_2026-02-26/
|
|
27
|
-
├── index.html # visual report
|
|
28
|
-
├── summary.md # markdown table
|
|
29
27
|
├── about-before.png
|
|
30
28
|
├── about-after.png
|
|
31
29
|
├── about-diff.png
|
|
@@ -33,12 +31,6 @@ Output lands in `.afterbefore/`:
|
|
|
33
31
|
└── about-slider.html # interactive slider
|
|
34
32
|
```
|
|
35
33
|
|
|
36
|
-
Open the report automatically:
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
npx afterbefore --open
|
|
40
|
-
```
|
|
41
|
-
|
|
42
34
|
## How it works
|
|
43
35
|
|
|
44
36
|
1. Reads `git diff` to find changed files
|
|
@@ -49,7 +41,6 @@ npx afterbefore --open
|
|
|
49
41
|
6. Starts two Next.js dev servers in parallel (base branch + current branch)
|
|
50
42
|
7. Captures full-page screenshots of each affected route on both servers using Playwright
|
|
51
43
|
8. Compares screenshots with pixelmatch and generates diff images
|
|
52
|
-
9. Builds an HTML report with side-by-side comparisons and interactive sliders
|
|
53
44
|
|
|
54
45
|
Layouts work the same way. Edit `app/dashboard/layout.tsx` and it captures every page under `app/dashboard/`.
|
|
55
46
|
|
|
@@ -64,7 +55,6 @@ If only global files changed (like `globals.css` or `tailwind.config.ts`) and no
|
|
|
64
55
|
| `--post` | `false` | Post results as a GitHub PR comment |
|
|
65
56
|
| `--threshold <percent>` | `0.1` | Ignore diffs below this percentage |
|
|
66
57
|
| `--max-routes <count>` | `6` | Cap the number of routes captured (0 = unlimited) |
|
|
67
|
-
| `--open` | `false` | Open the HTML report in your browser |
|
|
68
58
|
| `--width <pixels>` | `1280` | Viewport width |
|
|
69
59
|
| `--height <pixels>` | `720` | Viewport height |
|
|
70
60
|
| `--device <name>` | — | Playwright device, e.g. `"iPhone 14"` |
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/logger.ts
|
|
7
|
+
import { writeFileSync } from "fs";
|
|
7
8
|
import chalk from "chalk";
|
|
8
9
|
import ora from "ora";
|
|
9
10
|
var BAR_WIDTH = 20;
|
|
@@ -14,7 +15,13 @@ var Logger = class {
|
|
|
14
15
|
lastStep = 0;
|
|
15
16
|
lastLabel = "";
|
|
16
17
|
pipelineActive = false;
|
|
18
|
+
logBuffer = [];
|
|
19
|
+
log(level, message) {
|
|
20
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
21
|
+
this.logBuffer.push(`${ts} [${level}] ${message}`);
|
|
22
|
+
}
|
|
17
23
|
info(message) {
|
|
24
|
+
this.log("info", message);
|
|
18
25
|
if (this.pipelineActive) return;
|
|
19
26
|
if (this.spinner) {
|
|
20
27
|
this.spinner.clear();
|
|
@@ -25,6 +32,7 @@ var Logger = class {
|
|
|
25
32
|
}
|
|
26
33
|
}
|
|
27
34
|
success(message) {
|
|
35
|
+
this.log("ok", message);
|
|
28
36
|
if (this.pipelineActive) return;
|
|
29
37
|
if (this.spinner) {
|
|
30
38
|
this.spinner.clear();
|
|
@@ -35,6 +43,7 @@ var Logger = class {
|
|
|
35
43
|
}
|
|
36
44
|
}
|
|
37
45
|
warn(message) {
|
|
46
|
+
this.log("warn", message);
|
|
38
47
|
if (this.spinner) {
|
|
39
48
|
this.spinner.clear();
|
|
40
49
|
console.log(chalk.yellow("\u26A0"), message);
|
|
@@ -44,6 +53,7 @@ var Logger = class {
|
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
error(message) {
|
|
56
|
+
this.log("error", message);
|
|
47
57
|
if (this.spinner) {
|
|
48
58
|
this.spinner.clear();
|
|
49
59
|
console.error(chalk.red("\u2716"), message);
|
|
@@ -53,6 +63,7 @@ var Logger = class {
|
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
65
|
dim(message) {
|
|
66
|
+
this.log("debug", message);
|
|
56
67
|
if (this.pipelineActive) return;
|
|
57
68
|
if (this.spinner) {
|
|
58
69
|
this.spinner.clear();
|
|
@@ -63,6 +74,7 @@ var Logger = class {
|
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
spin(message) {
|
|
77
|
+
this.log("info", message);
|
|
66
78
|
this.clearSpinner();
|
|
67
79
|
this.spinner = ora(message).start();
|
|
68
80
|
return this.spinner;
|
|
@@ -76,6 +88,7 @@ var Logger = class {
|
|
|
76
88
|
this.lastLabel = "";
|
|
77
89
|
this.pipelineActive = true;
|
|
78
90
|
this.clearSpinner();
|
|
91
|
+
this.log("info", `Pipeline started (${total} steps)`);
|
|
79
92
|
const text = this.renderPipeline(0, "Starting...");
|
|
80
93
|
this.spinner = ora({
|
|
81
94
|
text: chalk.dim(text),
|
|
@@ -86,6 +99,7 @@ var Logger = class {
|
|
|
86
99
|
if (step === this.lastStep && label === this.lastLabel) return;
|
|
87
100
|
this.lastStep = step;
|
|
88
101
|
this.lastLabel = label;
|
|
102
|
+
this.log("step", `${step}/${this.pipelineTotal} ${label}`);
|
|
89
103
|
const text = chalk.dim(this.renderPipeline(step, label));
|
|
90
104
|
if (!this.spinner) {
|
|
91
105
|
this.spinner = ora({
|
|
@@ -98,6 +112,7 @@ var Logger = class {
|
|
|
98
112
|
}
|
|
99
113
|
completePipeline(finished = false) {
|
|
100
114
|
this.pipelineActive = false;
|
|
115
|
+
this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
|
|
101
116
|
if (this.spinner) {
|
|
102
117
|
if (finished) {
|
|
103
118
|
this.spinner.stop();
|
|
@@ -115,6 +130,10 @@ var Logger = class {
|
|
|
115
130
|
this.lastStep = 0;
|
|
116
131
|
this.lastLabel = "";
|
|
117
132
|
}
|
|
133
|
+
writeLogFile(filePath) {
|
|
134
|
+
if (this.logBuffer.length === 0) return;
|
|
135
|
+
writeFileSync(filePath, this.logBuffer.join("\n") + "\n", "utf-8");
|
|
136
|
+
}
|
|
118
137
|
renderPipeline(step, label) {
|
|
119
138
|
const total = this.pipelineTotal || 1;
|
|
120
139
|
const clampedStep = Math.max(0, Math.min(step, total));
|
|
@@ -222,7 +241,6 @@ function getCurrentBranch(cwd) {
|
|
|
222
241
|
// src/pipeline.ts
|
|
223
242
|
import { resolve as resolve4 } from "path";
|
|
224
243
|
import { unlinkSync } from "fs";
|
|
225
|
-
import { exec } from "child_process";
|
|
226
244
|
|
|
227
245
|
// src/config.ts
|
|
228
246
|
import { resolve } from "path";
|
|
@@ -881,8 +899,8 @@ function waitForServer(url, timeoutMs) {
|
|
|
881
899
|
async function startServer(projectDir, port) {
|
|
882
900
|
const url = `http://localhost:${port}`;
|
|
883
901
|
const pm = detectPackageManager(projectDir);
|
|
884
|
-
const
|
|
885
|
-
const [cmd, ...baseArgs] =
|
|
902
|
+
const exec = pmExec(pm);
|
|
903
|
+
const [cmd, ...baseArgs] = exec.split(" ");
|
|
886
904
|
const lockFile = join5(projectDir, ".next", "dev", "lock");
|
|
887
905
|
if (existsSync5(lockFile)) {
|
|
888
906
|
throw new AfterbeforeError(
|
|
@@ -1182,6 +1200,49 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
|
|
|
1182
1200
|
});
|
|
1183
1201
|
bySource.set(match.source, list);
|
|
1184
1202
|
}
|
|
1203
|
+
for (const target of targets) {
|
|
1204
|
+
if (bySource.has(target.original) && bySource.get(target.original).length > 0) continue;
|
|
1205
|
+
const fileName = target.original.split("/").pop() ?? "";
|
|
1206
|
+
const componentName = fileName.replace(/\.[a-z0-9]+$/i, "");
|
|
1207
|
+
if (!componentName || componentName.length < 2) continue;
|
|
1208
|
+
const pattern = new RegExp("\\b" + componentName + "\\b", "i");
|
|
1209
|
+
const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4"));
|
|
1210
|
+
for (const heading of headings) {
|
|
1211
|
+
const text = (heading.textContent ?? "").trim();
|
|
1212
|
+
if (!pattern.test(text)) continue;
|
|
1213
|
+
let container = heading;
|
|
1214
|
+
const headingTag = heading.tagName.toLowerCase();
|
|
1215
|
+
let walkParent = heading.parentElement;
|
|
1216
|
+
while (walkParent && walkParent !== document.body) {
|
|
1217
|
+
const parentTag = walkParent.tagName.toLowerCase();
|
|
1218
|
+
if (parentTag === "section" || parentTag === "article") {
|
|
1219
|
+
container = walkParent;
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
if (walkParent.getAttribute("role") === "region") {
|
|
1223
|
+
container = walkParent;
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
if (parentTag === "body" || parentTag === "main") break;
|
|
1227
|
+
const siblingHeadings = walkParent.querySelectorAll(`:scope > * ${headingTag}, :scope > ${headingTag}`);
|
|
1228
|
+
if (siblingHeadings.length > 1) break;
|
|
1229
|
+
container = walkParent;
|
|
1230
|
+
walkParent = walkParent.parentElement;
|
|
1231
|
+
}
|
|
1232
|
+
const rect = container.getBoundingClientRect();
|
|
1233
|
+
if (rect.width < 4 || rect.height < 4) continue;
|
|
1234
|
+
const list = bySource.get(target.original) ?? [];
|
|
1235
|
+
list.push({
|
|
1236
|
+
el: container,
|
|
1237
|
+
parent: pickParentContainer(container),
|
|
1238
|
+
name: componentName,
|
|
1239
|
+
top: rect.top + window.scrollY,
|
|
1240
|
+
left: rect.left + window.scrollX
|
|
1241
|
+
});
|
|
1242
|
+
bySource.set(target.original, list);
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1185
1246
|
const tagged = [];
|
|
1186
1247
|
for (let sourceIndex = 0; sourceIndex < targets.length; sourceIndex++) {
|
|
1187
1248
|
const source = targets[sourceIndex].original;
|
|
@@ -1208,6 +1269,94 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
|
|
|
1208
1269
|
{ changed: normalized, maxPerSource }
|
|
1209
1270
|
);
|
|
1210
1271
|
}
|
|
1272
|
+
async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir, contextBeforePath, contextAfterPath, results) {
|
|
1273
|
+
const deduped = Array.from(new Set(changedComponents.map(normalizePath)));
|
|
1274
|
+
const [afterInstances, beforeInstances] = await Promise.all([
|
|
1275
|
+
tagChangedComponentInstances(afterPage, deduped),
|
|
1276
|
+
tagChangedComponentInstances(beforePage, deduped)
|
|
1277
|
+
]);
|
|
1278
|
+
logger.dim(` Component detection on ${captureLabel}: ${deduped.length} source(s), ${afterInstances.length} after / ${beforeInstances.length} before instance(s)`);
|
|
1279
|
+
const afterBySource = groupBySource(afterInstances);
|
|
1280
|
+
const beforeBySource = groupBySource(beforeInstances);
|
|
1281
|
+
for (const source of deduped) {
|
|
1282
|
+
const afterList = afterBySource.get(source) ?? [];
|
|
1283
|
+
const beforeList = beforeBySource.get(source) ?? [];
|
|
1284
|
+
const pairCount = Math.min(afterList.length, beforeList.length);
|
|
1285
|
+
if (pairCount === 0) {
|
|
1286
|
+
if (afterList.length > 0 || beforeList.length > 0) {
|
|
1287
|
+
logger.dim(` ${source}: ${afterList.length} after / ${beforeList.length} before (skipping unpaired)`);
|
|
1288
|
+
}
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1292
|
+
const afterInstance = afterList[pairIndex];
|
|
1293
|
+
const beforeInstance = beforeList[pairIndex];
|
|
1294
|
+
const sourceSlug = sanitizeComponentLabel(source) || "component";
|
|
1295
|
+
const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
|
|
1296
|
+
const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
|
|
1297
|
+
const baseLabel = `${captureLabel} [${componentName} #${pairIndex + 1}]`;
|
|
1298
|
+
const parentPrefix = `${capturePrefix}~cmp.${itemSlug}~parent`;
|
|
1299
|
+
const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
|
|
1300
|
+
const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
|
|
1301
|
+
const [parentBeforeOk, parentAfterOk] = await Promise.all([
|
|
1302
|
+
captureByAttr(
|
|
1303
|
+
beforePage,
|
|
1304
|
+
"data-ab-parent-key",
|
|
1305
|
+
beforeInstance.parentKey,
|
|
1306
|
+
parentBeforePath
|
|
1307
|
+
),
|
|
1308
|
+
captureByAttr(
|
|
1309
|
+
afterPage,
|
|
1310
|
+
"data-ab-parent-key",
|
|
1311
|
+
afterInstance.parentKey,
|
|
1312
|
+
parentAfterPath
|
|
1313
|
+
)
|
|
1314
|
+
]);
|
|
1315
|
+
if (parentBeforeOk && parentAfterOk) {
|
|
1316
|
+
results.push({
|
|
1317
|
+
route: `${baseLabel} [parent]`,
|
|
1318
|
+
prefix: parentPrefix,
|
|
1319
|
+
beforePath: parentBeforePath,
|
|
1320
|
+
afterPath: parentAfterPath
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
const componentPrefix = `${capturePrefix}~cmp.${itemSlug}~component`;
|
|
1324
|
+
const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
|
|
1325
|
+
const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
|
|
1326
|
+
const [componentBeforeOk, componentAfterOk] = await Promise.all([
|
|
1327
|
+
captureByAttr(
|
|
1328
|
+
beforePage,
|
|
1329
|
+
"data-ab-comp-key",
|
|
1330
|
+
beforeInstance.componentKey,
|
|
1331
|
+
componentBeforePath
|
|
1332
|
+
),
|
|
1333
|
+
captureByAttr(
|
|
1334
|
+
afterPage,
|
|
1335
|
+
"data-ab-comp-key",
|
|
1336
|
+
afterInstance.componentKey,
|
|
1337
|
+
componentAfterPath
|
|
1338
|
+
)
|
|
1339
|
+
]);
|
|
1340
|
+
if (componentBeforeOk && componentAfterOk) {
|
|
1341
|
+
results.push({
|
|
1342
|
+
route: `${baseLabel} [component]`,
|
|
1343
|
+
prefix: componentPrefix,
|
|
1344
|
+
beforePath: componentBeforePath,
|
|
1345
|
+
afterPath: componentAfterPath
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
if (parentBeforeOk && parentAfterOk || componentBeforeOk && componentAfterOk) {
|
|
1349
|
+
const contextPrefix = `${capturePrefix}~cmp.${itemSlug}~context`;
|
|
1350
|
+
results.push({
|
|
1351
|
+
route: `${baseLabel} [context]`,
|
|
1352
|
+
prefix: contextPrefix,
|
|
1353
|
+
beforePath: contextBeforePath,
|
|
1354
|
+
afterPath: contextAfterPath
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1211
1360
|
async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLabel, outputDir, options, settle, results) {
|
|
1212
1361
|
const sections = await detectSections(afterPage, options.maxSectionsPerRoute);
|
|
1213
1362
|
if (sections.length === 0) return;
|
|
@@ -1304,86 +1453,17 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
|
1304
1453
|
]);
|
|
1305
1454
|
results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
|
|
1306
1455
|
if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
|
|
1307
|
-
|
|
1308
|
-
|
|
1456
|
+
await captureComponentInstances(
|
|
1457
|
+
afterPage,
|
|
1458
|
+
beforePage,
|
|
1459
|
+
task.changedComponents,
|
|
1460
|
+
task.prefix,
|
|
1461
|
+
task.label,
|
|
1462
|
+
outputDir,
|
|
1463
|
+
beforePath,
|
|
1464
|
+
afterPath,
|
|
1465
|
+
results
|
|
1309
1466
|
);
|
|
1310
|
-
const [afterInstances, beforeInstances] = await Promise.all([
|
|
1311
|
-
tagChangedComponentInstances(afterPage, changedComponents),
|
|
1312
|
-
tagChangedComponentInstances(beforePage, changedComponents)
|
|
1313
|
-
]);
|
|
1314
|
-
const afterBySource = groupBySource(afterInstances);
|
|
1315
|
-
const beforeBySource = groupBySource(beforeInstances);
|
|
1316
|
-
for (const source of changedComponents) {
|
|
1317
|
-
const afterList = afterBySource.get(source) ?? [];
|
|
1318
|
-
const beforeList = beforeBySource.get(source) ?? [];
|
|
1319
|
-
const pairCount = Math.min(afterList.length, beforeList.length);
|
|
1320
|
-
if (pairCount === 0) continue;
|
|
1321
|
-
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1322
|
-
const afterInstance = afterList[pairIndex];
|
|
1323
|
-
const beforeInstance = beforeList[pairIndex];
|
|
1324
|
-
const sourceSlug = sanitizeComponentLabel(source) || "component";
|
|
1325
|
-
const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
|
|
1326
|
-
const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
|
|
1327
|
-
const baseLabel = `${task.label} [${componentName} #${pairIndex + 1}]`;
|
|
1328
|
-
const contextPrefix = `${task.prefix}~cmp.${itemSlug}~context`;
|
|
1329
|
-
results.push({
|
|
1330
|
-
route: `${baseLabel} [context]`,
|
|
1331
|
-
prefix: contextPrefix,
|
|
1332
|
-
beforePath,
|
|
1333
|
-
afterPath
|
|
1334
|
-
});
|
|
1335
|
-
const parentPrefix = `${task.prefix}~cmp.${itemSlug}~parent`;
|
|
1336
|
-
const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
|
|
1337
|
-
const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
|
|
1338
|
-
const [parentBeforeOk, parentAfterOk] = await Promise.all([
|
|
1339
|
-
captureByAttr(
|
|
1340
|
-
beforePage,
|
|
1341
|
-
"data-ab-parent-key",
|
|
1342
|
-
beforeInstance.parentKey,
|
|
1343
|
-
parentBeforePath
|
|
1344
|
-
),
|
|
1345
|
-
captureByAttr(
|
|
1346
|
-
afterPage,
|
|
1347
|
-
"data-ab-parent-key",
|
|
1348
|
-
afterInstance.parentKey,
|
|
1349
|
-
parentAfterPath
|
|
1350
|
-
)
|
|
1351
|
-
]);
|
|
1352
|
-
if (parentBeforeOk && parentAfterOk) {
|
|
1353
|
-
results.push({
|
|
1354
|
-
route: `${baseLabel} [parent]`,
|
|
1355
|
-
prefix: parentPrefix,
|
|
1356
|
-
beforePath: parentBeforePath,
|
|
1357
|
-
afterPath: parentAfterPath
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
const componentPrefix = `${task.prefix}~cmp.${itemSlug}~component`;
|
|
1361
|
-
const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
|
|
1362
|
-
const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
|
|
1363
|
-
const [componentBeforeOk, componentAfterOk] = await Promise.all([
|
|
1364
|
-
captureByAttr(
|
|
1365
|
-
beforePage,
|
|
1366
|
-
"data-ab-comp-key",
|
|
1367
|
-
beforeInstance.componentKey,
|
|
1368
|
-
componentBeforePath
|
|
1369
|
-
),
|
|
1370
|
-
captureByAttr(
|
|
1371
|
-
afterPage,
|
|
1372
|
-
"data-ab-comp-key",
|
|
1373
|
-
afterInstance.componentKey,
|
|
1374
|
-
componentAfterPath
|
|
1375
|
-
)
|
|
1376
|
-
]);
|
|
1377
|
-
if (componentBeforeOk && componentAfterOk) {
|
|
1378
|
-
results.push({
|
|
1379
|
-
route: `${baseLabel} [component]`,
|
|
1380
|
-
prefix: componentPrefix,
|
|
1381
|
-
beforePath: componentBeforePath,
|
|
1382
|
-
afterPath: componentAfterPath
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
1467
|
}
|
|
1388
1468
|
if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
|
|
1389
1469
|
await captureAutoSections(
|
|
@@ -1443,6 +1523,19 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
|
1443
1523
|
beforePath: tabBeforePath,
|
|
1444
1524
|
afterPath: tabAfterPath
|
|
1445
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
|
+
}
|
|
1446
1539
|
if (options.autoSections && !task.skipAutoSections) {
|
|
1447
1540
|
await captureAutoSections(
|
|
1448
1541
|
afterPage,
|
|
@@ -1571,79 +1664,8 @@ async function compareScreenshots(captures, outputDir, threshold = 0.1, options)
|
|
|
1571
1664
|
}
|
|
1572
1665
|
|
|
1573
1666
|
// src/stages/report.ts
|
|
1574
|
-
import { writeFileSync } from "fs";
|
|
1575
|
-
import { join as join8 } from "path";
|
|
1576
1667
|
import { execSync as execSync4 } from "child_process";
|
|
1577
1668
|
|
|
1578
|
-
// src/templates/report.html.ts
|
|
1579
|
-
import { readFileSync as readFileSync6 } from "fs";
|
|
1580
|
-
function toBase64(filePath) {
|
|
1581
|
-
return readFileSync6(filePath).toString("base64");
|
|
1582
|
-
}
|
|
1583
|
-
function imgSrc(filePath) {
|
|
1584
|
-
return `data:image/png;base64,${toBase64(filePath)}`;
|
|
1585
|
-
}
|
|
1586
|
-
function generateReportHtml(results, outputDir) {
|
|
1587
|
-
const changed = results.filter((r) => r.changed);
|
|
1588
|
-
const unchanged = results.filter((r) => !r.changed);
|
|
1589
|
-
const card = (r) => {
|
|
1590
|
-
return `
|
|
1591
|
-
<div class="card ${r.changed ? "changed" : "unchanged"}">
|
|
1592
|
-
<div class="card-header">
|
|
1593
|
-
<span class="route">${r.route}</span>
|
|
1594
|
-
<span class="badge ${r.changed ? "badge-changed" : "badge-unchanged"}">
|
|
1595
|
-
${r.changed ? `${r.diffPercentage.toFixed(2)}% changed` : "No change"}
|
|
1596
|
-
</span>
|
|
1597
|
-
</div>
|
|
1598
|
-
<div class="images">
|
|
1599
|
-
<div class="img-col">
|
|
1600
|
-
<div class="label">Before</div>
|
|
1601
|
-
<img src="${imgSrc(r.beforePath)}" alt="Before" />
|
|
1602
|
-
</div>
|
|
1603
|
-
<div class="img-col">
|
|
1604
|
-
<div class="label">After</div>
|
|
1605
|
-
<img src="${imgSrc(r.afterPath)}" alt="After" />
|
|
1606
|
-
</div>
|
|
1607
|
-
</div>
|
|
1608
|
-
</div>`;
|
|
1609
|
-
};
|
|
1610
|
-
return `<!DOCTYPE html>
|
|
1611
|
-
<html lang="en">
|
|
1612
|
-
<head>
|
|
1613
|
-
<meta charset="utf-8" />
|
|
1614
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1615
|
-
<title>afterbefore Report</title>
|
|
1616
|
-
<style>
|
|
1617
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1618
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f9fafb; color: #111827; padding: 24px; }
|
|
1619
|
-
h1 { font-size: 24px; margin-bottom: 8px; }
|
|
1620
|
-
.summary { color: #6b7280; margin-bottom: 24px; font-size: 14px; }
|
|
1621
|
-
.grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
|
|
1622
|
-
.card { background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
|
|
1623
|
-
.card.changed { border-color: #fbbf24; }
|
|
1624
|
-
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #e5e7eb; }
|
|
1625
|
-
.route { font-weight: 600; font-size: 16px; font-family: monospace; }
|
|
1626
|
-
.badge { font-size: 12px; padding: 2px 8px; border-radius: 9999px; font-weight: 500; }
|
|
1627
|
-
.badge-changed { background: #fef3c7; color: #92400e; }
|
|
1628
|
-
.badge-unchanged { background: #d1fae5; color: #065f46; }
|
|
1629
|
-
.images { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #e5e7eb; }
|
|
1630
|
-
.img-col { background: #fff; padding: 8px; }
|
|
1631
|
-
.img-col img { width: 100%; height: auto; display: block; border-radius: 4px; }
|
|
1632
|
-
.label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 4px; font-weight: 600; }
|
|
1633
|
-
.section-title { font-size: 18px; font-weight: 600; margin: 24px 0 12px; }
|
|
1634
|
-
</style>
|
|
1635
|
-
</head>
|
|
1636
|
-
<body>
|
|
1637
|
-
<h1>afterbefore Report</h1>
|
|
1638
|
-
<p class="summary">${results.length} route(s) captured, ${changed.length} with visual changes.</p>
|
|
1639
|
-
|
|
1640
|
-
${changed.length > 0 ? `<h2 class="section-title">Changed (${changed.length})</h2><div class="grid">${changed.map(card).join("")}</div>` : ""}
|
|
1641
|
-
${unchanged.length > 0 ? `<h2 class="section-title">Unchanged (${unchanged.length})</h2><div class="grid">${unchanged.map(card).join("")}</div>` : ""}
|
|
1642
|
-
|
|
1643
|
-
</body>
|
|
1644
|
-
</html>`;
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
1669
|
// src/templates/summary.md.ts
|
|
1648
1670
|
function generateSummaryMd(results, gitDiff, options) {
|
|
1649
1671
|
const includeFilePaths = options?.includeFilePaths ?? true;
|
|
@@ -1741,15 +1763,6 @@ function postOrUpdateComment(prNumber, body) {
|
|
|
1741
1763
|
}
|
|
1742
1764
|
}
|
|
1743
1765
|
async function generateReport(results, outputDir, options) {
|
|
1744
|
-
await ensureDir(outputDir);
|
|
1745
|
-
const summaryMd = generateSummaryMd(results);
|
|
1746
|
-
const summaryPath = join8(outputDir, "summary.md");
|
|
1747
|
-
writeFileSync(summaryPath, summaryMd, "utf-8");
|
|
1748
|
-
logger.success(`Written summary to ${summaryPath}`);
|
|
1749
|
-
const reportHtml = generateReportHtml(results, outputDir);
|
|
1750
|
-
const indexPath = join8(outputDir, "index.html");
|
|
1751
|
-
writeFileSync(indexPath, reportHtml, "utf-8");
|
|
1752
|
-
logger.success(`Written report to ${indexPath}`);
|
|
1753
1766
|
if (options.post) {
|
|
1754
1767
|
const prNumber = findPrNumber();
|
|
1755
1768
|
if (!prNumber) {
|
|
@@ -1841,17 +1854,13 @@ function expandRoutes(routes, config, routeComponentMap) {
|
|
|
1841
1854
|
}
|
|
1842
1855
|
return tasks;
|
|
1843
1856
|
}
|
|
1844
|
-
function openInBrowser(filePath) {
|
|
1845
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1846
|
-
exec(`${cmd} "${filePath}"`);
|
|
1847
|
-
}
|
|
1848
1857
|
async function runPipeline(options) {
|
|
1849
1858
|
const { base, output, post, cwd } = options;
|
|
1850
1859
|
const sessionName = generateSessionName(cwd);
|
|
1851
1860
|
const outputDir = resolve4(cwd, output, sessionName);
|
|
1852
1861
|
const startTime = Date.now();
|
|
1853
1862
|
try {
|
|
1854
|
-
const version = true ? "0.1.
|
|
1863
|
+
const version = true ? "0.1.7" : "dev";
|
|
1855
1864
|
console.log(`
|
|
1856
1865
|
afterbefore v${version} \xB7 Comparing against ${base}
|
|
1857
1866
|
`);
|
|
@@ -1984,11 +1993,11 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
1984
1993
|
logger.success(
|
|
1985
1994
|
`Done in ${elapsed}s \u2014 ${results.length} route(s) captured, ${changedCount} with visual changes`
|
|
1986
1995
|
);
|
|
1987
|
-
logger.dim(` Report: ${outputDir}/index.html`);
|
|
1988
|
-
if (options.open) {
|
|
1989
|
-
openInBrowser(resolve4(outputDir, "index.html"));
|
|
1990
|
-
}
|
|
1991
1996
|
} finally {
|
|
1997
|
+
try {
|
|
1998
|
+
logger.writeLogFile(resolve4(outputDir, "debug.log"));
|
|
1999
|
+
} catch {
|
|
2000
|
+
}
|
|
1992
2001
|
await cleanupRegistry.runAll();
|
|
1993
2002
|
}
|
|
1994
2003
|
}
|
|
@@ -1997,7 +2006,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
1997
2006
|
var program = new Command();
|
|
1998
2007
|
program.name("afterbefore").description(
|
|
1999
2008
|
"Automatic before/after screenshot capture for PRs. Git diff is the config."
|
|
2000
|
-
).version("0.1.
|
|
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(
|
|
2001
2010
|
"--threshold <percent>",
|
|
2002
2011
|
"Diff threshold percentage (changes below this are ignored)",
|
|
2003
2012
|
"0.1"
|
|
@@ -2005,7 +2014,7 @@ program.name("afterbefore").description(
|
|
|
2005
2014
|
"--max-routes <count>",
|
|
2006
2015
|
"Maximum routes to capture (0 = unlimited)",
|
|
2007
2016
|
"6"
|
|
2008
|
-
).option("--
|
|
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) => {
|
|
2009
2018
|
const cwd = process.cwd();
|
|
2010
2019
|
if (!isGitRepo(cwd)) {
|
|
2011
2020
|
logger.error("Not a git repository. Run this from inside a git repo.");
|
|
@@ -2017,7 +2026,6 @@ program.name("afterbefore").description(
|
|
|
2017
2026
|
post: opts.post,
|
|
2018
2027
|
threshold: parseFloat(opts.threshold),
|
|
2019
2028
|
maxRoutes: parseInt(opts.maxRoutes, 10),
|
|
2020
|
-
open: opts.open,
|
|
2021
2029
|
width: parseInt(opts.width, 10),
|
|
2022
2030
|
height: parseInt(opts.height, 10),
|
|
2023
2031
|
device: opts.device,
|