afterbefore 0.1.6 → 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 +162 -185
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.js +160 -182
- 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
|
@@ -116,14 +116,14 @@ var Logger = class {
|
|
|
116
116
|
if (this.spinner) {
|
|
117
117
|
if (finished) {
|
|
118
118
|
this.spinner.stop();
|
|
119
|
-
const bar = "
|
|
119
|
+
const bar = "\u2588".repeat(BAR_WIDTH);
|
|
120
120
|
console.log(`${chalk.green("\u2714")} ${bar}`);
|
|
121
121
|
} else {
|
|
122
122
|
this.spinner.stop();
|
|
123
123
|
}
|
|
124
124
|
this.spinner = null;
|
|
125
125
|
} else if (finished) {
|
|
126
|
-
const bar = "
|
|
126
|
+
const bar = "\u2588".repeat(BAR_WIDTH);
|
|
127
127
|
console.log(`${chalk.green("\u2714")} ${bar}`);
|
|
128
128
|
}
|
|
129
129
|
this.pipelineTotal = 0;
|
|
@@ -138,7 +138,7 @@ var Logger = class {
|
|
|
138
138
|
const total = this.pipelineTotal || 1;
|
|
139
139
|
const clampedStep = Math.max(0, Math.min(step, total));
|
|
140
140
|
const filled = Math.round(clampedStep / total * BAR_WIDTH);
|
|
141
|
-
const bar = "
|
|
141
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
|
|
142
142
|
return ` ${bar} ${clampedStep}/${total} ${label}`;
|
|
143
143
|
}
|
|
144
144
|
clearSpinner() {
|
|
@@ -241,7 +241,6 @@ function getCurrentBranch(cwd) {
|
|
|
241
241
|
// src/pipeline.ts
|
|
242
242
|
import { resolve as resolve4 } from "path";
|
|
243
243
|
import { unlinkSync } from "fs";
|
|
244
|
-
import { exec } from "child_process";
|
|
245
244
|
|
|
246
245
|
// src/config.ts
|
|
247
246
|
import { resolve } from "path";
|
|
@@ -900,8 +899,8 @@ function waitForServer(url, timeoutMs) {
|
|
|
900
899
|
async function startServer(projectDir, port) {
|
|
901
900
|
const url = `http://localhost:${port}`;
|
|
902
901
|
const pm = detectPackageManager(projectDir);
|
|
903
|
-
const
|
|
904
|
-
const [cmd, ...baseArgs] =
|
|
902
|
+
const exec = pmExec(pm);
|
|
903
|
+
const [cmd, ...baseArgs] = exec.split(" ");
|
|
905
904
|
const lockFile = join5(projectDir, ".next", "dev", "lock");
|
|
906
905
|
if (existsSync5(lockFile)) {
|
|
907
906
|
throw new AfterbeforeError(
|
|
@@ -1201,6 +1200,49 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
|
|
|
1201
1200
|
});
|
|
1202
1201
|
bySource.set(match.source, list);
|
|
1203
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
|
+
}
|
|
1204
1246
|
const tagged = [];
|
|
1205
1247
|
for (let sourceIndex = 0; sourceIndex < targets.length; sourceIndex++) {
|
|
1206
1248
|
const source = targets[sourceIndex].original;
|
|
@@ -1227,6 +1269,94 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
|
|
|
1227
1269
|
{ changed: normalized, maxPerSource }
|
|
1228
1270
|
);
|
|
1229
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
|
+
}
|
|
1230
1360
|
async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLabel, outputDir, options, settle, results) {
|
|
1231
1361
|
const sections = await detectSections(afterPage, options.maxSectionsPerRoute);
|
|
1232
1362
|
if (sections.length === 0) return;
|
|
@@ -1323,94 +1453,17 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
|
1323
1453
|
]);
|
|
1324
1454
|
results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
|
|
1325
1455
|
if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
|
|
1326
|
-
|
|
1327
|
-
|
|
1456
|
+
await captureComponentInstances(
|
|
1457
|
+
afterPage,
|
|
1458
|
+
beforePage,
|
|
1459
|
+
task.changedComponents,
|
|
1460
|
+
task.prefix,
|
|
1461
|
+
task.label,
|
|
1462
|
+
outputDir,
|
|
1463
|
+
beforePath,
|
|
1464
|
+
afterPath,
|
|
1465
|
+
results
|
|
1328
1466
|
);
|
|
1329
|
-
const [afterInstances, beforeInstances] = await Promise.all([
|
|
1330
|
-
tagChangedComponentInstances(afterPage, changedComponents),
|
|
1331
|
-
tagChangedComponentInstances(beforePage, changedComponents)
|
|
1332
|
-
]);
|
|
1333
|
-
logger.dim(` Component detection on ${task.route}: ${changedComponents.length} source(s), ${afterInstances.length} after / ${beforeInstances.length} before instance(s)`);
|
|
1334
|
-
const afterBySource = groupBySource(afterInstances);
|
|
1335
|
-
const beforeBySource = groupBySource(beforeInstances);
|
|
1336
|
-
for (const source of changedComponents) {
|
|
1337
|
-
const afterList = afterBySource.get(source) ?? [];
|
|
1338
|
-
const beforeList = beforeBySource.get(source) ?? [];
|
|
1339
|
-
const pairCount = Math.min(afterList.length, beforeList.length);
|
|
1340
|
-
if (pairCount === 0) {
|
|
1341
|
-
if (afterList.length > 0 || beforeList.length > 0) {
|
|
1342
|
-
logger.dim(` ${source}: ${afterList.length} after / ${beforeList.length} before (skipping unpaired)`);
|
|
1343
|
-
}
|
|
1344
|
-
continue;
|
|
1345
|
-
}
|
|
1346
|
-
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1347
|
-
const afterInstance = afterList[pairIndex];
|
|
1348
|
-
const beforeInstance = beforeList[pairIndex];
|
|
1349
|
-
const sourceSlug = sanitizeComponentLabel(source) || "component";
|
|
1350
|
-
const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
|
|
1351
|
-
const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
|
|
1352
|
-
const baseLabel = `${task.label} [${componentName} #${pairIndex + 1}]`;
|
|
1353
|
-
const parentPrefix = `${task.prefix}~cmp.${itemSlug}~parent`;
|
|
1354
|
-
const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
|
|
1355
|
-
const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
|
|
1356
|
-
const [parentBeforeOk, parentAfterOk] = await Promise.all([
|
|
1357
|
-
captureByAttr(
|
|
1358
|
-
beforePage,
|
|
1359
|
-
"data-ab-parent-key",
|
|
1360
|
-
beforeInstance.parentKey,
|
|
1361
|
-
parentBeforePath
|
|
1362
|
-
),
|
|
1363
|
-
captureByAttr(
|
|
1364
|
-
afterPage,
|
|
1365
|
-
"data-ab-parent-key",
|
|
1366
|
-
afterInstance.parentKey,
|
|
1367
|
-
parentAfterPath
|
|
1368
|
-
)
|
|
1369
|
-
]);
|
|
1370
|
-
if (parentBeforeOk && parentAfterOk) {
|
|
1371
|
-
results.push({
|
|
1372
|
-
route: `${baseLabel} [parent]`,
|
|
1373
|
-
prefix: parentPrefix,
|
|
1374
|
-
beforePath: parentBeforePath,
|
|
1375
|
-
afterPath: parentAfterPath
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
const componentPrefix = `${task.prefix}~cmp.${itemSlug}~component`;
|
|
1379
|
-
const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
|
|
1380
|
-
const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
|
|
1381
|
-
const [componentBeforeOk, componentAfterOk] = await Promise.all([
|
|
1382
|
-
captureByAttr(
|
|
1383
|
-
beforePage,
|
|
1384
|
-
"data-ab-comp-key",
|
|
1385
|
-
beforeInstance.componentKey,
|
|
1386
|
-
componentBeforePath
|
|
1387
|
-
),
|
|
1388
|
-
captureByAttr(
|
|
1389
|
-
afterPage,
|
|
1390
|
-
"data-ab-comp-key",
|
|
1391
|
-
afterInstance.componentKey,
|
|
1392
|
-
componentAfterPath
|
|
1393
|
-
)
|
|
1394
|
-
]);
|
|
1395
|
-
if (componentBeforeOk && componentAfterOk) {
|
|
1396
|
-
results.push({
|
|
1397
|
-
route: `${baseLabel} [component]`,
|
|
1398
|
-
prefix: componentPrefix,
|
|
1399
|
-
beforePath: componentBeforePath,
|
|
1400
|
-
afterPath: componentAfterPath
|
|
1401
|
-
});
|
|
1402
|
-
}
|
|
1403
|
-
if (parentBeforeOk && parentAfterOk || componentBeforeOk && componentAfterOk) {
|
|
1404
|
-
const contextPrefix = `${task.prefix}~cmp.${itemSlug}~context`;
|
|
1405
|
-
results.push({
|
|
1406
|
-
route: `${baseLabel} [context]`,
|
|
1407
|
-
prefix: contextPrefix,
|
|
1408
|
-
beforePath,
|
|
1409
|
-
afterPath
|
|
1410
|
-
});
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
1467
|
}
|
|
1415
1468
|
if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
|
|
1416
1469
|
await captureAutoSections(
|
|
@@ -1470,6 +1523,19 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
|
1470
1523
|
beforePath: tabBeforePath,
|
|
1471
1524
|
afterPath: tabAfterPath
|
|
1472
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
|
+
}
|
|
1473
1539
|
if (options.autoSections && !task.skipAutoSections) {
|
|
1474
1540
|
await captureAutoSections(
|
|
1475
1541
|
afterPage,
|
|
@@ -1598,79 +1664,8 @@ async function compareScreenshots(captures, outputDir, threshold = 0.1, options)
|
|
|
1598
1664
|
}
|
|
1599
1665
|
|
|
1600
1666
|
// src/stages/report.ts
|
|
1601
|
-
import { writeFileSync as writeFileSync2 } from "fs";
|
|
1602
|
-
import { join as join8 } from "path";
|
|
1603
1667
|
import { execSync as execSync4 } from "child_process";
|
|
1604
1668
|
|
|
1605
|
-
// src/templates/report.html.ts
|
|
1606
|
-
import { readFileSync as readFileSync6 } from "fs";
|
|
1607
|
-
function toBase64(filePath) {
|
|
1608
|
-
return readFileSync6(filePath).toString("base64");
|
|
1609
|
-
}
|
|
1610
|
-
function imgSrc(filePath) {
|
|
1611
|
-
return `data:image/png;base64,${toBase64(filePath)}`;
|
|
1612
|
-
}
|
|
1613
|
-
function generateReportHtml(results, outputDir) {
|
|
1614
|
-
const changed = results.filter((r) => r.changed);
|
|
1615
|
-
const unchanged = results.filter((r) => !r.changed);
|
|
1616
|
-
const card = (r) => {
|
|
1617
|
-
return `
|
|
1618
|
-
<div class="card ${r.changed ? "changed" : "unchanged"}">
|
|
1619
|
-
<div class="card-header">
|
|
1620
|
-
<span class="route">${r.route}</span>
|
|
1621
|
-
<span class="badge ${r.changed ? "badge-changed" : "badge-unchanged"}">
|
|
1622
|
-
${r.changed ? `${r.diffPercentage.toFixed(2)}% changed` : "No change"}
|
|
1623
|
-
</span>
|
|
1624
|
-
</div>
|
|
1625
|
-
<div class="images">
|
|
1626
|
-
<div class="img-col">
|
|
1627
|
-
<div class="label">Before</div>
|
|
1628
|
-
<img src="${imgSrc(r.beforePath)}" alt="Before" />
|
|
1629
|
-
</div>
|
|
1630
|
-
<div class="img-col">
|
|
1631
|
-
<div class="label">After</div>
|
|
1632
|
-
<img src="${imgSrc(r.afterPath)}" alt="After" />
|
|
1633
|
-
</div>
|
|
1634
|
-
</div>
|
|
1635
|
-
</div>`;
|
|
1636
|
-
};
|
|
1637
|
-
return `<!DOCTYPE html>
|
|
1638
|
-
<html lang="en">
|
|
1639
|
-
<head>
|
|
1640
|
-
<meta charset="utf-8" />
|
|
1641
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1642
|
-
<title>afterbefore Report</title>
|
|
1643
|
-
<style>
|
|
1644
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1645
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f9fafb; color: #111827; padding: 24px; }
|
|
1646
|
-
h1 { font-size: 24px; margin-bottom: 8px; }
|
|
1647
|
-
.summary { color: #6b7280; margin-bottom: 24px; font-size: 14px; }
|
|
1648
|
-
.grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
|
|
1649
|
-
.card { background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
|
|
1650
|
-
.card.changed { border-color: #fbbf24; }
|
|
1651
|
-
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #e5e7eb; }
|
|
1652
|
-
.route { font-weight: 600; font-size: 16px; font-family: monospace; }
|
|
1653
|
-
.badge { font-size: 12px; padding: 2px 8px; border-radius: 9999px; font-weight: 500; }
|
|
1654
|
-
.badge-changed { background: #fef3c7; color: #92400e; }
|
|
1655
|
-
.badge-unchanged { background: #d1fae5; color: #065f46; }
|
|
1656
|
-
.images { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #e5e7eb; }
|
|
1657
|
-
.img-col { background: #fff; padding: 8px; }
|
|
1658
|
-
.img-col img { width: 100%; height: auto; display: block; border-radius: 4px; }
|
|
1659
|
-
.label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 4px; font-weight: 600; }
|
|
1660
|
-
.section-title { font-size: 18px; font-weight: 600; margin: 24px 0 12px; }
|
|
1661
|
-
</style>
|
|
1662
|
-
</head>
|
|
1663
|
-
<body>
|
|
1664
|
-
<h1>afterbefore Report</h1>
|
|
1665
|
-
<p class="summary">${results.length} route(s) captured, ${changed.length} with visual changes.</p>
|
|
1666
|
-
|
|
1667
|
-
${changed.length > 0 ? `<h2 class="section-title">Changed (${changed.length})</h2><div class="grid">${changed.map(card).join("")}</div>` : ""}
|
|
1668
|
-
${unchanged.length > 0 ? `<h2 class="section-title">Unchanged (${unchanged.length})</h2><div class="grid">${unchanged.map(card).join("")}</div>` : ""}
|
|
1669
|
-
|
|
1670
|
-
</body>
|
|
1671
|
-
</html>`;
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
1669
|
// src/templates/summary.md.ts
|
|
1675
1670
|
function generateSummaryMd(results, gitDiff, options) {
|
|
1676
1671
|
const includeFilePaths = options?.includeFilePaths ?? true;
|
|
@@ -1768,15 +1763,6 @@ function postOrUpdateComment(prNumber, body) {
|
|
|
1768
1763
|
}
|
|
1769
1764
|
}
|
|
1770
1765
|
async function generateReport(results, outputDir, options) {
|
|
1771
|
-
await ensureDir(outputDir);
|
|
1772
|
-
const summaryMd = generateSummaryMd(results);
|
|
1773
|
-
const summaryPath = join8(outputDir, "summary.md");
|
|
1774
|
-
writeFileSync2(summaryPath, summaryMd, "utf-8");
|
|
1775
|
-
logger.success(`Written summary to ${summaryPath}`);
|
|
1776
|
-
const reportHtml = generateReportHtml(results, outputDir);
|
|
1777
|
-
const indexPath = join8(outputDir, "index.html");
|
|
1778
|
-
writeFileSync2(indexPath, reportHtml, "utf-8");
|
|
1779
|
-
logger.success(`Written report to ${indexPath}`);
|
|
1780
1766
|
if (options.post) {
|
|
1781
1767
|
const prNumber = findPrNumber();
|
|
1782
1768
|
if (!prNumber) {
|
|
@@ -1868,17 +1854,13 @@ function expandRoutes(routes, config, routeComponentMap) {
|
|
|
1868
1854
|
}
|
|
1869
1855
|
return tasks;
|
|
1870
1856
|
}
|
|
1871
|
-
function openInBrowser(filePath) {
|
|
1872
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1873
|
-
exec(`${cmd} "${filePath}"`);
|
|
1874
|
-
}
|
|
1875
1857
|
async function runPipeline(options) {
|
|
1876
1858
|
const { base, output, post, cwd } = options;
|
|
1877
1859
|
const sessionName = generateSessionName(cwd);
|
|
1878
1860
|
const outputDir = resolve4(cwd, output, sessionName);
|
|
1879
1861
|
const startTime = Date.now();
|
|
1880
1862
|
try {
|
|
1881
|
-
const version = true ? "0.1.
|
|
1863
|
+
const version = true ? "0.1.7" : "dev";
|
|
1882
1864
|
console.log(`
|
|
1883
1865
|
afterbefore v${version} \xB7 Comparing against ${base}
|
|
1884
1866
|
`);
|
|
@@ -2011,10 +1993,6 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2011
1993
|
logger.success(
|
|
2012
1994
|
`Done in ${elapsed}s \u2014 ${results.length} route(s) captured, ${changedCount} with visual changes`
|
|
2013
1995
|
);
|
|
2014
|
-
logger.dim(` Report: ${outputDir}/index.html`);
|
|
2015
|
-
if (options.open) {
|
|
2016
|
-
openInBrowser(resolve4(outputDir, "index.html"));
|
|
2017
|
-
}
|
|
2018
1996
|
} finally {
|
|
2019
1997
|
try {
|
|
2020
1998
|
logger.writeLogFile(resolve4(outputDir, "debug.log"));
|
|
@@ -2028,7 +2006,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2028
2006
|
var program = new Command();
|
|
2029
2007
|
program.name("afterbefore").description(
|
|
2030
2008
|
"Automatic before/after screenshot capture for PRs. Git diff is the config."
|
|
2031
|
-
).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(
|
|
2032
2010
|
"--threshold <percent>",
|
|
2033
2011
|
"Diff threshold percentage (changes below this are ignored)",
|
|
2034
2012
|
"0.1"
|
|
@@ -2036,7 +2014,7 @@ program.name("afterbefore").description(
|
|
|
2036
2014
|
"--max-routes <count>",
|
|
2037
2015
|
"Maximum routes to capture (0 = unlimited)",
|
|
2038
2016
|
"6"
|
|
2039
|
-
).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) => {
|
|
2040
2018
|
const cwd = process.cwd();
|
|
2041
2019
|
if (!isGitRepo(cwd)) {
|
|
2042
2020
|
logger.error("Not a git repository. Run this from inside a git repo.");
|
|
@@ -2048,7 +2026,6 @@ program.name("afterbefore").description(
|
|
|
2048
2026
|
post: opts.post,
|
|
2049
2027
|
threshold: parseFloat(opts.threshold),
|
|
2050
2028
|
maxRoutes: parseInt(opts.maxRoutes, 10),
|
|
2051
|
-
open: opts.open,
|
|
2052
2029
|
width: parseInt(opts.width, 10),
|
|
2053
2030
|
height: parseInt(opts.height, 10),
|
|
2054
2031
|
device: opts.device,
|