afterbefore 0.1.11 → 0.1.12
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 +212 -325
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +211 -324
- package/dist/index.js.map +1 -1
- package/package.json +3 -5
package/dist/cli.js
CHANGED
|
@@ -240,8 +240,8 @@ function getCurrentBranch(cwd) {
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
// src/pipeline.ts
|
|
243
|
-
import { resolve as
|
|
244
|
-
import { unlinkSync } from "fs";
|
|
243
|
+
import { resolve as resolve3 } from "path";
|
|
244
|
+
import { unlinkSync as unlinkSync2 } from "fs";
|
|
245
245
|
|
|
246
246
|
// src/config.ts
|
|
247
247
|
import { resolve } from "path";
|
|
@@ -276,7 +276,7 @@ async function ensureDir(dir) {
|
|
|
276
276
|
// src/utils/port.ts
|
|
277
277
|
import { createServer } from "net";
|
|
278
278
|
function findPort() {
|
|
279
|
-
return new Promise((
|
|
279
|
+
return new Promise((resolve4, reject) => {
|
|
280
280
|
const server = createServer();
|
|
281
281
|
server.listen(0, () => {
|
|
282
282
|
const addr = server.address();
|
|
@@ -286,7 +286,7 @@ function findPort() {
|
|
|
286
286
|
return;
|
|
287
287
|
}
|
|
288
288
|
const port = addr.port;
|
|
289
|
-
server.close(() =>
|
|
289
|
+
server.close(() => resolve4(port));
|
|
290
290
|
});
|
|
291
291
|
server.on("error", reject);
|
|
292
292
|
});
|
|
@@ -299,105 +299,6 @@ async function findAvailablePort(exclude) {
|
|
|
299
299
|
throw new Error("Failed to find available port after 5 attempts");
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
// src/utils/bgcolor.ts
|
|
303
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
304
|
-
import { resolve as resolve2 } from "path";
|
|
305
|
-
var DEFAULT_BG = "#0a0a0a";
|
|
306
|
-
var GLOBAL_CSS_PATHS = [
|
|
307
|
-
"app/globals.css",
|
|
308
|
-
"src/app/globals.css",
|
|
309
|
-
"styles/globals.css",
|
|
310
|
-
"src/styles/globals.css",
|
|
311
|
-
"app/global.css",
|
|
312
|
-
"src/app/global.css"
|
|
313
|
-
];
|
|
314
|
-
function hslToHex(h, s, l) {
|
|
315
|
-
s /= 100;
|
|
316
|
-
l /= 100;
|
|
317
|
-
const a = s * Math.min(l, 1 - l);
|
|
318
|
-
const f = (n) => {
|
|
319
|
-
const k = (n + h / 30) % 12;
|
|
320
|
-
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
321
|
-
return Math.round(255 * color).toString(16).padStart(2, "0");
|
|
322
|
-
};
|
|
323
|
-
return `#${f(0)}${f(8)}${f(4)}`;
|
|
324
|
-
}
|
|
325
|
-
function parseColorValue(raw) {
|
|
326
|
-
const v = raw.trim();
|
|
327
|
-
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
328
|
-
if (v.length === 4) {
|
|
329
|
-
return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
|
|
330
|
-
}
|
|
331
|
-
return v.slice(0, 7);
|
|
332
|
-
}
|
|
333
|
-
const hslMatch = v.match(
|
|
334
|
-
/^hsl\(\s*([\d.]+)[,\s]+\s*([\d.]+)%[,\s]+\s*([\d.]+)%/
|
|
335
|
-
);
|
|
336
|
-
if (hslMatch) {
|
|
337
|
-
return hslToHex(
|
|
338
|
-
parseFloat(hslMatch[1]),
|
|
339
|
-
parseFloat(hslMatch[2]),
|
|
340
|
-
parseFloat(hslMatch[3])
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
const bareHsl = v.match(/^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/);
|
|
344
|
-
if (bareHsl) {
|
|
345
|
-
return hslToHex(
|
|
346
|
-
parseFloat(bareHsl[1]),
|
|
347
|
-
parseFloat(bareHsl[2]),
|
|
348
|
-
parseFloat(bareHsl[3])
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
const rgbMatch = v.match(
|
|
352
|
-
/^rgb\(\s*([\d.]+)[,\s]+\s*([\d.]+)[,\s]+\s*([\d.]+)/
|
|
353
|
-
);
|
|
354
|
-
if (rgbMatch) {
|
|
355
|
-
const toHex = (n) => Math.round(parseFloat(n)).toString(16).padStart(2, "0");
|
|
356
|
-
return `#${toHex(rgbMatch[1])}${toHex(rgbMatch[2])}${toHex(rgbMatch[3])}`;
|
|
357
|
-
}
|
|
358
|
-
return null;
|
|
359
|
-
}
|
|
360
|
-
function detectBgColor(cwd) {
|
|
361
|
-
for (const relPath of GLOBAL_CSS_PATHS) {
|
|
362
|
-
const absPath = resolve2(cwd, relPath);
|
|
363
|
-
if (!existsSync2(absPath)) continue;
|
|
364
|
-
const css = readFileSync2(absPath, "utf-8");
|
|
365
|
-
const darkBlock = css.match(/\.dark\s*\{([^}]+)\}/);
|
|
366
|
-
if (darkBlock) {
|
|
367
|
-
const bgVar = darkBlock[1].match(/--background\s*:\s*([^;]+)/);
|
|
368
|
-
if (bgVar) {
|
|
369
|
-
const color = parseColorValue(bgVar[1]);
|
|
370
|
-
if (color) return color;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
const rootBlock = css.match(/:root\s*\{([^}]+)\}/);
|
|
374
|
-
if (rootBlock) {
|
|
375
|
-
const bgVar = rootBlock[1].match(/--background\s*:\s*([^;]+)/);
|
|
376
|
-
if (bgVar) {
|
|
377
|
-
const color = parseColorValue(bgVar[1]);
|
|
378
|
-
if (color) return color;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
const anyBgVar = css.match(/--background\s*:\s*([^;]+)/);
|
|
382
|
-
if (anyBgVar) {
|
|
383
|
-
const color = parseColorValue(anyBgVar[1]);
|
|
384
|
-
if (color) return color;
|
|
385
|
-
}
|
|
386
|
-
const bodyBg = css.match(
|
|
387
|
-
/body\s*\{[^}]*?background(?:-color)?\s*:\s*([^;]+)/
|
|
388
|
-
);
|
|
389
|
-
if (bodyBg) {
|
|
390
|
-
const val = bodyBg[1].trim();
|
|
391
|
-
if (!val.startsWith("var(")) {
|
|
392
|
-
const color = parseColorValue(val);
|
|
393
|
-
if (color) return color;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
break;
|
|
397
|
-
}
|
|
398
|
-
return DEFAULT_BG;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
302
|
// src/stages/diff.ts
|
|
402
303
|
var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
|
|
403
304
|
function parseDiffOutput(raw) {
|
|
@@ -471,13 +372,13 @@ function classifyFiles(files) {
|
|
|
471
372
|
}
|
|
472
373
|
|
|
473
374
|
// src/stages/graph.ts
|
|
474
|
-
import { readdirSync, readFileSync as
|
|
375
|
+
import { readdirSync, readFileSync as readFileSync3 } from "fs";
|
|
475
376
|
import { join as join2, relative } from "path";
|
|
476
377
|
import { init, parse } from "es-module-lexer";
|
|
477
378
|
|
|
478
379
|
// src/stages/resolve.ts
|
|
479
|
-
import { existsSync as
|
|
480
|
-
import { resolve as
|
|
380
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
381
|
+
import { resolve as resolve2, dirname, join } from "path";
|
|
481
382
|
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
482
383
|
function createResolver(projectRoot) {
|
|
483
384
|
const mappings = loadPathMappings(projectRoot);
|
|
@@ -485,7 +386,7 @@ function createResolver(projectRoot) {
|
|
|
485
386
|
function cachedExists(p) {
|
|
486
387
|
const cached = existsCache.get(p);
|
|
487
388
|
if (cached !== void 0) return cached;
|
|
488
|
-
const result =
|
|
389
|
+
const result = existsSync2(p);
|
|
489
390
|
existsCache.set(p, result);
|
|
490
391
|
return result;
|
|
491
392
|
}
|
|
@@ -511,14 +412,14 @@ function createResolver(projectRoot) {
|
|
|
511
412
|
return (specifier, fromFile) => {
|
|
512
413
|
if (specifier.startsWith(".")) {
|
|
513
414
|
const dir = dirname(fromFile);
|
|
514
|
-
const candidate =
|
|
415
|
+
const candidate = resolve2(dir, specifier);
|
|
515
416
|
return tryResolve(candidate);
|
|
516
417
|
}
|
|
517
418
|
for (const mapping of mappings) {
|
|
518
419
|
if (!specifier.startsWith(mapping.prefix)) continue;
|
|
519
420
|
const rest = specifier.slice(mapping.prefix.length);
|
|
520
421
|
for (const target of mapping.targets) {
|
|
521
|
-
const candidate =
|
|
422
|
+
const candidate = resolve2(projectRoot, target + rest);
|
|
522
423
|
const result = tryResolve(candidate);
|
|
523
424
|
if (result) return result;
|
|
524
425
|
}
|
|
@@ -528,12 +429,12 @@ function createResolver(projectRoot) {
|
|
|
528
429
|
}
|
|
529
430
|
function loadPathMappings(projectRoot) {
|
|
530
431
|
const tsconfigPath = join(projectRoot, "tsconfig.json");
|
|
531
|
-
if (!
|
|
432
|
+
if (!existsSync2(tsconfigPath)) {
|
|
532
433
|
logger.dim("No tsconfig.json found, skipping path alias resolution");
|
|
533
434
|
return [];
|
|
534
435
|
}
|
|
535
436
|
try {
|
|
536
|
-
const raw =
|
|
437
|
+
const raw = readFileSync2(tsconfigPath, "utf-8");
|
|
537
438
|
const cleaned = stripJsonComments(raw);
|
|
538
439
|
const config = JSON.parse(cleaned);
|
|
539
440
|
const paths = config?.compilerOptions?.paths;
|
|
@@ -633,7 +534,7 @@ function collectFiles(dir) {
|
|
|
633
534
|
function parseImports(filePath) {
|
|
634
535
|
let source;
|
|
635
536
|
try {
|
|
636
|
-
source =
|
|
537
|
+
source = readFileSync3(filePath, "utf-8");
|
|
637
538
|
} catch {
|
|
638
539
|
return [];
|
|
639
540
|
}
|
|
@@ -653,7 +554,7 @@ function parseImports(filePath) {
|
|
|
653
554
|
}
|
|
654
555
|
async function buildImportGraph(projectRoot) {
|
|
655
556
|
await init;
|
|
656
|
-
const
|
|
557
|
+
const resolve4 = createResolver(projectRoot);
|
|
657
558
|
const allFiles = [];
|
|
658
559
|
for (const dir of SOURCE_DIRS) {
|
|
659
560
|
const fullDir = join2(projectRoot, dir);
|
|
@@ -667,7 +568,7 @@ async function buildImportGraph(projectRoot) {
|
|
|
667
568
|
const specifiers = parseImports(filePath);
|
|
668
569
|
const deps = /* @__PURE__ */ new Set();
|
|
669
570
|
for (const spec of specifiers) {
|
|
670
|
-
const resolved =
|
|
571
|
+
const resolved = resolve4(spec, filePath);
|
|
671
572
|
if (!resolved) continue;
|
|
672
573
|
const relResolved = relative(projectRoot, resolved);
|
|
673
574
|
deps.add(relResolved);
|
|
@@ -826,13 +727,13 @@ import { join as join4 } from "path";
|
|
|
826
727
|
import { tmpdir } from "os";
|
|
827
728
|
|
|
828
729
|
// src/utils/pm.ts
|
|
829
|
-
import { existsSync as
|
|
730
|
+
import { existsSync as existsSync3 } from "fs";
|
|
830
731
|
import { join as join3 } from "path";
|
|
831
732
|
function detectPackageManager(dir) {
|
|
832
|
-
if (
|
|
733
|
+
if (existsSync3(join3(dir, "bun.lockb")) || existsSync3(join3(dir, "bun.lock")))
|
|
833
734
|
return "bun";
|
|
834
|
-
if (
|
|
835
|
-
if (
|
|
735
|
+
if (existsSync3(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
736
|
+
if (existsSync3(join3(dir, "yarn.lock"))) return "yarn";
|
|
836
737
|
return "npm";
|
|
837
738
|
}
|
|
838
739
|
function pmExec(pm) {
|
|
@@ -889,11 +790,11 @@ async function createWorktree(base, cwd) {
|
|
|
889
790
|
|
|
890
791
|
// src/stages/server.ts
|
|
891
792
|
import { spawn } from "child_process";
|
|
892
|
-
import { existsSync as
|
|
793
|
+
import { existsSync as existsSync4 } from "fs";
|
|
893
794
|
import { join as join5 } from "path";
|
|
894
795
|
function waitForServer(url, timeoutMs) {
|
|
895
796
|
const start = Date.now();
|
|
896
|
-
return new Promise((
|
|
797
|
+
return new Promise((resolve4, reject) => {
|
|
897
798
|
const poll = async () => {
|
|
898
799
|
if (Date.now() - start > timeoutMs) {
|
|
899
800
|
reject(
|
|
@@ -906,7 +807,7 @@ function waitForServer(url, timeoutMs) {
|
|
|
906
807
|
}
|
|
907
808
|
try {
|
|
908
809
|
await fetch(url);
|
|
909
|
-
|
|
810
|
+
resolve4();
|
|
910
811
|
} catch {
|
|
911
812
|
setTimeout(poll, 150);
|
|
912
813
|
}
|
|
@@ -920,7 +821,7 @@ async function startServer(projectDir, port) {
|
|
|
920
821
|
const exec2 = pmExec(pm);
|
|
921
822
|
const [cmd, ...baseArgs] = exec2.split(" ");
|
|
922
823
|
const lockFile = join5(projectDir, ".next", "dev", "lock");
|
|
923
|
-
if (
|
|
824
|
+
if (existsSync4(lockFile)) {
|
|
924
825
|
throw new AfterbeforeError(
|
|
925
826
|
`Another Next.js dev server is running in ${projectDir} (.next/dev/lock exists).`,
|
|
926
827
|
`Stop the other dev server first, or delete .next/dev/lock if it's stale.`
|
|
@@ -947,17 +848,17 @@ async function stopServer(server) {
|
|
|
947
848
|
process.kill(-pid, "SIGTERM");
|
|
948
849
|
} catch {
|
|
949
850
|
}
|
|
950
|
-
await new Promise((
|
|
851
|
+
await new Promise((resolve4) => {
|
|
951
852
|
const timeout = setTimeout(() => {
|
|
952
853
|
try {
|
|
953
854
|
process.kill(-pid, "SIGKILL");
|
|
954
855
|
} catch {
|
|
955
856
|
}
|
|
956
|
-
|
|
857
|
+
resolve4();
|
|
957
858
|
}, 5e3);
|
|
958
859
|
server.process.on("exit", () => {
|
|
959
860
|
clearTimeout(timeout);
|
|
960
|
-
|
|
861
|
+
resolve4();
|
|
961
862
|
});
|
|
962
863
|
});
|
|
963
864
|
}
|
|
@@ -1117,7 +1018,7 @@ async function launchBrowser() {
|
|
|
1117
1018
|
return await chromium.launch();
|
|
1118
1019
|
}
|
|
1119
1020
|
}
|
|
1120
|
-
var MAX_COMPONENT_INSTANCES_PER_SOURCE =
|
|
1021
|
+
var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
|
|
1121
1022
|
function sanitizeComponentLabel(label) {
|
|
1122
1023
|
const noExt = label.replace(/\.[a-z0-9]+$/i, "");
|
|
1123
1024
|
return sanitizeLabel(noExt, 60);
|
|
@@ -1287,9 +1188,21 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
|
|
|
1287
1188
|
const source = targets[sourceIndex].original;
|
|
1288
1189
|
const list = bySource.get(source) ?? [];
|
|
1289
1190
|
list.sort((a, b) => a.top === b.top ? a.left - b.left : a.top - b.top);
|
|
1290
|
-
const
|
|
1191
|
+
const deduped = [];
|
|
1192
|
+
const seenDims = [];
|
|
1193
|
+
for (const candidate of list) {
|
|
1194
|
+
const rect = candidate.el.getBoundingClientRect();
|
|
1195
|
+
const isDuplicate = seenDims.some(
|
|
1196
|
+
(d) => Math.abs(d.w - rect.width) <= 2 && Math.abs(d.h - rect.height) <= 2
|
|
1197
|
+
);
|
|
1198
|
+
if (!isDuplicate) {
|
|
1199
|
+
seenDims.push({ w: rect.width, h: rect.height });
|
|
1200
|
+
deduped.push(candidate);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
const limit = Math.min(deduped.length, maxPerSource2);
|
|
1291
1204
|
for (let i = 0; i < limit; i++) {
|
|
1292
|
-
const instance =
|
|
1205
|
+
const instance = deduped[i];
|
|
1293
1206
|
const componentKey = `ab-comp-${sourceIndex}-${i}`;
|
|
1294
1207
|
const parentKey = `ab-parent-${sourceIndex}-${i}`;
|
|
1295
1208
|
instance.el.setAttribute("data-ab-comp-key", componentKey);
|
|
@@ -1308,7 +1221,7 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
|
|
|
1308
1221
|
{ changed: normalized, maxPerSource }
|
|
1309
1222
|
);
|
|
1310
1223
|
}
|
|
1311
|
-
async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir,
|
|
1224
|
+
async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir, results) {
|
|
1312
1225
|
const deduped = Array.from(new Set(changedComponents.map(normalizePath)));
|
|
1313
1226
|
const [afterInstances, beforeInstances] = await Promise.all([
|
|
1314
1227
|
tagChangedComponentInstances(afterPage, deduped),
|
|
@@ -1356,7 +1269,11 @@ async function captureComponentInstances(afterPage, beforePage, changedComponent
|
|
|
1356
1269
|
route: `${baseLabel} [parent]`,
|
|
1357
1270
|
prefix: parentPrefix,
|
|
1358
1271
|
beforePath: parentBeforePath,
|
|
1359
|
-
afterPath: parentAfterPath
|
|
1272
|
+
afterPath: parentAfterPath,
|
|
1273
|
+
level: "parent",
|
|
1274
|
+
parentPrefix: capturePrefix,
|
|
1275
|
+
componentSource: source,
|
|
1276
|
+
componentName
|
|
1360
1277
|
});
|
|
1361
1278
|
}
|
|
1362
1279
|
const componentPrefix = `${capturePrefix}~cmp.${itemSlug}~component`;
|
|
@@ -1381,16 +1298,11 @@ async function captureComponentInstances(afterPage, beforePage, changedComponent
|
|
|
1381
1298
|
route: `${baseLabel} [component]`,
|
|
1382
1299
|
prefix: componentPrefix,
|
|
1383
1300
|
beforePath: componentBeforePath,
|
|
1384
|
-
afterPath: componentAfterPath
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
results.push({
|
|
1390
|
-
route: `${baseLabel} [context]`,
|
|
1391
|
-
prefix: contextPrefix,
|
|
1392
|
-
beforePath: contextBeforePath,
|
|
1393
|
-
afterPath: contextAfterPath
|
|
1301
|
+
afterPath: componentAfterPath,
|
|
1302
|
+
level: "component",
|
|
1303
|
+
parentPrefix: capturePrefix,
|
|
1304
|
+
componentSource: source,
|
|
1305
|
+
componentName
|
|
1394
1306
|
});
|
|
1395
1307
|
}
|
|
1396
1308
|
}
|
|
@@ -1431,7 +1343,8 @@ async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLa
|
|
|
1431
1343
|
route: sectionLabel,
|
|
1432
1344
|
prefix: sectionPrefix,
|
|
1433
1345
|
beforePath: sectionBeforePath,
|
|
1434
|
-
afterPath: sectionAfterPath
|
|
1346
|
+
afterPath: sectionAfterPath,
|
|
1347
|
+
level: "section"
|
|
1435
1348
|
});
|
|
1436
1349
|
} catch {
|
|
1437
1350
|
}
|
|
@@ -1483,7 +1396,8 @@ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl,
|
|
|
1483
1396
|
route: tabLabel,
|
|
1484
1397
|
prefix: tabPrefix,
|
|
1485
1398
|
beforePath: tabBeforePath,
|
|
1486
|
-
afterPath: tabAfterPath
|
|
1399
|
+
afterPath: tabAfterPath,
|
|
1400
|
+
level: "tab"
|
|
1487
1401
|
});
|
|
1488
1402
|
if ((task.changedComponents?.length ?? 0) > 0) {
|
|
1489
1403
|
await captureComponentInstances(
|
|
@@ -1493,8 +1407,6 @@ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl,
|
|
|
1493
1407
|
tabPrefix,
|
|
1494
1408
|
tabLabel,
|
|
1495
1409
|
outputDir,
|
|
1496
|
-
tabBeforePath,
|
|
1497
|
-
tabAfterPath,
|
|
1498
1410
|
results
|
|
1499
1411
|
);
|
|
1500
1412
|
}
|
|
@@ -1521,6 +1433,90 @@ async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl,
|
|
|
1521
1433
|
]);
|
|
1522
1434
|
}
|
|
1523
1435
|
}
|
|
1436
|
+
async function captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options) {
|
|
1437
|
+
const results = [];
|
|
1438
|
+
const [beforePage, afterPage] = await Promise.all([
|
|
1439
|
+
beforeCtx.newPage(),
|
|
1440
|
+
afterCtx.newPage()
|
|
1441
|
+
]);
|
|
1442
|
+
try {
|
|
1443
|
+
const beforePath = join6(outputDir, `${task.prefix}-before.png`);
|
|
1444
|
+
const afterPath = join6(outputDir, `${task.prefix}-after.png`);
|
|
1445
|
+
const settle = async (page) => {
|
|
1446
|
+
await page.evaluate("document.fonts.ready");
|
|
1447
|
+
if (options.delay > 0) {
|
|
1448
|
+
await page.waitForTimeout(options.delay);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
const performActions = async (page, actions) => {
|
|
1452
|
+
for (const action of actions) {
|
|
1453
|
+
if (action.click) {
|
|
1454
|
+
await page.locator(action.click).first().click();
|
|
1455
|
+
}
|
|
1456
|
+
if (action.scroll) {
|
|
1457
|
+
await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
|
|
1458
|
+
}
|
|
1459
|
+
if (action.wait && action.wait > 0) {
|
|
1460
|
+
await page.waitForTimeout(action.wait);
|
|
1461
|
+
}
|
|
1462
|
+
await settle(page);
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
const screenshot = async (page, path) => {
|
|
1466
|
+
if (task.selector) {
|
|
1467
|
+
const el = page.locator(task.selector).first();
|
|
1468
|
+
await el.screenshot({ path });
|
|
1469
|
+
} else {
|
|
1470
|
+
await page.screenshot({ path, fullPage: true });
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
await Promise.all([
|
|
1474
|
+
beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
|
|
1475
|
+
afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
|
|
1476
|
+
]);
|
|
1477
|
+
results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath, level: "page" });
|
|
1478
|
+
if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
|
|
1479
|
+
await captureComponentInstances(
|
|
1480
|
+
afterPage,
|
|
1481
|
+
beforePage,
|
|
1482
|
+
task.changedComponents,
|
|
1483
|
+
task.prefix,
|
|
1484
|
+
task.label,
|
|
1485
|
+
outputDir,
|
|
1486
|
+
results
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
|
|
1490
|
+
await captureAutoSections(
|
|
1491
|
+
afterPage,
|
|
1492
|
+
beforePage,
|
|
1493
|
+
task.prefix,
|
|
1494
|
+
task.label,
|
|
1495
|
+
outputDir,
|
|
1496
|
+
options,
|
|
1497
|
+
settle,
|
|
1498
|
+
results
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
|
|
1502
|
+
await captureAutoTabs(
|
|
1503
|
+
afterPage,
|
|
1504
|
+
beforePage,
|
|
1505
|
+
task,
|
|
1506
|
+
beforeUrl,
|
|
1507
|
+
afterUrl,
|
|
1508
|
+
outputDir,
|
|
1509
|
+
options,
|
|
1510
|
+
settle,
|
|
1511
|
+
results
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
} finally {
|
|
1515
|
+
await Promise.all([beforePage.close(), afterPage.close()]);
|
|
1516
|
+
}
|
|
1517
|
+
return results;
|
|
1518
|
+
}
|
|
1519
|
+
var BATCH_SIZE = 3;
|
|
1524
1520
|
async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
1525
1521
|
const browser = options.browser ?? await launchBrowser();
|
|
1526
1522
|
const ownsBrowser = !options.browser;
|
|
@@ -1535,85 +1531,20 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
|
1535
1531
|
browser.newContext(contextOpts),
|
|
1536
1532
|
browser.newContext(contextOpts)
|
|
1537
1533
|
]);
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
if (options.delay > 0) {
|
|
1550
|
-
await page.waitForTimeout(options.delay);
|
|
1551
|
-
}
|
|
1552
|
-
};
|
|
1553
|
-
const performActions = async (page, actions) => {
|
|
1554
|
-
for (const action of actions) {
|
|
1555
|
-
if (action.click) {
|
|
1556
|
-
await page.locator(action.click).first().click();
|
|
1557
|
-
}
|
|
1558
|
-
if (action.scroll) {
|
|
1559
|
-
await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
|
|
1560
|
-
}
|
|
1561
|
-
if (action.wait && action.wait > 0) {
|
|
1562
|
-
await page.waitForTimeout(action.wait);
|
|
1563
|
-
}
|
|
1564
|
-
await settle(page);
|
|
1565
|
-
}
|
|
1566
|
-
};
|
|
1567
|
-
const screenshot = async (page, path) => {
|
|
1568
|
-
if (task.selector) {
|
|
1569
|
-
const el = page.locator(task.selector).first();
|
|
1570
|
-
await el.screenshot({ path });
|
|
1534
|
+
for (let batchStart = 0; batchStart < tasks.length; batchStart += BATCH_SIZE) {
|
|
1535
|
+
const batch = tasks.slice(batchStart, batchStart + BATCH_SIZE);
|
|
1536
|
+
const batchResults = await Promise.allSettled(
|
|
1537
|
+
batch.map((task, idx) => {
|
|
1538
|
+
options.onProgress?.(batchStart + idx + 1, task.label);
|
|
1539
|
+
return captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options);
|
|
1540
|
+
})
|
|
1541
|
+
);
|
|
1542
|
+
for (const result of batchResults) {
|
|
1543
|
+
if (result.status === "fulfilled") {
|
|
1544
|
+
results.push(...result.value);
|
|
1571
1545
|
} else {
|
|
1572
|
-
|
|
1546
|
+
logger.dim(`Capture failed: ${result.reason}`);
|
|
1573
1547
|
}
|
|
1574
|
-
};
|
|
1575
|
-
await Promise.all([
|
|
1576
|
-
beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
|
|
1577
|
-
afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
|
|
1578
|
-
]);
|
|
1579
|
-
results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
|
|
1580
|
-
if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
|
|
1581
|
-
await captureComponentInstances(
|
|
1582
|
-
afterPage,
|
|
1583
|
-
beforePage,
|
|
1584
|
-
task.changedComponents,
|
|
1585
|
-
task.prefix,
|
|
1586
|
-
task.label,
|
|
1587
|
-
outputDir,
|
|
1588
|
-
beforePath,
|
|
1589
|
-
afterPath,
|
|
1590
|
-
results
|
|
1591
|
-
);
|
|
1592
|
-
}
|
|
1593
|
-
if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
|
|
1594
|
-
await captureAutoSections(
|
|
1595
|
-
afterPage,
|
|
1596
|
-
beforePage,
|
|
1597
|
-
task.prefix,
|
|
1598
|
-
task.label,
|
|
1599
|
-
outputDir,
|
|
1600
|
-
options,
|
|
1601
|
-
settle,
|
|
1602
|
-
results
|
|
1603
|
-
);
|
|
1604
|
-
}
|
|
1605
|
-
if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
|
|
1606
|
-
await captureAutoTabs(
|
|
1607
|
-
afterPage,
|
|
1608
|
-
beforePage,
|
|
1609
|
-
task,
|
|
1610
|
-
beforeUrl,
|
|
1611
|
-
afterUrl,
|
|
1612
|
-
outputDir,
|
|
1613
|
-
options,
|
|
1614
|
-
settle,
|
|
1615
|
-
results
|
|
1616
|
-
);
|
|
1617
1548
|
}
|
|
1618
1549
|
}
|
|
1619
1550
|
await Promise.all([beforeCtx.close(), afterCtx.close()]);
|
|
@@ -1626,109 +1557,70 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
|
1626
1557
|
}
|
|
1627
1558
|
|
|
1628
1559
|
// src/stages/compare.ts
|
|
1629
|
-
import {
|
|
1630
|
-
import {
|
|
1631
|
-
import {
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
const
|
|
1635
|
-
const height = Math.max(img1.height, img2.height);
|
|
1636
|
-
const pad = (src) => {
|
|
1637
|
-
if (src.width === width && src.height === height) return src;
|
|
1638
|
-
const padded = new PNG({ width, height });
|
|
1639
|
-
for (let i = 0; i < padded.data.length; i += 4) {
|
|
1640
|
-
padded.data[i] = 255;
|
|
1641
|
-
padded.data[i + 1] = 255;
|
|
1642
|
-
padded.data[i + 2] = 255;
|
|
1643
|
-
padded.data[i + 3] = 255;
|
|
1644
|
-
}
|
|
1645
|
-
PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);
|
|
1646
|
-
return padded;
|
|
1647
|
-
};
|
|
1648
|
-
return [pad(img1), pad(img2)];
|
|
1649
|
-
}
|
|
1650
|
-
async function generateComposite(beforePath, afterPath, outputPath, browser, bgColor) {
|
|
1651
|
-
const beforeBuf = readFileSync5(beforePath);
|
|
1652
|
-
const afterBuf = readFileSync5(afterPath);
|
|
1653
|
-
const beforeUri = `data:image/png;base64,${beforeBuf.toString("base64")}`;
|
|
1654
|
-
const afterUri = `data:image/png;base64,${afterBuf.toString("base64")}`;
|
|
1655
|
-
const beforePng = PNG.sync.read(beforeBuf);
|
|
1656
|
-
const afterPng = PNG.sync.read(afterBuf);
|
|
1657
|
-
const imgW = Math.max(beforePng.width, afterPng.width);
|
|
1658
|
-
const imgH = Math.max(beforePng.height, afterPng.height);
|
|
1659
|
-
const PADDING = 120;
|
|
1660
|
-
const GAP = 80;
|
|
1661
|
-
const LABEL_H = 70;
|
|
1662
|
-
const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
|
|
1663
|
-
const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
|
|
1664
|
-
const maxImgH = canvasH - PADDING * 2 - LABEL_H;
|
|
1665
|
-
const page = await browser.newPage({
|
|
1666
|
-
viewport: { width: canvasW, height: canvasH },
|
|
1667
|
-
deviceScaleFactor: 1
|
|
1668
|
-
});
|
|
1669
|
-
const html = `<!DOCTYPE html>
|
|
1670
|
-
<html><head><style>
|
|
1671
|
-
* { margin: 0; box-sizing: border-box; }
|
|
1672
|
-
body { background: ${bgColor}; display: flex; justify-content: center; align-items: center; width: ${canvasW}px; height: ${canvasH}px; padding: ${PADDING}px; gap: ${GAP}px; overflow: hidden; }
|
|
1673
|
-
.col { flex: 1; display: flex; flex-direction: column; align-items: center; min-width: 0; max-height: 100%; }
|
|
1674
|
-
img { width: 100%; max-height: ${maxImgH}px; object-fit: contain; }
|
|
1675
|
-
.label { margin-top: 40px; font: 500 30px/1 system-ui, sans-serif; flex-shrink: 0; }
|
|
1676
|
-
.before { color: #888; }
|
|
1677
|
-
.after { color: #22c55e; }
|
|
1678
|
-
</style></head><body>
|
|
1679
|
-
<div class="col"><img src="${beforeUri}"><div class="label before">Before</div></div>
|
|
1680
|
-
<div class="col"><img src="${afterUri}"><div class="label after">After</div></div>
|
|
1681
|
-
</body></html>`;
|
|
1682
|
-
await page.setContent(html, { waitUntil: "load" });
|
|
1683
|
-
await page.waitForTimeout(200);
|
|
1684
|
-
await page.screenshot({ path: outputPath });
|
|
1685
|
-
await page.close();
|
|
1686
|
-
}
|
|
1687
|
-
async function compareOne(capture, outputDir, threshold, options) {
|
|
1688
|
-
const dir = dirname2(capture.beforePath);
|
|
1689
|
-
const comparePath = join7(dir, `${capture.prefix}-compare.png`);
|
|
1690
|
-
const beforeBuffer = readFileSync5(capture.beforePath);
|
|
1691
|
-
const afterBuffer = readFileSync5(capture.afterPath);
|
|
1692
|
-
const beforeImg = PNG.sync.read(beforeBuffer);
|
|
1693
|
-
const afterImg = PNG.sync.read(afterBuffer);
|
|
1694
|
-
const [normBefore, normAfter] = normalizeDimensions(beforeImg, afterImg);
|
|
1695
|
-
const { width, height } = normBefore;
|
|
1696
|
-
const totalPixels = width * height;
|
|
1697
|
-
const diffPixels = pixelmatch(
|
|
1698
|
-
normBefore.data,
|
|
1699
|
-
normAfter.data,
|
|
1700
|
-
new Uint8Array(width * height * 4),
|
|
1701
|
-
width,
|
|
1702
|
-
height,
|
|
1703
|
-
{ threshold: 0.1 }
|
|
1704
|
-
);
|
|
1705
|
-
const diffPercentage = diffPixels / totalPixels * 100;
|
|
1706
|
-
const changed = diffPercentage > threshold;
|
|
1707
|
-
await generateComposite(
|
|
1560
|
+
import { join as join7 } from "path";
|
|
1561
|
+
import { unlinkSync } from "fs";
|
|
1562
|
+
import { ODiffServer } from "odiff-bin";
|
|
1563
|
+
async function compareOne(capture, outputDir, threshold, server) {
|
|
1564
|
+
const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
|
|
1565
|
+
const result = await server.compare(
|
|
1708
1566
|
capture.beforePath,
|
|
1709
1567
|
capture.afterPath,
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
options.bgColor
|
|
1568
|
+
diffPath,
|
|
1569
|
+
{ threshold: 0.1, antialiasing: true }
|
|
1713
1570
|
);
|
|
1571
|
+
if (result.match) {
|
|
1572
|
+
return {
|
|
1573
|
+
route: capture.route,
|
|
1574
|
+
prefix: capture.prefix,
|
|
1575
|
+
beforePath: capture.beforePath,
|
|
1576
|
+
afterPath: capture.afterPath,
|
|
1577
|
+
diffPixels: 0,
|
|
1578
|
+
totalPixels: 0,
|
|
1579
|
+
diffPercentage: 0,
|
|
1580
|
+
changed: false
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
if (result.reason === "pixel-diff") {
|
|
1584
|
+
const changed = result.diffPercentage > threshold;
|
|
1585
|
+
if (!changed) {
|
|
1586
|
+
try {
|
|
1587
|
+
unlinkSync(diffPath);
|
|
1588
|
+
} catch {
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return {
|
|
1592
|
+
route: capture.route,
|
|
1593
|
+
prefix: capture.prefix,
|
|
1594
|
+
beforePath: capture.beforePath,
|
|
1595
|
+
afterPath: capture.afterPath,
|
|
1596
|
+
diffPixels: result.diffCount,
|
|
1597
|
+
totalPixels: 0,
|
|
1598
|
+
diffPercentage: result.diffPercentage,
|
|
1599
|
+
changed
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1714
1602
|
return {
|
|
1715
1603
|
route: capture.route,
|
|
1716
1604
|
prefix: capture.prefix,
|
|
1717
1605
|
beforePath: capture.beforePath,
|
|
1718
1606
|
afterPath: capture.afterPath,
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
changed
|
|
1607
|
+
diffPixels: 0,
|
|
1608
|
+
totalPixels: 0,
|
|
1609
|
+
diffPercentage: 100,
|
|
1610
|
+
changed: true
|
|
1724
1611
|
};
|
|
1725
1612
|
}
|
|
1726
|
-
async function compareScreenshots(captures, outputDir, threshold = 0.1
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
results
|
|
1613
|
+
async function compareScreenshots(captures, outputDir, threshold = 0.1) {
|
|
1614
|
+
const server = new ODiffServer();
|
|
1615
|
+
try {
|
|
1616
|
+
const results = [];
|
|
1617
|
+
for (const capture of captures) {
|
|
1618
|
+
results.push(await compareOne(capture, outputDir, threshold, server));
|
|
1619
|
+
}
|
|
1620
|
+
return results;
|
|
1621
|
+
} finally {
|
|
1622
|
+
server.stop();
|
|
1730
1623
|
}
|
|
1731
|
-
return results;
|
|
1732
1624
|
}
|
|
1733
1625
|
|
|
1734
1626
|
// src/stages/report.ts
|
|
@@ -1899,10 +1791,10 @@ function expandRoutes(routes, config, routeComponentMap) {
|
|
|
1899
1791
|
async function runPipeline(options) {
|
|
1900
1792
|
const { base, output, post, cwd } = options;
|
|
1901
1793
|
const sessionName = generateSessionName(cwd);
|
|
1902
|
-
const outputDir =
|
|
1794
|
+
const outputDir = resolve3(cwd, output, sessionName);
|
|
1903
1795
|
const startTime = Date.now();
|
|
1904
1796
|
try {
|
|
1905
|
-
const version = true ? "0.1.
|
|
1797
|
+
const version = true ? "0.1.12" : "dev";
|
|
1906
1798
|
console.log(`
|
|
1907
1799
|
afterbefore v${version} \xB7 Comparing against ${base}
|
|
1908
1800
|
`);
|
|
@@ -2003,21 +1895,16 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2003
1895
|
}
|
|
2004
1896
|
);
|
|
2005
1897
|
logger.pipeline(7, "Comparing screenshots...");
|
|
2006
|
-
const
|
|
2007
|
-
const allResults = await compareScreenshots(captures, outputDir, options.threshold, { browser, bgColor });
|
|
1898
|
+
const allResults = await compareScreenshots(captures, outputDir, options.threshold);
|
|
2008
1899
|
const results = allResults.filter((r) => {
|
|
2009
1900
|
const isSubCapture = r.prefix.includes("~");
|
|
2010
1901
|
if (isSubCapture && !r.changed) {
|
|
2011
1902
|
try {
|
|
2012
|
-
|
|
2013
|
-
} catch {
|
|
2014
|
-
}
|
|
2015
|
-
try {
|
|
2016
|
-
unlinkSync(r.afterPath);
|
|
1903
|
+
unlinkSync2(r.beforePath);
|
|
2017
1904
|
} catch {
|
|
2018
1905
|
}
|
|
2019
1906
|
try {
|
|
2020
|
-
|
|
1907
|
+
unlinkSync2(r.afterPath);
|
|
2021
1908
|
} catch {
|
|
2022
1909
|
}
|
|
2023
1910
|
return false;
|
|
@@ -2036,7 +1923,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2036
1923
|
);
|
|
2037
1924
|
} finally {
|
|
2038
1925
|
try {
|
|
2039
|
-
logger.writeLogFile(
|
|
1926
|
+
logger.writeLogFile(resolve3(outputDir, "debug.log"));
|
|
2040
1927
|
} catch {
|
|
2041
1928
|
}
|
|
2042
1929
|
await cleanupRegistry.runAll();
|
|
@@ -2047,7 +1934,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
|
|
|
2047
1934
|
var program = new Command();
|
|
2048
1935
|
program.name("afterbefore").description(
|
|
2049
1936
|
"Automatic before/after screenshot capture for PRs. Git diff is the config."
|
|
2050
|
-
).version("0.1.
|
|
1937
|
+
).version("0.1.12").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(
|
|
2051
1938
|
"--threshold <percent>",
|
|
2052
1939
|
"Diff threshold percentage (changes below this are ignored)",
|
|
2053
1940
|
"0.1"
|