afterbefore 0.1.18 → 0.2.0
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 +6 -33
- package/dist/chunk-XRQ4MAEV.js +204 -0
- package/dist/chunk-XRQ4MAEV.js.map +1 -0
- package/dist/next.d.ts +5 -0
- package/dist/next.js +28 -0
- package/dist/next.js.map +1 -0
- package/dist/overlay/index.d.ts +5 -0
- package/dist/overlay/index.js +1176 -0
- package/dist/overlay/index.js.map +1 -0
- package/dist/server/middleware.d.ts +8 -0
- package/dist/server/middleware.js +29 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/route.d.ts +10 -0
- package/dist/server/route.js +30 -0
- package/dist/server/route.js.map +1 -0
- package/package.json +36 -22
- package/dist/cli.js +0 -2667
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -91
- package/dist/index.js +0 -2602
- package/dist/index.js.map +0 -1
package/dist/index.js
DELETED
|
@@ -1,2602 +0,0 @@
|
|
|
1
|
-
// src/pipeline.ts
|
|
2
|
-
import { resolve as resolve4, basename } from "path";
|
|
3
|
-
import { writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
4
|
-
import { execSync as execSync5 } from "child_process";
|
|
5
|
-
import chalk2 from "chalk";
|
|
6
|
-
|
|
7
|
-
// src/logger.ts
|
|
8
|
-
import { writeFileSync } from "fs";
|
|
9
|
-
import chalk from "chalk";
|
|
10
|
-
import ora from "ora";
|
|
11
|
-
var BAR_WIDTH = 20;
|
|
12
|
-
var BAR_SPINNER = { interval: 80, frames: [" "] };
|
|
13
|
-
var Logger = class {
|
|
14
|
-
spinner = null;
|
|
15
|
-
pipelineTotal = 0;
|
|
16
|
-
lastStep = 0;
|
|
17
|
-
lastLabel = "";
|
|
18
|
-
pipelineActive = false;
|
|
19
|
-
logBuffer = [];
|
|
20
|
-
isTTY = !!process.stderr.isTTY;
|
|
21
|
-
log(level, message) {
|
|
22
|
-
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
23
|
-
this.logBuffer.push(`${ts} [${level}] ${message}`);
|
|
24
|
-
}
|
|
25
|
-
info(message) {
|
|
26
|
-
this.log("info", message);
|
|
27
|
-
if (this.pipelineActive) return;
|
|
28
|
-
if (this.spinner) {
|
|
29
|
-
this.spinner.clear();
|
|
30
|
-
console.log(chalk.blue("\u2139"), message);
|
|
31
|
-
this.spinner.render();
|
|
32
|
-
} else {
|
|
33
|
-
console.log(chalk.blue("\u2139"), message);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
success(message) {
|
|
37
|
-
this.log("ok", message);
|
|
38
|
-
if (this.pipelineActive) return;
|
|
39
|
-
if (this.spinner) {
|
|
40
|
-
this.spinner.clear();
|
|
41
|
-
console.log(chalk.green("\u2714"), message);
|
|
42
|
-
this.spinner.render();
|
|
43
|
-
} else {
|
|
44
|
-
console.log(chalk.green("\u2714"), message);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
warn(message) {
|
|
48
|
-
this.log("warn", message);
|
|
49
|
-
if (this.spinner) {
|
|
50
|
-
this.spinner.clear();
|
|
51
|
-
console.log(chalk.yellow("\u26A0"), message);
|
|
52
|
-
this.spinner.render();
|
|
53
|
-
} else {
|
|
54
|
-
console.log(chalk.yellow("\u26A0"), message);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
error(message) {
|
|
58
|
-
this.log("error", message);
|
|
59
|
-
if (this.spinner) {
|
|
60
|
-
this.spinner.clear();
|
|
61
|
-
console.error(chalk.red("\u2716"), message);
|
|
62
|
-
this.spinner.render();
|
|
63
|
-
} else {
|
|
64
|
-
console.error(chalk.red("\u2716"), message);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
dim(message) {
|
|
68
|
-
this.log("debug", message);
|
|
69
|
-
if (this.pipelineActive) return;
|
|
70
|
-
if (this.spinner) {
|
|
71
|
-
this.spinner.clear();
|
|
72
|
-
console.log(chalk.dim(message));
|
|
73
|
-
this.spinner.render();
|
|
74
|
-
} else {
|
|
75
|
-
console.log(chalk.dim(message));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
spin(message) {
|
|
79
|
-
this.log("info", message);
|
|
80
|
-
this.clearSpinner();
|
|
81
|
-
this.spinner = ora(message).start();
|
|
82
|
-
return this.spinner;
|
|
83
|
-
}
|
|
84
|
-
stopSpinner() {
|
|
85
|
-
this.clearSpinner();
|
|
86
|
-
}
|
|
87
|
-
startPipeline(total) {
|
|
88
|
-
this.pipelineTotal = total;
|
|
89
|
-
this.lastStep = 0;
|
|
90
|
-
this.lastLabel = "";
|
|
91
|
-
this.pipelineActive = true;
|
|
92
|
-
this.clearSpinner();
|
|
93
|
-
this.log("info", `Pipeline started (${total} steps)`);
|
|
94
|
-
const text = this.renderPipeline(0, "Starting...");
|
|
95
|
-
if (this.isTTY) {
|
|
96
|
-
this.spinner = ora({
|
|
97
|
-
text: chalk.dim(text),
|
|
98
|
-
spinner: BAR_SPINNER
|
|
99
|
-
}).start();
|
|
100
|
-
} else {
|
|
101
|
-
console.error(chalk.dim(text));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
pipeline(step, label) {
|
|
105
|
-
if (step === this.lastStep && label === this.lastLabel) return;
|
|
106
|
-
this.lastStep = step;
|
|
107
|
-
this.lastLabel = label;
|
|
108
|
-
this.log("step", `${step}/${this.pipelineTotal} ${label}`);
|
|
109
|
-
const text = chalk.dim(this.renderPipeline(step, label));
|
|
110
|
-
if (!this.isTTY) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (!this.spinner) {
|
|
114
|
-
this.spinner = ora({
|
|
115
|
-
text,
|
|
116
|
-
spinner: BAR_SPINNER
|
|
117
|
-
}).start();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
this.spinner.text = text;
|
|
121
|
-
this.spinner.render();
|
|
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
|
-
}
|
|
135
|
-
completePipeline(finished = false) {
|
|
136
|
-
this.pipelineActive = false;
|
|
137
|
-
this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
|
|
138
|
-
if (this.spinner) {
|
|
139
|
-
if (finished) {
|
|
140
|
-
const bar = "\u2588".repeat(BAR_WIDTH);
|
|
141
|
-
this.spinner.succeed(` ${bar}`);
|
|
142
|
-
} else {
|
|
143
|
-
this.spinner.stop();
|
|
144
|
-
}
|
|
145
|
-
this.spinner = null;
|
|
146
|
-
} else if (finished) {
|
|
147
|
-
const bar = "\u2588".repeat(BAR_WIDTH);
|
|
148
|
-
console.error(`${chalk.green("\u2714")} ${bar}`);
|
|
149
|
-
}
|
|
150
|
-
this.pipelineTotal = 0;
|
|
151
|
-
this.lastStep = 0;
|
|
152
|
-
this.lastLabel = "";
|
|
153
|
-
}
|
|
154
|
-
writeLogFile(filePath) {
|
|
155
|
-
if (this.logBuffer.length === 0) return;
|
|
156
|
-
writeFileSync(filePath, this.logBuffer.join("\n") + "\n", "utf-8");
|
|
157
|
-
}
|
|
158
|
-
renderPipeline(step, label) {
|
|
159
|
-
const total = this.pipelineTotal || 1;
|
|
160
|
-
const clampedStep = Math.max(0, Math.min(step, total));
|
|
161
|
-
const filled = Math.round(clampedStep / total * BAR_WIDTH);
|
|
162
|
-
const bar = "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
|
|
163
|
-
return ` ${bar} ${clampedStep}/${total} ${label}`;
|
|
164
|
-
}
|
|
165
|
-
clearSpinner() {
|
|
166
|
-
if (this.spinner) {
|
|
167
|
-
this.spinner.stop();
|
|
168
|
-
this.spinner = null;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
var logger = new Logger();
|
|
173
|
-
|
|
174
|
-
// src/cleanup.ts
|
|
175
|
-
var CleanupRegistry = class {
|
|
176
|
-
cleanups = [];
|
|
177
|
-
registered = false;
|
|
178
|
-
register(fn) {
|
|
179
|
-
this.cleanups.push(fn);
|
|
180
|
-
this.ensureSignalHandlers();
|
|
181
|
-
}
|
|
182
|
-
async runAll() {
|
|
183
|
-
const fns = [...this.cleanups].reverse();
|
|
184
|
-
this.cleanups = [];
|
|
185
|
-
for (const fn of fns) {
|
|
186
|
-
try {
|
|
187
|
-
await fn();
|
|
188
|
-
} catch (err) {
|
|
189
|
-
logger.warn(
|
|
190
|
-
`Cleanup failed: ${err instanceof Error ? err.message : String(err)}`
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
ensureSignalHandlers() {
|
|
196
|
-
if (this.registered) return;
|
|
197
|
-
this.registered = true;
|
|
198
|
-
const handler = async (signal) => {
|
|
199
|
-
logger.dim(`
|
|
200
|
-
Received ${signal}, cleaning up...`);
|
|
201
|
-
await this.runAll();
|
|
202
|
-
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
203
|
-
};
|
|
204
|
-
process.on("SIGINT", () => handler("SIGINT"));
|
|
205
|
-
process.on("SIGTERM", () => handler("SIGTERM"));
|
|
206
|
-
process.on("exit", () => {
|
|
207
|
-
if (this.cleanups.length > 0) {
|
|
208
|
-
for (const fn of [...this.cleanups].reverse()) {
|
|
209
|
-
try {
|
|
210
|
-
fn();
|
|
211
|
-
} catch {
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
this.cleanups = [];
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
var cleanupRegistry = new CleanupRegistry();
|
|
220
|
-
|
|
221
|
-
// src/config.ts
|
|
222
|
-
import { resolve } from "path";
|
|
223
|
-
import { existsSync, readFileSync } from "fs";
|
|
224
|
-
import { pathToFileURL } from "url";
|
|
225
|
-
var CONFIG_FILES = [
|
|
226
|
-
"afterbefore.config.json",
|
|
227
|
-
"afterbefore.config.js",
|
|
228
|
-
"afterbefore.config.mjs"
|
|
229
|
-
];
|
|
230
|
-
async function loadConfig(cwd) {
|
|
231
|
-
for (const name of CONFIG_FILES) {
|
|
232
|
-
const filePath = resolve(cwd, name);
|
|
233
|
-
if (!existsSync(filePath)) continue;
|
|
234
|
-
logger.dim(` Config: ${name}`);
|
|
235
|
-
if (name.endsWith(".json")) {
|
|
236
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
237
|
-
return JSON.parse(raw);
|
|
238
|
-
}
|
|
239
|
-
const mod = await import(pathToFileURL(filePath).href);
|
|
240
|
-
return mod.default ?? mod;
|
|
241
|
-
}
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// src/utils/fs.ts
|
|
246
|
-
import { mkdir } from "fs/promises";
|
|
247
|
-
async function ensureDir(dir) {
|
|
248
|
-
await mkdir(dir, { recursive: true });
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// src/utils/port.ts
|
|
252
|
-
import { createServer } from "net";
|
|
253
|
-
function findPort() {
|
|
254
|
-
return new Promise((resolve5, reject) => {
|
|
255
|
-
const server = createServer();
|
|
256
|
-
server.listen(0, () => {
|
|
257
|
-
const addr = server.address();
|
|
258
|
-
if (!addr || typeof addr === "string") {
|
|
259
|
-
server.close();
|
|
260
|
-
reject(new Error("Failed to get port"));
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
const port = addr.port;
|
|
264
|
-
server.close(() => resolve5(port));
|
|
265
|
-
});
|
|
266
|
-
server.on("error", reject);
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
async function findAvailablePort(exclude) {
|
|
270
|
-
for (let i = 0; i < 5; i++) {
|
|
271
|
-
const port = await findPort();
|
|
272
|
-
if (!exclude || !exclude.has(port)) return port;
|
|
273
|
-
}
|
|
274
|
-
throw new Error("Failed to find available port after 5 attempts");
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// src/utils/bgcolor.ts
|
|
278
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
279
|
-
import { resolve as resolve2 } from "path";
|
|
280
|
-
var DEFAULT_BG = "#0a0a0a";
|
|
281
|
-
var GLOBAL_CSS_PATHS = [
|
|
282
|
-
"app/globals.css",
|
|
283
|
-
"src/app/globals.css",
|
|
284
|
-
"styles/globals.css",
|
|
285
|
-
"src/styles/globals.css",
|
|
286
|
-
"app/global.css",
|
|
287
|
-
"src/app/global.css"
|
|
288
|
-
];
|
|
289
|
-
function hslToHex(h, s, l) {
|
|
290
|
-
s /= 100;
|
|
291
|
-
l /= 100;
|
|
292
|
-
const a = s * Math.min(l, 1 - l);
|
|
293
|
-
const f = (n) => {
|
|
294
|
-
const k = (n + h / 30) % 12;
|
|
295
|
-
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
296
|
-
return Math.round(255 * color).toString(16).padStart(2, "0");
|
|
297
|
-
};
|
|
298
|
-
return `#${f(0)}${f(8)}${f(4)}`;
|
|
299
|
-
}
|
|
300
|
-
function parseColorValue(raw) {
|
|
301
|
-
const v = raw.trim();
|
|
302
|
-
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
303
|
-
if (v.length === 4) {
|
|
304
|
-
return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`;
|
|
305
|
-
}
|
|
306
|
-
return v.slice(0, 7);
|
|
307
|
-
}
|
|
308
|
-
const hslMatch = v.match(
|
|
309
|
-
/^hsl\(\s*([\d.]+)[,\s]+\s*([\d.]+)%[,\s]+\s*([\d.]+)%/
|
|
310
|
-
);
|
|
311
|
-
if (hslMatch) {
|
|
312
|
-
return hslToHex(
|
|
313
|
-
parseFloat(hslMatch[1]),
|
|
314
|
-
parseFloat(hslMatch[2]),
|
|
315
|
-
parseFloat(hslMatch[3])
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
const bareHsl = v.match(/^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/);
|
|
319
|
-
if (bareHsl) {
|
|
320
|
-
return hslToHex(
|
|
321
|
-
parseFloat(bareHsl[1]),
|
|
322
|
-
parseFloat(bareHsl[2]),
|
|
323
|
-
parseFloat(bareHsl[3])
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
const rgbMatch = v.match(
|
|
327
|
-
/^rgb\(\s*([\d.]+)[,\s]+\s*([\d.]+)[,\s]+\s*([\d.]+)/
|
|
328
|
-
);
|
|
329
|
-
if (rgbMatch) {
|
|
330
|
-
const toHex = (n) => Math.round(parseFloat(n)).toString(16).padStart(2, "0");
|
|
331
|
-
return `#${toHex(rgbMatch[1])}${toHex(rgbMatch[2])}${toHex(rgbMatch[3])}`;
|
|
332
|
-
}
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
function detectBgColor(cwd) {
|
|
336
|
-
for (const relPath of GLOBAL_CSS_PATHS) {
|
|
337
|
-
const absPath = resolve2(cwd, relPath);
|
|
338
|
-
if (!existsSync2(absPath)) continue;
|
|
339
|
-
const css = readFileSync2(absPath, "utf-8");
|
|
340
|
-
const darkBlock = css.match(/\.dark\s*\{([^}]+)\}/);
|
|
341
|
-
if (darkBlock) {
|
|
342
|
-
const bgVar = darkBlock[1].match(/--background\s*:\s*([^;]+)/);
|
|
343
|
-
if (bgVar) {
|
|
344
|
-
const color = parseColorValue(bgVar[1]);
|
|
345
|
-
if (color) return color;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
const rootBlock = css.match(/:root\s*\{([^}]+)\}/);
|
|
349
|
-
if (rootBlock) {
|
|
350
|
-
const bgVar = rootBlock[1].match(/--background\s*:\s*([^;]+)/);
|
|
351
|
-
if (bgVar) {
|
|
352
|
-
const color = parseColorValue(bgVar[1]);
|
|
353
|
-
if (color) return color;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
const anyBgVar = css.match(/--background\s*:\s*([^;]+)/);
|
|
357
|
-
if (anyBgVar) {
|
|
358
|
-
const color = parseColorValue(anyBgVar[1]);
|
|
359
|
-
if (color) return color;
|
|
360
|
-
}
|
|
361
|
-
const bodyBg = css.match(
|
|
362
|
-
/body\s*\{[^}]*?background(?:-color)?\s*:\s*([^;]+)/
|
|
363
|
-
);
|
|
364
|
-
if (bodyBg) {
|
|
365
|
-
const val = bodyBg[1].trim();
|
|
366
|
-
if (!val.startsWith("var(")) {
|
|
367
|
-
const color = parseColorValue(val);
|
|
368
|
-
if (color) return color;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
break;
|
|
372
|
-
}
|
|
373
|
-
return DEFAULT_BG;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// src/utils/git.ts
|
|
377
|
-
import { execSync } from "child_process";
|
|
378
|
-
|
|
379
|
-
// src/errors.ts
|
|
380
|
-
var AfterbeforeError = class extends Error {
|
|
381
|
-
constructor(message, suggestion) {
|
|
382
|
-
super(message);
|
|
383
|
-
this.suggestion = suggestion;
|
|
384
|
-
this.name = "AfterbeforeError";
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
// src/utils/git.ts
|
|
389
|
-
function git(args, cwd) {
|
|
390
|
-
try {
|
|
391
|
-
return execSync(`git ${args}`, {
|
|
392
|
-
cwd,
|
|
393
|
-
encoding: "utf-8",
|
|
394
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
395
|
-
}).trim();
|
|
396
|
-
} catch (err) {
|
|
397
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
398
|
-
if (message.includes("ENOENT") || message.includes("not found")) {
|
|
399
|
-
throw new AfterbeforeError(
|
|
400
|
-
"Git is not installed or not in PATH.",
|
|
401
|
-
"Install git: https://git-scm.com/downloads"
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
throw err;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
function getMergeBase(base, cwd) {
|
|
408
|
-
try {
|
|
409
|
-
return git(`merge-base ${base} HEAD`, cwd);
|
|
410
|
-
} catch {
|
|
411
|
-
throw new AfterbeforeError(
|
|
412
|
-
`Could not find merge base for "${base}". The branch or ref may not exist.`,
|
|
413
|
-
`Run "git branch -a" to see available branches. Did you mean "master" instead of "main"?`
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
function getDiffNameStatus(base, cwd) {
|
|
418
|
-
const mergeBase = getMergeBase(base, cwd);
|
|
419
|
-
return git(`diff --name-status ${mergeBase}`, cwd);
|
|
420
|
-
}
|
|
421
|
-
function getGitDiff(base, cwd) {
|
|
422
|
-
const mergeBase = getMergeBase(base, cwd);
|
|
423
|
-
return git(`diff ${mergeBase}`, cwd);
|
|
424
|
-
}
|
|
425
|
-
function getCurrentBranch(cwd) {
|
|
426
|
-
return git("rev-parse --abbrev-ref HEAD", cwd);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// src/stages/diff.ts
|
|
430
|
-
var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
|
|
431
|
-
function parseDiffOutput(raw) {
|
|
432
|
-
if (!raw.trim()) return [];
|
|
433
|
-
const results = [];
|
|
434
|
-
for (const line of raw.trim().split("\n")) {
|
|
435
|
-
const parts = line.split(" ");
|
|
436
|
-
const statusRaw = parts[0].charAt(0);
|
|
437
|
-
if (!VALID_STATUSES.has(statusRaw)) continue;
|
|
438
|
-
const status = statusRaw;
|
|
439
|
-
if (status === "R" || status === "C") {
|
|
440
|
-
results.push({ status, oldPath: parts[1], path: parts[2] });
|
|
441
|
-
} else {
|
|
442
|
-
results.push({ status, path: parts[1] });
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
return results;
|
|
446
|
-
}
|
|
447
|
-
function getChangedFiles(base, cwd) {
|
|
448
|
-
const raw = getDiffNameStatus(base, cwd);
|
|
449
|
-
return parseDiffOutput(raw);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// src/stages/classify.ts
|
|
453
|
-
var GLOBAL_FILES = /* @__PURE__ */ new Set([
|
|
454
|
-
"tailwind.config.ts",
|
|
455
|
-
"tailwind.config.js",
|
|
456
|
-
"postcss.config.js",
|
|
457
|
-
"postcss.config.mjs",
|
|
458
|
-
"postcss.config.cjs"
|
|
459
|
-
]);
|
|
460
|
-
function isGlobalVisualFile(filePath) {
|
|
461
|
-
const p = filePath.replace(/^src\//, "");
|
|
462
|
-
return GLOBAL_FILES.has(p) || /globals?\.(css|scss)$/.test(p);
|
|
463
|
-
}
|
|
464
|
-
function classifyFile(filePath) {
|
|
465
|
-
const p = filePath.replace(/^src\//, "");
|
|
466
|
-
if (/\.(test|spec)\.[tj]sx?$/.test(p) || /^tests?\//.test(p) || /\/__tests__\//.test(p) || p.includes(".test.") || p.includes(".spec.")) {
|
|
467
|
-
return "test";
|
|
468
|
-
}
|
|
469
|
-
if (/^(tsconfig|next\.config|tailwind\.config|postcss\.config|\.eslint|\.prettier|vitest\.config|jest\.config)/.test(
|
|
470
|
-
p
|
|
471
|
-
) || p === "package.json" || p === "package-lock.json" || p.endsWith(".config.ts") || p.endsWith(".config.js") || p.endsWith(".config.mjs")) {
|
|
472
|
-
return "config";
|
|
473
|
-
}
|
|
474
|
-
if (/\.(css|scss|sass|less)$/.test(p) || p === "tailwind.config.ts") {
|
|
475
|
-
return "style";
|
|
476
|
-
}
|
|
477
|
-
if (/\/layout\.[tj]sx?$/.test(p) || /^app\/layout\.[tj]sx?$/.test(p)) {
|
|
478
|
-
return "layout";
|
|
479
|
-
}
|
|
480
|
-
if (/\/page\.[tj]sx?$/.test(p) || /^app\/page\.[tj]sx?$/.test(p)) {
|
|
481
|
-
return "page";
|
|
482
|
-
}
|
|
483
|
-
if (/^(components|app)\//.test(p) && /\.[tj]sx?$/.test(p)) {
|
|
484
|
-
return "component";
|
|
485
|
-
}
|
|
486
|
-
if (/^(lib|utils|hooks|helpers|services|api)\//.test(p) && /\.[tj]sx?$/.test(p)) {
|
|
487
|
-
return "utility";
|
|
488
|
-
}
|
|
489
|
-
if (/\.[tj]sx?$/.test(p)) {
|
|
490
|
-
return "component";
|
|
491
|
-
}
|
|
492
|
-
return "other";
|
|
493
|
-
}
|
|
494
|
-
function classifyFiles(files) {
|
|
495
|
-
return files.map((f) => ({
|
|
496
|
-
...f,
|
|
497
|
-
category: classifyFile(f.path)
|
|
498
|
-
}));
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// src/stages/graph.ts
|
|
502
|
-
import { readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
503
|
-
import { join as join2, relative } from "path";
|
|
504
|
-
import { init, parse } from "es-module-lexer";
|
|
505
|
-
|
|
506
|
-
// src/stages/resolve.ts
|
|
507
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3, statSync } from "fs";
|
|
508
|
-
import { resolve as resolve3, dirname, join } from "path";
|
|
509
|
-
var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
510
|
-
function createResolver(projectRoot) {
|
|
511
|
-
const mappings = loadPathMappings(projectRoot);
|
|
512
|
-
const existsCache = /* @__PURE__ */ new Map();
|
|
513
|
-
function cachedExists(p) {
|
|
514
|
-
const cached = existsCache.get(p);
|
|
515
|
-
if (cached !== void 0) return cached;
|
|
516
|
-
const result = existsSync3(p);
|
|
517
|
-
existsCache.set(p, result);
|
|
518
|
-
return result;
|
|
519
|
-
}
|
|
520
|
-
function tryResolve(candidate) {
|
|
521
|
-
if (cachedExists(candidate) && !isDirectory(candidate)) return candidate;
|
|
522
|
-
for (const ext of EXTENSIONS) {
|
|
523
|
-
const withExt = candidate + ext;
|
|
524
|
-
if (cachedExists(withExt)) return withExt;
|
|
525
|
-
}
|
|
526
|
-
for (const ext of EXTENSIONS) {
|
|
527
|
-
const indexFile = join(candidate, `index${ext}`);
|
|
528
|
-
if (cachedExists(indexFile)) return indexFile;
|
|
529
|
-
}
|
|
530
|
-
return null;
|
|
531
|
-
}
|
|
532
|
-
function isDirectory(p) {
|
|
533
|
-
try {
|
|
534
|
-
return statSync(p).isDirectory();
|
|
535
|
-
} catch {
|
|
536
|
-
return false;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
return (specifier, fromFile) => {
|
|
540
|
-
if (specifier.startsWith(".")) {
|
|
541
|
-
const dir = dirname(fromFile);
|
|
542
|
-
const candidate = resolve3(dir, specifier);
|
|
543
|
-
return tryResolve(candidate);
|
|
544
|
-
}
|
|
545
|
-
for (const mapping of mappings) {
|
|
546
|
-
if (!specifier.startsWith(mapping.prefix)) continue;
|
|
547
|
-
const rest = specifier.slice(mapping.prefix.length);
|
|
548
|
-
for (const target of mapping.targets) {
|
|
549
|
-
const candidate = resolve3(projectRoot, target + rest);
|
|
550
|
-
const result = tryResolve(candidate);
|
|
551
|
-
if (result) return result;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
return null;
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
function loadPathMappings(projectRoot) {
|
|
558
|
-
const tsconfigPath = join(projectRoot, "tsconfig.json");
|
|
559
|
-
if (!existsSync3(tsconfigPath)) {
|
|
560
|
-
logger.dim("No tsconfig.json found, skipping path alias resolution");
|
|
561
|
-
return [];
|
|
562
|
-
}
|
|
563
|
-
try {
|
|
564
|
-
const raw = readFileSync3(tsconfigPath, "utf-8");
|
|
565
|
-
const cleaned = stripJsonComments(raw);
|
|
566
|
-
const config = JSON.parse(cleaned);
|
|
567
|
-
const paths = config?.compilerOptions?.paths;
|
|
568
|
-
if (!paths) return [];
|
|
569
|
-
const baseUrl = config?.compilerOptions?.baseUrl || ".";
|
|
570
|
-
const mappings = [];
|
|
571
|
-
for (const [pattern, targets] of Object.entries(paths)) {
|
|
572
|
-
const prefix = pattern.replace(/\*$/, "");
|
|
573
|
-
const resolvedTargets = targets.map(
|
|
574
|
-
(t) => t.replace(/\*$/, "")
|
|
575
|
-
);
|
|
576
|
-
const absoluteTargets = resolvedTargets.map(
|
|
577
|
-
(t) => join(baseUrl, t)
|
|
578
|
-
);
|
|
579
|
-
mappings.push({ prefix, targets: absoluteTargets });
|
|
580
|
-
}
|
|
581
|
-
return mappings;
|
|
582
|
-
} catch (e) {
|
|
583
|
-
logger.warn(`Failed to parse tsconfig.json: ${e}`);
|
|
584
|
-
return [];
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
function stripJsonComments(input) {
|
|
588
|
-
let result = "";
|
|
589
|
-
let i = 0;
|
|
590
|
-
const len = input.length;
|
|
591
|
-
while (i < len) {
|
|
592
|
-
const ch = input[i];
|
|
593
|
-
if (ch === '"') {
|
|
594
|
-
let j = i + 1;
|
|
595
|
-
while (j < len) {
|
|
596
|
-
if (input[j] === "\\") {
|
|
597
|
-
j += 2;
|
|
598
|
-
} else if (input[j] === '"') {
|
|
599
|
-
j++;
|
|
600
|
-
break;
|
|
601
|
-
} else {
|
|
602
|
-
j++;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
result += input.slice(i, j);
|
|
606
|
-
i = j;
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
if (ch === "/" && input[i + 1] === "/") {
|
|
610
|
-
i += 2;
|
|
611
|
-
while (i < len && input[i] !== "\n") i++;
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
if (ch === "/" && input[i + 1] === "*") {
|
|
615
|
-
i += 2;
|
|
616
|
-
while (i < len && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
617
|
-
i += 2;
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
if (ch === ",") {
|
|
621
|
-
let j = i + 1;
|
|
622
|
-
while (j < len && /\s/.test(input[j])) j++;
|
|
623
|
-
if (input[j] === "}" || input[j] === "]") {
|
|
624
|
-
i = j;
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
result += ch;
|
|
629
|
-
i++;
|
|
630
|
-
}
|
|
631
|
-
return result;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// src/stages/graph.ts
|
|
635
|
-
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
636
|
-
var SOURCE_DIRS = ["app", "src", "components", "lib"];
|
|
637
|
-
function collectFiles(dir) {
|
|
638
|
-
const results = [];
|
|
639
|
-
let entries;
|
|
640
|
-
try {
|
|
641
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
642
|
-
} catch {
|
|
643
|
-
return results;
|
|
644
|
-
}
|
|
645
|
-
for (const entry of entries) {
|
|
646
|
-
const fullPath = join2(dir, entry.name);
|
|
647
|
-
if (entry.isDirectory()) {
|
|
648
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") {
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
results.push(...collectFiles(fullPath));
|
|
652
|
-
} else if (entry.isFile()) {
|
|
653
|
-
const ext = entry.name.slice(entry.name.lastIndexOf("."));
|
|
654
|
-
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
655
|
-
results.push(fullPath);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
return results;
|
|
660
|
-
}
|
|
661
|
-
function parseImports(filePath) {
|
|
662
|
-
let source;
|
|
663
|
-
try {
|
|
664
|
-
source = readFileSync4(filePath, "utf-8");
|
|
665
|
-
} catch {
|
|
666
|
-
return [];
|
|
667
|
-
}
|
|
668
|
-
try {
|
|
669
|
-
const cleaned = source.replace(/import\s+type\s+/g, "import ").replace(/export\s+type\s+/g, "export ");
|
|
670
|
-
const [imports] = parse(cleaned);
|
|
671
|
-
return imports.map((imp) => imp.n).filter((n) => n !== void 0 && n !== "");
|
|
672
|
-
} catch {
|
|
673
|
-
const specifiers = [];
|
|
674
|
-
const importRegex = /(?:import|from)\s+["']([^"']+)["']/g;
|
|
675
|
-
let match;
|
|
676
|
-
while ((match = importRegex.exec(source)) !== null) {
|
|
677
|
-
specifiers.push(match[1]);
|
|
678
|
-
}
|
|
679
|
-
return specifiers;
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
async function buildImportGraph(projectRoot) {
|
|
683
|
-
await init;
|
|
684
|
-
const resolve5 = createResolver(projectRoot);
|
|
685
|
-
const allFiles = [];
|
|
686
|
-
for (const dir of SOURCE_DIRS) {
|
|
687
|
-
const fullDir = join2(projectRoot, dir);
|
|
688
|
-
allFiles.push(...collectFiles(fullDir));
|
|
689
|
-
}
|
|
690
|
-
const fileSet = new Set(allFiles);
|
|
691
|
-
const forward = /* @__PURE__ */ new Map();
|
|
692
|
-
const reverse = /* @__PURE__ */ new Map();
|
|
693
|
-
for (const filePath of fileSet) {
|
|
694
|
-
const relPath = relative(projectRoot, filePath);
|
|
695
|
-
const specifiers = parseImports(filePath);
|
|
696
|
-
const deps = /* @__PURE__ */ new Set();
|
|
697
|
-
for (const spec of specifiers) {
|
|
698
|
-
const resolved = resolve5(spec, filePath);
|
|
699
|
-
if (!resolved) continue;
|
|
700
|
-
const relResolved = relative(projectRoot, resolved);
|
|
701
|
-
deps.add(relResolved);
|
|
702
|
-
if (!reverse.has(relResolved)) {
|
|
703
|
-
reverse.set(relResolved, /* @__PURE__ */ new Set());
|
|
704
|
-
}
|
|
705
|
-
reverse.get(relResolved).add(relPath);
|
|
706
|
-
}
|
|
707
|
-
forward.set(relPath, deps);
|
|
708
|
-
}
|
|
709
|
-
logger.dim(`Import graph: ${fileSet.size} files, ${countEdges(forward)} edges`);
|
|
710
|
-
return { forward, reverse };
|
|
711
|
-
}
|
|
712
|
-
function countEdges(forward) {
|
|
713
|
-
let count = 0;
|
|
714
|
-
for (const deps of forward.values()) {
|
|
715
|
-
count += deps.size;
|
|
716
|
-
}
|
|
717
|
-
return count;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// src/utils/nextjs.ts
|
|
721
|
-
function pagePathToRoute(pagePath) {
|
|
722
|
-
let p = pagePath.replace(/^src\//, "");
|
|
723
|
-
const match = p.match(/^app\/(.*)\/page\.[tj]sx?$/);
|
|
724
|
-
if (!match) {
|
|
725
|
-
if (/^app\/page\.[tj]sx?$/.test(p)) return "/";
|
|
726
|
-
return null;
|
|
727
|
-
}
|
|
728
|
-
let route = match[1];
|
|
729
|
-
if (/\[.*\]/.test(route)) {
|
|
730
|
-
logger.warn(`Skipping dynamic route: /${route} (dynamic segments not supported)`);
|
|
731
|
-
return null;
|
|
732
|
-
}
|
|
733
|
-
route = route.split("/").filter((seg) => !seg.startsWith("(")).join("/");
|
|
734
|
-
return `/${route}` || "/";
|
|
735
|
-
}
|
|
736
|
-
function isPageFile(filePath) {
|
|
737
|
-
const p = filePath.replace(/^src\//, "");
|
|
738
|
-
return /^app\/(.+\/)?page\.[tj]sx?$/.test(p);
|
|
739
|
-
}
|
|
740
|
-
function isLayoutFile(filePath) {
|
|
741
|
-
const p = filePath.replace(/^src\//, "");
|
|
742
|
-
return /^app\/(.+\/)?layout\.[tj]sx?$/.test(p);
|
|
743
|
-
}
|
|
744
|
-
function getLayoutDir(filePath) {
|
|
745
|
-
const p = filePath.replace(/^src\//, "");
|
|
746
|
-
const match = p.match(/^(app\/.*\/)layout\.[tj]sx?$/);
|
|
747
|
-
if (!match) return "app/";
|
|
748
|
-
return match[1];
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// src/utils/path.ts
|
|
752
|
-
function normalizePath(filePath) {
|
|
753
|
-
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
754
|
-
}
|
|
755
|
-
function sanitizeLabel(label, maxLength = 40) {
|
|
756
|
-
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, maxLength);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// src/stages/impact.ts
|
|
760
|
-
var DEFAULT_MAX_DEPTH = 10;
|
|
761
|
-
function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
762
|
-
const routeMap = /* @__PURE__ */ new Map();
|
|
763
|
-
for (const file of changedFiles) {
|
|
764
|
-
const visited = /* @__PURE__ */ new Set();
|
|
765
|
-
const queue = [
|
|
766
|
-
{ path: file, depth: 0, chain: [file] }
|
|
767
|
-
];
|
|
768
|
-
visited.add(file);
|
|
769
|
-
while (queue.length > 0) {
|
|
770
|
-
const { path, depth, chain } = queue.shift();
|
|
771
|
-
if (isPageFile(path)) {
|
|
772
|
-
const route = pagePathToRoute(path);
|
|
773
|
-
if (route !== null && !routeMap.has(route)) {
|
|
774
|
-
routeMap.set(route, {
|
|
775
|
-
pagePath: path,
|
|
776
|
-
route,
|
|
777
|
-
reason: depth === 0 ? "direct" : "transitive",
|
|
778
|
-
depth,
|
|
779
|
-
triggerChain: chain
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
if (depth >= maxDepth) continue;
|
|
784
|
-
const importers = graph.reverse.get(path);
|
|
785
|
-
if (!importers) continue;
|
|
786
|
-
for (const importer of importers) {
|
|
787
|
-
if (visited.has(importer)) continue;
|
|
788
|
-
visited.add(importer);
|
|
789
|
-
queue.push({ path: importer, depth: depth + 1, chain: [...chain, importer] });
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
for (const file of changedFiles) {
|
|
794
|
-
if (!isLayoutFile(file)) continue;
|
|
795
|
-
const layoutDir = getLayoutDir(file);
|
|
796
|
-
for (const knownFile of graph.forward.keys()) {
|
|
797
|
-
if (knownFile.startsWith(layoutDir) && isPageFile(knownFile)) {
|
|
798
|
-
const route = pagePathToRoute(knownFile);
|
|
799
|
-
if (route !== null && !routeMap.has(route)) {
|
|
800
|
-
routeMap.set(route, {
|
|
801
|
-
pagePath: knownFile,
|
|
802
|
-
route,
|
|
803
|
-
reason: "transitive",
|
|
804
|
-
depth: 1,
|
|
805
|
-
triggerChain: [file, knownFile]
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
const routes = Array.from(routeMap.values()).sort(
|
|
812
|
-
(a, b) => a.depth - b.depth
|
|
813
|
-
);
|
|
814
|
-
if (maxRoutes > 0 && routes.length > maxRoutes) {
|
|
815
|
-
const skipped = routes.length - maxRoutes;
|
|
816
|
-
logger.warn(
|
|
817
|
-
`Limiting to ${maxRoutes} route(s), skipping ${skipped} deeper route(s). Use --max-routes 0 for unlimited.`
|
|
818
|
-
);
|
|
819
|
-
const limited = routes.slice(0, maxRoutes);
|
|
820
|
-
logger.dim(`Found ${limited.length} affected route(s) (of ${routes.length} total)`);
|
|
821
|
-
return limited;
|
|
822
|
-
}
|
|
823
|
-
logger.dim(`Found ${routes.length} affected route(s)`);
|
|
824
|
-
return routes;
|
|
825
|
-
}
|
|
826
|
-
function findRoutesForFile(file, graph, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
827
|
-
const routes = /* @__PURE__ */ new Set();
|
|
828
|
-
const start = normalizePath(file);
|
|
829
|
-
const queue = [{ path: start, depth: 0 }];
|
|
830
|
-
const visited = /* @__PURE__ */ new Set([start]);
|
|
831
|
-
while (queue.length > 0) {
|
|
832
|
-
const { path, depth } = queue.shift();
|
|
833
|
-
if (isPageFile(path)) {
|
|
834
|
-
const route = pagePathToRoute(path);
|
|
835
|
-
if (route) routes.add(route);
|
|
836
|
-
}
|
|
837
|
-
if (depth >= maxDepth) continue;
|
|
838
|
-
const importers = graph.reverse.get(path);
|
|
839
|
-
if (!importers) continue;
|
|
840
|
-
for (const importer of importers) {
|
|
841
|
-
if (visited.has(importer)) continue;
|
|
842
|
-
visited.add(importer);
|
|
843
|
-
queue.push({ path: importer, depth: depth + 1 });
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
return Array.from(routes);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// src/stages/worktree.ts
|
|
850
|
-
import { exec as execCb, execSync as execSync2 } from "child_process";
|
|
851
|
-
import { promisify } from "util";
|
|
852
|
-
import { rm, mkdtemp } from "fs/promises";
|
|
853
|
-
import { join as join4 } from "path";
|
|
854
|
-
import { tmpdir } from "os";
|
|
855
|
-
|
|
856
|
-
// src/utils/pm.ts
|
|
857
|
-
import { existsSync as existsSync4 } from "fs";
|
|
858
|
-
import { join as join3 } from "path";
|
|
859
|
-
function detectPackageManager(dir) {
|
|
860
|
-
if (existsSync4(join3(dir, "bun.lockb")) || existsSync4(join3(dir, "bun.lock")))
|
|
861
|
-
return "bun";
|
|
862
|
-
if (existsSync4(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
863
|
-
if (existsSync4(join3(dir, "yarn.lock"))) return "yarn";
|
|
864
|
-
return "npm";
|
|
865
|
-
}
|
|
866
|
-
function pmExec(pm) {
|
|
867
|
-
switch (pm) {
|
|
868
|
-
case "bun":
|
|
869
|
-
return "bunx";
|
|
870
|
-
case "pnpm":
|
|
871
|
-
return "pnpm exec";
|
|
872
|
-
case "yarn":
|
|
873
|
-
return "yarn";
|
|
874
|
-
default:
|
|
875
|
-
return "npx";
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// src/stages/worktree.ts
|
|
880
|
-
var exec = promisify(execCb);
|
|
881
|
-
async function createWorktree(base, cwd) {
|
|
882
|
-
const worktreeDir = await mkdtemp(join4(tmpdir(), "afterbefore-wt-"));
|
|
883
|
-
logger.dim(`Creating worktree for ${base} at ${worktreeDir}`);
|
|
884
|
-
try {
|
|
885
|
-
await exec(`git worktree add "${worktreeDir}" "${base}"`, { cwd });
|
|
886
|
-
} catch (err) {
|
|
887
|
-
throw new AfterbeforeError(
|
|
888
|
-
`Failed to create worktree for ref "${base}".`,
|
|
889
|
-
`Make sure the branch/ref "${base}" exists. Run "git branch -a" to see available refs.`
|
|
890
|
-
);
|
|
891
|
-
}
|
|
892
|
-
const pm = detectPackageManager(cwd);
|
|
893
|
-
logger.dim(`Installing dependencies with ${pm}`);
|
|
894
|
-
await exec(`${pm} install`, { cwd: worktreeDir });
|
|
895
|
-
const cleanup = async () => {
|
|
896
|
-
logger.dim(`Cleaning up worktree at ${worktreeDir}`);
|
|
897
|
-
try {
|
|
898
|
-
execSync2(`git worktree remove --force "${worktreeDir}"`, {
|
|
899
|
-
cwd,
|
|
900
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
901
|
-
});
|
|
902
|
-
} catch {
|
|
903
|
-
try {
|
|
904
|
-
await rm(worktreeDir, { recursive: true, force: true });
|
|
905
|
-
execSync2("git worktree prune", {
|
|
906
|
-
cwd,
|
|
907
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
908
|
-
});
|
|
909
|
-
} catch {
|
|
910
|
-
logger.warn(`Failed to clean up worktree at ${worktreeDir}`);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
};
|
|
914
|
-
cleanupRegistry.register(cleanup);
|
|
915
|
-
return { path: worktreeDir, ref: base, cleanup };
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// src/stages/server.ts
|
|
919
|
-
import { spawn } from "child_process";
|
|
920
|
-
import { existsSync as existsSync5 } from "fs";
|
|
921
|
-
import { join as join5 } from "path";
|
|
922
|
-
function waitForServer(url, timeoutMs) {
|
|
923
|
-
const start = Date.now();
|
|
924
|
-
return new Promise((resolve5, reject) => {
|
|
925
|
-
const poll = async () => {
|
|
926
|
-
if (Date.now() - start > timeoutMs) {
|
|
927
|
-
reject(
|
|
928
|
-
new AfterbeforeError(
|
|
929
|
-
`Server at ${url} did not respond within ${timeoutMs / 1e3}s.`,
|
|
930
|
-
`Try running "next dev" manually to check for errors. Also check if port ${url.split(":").pop()} is already in use.`
|
|
931
|
-
)
|
|
932
|
-
);
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
try {
|
|
936
|
-
await fetch(url);
|
|
937
|
-
resolve5();
|
|
938
|
-
} catch {
|
|
939
|
-
setTimeout(poll, 150);
|
|
940
|
-
}
|
|
941
|
-
};
|
|
942
|
-
poll();
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
async function startServer(projectDir, port) {
|
|
946
|
-
const url = `http://localhost:${port}`;
|
|
947
|
-
const pm = detectPackageManager(projectDir);
|
|
948
|
-
const exec2 = pmExec(pm);
|
|
949
|
-
const [cmd, ...baseArgs] = exec2.split(" ");
|
|
950
|
-
const lockFile = join5(projectDir, ".next", "dev", "lock");
|
|
951
|
-
if (existsSync5(lockFile)) {
|
|
952
|
-
throw new AfterbeforeError(
|
|
953
|
-
`Another Next.js dev server is running in ${projectDir} (.next/dev/lock exists).`,
|
|
954
|
-
`Stop the other dev server first, or delete .next/dev/lock if it's stale.`
|
|
955
|
-
);
|
|
956
|
-
}
|
|
957
|
-
logger.info(`Starting Next.js dev server on ${url} (using ${pm})`);
|
|
958
|
-
const child = spawn(cmd, [...baseArgs, "next", "dev", "-p", String(port)], {
|
|
959
|
-
cwd: projectDir,
|
|
960
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
961
|
-
detached: true
|
|
962
|
-
});
|
|
963
|
-
child.stderr?.on("data", (data) => {
|
|
964
|
-
const msg = data.toString().trim();
|
|
965
|
-
if (msg) logger.dim(`[next:${port}] ${msg}`);
|
|
966
|
-
});
|
|
967
|
-
await waitForServer(url, 6e4);
|
|
968
|
-
logger.success(`Server ready at ${url}`);
|
|
969
|
-
return { port, process: child, url };
|
|
970
|
-
}
|
|
971
|
-
async function stopServer(server) {
|
|
972
|
-
logger.dim(`Stopping server on port ${server.port}`);
|
|
973
|
-
const pid = server.process.pid;
|
|
974
|
-
try {
|
|
975
|
-
process.kill(-pid, "SIGTERM");
|
|
976
|
-
} catch {
|
|
977
|
-
}
|
|
978
|
-
await new Promise((resolve5) => {
|
|
979
|
-
const timeout = setTimeout(() => {
|
|
980
|
-
try {
|
|
981
|
-
process.kill(-pid, "SIGKILL");
|
|
982
|
-
} catch {
|
|
983
|
-
}
|
|
984
|
-
resolve5();
|
|
985
|
-
}, 5e3);
|
|
986
|
-
server.process.on("exit", () => {
|
|
987
|
-
clearTimeout(timeout);
|
|
988
|
-
resolve5();
|
|
989
|
-
});
|
|
990
|
-
});
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
// src/stages/capture.ts
|
|
994
|
-
import { join as join6 } from "path";
|
|
995
|
-
import { execSync as execSync3 } from "child_process";
|
|
996
|
-
import { chromium, devices } from "playwright";
|
|
997
|
-
|
|
998
|
-
// src/utils/tabs.ts
|
|
999
|
-
async function detectTabs(page, maxTabs) {
|
|
1000
|
-
const allTabs = await page.evaluate(() => {
|
|
1001
|
-
const tablists = document.querySelectorAll('[role="tablist"]');
|
|
1002
|
-
const results = [];
|
|
1003
|
-
for (const tablist of tablists) {
|
|
1004
|
-
if (tablist.closest('[role="tabpanel"]')) continue;
|
|
1005
|
-
const tabs = tablist.querySelectorAll('[role="tab"]');
|
|
1006
|
-
for (const tab of tabs) {
|
|
1007
|
-
const label = (tab.textContent ?? "").trim();
|
|
1008
|
-
if (!label) continue;
|
|
1009
|
-
const selected = tab.getAttribute("aria-selected") === "true" || tab.getAttribute("data-state") === "active";
|
|
1010
|
-
results.push({ label, selected });
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
return results;
|
|
1014
|
-
});
|
|
1015
|
-
return allTabs.filter((t) => !t.selected).slice(0, maxTabs);
|
|
1016
|
-
}
|
|
1017
|
-
function sanitizeTabLabel(label) {
|
|
1018
|
-
return sanitizeLabel(label);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// src/utils/sections.ts
|
|
1022
|
-
async function detectSections(page, maxSections) {
|
|
1023
|
-
const sections = await page.evaluate((max) => {
|
|
1024
|
-
const headings = Array.from(document.querySelectorAll("h2, h3"));
|
|
1025
|
-
const valid = headings.filter((h) => (h.textContent ?? "").trim().length > 0);
|
|
1026
|
-
if (valid.length < 3) return [];
|
|
1027
|
-
const results = [];
|
|
1028
|
-
const tagged = /* @__PURE__ */ new Set();
|
|
1029
|
-
let idx = 0;
|
|
1030
|
-
for (const heading of valid) {
|
|
1031
|
-
if (idx >= max) break;
|
|
1032
|
-
const container = findSectionContainer(heading);
|
|
1033
|
-
if (!container || tagged.has(container)) continue;
|
|
1034
|
-
if (container.scrollHeight > document.documentElement.scrollHeight * 0.9) continue;
|
|
1035
|
-
container.setAttribute("data-ab-section", String(idx));
|
|
1036
|
-
heading.setAttribute("data-ab-heading", String(idx));
|
|
1037
|
-
tagged.add(container);
|
|
1038
|
-
results.push({
|
|
1039
|
-
label: (heading.textContent ?? "").trim(),
|
|
1040
|
-
index: idx
|
|
1041
|
-
});
|
|
1042
|
-
idx++;
|
|
1043
|
-
}
|
|
1044
|
-
return results;
|
|
1045
|
-
function findSectionContainer(heading) {
|
|
1046
|
-
let el = heading;
|
|
1047
|
-
const headingTag = heading.tagName.toLowerCase();
|
|
1048
|
-
while (el && el !== document.body) {
|
|
1049
|
-
const parentEl = el.parentElement;
|
|
1050
|
-
if (!parentEl) return el;
|
|
1051
|
-
const parentTag = parentEl.tagName.toLowerCase();
|
|
1052
|
-
if (parentTag === "section" || parentTag === "article") return parentEl;
|
|
1053
|
-
if (parentEl.getAttribute("role") === "region") return parentEl;
|
|
1054
|
-
if (parentTag === "body" || parentTag === "main") return el;
|
|
1055
|
-
const siblingHeadings = parentEl.querySelectorAll(`:scope > * ${headingTag}, :scope > ${headingTag}`);
|
|
1056
|
-
if (siblingHeadings.length > 1) return el;
|
|
1057
|
-
el = parentEl;
|
|
1058
|
-
}
|
|
1059
|
-
return el;
|
|
1060
|
-
}
|
|
1061
|
-
}, maxSections);
|
|
1062
|
-
return sections;
|
|
1063
|
-
}
|
|
1064
|
-
async function tagSectionOnPage(page, headingText, index) {
|
|
1065
|
-
return page.evaluate(
|
|
1066
|
-
({ text, idx }) => {
|
|
1067
|
-
const headings = Array.from(document.querySelectorAll("h2, h3"));
|
|
1068
|
-
const match = headings.find(
|
|
1069
|
-
(h) => (h.textContent ?? "").trim() === text
|
|
1070
|
-
);
|
|
1071
|
-
if (!match) return false;
|
|
1072
|
-
let el = match;
|
|
1073
|
-
const headingTag = match.tagName.toLowerCase();
|
|
1074
|
-
while (el && el !== document.body) {
|
|
1075
|
-
const parentEl = el.parentElement;
|
|
1076
|
-
if (!parentEl) break;
|
|
1077
|
-
const parentTag = parentEl.tagName.toLowerCase();
|
|
1078
|
-
if (parentTag === "section" || parentTag === "article") {
|
|
1079
|
-
el = parentEl;
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
if (parentEl.getAttribute("role") === "region") {
|
|
1083
|
-
el = parentEl;
|
|
1084
|
-
break;
|
|
1085
|
-
}
|
|
1086
|
-
if (parentTag === "body" || parentTag === "main") break;
|
|
1087
|
-
const siblingHeadings = parentEl.querySelectorAll(
|
|
1088
|
-
`:scope > * ${headingTag}, :scope > ${headingTag}`
|
|
1089
|
-
);
|
|
1090
|
-
if (siblingHeadings.length > 1) break;
|
|
1091
|
-
el = parentEl;
|
|
1092
|
-
}
|
|
1093
|
-
if (el) {
|
|
1094
|
-
el.setAttribute("data-ab-section", String(idx));
|
|
1095
|
-
match.setAttribute("data-ab-heading", String(idx));
|
|
1096
|
-
return true;
|
|
1097
|
-
}
|
|
1098
|
-
return false;
|
|
1099
|
-
},
|
|
1100
|
-
{ text: headingText, idx: index }
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
async function hideSectionHeading(page, sectionIndex) {
|
|
1104
|
-
await page.evaluate((idx) => {
|
|
1105
|
-
const heading = document.querySelector(`[data-ab-heading="${idx}"]`);
|
|
1106
|
-
if (heading instanceof HTMLElement) {
|
|
1107
|
-
heading.style.display = "none";
|
|
1108
|
-
}
|
|
1109
|
-
}, sectionIndex);
|
|
1110
|
-
}
|
|
1111
|
-
async function showSectionHeading(page, sectionIndex) {
|
|
1112
|
-
await page.evaluate((idx) => {
|
|
1113
|
-
const heading = document.querySelector(`[data-ab-heading="${idx}"]`);
|
|
1114
|
-
if (heading instanceof HTMLElement) {
|
|
1115
|
-
heading.style.display = "";
|
|
1116
|
-
}
|
|
1117
|
-
}, sectionIndex);
|
|
1118
|
-
}
|
|
1119
|
-
async function cleanupSectionTags(page) {
|
|
1120
|
-
await page.evaluate(() => {
|
|
1121
|
-
for (const el of document.querySelectorAll("[data-ab-section]")) {
|
|
1122
|
-
el.removeAttribute("data-ab-section");
|
|
1123
|
-
}
|
|
1124
|
-
for (const el of document.querySelectorAll("[data-ab-heading]")) {
|
|
1125
|
-
el.removeAttribute("data-ab-heading");
|
|
1126
|
-
if (el instanceof HTMLElement) {
|
|
1127
|
-
el.style.display = "";
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
function sanitizeSectionLabel(label) {
|
|
1133
|
-
return sanitizeLabel(label);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// src/stages/capture.ts
|
|
1137
|
-
async function launchBrowser() {
|
|
1138
|
-
try {
|
|
1139
|
-
return await chromium.launch();
|
|
1140
|
-
} catch {
|
|
1141
|
-
logger.dim("Chromium not found, installing...");
|
|
1142
|
-
try {
|
|
1143
|
-
execSync3("npx playwright install chromium", {
|
|
1144
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1145
|
-
});
|
|
1146
|
-
return await chromium.launch();
|
|
1147
|
-
} catch {
|
|
1148
|
-
throw new AfterbeforeError(
|
|
1149
|
-
"Could not install or launch Playwright Chromium.",
|
|
1150
|
-
'Run "npx playwright install chromium" manually, then try again.'
|
|
1151
|
-
);
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
|
|
1156
|
-
function sanitizeComponentLabel(label) {
|
|
1157
|
-
const noExt = label.replace(/\.[a-z0-9]+$/i, "");
|
|
1158
|
-
return sanitizeLabel(noExt, 60);
|
|
1159
|
-
}
|
|
1160
|
-
function groupBySource(instances) {
|
|
1161
|
-
const map = /* @__PURE__ */ new Map();
|
|
1162
|
-
for (const instance of instances) {
|
|
1163
|
-
const list = map.get(instance.source) ?? [];
|
|
1164
|
-
list.push(instance);
|
|
1165
|
-
map.set(instance.source, list);
|
|
1166
|
-
}
|
|
1167
|
-
for (const [source, list] of map.entries()) {
|
|
1168
|
-
list.sort((a, b) => a.index - b.index);
|
|
1169
|
-
map.set(source, list);
|
|
1170
|
-
}
|
|
1171
|
-
return map;
|
|
1172
|
-
}
|
|
1173
|
-
async function captureByAttr(page, attrName, attrValue, outPath) {
|
|
1174
|
-
try {
|
|
1175
|
-
const locator = page.locator(`[${attrName}="${attrValue}"]`).first();
|
|
1176
|
-
if (await locator.count() === 0) return false;
|
|
1177
|
-
await locator.screenshot({ path: outPath });
|
|
1178
|
-
return true;
|
|
1179
|
-
} catch {
|
|
1180
|
-
return false;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
async function tagChangedComponentInstances(page, changedComponents, maxPerSource = MAX_COMPONENT_INSTANCES_PER_SOURCE) {
|
|
1184
|
-
const normalized = changedComponents.map(normalizePath);
|
|
1185
|
-
return page.evaluate(
|
|
1186
|
-
({ changed, maxPerSource: maxPerSource2 }) => {
|
|
1187
|
-
const normalize = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1188
|
-
const targets = changed.map((original) => {
|
|
1189
|
-
const norm = normalize(original);
|
|
1190
|
-
const noSrc = norm.replace(/^src\//, "");
|
|
1191
|
-
return {
|
|
1192
|
-
original: norm,
|
|
1193
|
-
variants: Array.from(/* @__PURE__ */ new Set([norm, noSrc, `src/${noSrc}`]))
|
|
1194
|
-
};
|
|
1195
|
-
});
|
|
1196
|
-
for (const el of document.querySelectorAll("[data-ab-comp-key]")) {
|
|
1197
|
-
el.removeAttribute("data-ab-comp-key");
|
|
1198
|
-
}
|
|
1199
|
-
for (const el of document.querySelectorAll("[data-ab-parent-key]")) {
|
|
1200
|
-
el.removeAttribute("data-ab-parent-key");
|
|
1201
|
-
}
|
|
1202
|
-
const getFiber = (el) => {
|
|
1203
|
-
const anyEl = el;
|
|
1204
|
-
for (const key in anyEl) {
|
|
1205
|
-
if (key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$")) {
|
|
1206
|
-
return anyEl[key];
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
return null;
|
|
1210
|
-
};
|
|
1211
|
-
const matchSource = (rawSource) => {
|
|
1212
|
-
const normalizedSource = normalize(rawSource);
|
|
1213
|
-
for (const target of targets) {
|
|
1214
|
-
for (const variant of target.variants) {
|
|
1215
|
-
if (normalizedSource === variant || normalizedSource.endsWith(`/${variant}`)) {
|
|
1216
|
-
return target.original;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
return null;
|
|
1221
|
-
};
|
|
1222
|
-
const getComponentMatch = (el) => {
|
|
1223
|
-
const fiber = getFiber(el);
|
|
1224
|
-
if (!fiber) return null;
|
|
1225
|
-
let current = fiber;
|
|
1226
|
-
while (current) {
|
|
1227
|
-
const sourceFile = current?._debugSource?.fileName ?? current?.elementType?._debugSource?.fileName ?? current?.type?._debugSource?.fileName;
|
|
1228
|
-
if (typeof sourceFile === "string") {
|
|
1229
|
-
const source = matchSource(sourceFile);
|
|
1230
|
-
if (source) {
|
|
1231
|
-
const type = current?.elementType ?? current?.type;
|
|
1232
|
-
const name = typeof type === "function" && (type.displayName || type.name) || (typeof type === "string" ? type : void 0) || source.split("/").pop() || source;
|
|
1233
|
-
return { source, name };
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
current = current.return;
|
|
1237
|
-
}
|
|
1238
|
-
return null;
|
|
1239
|
-
};
|
|
1240
|
-
const pickParentContainer = (el) => {
|
|
1241
|
-
const ownRect = el.getBoundingClientRect();
|
|
1242
|
-
let parent = el.parentElement;
|
|
1243
|
-
while (parent && parent !== document.body) {
|
|
1244
|
-
const rect = parent.getBoundingClientRect();
|
|
1245
|
-
if (rect.width >= ownRect.width * 1.15 || rect.height >= ownRect.height * 1.15) {
|
|
1246
|
-
return parent;
|
|
1247
|
-
}
|
|
1248
|
-
parent = parent.parentElement;
|
|
1249
|
-
}
|
|
1250
|
-
return el.parentElement ?? el;
|
|
1251
|
-
};
|
|
1252
|
-
const bySource = /* @__PURE__ */ new Map();
|
|
1253
|
-
const elements = Array.from(document.querySelectorAll("body *"));
|
|
1254
|
-
for (const el of elements) {
|
|
1255
|
-
const rect = el.getBoundingClientRect();
|
|
1256
|
-
if (rect.width < 4 || rect.height < 4) continue;
|
|
1257
|
-
if (rect.bottom < 0 || rect.right < 0) continue;
|
|
1258
|
-
const match = getComponentMatch(el);
|
|
1259
|
-
if (!match) continue;
|
|
1260
|
-
const parent = el.parentElement;
|
|
1261
|
-
if (parent) {
|
|
1262
|
-
const parentMatch = getComponentMatch(parent);
|
|
1263
|
-
if (parentMatch && parentMatch.source === match.source) {
|
|
1264
|
-
continue;
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
const list = bySource.get(match.source) ?? [];
|
|
1268
|
-
list.push({
|
|
1269
|
-
el,
|
|
1270
|
-
parent: pickParentContainer(el),
|
|
1271
|
-
name: match.name,
|
|
1272
|
-
top: rect.top + window.scrollY,
|
|
1273
|
-
left: rect.left + window.scrollX
|
|
1274
|
-
});
|
|
1275
|
-
bySource.set(match.source, list);
|
|
1276
|
-
}
|
|
1277
|
-
for (const target of targets) {
|
|
1278
|
-
if (bySource.has(target.original) && bySource.get(target.original).length > 0) continue;
|
|
1279
|
-
const fileName = target.original.split("/").pop() ?? "";
|
|
1280
|
-
const componentName = fileName.replace(/\.[a-z0-9]+$/i, "");
|
|
1281
|
-
if (!componentName || componentName.length < 2) continue;
|
|
1282
|
-
const pattern = new RegExp("\\b" + componentName + "\\b", "i");
|
|
1283
|
-
const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4"));
|
|
1284
|
-
for (const heading of headings) {
|
|
1285
|
-
const text = (heading.textContent ?? "").trim();
|
|
1286
|
-
if (!pattern.test(text)) continue;
|
|
1287
|
-
let container = heading;
|
|
1288
|
-
const headingTag = heading.tagName.toLowerCase();
|
|
1289
|
-
let walkParent = heading.parentElement;
|
|
1290
|
-
while (walkParent && walkParent !== document.body) {
|
|
1291
|
-
const parentTag = walkParent.tagName.toLowerCase();
|
|
1292
|
-
if (parentTag === "section" || parentTag === "article") {
|
|
1293
|
-
container = walkParent;
|
|
1294
|
-
break;
|
|
1295
|
-
}
|
|
1296
|
-
if (walkParent.getAttribute("role") === "region") {
|
|
1297
|
-
container = walkParent;
|
|
1298
|
-
break;
|
|
1299
|
-
}
|
|
1300
|
-
if (parentTag === "body" || parentTag === "main") break;
|
|
1301
|
-
const siblingHeadings = walkParent.querySelectorAll(`:scope > * ${headingTag}, :scope > ${headingTag}`);
|
|
1302
|
-
if (siblingHeadings.length > 1) break;
|
|
1303
|
-
container = walkParent;
|
|
1304
|
-
walkParent = walkParent.parentElement;
|
|
1305
|
-
}
|
|
1306
|
-
const rect = container.getBoundingClientRect();
|
|
1307
|
-
if (rect.width < 4 || rect.height < 4) continue;
|
|
1308
|
-
const list = bySource.get(target.original) ?? [];
|
|
1309
|
-
list.push({
|
|
1310
|
-
el: container,
|
|
1311
|
-
parent: pickParentContainer(container),
|
|
1312
|
-
name: componentName,
|
|
1313
|
-
top: rect.top + window.scrollY,
|
|
1314
|
-
left: rect.left + window.scrollX
|
|
1315
|
-
});
|
|
1316
|
-
bySource.set(target.original, list);
|
|
1317
|
-
break;
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
const tagged = [];
|
|
1321
|
-
for (let sourceIndex = 0; sourceIndex < targets.length; sourceIndex++) {
|
|
1322
|
-
const source = targets[sourceIndex].original;
|
|
1323
|
-
const list = bySource.get(source) ?? [];
|
|
1324
|
-
list.sort((a, b) => a.top === b.top ? a.left - b.left : a.top - b.top);
|
|
1325
|
-
const deduped = [];
|
|
1326
|
-
const seenDims = [];
|
|
1327
|
-
for (const candidate of list) {
|
|
1328
|
-
const rect = candidate.el.getBoundingClientRect();
|
|
1329
|
-
const isDuplicate = seenDims.some(
|
|
1330
|
-
(d) => Math.abs(d.w - rect.width) <= 2 && Math.abs(d.h - rect.height) <= 2
|
|
1331
|
-
);
|
|
1332
|
-
if (!isDuplicate) {
|
|
1333
|
-
seenDims.push({ w: rect.width, h: rect.height });
|
|
1334
|
-
deduped.push(candidate);
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
const limit = Math.min(deduped.length, maxPerSource2);
|
|
1338
|
-
for (let i = 0; i < limit; i++) {
|
|
1339
|
-
const instance = deduped[i];
|
|
1340
|
-
const componentKey = `ab-comp-${sourceIndex}-${i}`;
|
|
1341
|
-
const parentKey = `ab-parent-${sourceIndex}-${i}`;
|
|
1342
|
-
instance.el.setAttribute("data-ab-comp-key", componentKey);
|
|
1343
|
-
instance.parent.setAttribute("data-ab-parent-key", parentKey);
|
|
1344
|
-
tagged.push({
|
|
1345
|
-
source,
|
|
1346
|
-
name: instance.name,
|
|
1347
|
-
index: i,
|
|
1348
|
-
componentKey,
|
|
1349
|
-
parentKey
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
return tagged;
|
|
1354
|
-
},
|
|
1355
|
-
{ changed: normalized, maxPerSource }
|
|
1356
|
-
);
|
|
1357
|
-
}
|
|
1358
|
-
async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir, results) {
|
|
1359
|
-
const deduped = Array.from(new Set(changedComponents.map(normalizePath)));
|
|
1360
|
-
const [afterInstances, beforeInstances] = await Promise.all([
|
|
1361
|
-
tagChangedComponentInstances(afterPage, deduped),
|
|
1362
|
-
tagChangedComponentInstances(beforePage, deduped)
|
|
1363
|
-
]);
|
|
1364
|
-
logger.dim(` Component detection on ${captureLabel}: ${deduped.length} source(s), ${afterInstances.length} after / ${beforeInstances.length} before instance(s)`);
|
|
1365
|
-
const afterBySource = groupBySource(afterInstances);
|
|
1366
|
-
const beforeBySource = groupBySource(beforeInstances);
|
|
1367
|
-
for (const source of deduped) {
|
|
1368
|
-
const afterList = afterBySource.get(source) ?? [];
|
|
1369
|
-
const beforeList = beforeBySource.get(source) ?? [];
|
|
1370
|
-
const pairCount = Math.min(afterList.length, beforeList.length);
|
|
1371
|
-
if (pairCount === 0) {
|
|
1372
|
-
if (afterList.length > 0 || beforeList.length > 0) {
|
|
1373
|
-
logger.dim(` ${source}: ${afterList.length} after / ${beforeList.length} before (skipping unpaired)`);
|
|
1374
|
-
}
|
|
1375
|
-
continue;
|
|
1376
|
-
}
|
|
1377
|
-
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
1378
|
-
const afterInstance = afterList[pairIndex];
|
|
1379
|
-
const beforeInstance = beforeList[pairIndex];
|
|
1380
|
-
const sourceSlug = sanitizeComponentLabel(source) || "component";
|
|
1381
|
-
const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
|
|
1382
|
-
const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
|
|
1383
|
-
const baseLabel = `${captureLabel} [${componentName} #${pairIndex + 1}]`;
|
|
1384
|
-
const parentPrefix = `${capturePrefix}~cmp.${itemSlug}~parent`;
|
|
1385
|
-
const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
|
|
1386
|
-
const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
|
|
1387
|
-
const [parentBeforeOk, parentAfterOk] = await Promise.all([
|
|
1388
|
-
captureByAttr(
|
|
1389
|
-
beforePage,
|
|
1390
|
-
"data-ab-parent-key",
|
|
1391
|
-
beforeInstance.parentKey,
|
|
1392
|
-
parentBeforePath
|
|
1393
|
-
),
|
|
1394
|
-
captureByAttr(
|
|
1395
|
-
afterPage,
|
|
1396
|
-
"data-ab-parent-key",
|
|
1397
|
-
afterInstance.parentKey,
|
|
1398
|
-
parentAfterPath
|
|
1399
|
-
)
|
|
1400
|
-
]);
|
|
1401
|
-
if (parentBeforeOk && parentAfterOk) {
|
|
1402
|
-
results.push({
|
|
1403
|
-
route: `${baseLabel} [parent]`,
|
|
1404
|
-
prefix: parentPrefix,
|
|
1405
|
-
beforePath: parentBeforePath,
|
|
1406
|
-
afterPath: parentAfterPath,
|
|
1407
|
-
level: "parent",
|
|
1408
|
-
parentPrefix: capturePrefix,
|
|
1409
|
-
componentSource: source,
|
|
1410
|
-
componentName
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
const componentPrefix = `${capturePrefix}~cmp.${itemSlug}~component`;
|
|
1414
|
-
const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
|
|
1415
|
-
const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
|
|
1416
|
-
const [componentBeforeOk, componentAfterOk] = await Promise.all([
|
|
1417
|
-
captureByAttr(
|
|
1418
|
-
beforePage,
|
|
1419
|
-
"data-ab-comp-key",
|
|
1420
|
-
beforeInstance.componentKey,
|
|
1421
|
-
componentBeforePath
|
|
1422
|
-
),
|
|
1423
|
-
captureByAttr(
|
|
1424
|
-
afterPage,
|
|
1425
|
-
"data-ab-comp-key",
|
|
1426
|
-
afterInstance.componentKey,
|
|
1427
|
-
componentAfterPath
|
|
1428
|
-
)
|
|
1429
|
-
]);
|
|
1430
|
-
if (componentBeforeOk && componentAfterOk) {
|
|
1431
|
-
results.push({
|
|
1432
|
-
route: `${baseLabel} [component]`,
|
|
1433
|
-
prefix: componentPrefix,
|
|
1434
|
-
beforePath: componentBeforePath,
|
|
1435
|
-
afterPath: componentAfterPath,
|
|
1436
|
-
level: "component",
|
|
1437
|
-
parentPrefix: capturePrefix,
|
|
1438
|
-
componentSource: source,
|
|
1439
|
-
componentName
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLabel, outputDir, options, settle, results) {
|
|
1446
|
-
const sections = await detectSections(afterPage, options.maxSectionsPerRoute);
|
|
1447
|
-
if (sections.length === 0) return;
|
|
1448
|
-
const usedSlugs = /* @__PURE__ */ new Set();
|
|
1449
|
-
for (const section of sections) {
|
|
1450
|
-
let slug = sanitizeSectionLabel(section.label);
|
|
1451
|
-
if (!slug) continue;
|
|
1452
|
-
if (usedSlugs.has(slug)) {
|
|
1453
|
-
let suffix = 2;
|
|
1454
|
-
while (usedSlugs.has(`${slug}-${suffix}`)) suffix++;
|
|
1455
|
-
slug = `${slug}-${suffix}`;
|
|
1456
|
-
}
|
|
1457
|
-
usedSlugs.add(slug);
|
|
1458
|
-
const sectionPrefix = `${parentPrefix}~s.${slug}`;
|
|
1459
|
-
const sectionLabel = `${parentLabel} [${section.label}]`;
|
|
1460
|
-
const sectionAfterPath = join6(outputDir, `${sectionPrefix}-after.png`);
|
|
1461
|
-
const sectionBeforePath = join6(outputDir, `${sectionPrefix}-before.png`);
|
|
1462
|
-
try {
|
|
1463
|
-
await hideSectionHeading(afterPage, section.index);
|
|
1464
|
-
const afterEl = afterPage.locator(`[data-ab-section="${section.index}"]`).first();
|
|
1465
|
-
await afterEl.screenshot({ path: sectionAfterPath });
|
|
1466
|
-
await showSectionHeading(afterPage, section.index);
|
|
1467
|
-
const found = await tagSectionOnPage(beforePage, section.label, section.index);
|
|
1468
|
-
if (found) {
|
|
1469
|
-
await hideSectionHeading(beforePage, section.index);
|
|
1470
|
-
const beforeEl = beforePage.locator(`[data-ab-section="${section.index}"]`).first();
|
|
1471
|
-
await beforeEl.screenshot({ path: sectionBeforePath });
|
|
1472
|
-
await showSectionHeading(beforePage, section.index);
|
|
1473
|
-
} else {
|
|
1474
|
-
continue;
|
|
1475
|
-
}
|
|
1476
|
-
results.push({
|
|
1477
|
-
route: sectionLabel,
|
|
1478
|
-
prefix: sectionPrefix,
|
|
1479
|
-
beforePath: sectionBeforePath,
|
|
1480
|
-
afterPath: sectionAfterPath,
|
|
1481
|
-
level: "section"
|
|
1482
|
-
});
|
|
1483
|
-
} catch {
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
await cleanupSectionTags(afterPage);
|
|
1487
|
-
await cleanupSectionTags(beforePage);
|
|
1488
|
-
}
|
|
1489
|
-
async function captureAutoTabs(afterPage, beforePage, task, beforeUrl, afterUrl, outputDir, options, settle, results) {
|
|
1490
|
-
const tabs = await detectTabs(afterPage, options.maxTabsPerRoute);
|
|
1491
|
-
const usedPrefixes = /* @__PURE__ */ new Set();
|
|
1492
|
-
for (const tab of tabs) {
|
|
1493
|
-
let slug = sanitizeTabLabel(tab.label);
|
|
1494
|
-
if (!slug) continue;
|
|
1495
|
-
if (usedPrefixes.has(slug)) {
|
|
1496
|
-
let suffix = 2;
|
|
1497
|
-
while (usedPrefixes.has(`${slug}-${suffix}`)) suffix++;
|
|
1498
|
-
slug = `${slug}-${suffix}`;
|
|
1499
|
-
}
|
|
1500
|
-
usedPrefixes.add(slug);
|
|
1501
|
-
const tabPrefix = `${task.prefix}~${slug}`;
|
|
1502
|
-
const tabLabel = `${task.label} [${tab.label}]`;
|
|
1503
|
-
const tabBeforePath = join6(outputDir, `${tabPrefix}-before.png`);
|
|
1504
|
-
const tabAfterPath = join6(outputDir, `${tabPrefix}-after.png`);
|
|
1505
|
-
try {
|
|
1506
|
-
const afterUrlBefore = afterPage.url();
|
|
1507
|
-
await afterPage.getByRole("tab", { name: tab.label }).first().click();
|
|
1508
|
-
await settle(afterPage);
|
|
1509
|
-
if (afterPage.url() !== afterUrlBefore) {
|
|
1510
|
-
await afterPage.goBack({ waitUntil: "networkidle" });
|
|
1511
|
-
await settle(afterPage);
|
|
1512
|
-
continue;
|
|
1513
|
-
}
|
|
1514
|
-
await afterPage.screenshot({ path: tabAfterPath, fullPage: true });
|
|
1515
|
-
try {
|
|
1516
|
-
const beforeUrlBefore = beforePage.url();
|
|
1517
|
-
await beforePage.getByRole("tab", { name: tab.label }).first().click({ timeout: 2e3 });
|
|
1518
|
-
await settle(beforePage);
|
|
1519
|
-
if (beforePage.url() !== beforeUrlBefore) {
|
|
1520
|
-
await beforePage.goBack({ waitUntil: "networkidle" });
|
|
1521
|
-
await settle(beforePage);
|
|
1522
|
-
await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
|
|
1523
|
-
} else {
|
|
1524
|
-
await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
|
|
1525
|
-
}
|
|
1526
|
-
} catch {
|
|
1527
|
-
await beforePage.screenshot({ path: tabBeforePath, fullPage: true });
|
|
1528
|
-
}
|
|
1529
|
-
results.push({
|
|
1530
|
-
route: tabLabel,
|
|
1531
|
-
prefix: tabPrefix,
|
|
1532
|
-
beforePath: tabBeforePath,
|
|
1533
|
-
afterPath: tabAfterPath,
|
|
1534
|
-
level: "tab"
|
|
1535
|
-
});
|
|
1536
|
-
if ((task.changedComponents?.length ?? 0) > 0) {
|
|
1537
|
-
await captureComponentInstances(
|
|
1538
|
-
afterPage,
|
|
1539
|
-
beforePage,
|
|
1540
|
-
task.changedComponents,
|
|
1541
|
-
tabPrefix,
|
|
1542
|
-
tabLabel,
|
|
1543
|
-
outputDir,
|
|
1544
|
-
results
|
|
1545
|
-
);
|
|
1546
|
-
}
|
|
1547
|
-
if (options.autoSections && !task.skipAutoSections) {
|
|
1548
|
-
await captureAutoSections(
|
|
1549
|
-
afterPage,
|
|
1550
|
-
beforePage,
|
|
1551
|
-
tabPrefix,
|
|
1552
|
-
tabLabel,
|
|
1553
|
-
outputDir,
|
|
1554
|
-
options,
|
|
1555
|
-
settle,
|
|
1556
|
-
results
|
|
1557
|
-
);
|
|
1558
|
-
}
|
|
1559
|
-
} catch {
|
|
1560
|
-
logger.dim(` Skipped tab "${tab.label}" on ${task.route}`);
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
if (tabs.length > 0) {
|
|
1564
|
-
await Promise.all([
|
|
1565
|
-
beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }),
|
|
1566
|
-
afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" })
|
|
1567
|
-
]);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
async function captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options) {
|
|
1571
|
-
const results = [];
|
|
1572
|
-
const [beforePage, afterPage] = await Promise.all([
|
|
1573
|
-
beforeCtx.newPage(),
|
|
1574
|
-
afterCtx.newPage()
|
|
1575
|
-
]);
|
|
1576
|
-
try {
|
|
1577
|
-
const beforePath = join6(outputDir, `${task.prefix}-before.png`);
|
|
1578
|
-
const afterPath = join6(outputDir, `${task.prefix}-after.png`);
|
|
1579
|
-
const settle = async (page) => {
|
|
1580
|
-
await page.evaluate("document.fonts.ready");
|
|
1581
|
-
if (options.delay > 0) {
|
|
1582
|
-
await page.waitForTimeout(options.delay);
|
|
1583
|
-
}
|
|
1584
|
-
};
|
|
1585
|
-
const performActions = async (page, actions) => {
|
|
1586
|
-
for (const action of actions) {
|
|
1587
|
-
if (action.click) {
|
|
1588
|
-
await page.locator(action.click).first().click();
|
|
1589
|
-
}
|
|
1590
|
-
if (action.scroll) {
|
|
1591
|
-
await page.locator(action.scroll).first().scrollIntoViewIfNeeded();
|
|
1592
|
-
}
|
|
1593
|
-
if (action.wait && action.wait > 0) {
|
|
1594
|
-
await page.waitForTimeout(action.wait);
|
|
1595
|
-
}
|
|
1596
|
-
await settle(page);
|
|
1597
|
-
}
|
|
1598
|
-
};
|
|
1599
|
-
const screenshot = async (page, path) => {
|
|
1600
|
-
if (task.selector) {
|
|
1601
|
-
const el = page.locator(task.selector).first();
|
|
1602
|
-
await el.screenshot({ path });
|
|
1603
|
-
} else {
|
|
1604
|
-
await page.screenshot({ path, fullPage: true });
|
|
1605
|
-
}
|
|
1606
|
-
};
|
|
1607
|
-
await Promise.all([
|
|
1608
|
-
beforePage.goto(`${beforeUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(beforePage)).then(() => task.actions ? performActions(beforePage, task.actions) : void 0).then(() => screenshot(beforePage, beforePath)),
|
|
1609
|
-
afterPage.goto(`${afterUrl}${task.route}`, { waitUntil: "networkidle" }).then(() => settle(afterPage)).then(() => task.actions ? performActions(afterPage, task.actions) : void 0).then(() => screenshot(afterPage, afterPath))
|
|
1610
|
-
]);
|
|
1611
|
-
results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath, level: "page" });
|
|
1612
|
-
if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
|
|
1613
|
-
await captureComponentInstances(
|
|
1614
|
-
afterPage,
|
|
1615
|
-
beforePage,
|
|
1616
|
-
task.changedComponents,
|
|
1617
|
-
task.prefix,
|
|
1618
|
-
task.label,
|
|
1619
|
-
outputDir,
|
|
1620
|
-
results
|
|
1621
|
-
);
|
|
1622
|
-
}
|
|
1623
|
-
if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
|
|
1624
|
-
await captureAutoSections(
|
|
1625
|
-
afterPage,
|
|
1626
|
-
beforePage,
|
|
1627
|
-
task.prefix,
|
|
1628
|
-
task.label,
|
|
1629
|
-
outputDir,
|
|
1630
|
-
options,
|
|
1631
|
-
settle,
|
|
1632
|
-
results
|
|
1633
|
-
);
|
|
1634
|
-
}
|
|
1635
|
-
if (options.autoTabs && !task.actions && !task.selector && !task.skipAutoTabs) {
|
|
1636
|
-
await captureAutoTabs(
|
|
1637
|
-
afterPage,
|
|
1638
|
-
beforePage,
|
|
1639
|
-
task,
|
|
1640
|
-
beforeUrl,
|
|
1641
|
-
afterUrl,
|
|
1642
|
-
outputDir,
|
|
1643
|
-
options,
|
|
1644
|
-
settle,
|
|
1645
|
-
results
|
|
1646
|
-
);
|
|
1647
|
-
}
|
|
1648
|
-
} finally {
|
|
1649
|
-
await Promise.all([beforePage.close(), afterPage.close()]);
|
|
1650
|
-
}
|
|
1651
|
-
return results;
|
|
1652
|
-
}
|
|
1653
|
-
var BATCH_SIZE = 3;
|
|
1654
|
-
async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
|
|
1655
|
-
const browser = options.browser ?? await launchBrowser();
|
|
1656
|
-
const ownsBrowser = !options.browser;
|
|
1657
|
-
const results = [];
|
|
1658
|
-
try {
|
|
1659
|
-
const device = options.device ? devices[options.device] : void 0;
|
|
1660
|
-
const contextOpts = device ? { ...device } : {
|
|
1661
|
-
viewport: { width: options.width, height: options.height },
|
|
1662
|
-
deviceScaleFactor: 2
|
|
1663
|
-
};
|
|
1664
|
-
const [beforeCtx, afterCtx] = await Promise.all([
|
|
1665
|
-
browser.newContext(contextOpts),
|
|
1666
|
-
browser.newContext(contextOpts)
|
|
1667
|
-
]);
|
|
1668
|
-
for (let batchStart = 0; batchStart < tasks.length; batchStart += BATCH_SIZE) {
|
|
1669
|
-
const batch = tasks.slice(batchStart, batchStart + BATCH_SIZE);
|
|
1670
|
-
const batchResults = await Promise.allSettled(
|
|
1671
|
-
batch.map((task, idx) => {
|
|
1672
|
-
options.onProgress?.(batchStart + idx + 1, task.label);
|
|
1673
|
-
return captureOneRoute(task, beforeCtx, afterCtx, beforeUrl, afterUrl, outputDir, options);
|
|
1674
|
-
})
|
|
1675
|
-
);
|
|
1676
|
-
for (const result of batchResults) {
|
|
1677
|
-
if (result.status === "fulfilled") {
|
|
1678
|
-
results.push(...result.value);
|
|
1679
|
-
} else {
|
|
1680
|
-
logger.dim(`Capture failed: ${result.reason}`);
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
await Promise.all([beforeCtx.close(), afterCtx.close()]);
|
|
1685
|
-
} finally {
|
|
1686
|
-
if (ownsBrowser) {
|
|
1687
|
-
await browser.close();
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
return results;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
// src/stages/compare.ts
|
|
1694
|
-
import { join as join7, dirname as dirname2 } from "path";
|
|
1695
|
-
import { unlinkSync } from "fs";
|
|
1696
|
-
import { ODiffServer } from "odiff-bin";
|
|
1697
|
-
import sharp from "sharp";
|
|
1698
|
-
async function trimImage(path) {
|
|
1699
|
-
const { data, info } = await sharp(path).trim({ threshold: 50 }).toBuffer({ resolveWithObject: true });
|
|
1700
|
-
return { data, width: info.width, height: info.height };
|
|
1701
|
-
}
|
|
1702
|
-
function roundCornersSvg(w, h, r) {
|
|
1703
|
-
return Buffer.from(
|
|
1704
|
-
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`
|
|
1705
|
-
);
|
|
1706
|
-
}
|
|
1707
|
-
async function roundImage(buf, width, height, radius) {
|
|
1708
|
-
const mask = roundCornersSvg(width, height, radius);
|
|
1709
|
-
return sharp(buf).resize(width, height, { fit: "fill" }).composite([{ input: mask, blend: "dest-in" }]).png().toBuffer();
|
|
1710
|
-
}
|
|
1711
|
-
async function generateComposite(beforePath, afterPath, outputPath, bgColor, metadata) {
|
|
1712
|
-
const [beforeTrimmed, afterTrimmed] = await Promise.all([
|
|
1713
|
-
trimImage(beforePath),
|
|
1714
|
-
trimImage(afterPath)
|
|
1715
|
-
]);
|
|
1716
|
-
const CANVAS_W = 1200;
|
|
1717
|
-
const PADDING = 32;
|
|
1718
|
-
const GAP = 24;
|
|
1719
|
-
const HEADER_H = 48;
|
|
1720
|
-
const BADGE_H = 40;
|
|
1721
|
-
const FOOTER_H = 24;
|
|
1722
|
-
const CORNER_R = 12;
|
|
1723
|
-
const contentW = CANVAS_W - PADDING * 2;
|
|
1724
|
-
const colW = Math.floor((contentW - GAP) / 2);
|
|
1725
|
-
const imgW = Math.max(beforeTrimmed.width, afterTrimmed.width);
|
|
1726
|
-
const imgH = Math.max(beforeTrimmed.height, afterTrimmed.height);
|
|
1727
|
-
const imgAspect = imgH / (imgW || 1);
|
|
1728
|
-
const scaledH = Math.min(Math.round(colW * imgAspect), 520);
|
|
1729
|
-
const CANVAS_H = PADDING + HEADER_H + scaledH + BADGE_H + FOOTER_H + PADDING;
|
|
1730
|
-
const [beforeBuf, afterBuf] = await Promise.all(
|
|
1731
|
-
[beforeTrimmed, afterTrimmed].map(async (trimmed) => {
|
|
1732
|
-
const resized = await sharp(trimmed.data).resize(colW, scaledH, { fit: "contain", background: bgColor }).toBuffer({ resolveWithObject: true });
|
|
1733
|
-
return {
|
|
1734
|
-
data: await roundImage(resized.data, resized.info.width, resized.info.height, CORNER_R),
|
|
1735
|
-
width: resized.info.width,
|
|
1736
|
-
height: resized.info.height
|
|
1737
|
-
};
|
|
1738
|
-
})
|
|
1739
|
-
);
|
|
1740
|
-
const imgTop = PADDING + HEADER_H;
|
|
1741
|
-
const beforeLeft = PADDING;
|
|
1742
|
-
const afterLeft = PADDING + colW + GAP;
|
|
1743
|
-
const badgeY = imgTop + scaledH + 8;
|
|
1744
|
-
const beforeBadgeCX = PADDING + Math.floor(colW / 2);
|
|
1745
|
-
const afterBadgeCX = PADDING + colW + GAP + Math.floor(colW / 2);
|
|
1746
|
-
const diffPct = metadata.diffPercentage.toFixed(1);
|
|
1747
|
-
const diffBadgeW = Math.max(80, diffPct.length * 12 + 40);
|
|
1748
|
-
const overlaySvg = Buffer.from(
|
|
1749
|
-
`<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
|
|
1750
|
-
<!-- Header: route name + diff badge -->
|
|
1751
|
-
<text x="${PADDING}" y="${PADDING + 28}"
|
|
1752
|
-
font-family="'SF Mono', 'Fira Code', 'Consolas', monospace" font-size="18" font-weight="600"
|
|
1753
|
-
fill="#e0e0e0">${escapeXml(metadata.route)}</text>
|
|
1754
|
-
|
|
1755
|
-
<rect x="${CANVAS_W - PADDING - diffBadgeW}" y="${PADDING + 6}" width="${diffBadgeW}" height="28" rx="14"
|
|
1756
|
-
fill="#f59e0b" fill-opacity="0.2"/>
|
|
1757
|
-
<text x="${CANVAS_W - PADDING - diffBadgeW / 2}" y="${PADDING + 25}"
|
|
1758
|
-
font-family="system-ui, sans-serif" font-size="13" font-weight="700" text-anchor="middle"
|
|
1759
|
-
fill="#f59e0b">${diffPct}% changed</text>
|
|
1760
|
-
|
|
1761
|
-
<!-- "Before" pill badge -->
|
|
1762
|
-
<rect x="${beforeBadgeCX - 44}" y="${badgeY}" width="88" height="28" rx="14"
|
|
1763
|
-
fill="#555" fill-opacity="0.5"/>
|
|
1764
|
-
<text x="${beforeBadgeCX}" y="${badgeY + 19}"
|
|
1765
|
-
font-family="system-ui, sans-serif" font-size="13" font-weight="600" text-anchor="middle"
|
|
1766
|
-
fill="#ccc">Before</text>
|
|
1767
|
-
|
|
1768
|
-
<!-- "After" pill badge -->
|
|
1769
|
-
<rect x="${afterBadgeCX - 40}" y="${badgeY}" width="80" height="28" rx="14"
|
|
1770
|
-
fill="#22c55e" fill-opacity="0.25"/>
|
|
1771
|
-
<text x="${afterBadgeCX}" y="${badgeY + 19}"
|
|
1772
|
-
font-family="system-ui, sans-serif" font-size="13" font-weight="600" text-anchor="middle"
|
|
1773
|
-
fill="#22c55e">After</text>
|
|
1774
|
-
|
|
1775
|
-
<!-- Branding -->
|
|
1776
|
-
<text x="${CANVAS_W - PADDING}" y="${CANVAS_H - 10}"
|
|
1777
|
-
font-family="system-ui, sans-serif" font-size="10" text-anchor="end"
|
|
1778
|
-
fill="#444">afterbefore</text>
|
|
1779
|
-
</svg>`
|
|
1780
|
-
);
|
|
1781
|
-
await sharp({
|
|
1782
|
-
create: {
|
|
1783
|
-
width: CANVAS_W,
|
|
1784
|
-
height: CANVAS_H,
|
|
1785
|
-
channels: 4,
|
|
1786
|
-
background: "#1a1a2e"
|
|
1787
|
-
}
|
|
1788
|
-
}).composite([
|
|
1789
|
-
{ input: beforeBuf.data, left: beforeLeft, top: imgTop },
|
|
1790
|
-
{ input: afterBuf.data, left: afterLeft, top: imgTop },
|
|
1791
|
-
{ input: overlaySvg, left: 0, top: 0 }
|
|
1792
|
-
]).png().toFile(outputPath);
|
|
1793
|
-
}
|
|
1794
|
-
function escapeXml(str) {
|
|
1795
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1796
|
-
}
|
|
1797
|
-
async function compareOne(capture, outputDir, threshold, server, options) {
|
|
1798
|
-
const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
|
|
1799
|
-
const dir = dirname2(capture.beforePath);
|
|
1800
|
-
const comparePath = join7(dir, `${capture.prefix}-compare.png`);
|
|
1801
|
-
const result = await server.compare(
|
|
1802
|
-
capture.beforePath,
|
|
1803
|
-
capture.afterPath,
|
|
1804
|
-
diffPath,
|
|
1805
|
-
{ threshold: 0.1, antialiasing: true }
|
|
1806
|
-
);
|
|
1807
|
-
try {
|
|
1808
|
-
unlinkSync(diffPath);
|
|
1809
|
-
} catch {
|
|
1810
|
-
}
|
|
1811
|
-
let diffPixels = 0;
|
|
1812
|
-
let totalPixels = 0;
|
|
1813
|
-
let diffPercentage = 0;
|
|
1814
|
-
let changed = false;
|
|
1815
|
-
if (result.match) {
|
|
1816
|
-
} else if (result.reason === "pixel-diff") {
|
|
1817
|
-
diffPixels = result.diffCount;
|
|
1818
|
-
diffPercentage = result.diffPercentage;
|
|
1819
|
-
changed = diffPercentage > threshold;
|
|
1820
|
-
} else {
|
|
1821
|
-
diffPercentage = 100;
|
|
1822
|
-
changed = true;
|
|
1823
|
-
}
|
|
1824
|
-
if (changed) {
|
|
1825
|
-
await generateComposite(
|
|
1826
|
-
capture.beforePath,
|
|
1827
|
-
capture.afterPath,
|
|
1828
|
-
comparePath,
|
|
1829
|
-
options.bgColor,
|
|
1830
|
-
{ route: capture.route, diffPercentage }
|
|
1831
|
-
);
|
|
1832
|
-
} else {
|
|
1833
|
-
try {
|
|
1834
|
-
unlinkSync(capture.beforePath);
|
|
1835
|
-
} catch {
|
|
1836
|
-
}
|
|
1837
|
-
try {
|
|
1838
|
-
unlinkSync(capture.afterPath);
|
|
1839
|
-
} catch {
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
return {
|
|
1843
|
-
route: capture.route,
|
|
1844
|
-
prefix: capture.prefix,
|
|
1845
|
-
beforePath: capture.beforePath,
|
|
1846
|
-
afterPath: capture.afterPath,
|
|
1847
|
-
comparePath: changed ? comparePath : "",
|
|
1848
|
-
diffPixels,
|
|
1849
|
-
totalPixels,
|
|
1850
|
-
diffPercentage,
|
|
1851
|
-
changed
|
|
1852
|
-
};
|
|
1853
|
-
}
|
|
1854
|
-
async function compareScreenshots(captures, outputDir, threshold = 0.1, options) {
|
|
1855
|
-
const server = new ODiffServer();
|
|
1856
|
-
try {
|
|
1857
|
-
const results = [];
|
|
1858
|
-
for (const capture of captures) {
|
|
1859
|
-
results.push(await compareOne(capture, outputDir, threshold, server, options));
|
|
1860
|
-
}
|
|
1861
|
-
return results;
|
|
1862
|
-
} finally {
|
|
1863
|
-
server.stop();
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
// src/stages/report.ts
|
|
1868
|
-
import { execSync as execSync4 } from "child_process";
|
|
1869
|
-
|
|
1870
|
-
// src/templates/summary.md.ts
|
|
1871
|
-
function generateSummaryMd(results, gitDiff, options) {
|
|
1872
|
-
const includeFilePaths = options?.includeFilePaths ?? true;
|
|
1873
|
-
const changed = results.filter((r) => r.changed);
|
|
1874
|
-
const lines = [];
|
|
1875
|
-
lines.push("<!-- afterbefore -->");
|
|
1876
|
-
lines.push("");
|
|
1877
|
-
lines.push("## afterbefore Report");
|
|
1878
|
-
lines.push("");
|
|
1879
|
-
if (changed.length === 0) {
|
|
1880
|
-
lines.push("No visual changes detected.");
|
|
1881
|
-
lines.push("");
|
|
1882
|
-
lines.push(`${results.length} route(s) captured, all unchanged.`);
|
|
1883
|
-
return lines.join("\n");
|
|
1884
|
-
}
|
|
1885
|
-
lines.push(
|
|
1886
|
-
`${results.length} route(s) captured, **${changed.length}** with visual changes.`
|
|
1887
|
-
);
|
|
1888
|
-
lines.push("");
|
|
1889
|
-
lines.push("| Route | Diff % | Status |");
|
|
1890
|
-
lines.push("|-------|--------|--------|");
|
|
1891
|
-
for (const r of results) {
|
|
1892
|
-
const status = r.changed ? "Changed" : "Unchanged";
|
|
1893
|
-
const pct = r.changed ? `${r.diffPercentage.toFixed(2)}%` : "0%";
|
|
1894
|
-
lines.push(`| \`${r.route}\` | ${pct} | ${status} |`);
|
|
1895
|
-
}
|
|
1896
|
-
if (gitDiff) {
|
|
1897
|
-
lines.push("");
|
|
1898
|
-
lines.push("### Changed files");
|
|
1899
|
-
lines.push("```diff");
|
|
1900
|
-
lines.push(gitDiff);
|
|
1901
|
-
lines.push("```");
|
|
1902
|
-
}
|
|
1903
|
-
if (includeFilePaths) {
|
|
1904
|
-
lines.push("");
|
|
1905
|
-
lines.push("### Screenshots");
|
|
1906
|
-
lines.push("| Route | Before | After | Compare |");
|
|
1907
|
-
lines.push("|-------|--------|-------|---------|");
|
|
1908
|
-
for (const r of changed) {
|
|
1909
|
-
lines.push(`| \`${r.route}\` | \`${r.beforePath}\` | \`${r.afterPath}\` | \`${r.comparePath}\` |`);
|
|
1910
|
-
}
|
|
1911
|
-
lines.push("");
|
|
1912
|
-
lines.push("Review the before/after screenshots above to verify the visual changes match the code diff.");
|
|
1913
|
-
}
|
|
1914
|
-
return lines.join("\n");
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
// src/stages/report.ts
|
|
1918
|
-
var COMMENT_MARKER = "<!-- afterbefore -->";
|
|
1919
|
-
function isGhInstalled() {
|
|
1920
|
-
try {
|
|
1921
|
-
execSync4("gh --version", { stdio: ["pipe", "pipe", "pipe"] });
|
|
1922
|
-
return true;
|
|
1923
|
-
} catch {
|
|
1924
|
-
return false;
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
function findPrNumber() {
|
|
1928
|
-
if (!isGhInstalled()) {
|
|
1929
|
-
logger.warn(
|
|
1930
|
-
"GitHub CLI (gh) is not installed. Install it from https://cli.github.com"
|
|
1931
|
-
);
|
|
1932
|
-
return null;
|
|
1933
|
-
}
|
|
1934
|
-
try {
|
|
1935
|
-
const output = execSync4("gh pr view --json number -q .number", {
|
|
1936
|
-
encoding: "utf-8",
|
|
1937
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1938
|
-
}).trim();
|
|
1939
|
-
return output || null;
|
|
1940
|
-
} catch {
|
|
1941
|
-
return null;
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
function findExistingCommentId(prNumber) {
|
|
1945
|
-
try {
|
|
1946
|
-
const output = execSync4(
|
|
1947
|
-
`gh api repos/{owner}/{repo}/issues/${prNumber}/comments --jq '.[] | select(.body | contains("${COMMENT_MARKER}")) | .id'`,
|
|
1948
|
-
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
1949
|
-
).trim();
|
|
1950
|
-
const ids = output.split("\n").filter(Boolean);
|
|
1951
|
-
return ids[0] || null;
|
|
1952
|
-
} catch {
|
|
1953
|
-
return null;
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
function postOrUpdateComment(prNumber, body) {
|
|
1957
|
-
const existingId = findExistingCommentId(prNumber);
|
|
1958
|
-
if (existingId) {
|
|
1959
|
-
logger.info(`Updating existing PR comment (id: ${existingId})`);
|
|
1960
|
-
execSync4(
|
|
1961
|
-
`gh api repos/{owner}/{repo}/issues/comments/${existingId} -X PATCH -f body=@-`,
|
|
1962
|
-
{
|
|
1963
|
-
input: body,
|
|
1964
|
-
encoding: "utf-8",
|
|
1965
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1966
|
-
}
|
|
1967
|
-
);
|
|
1968
|
-
} else {
|
|
1969
|
-
logger.info(`Creating new PR comment`);
|
|
1970
|
-
execSync4(
|
|
1971
|
-
`gh api repos/{owner}/{repo}/issues/${prNumber}/comments -f body=@-`,
|
|
1972
|
-
{
|
|
1973
|
-
input: body,
|
|
1974
|
-
encoding: "utf-8",
|
|
1975
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
1976
|
-
}
|
|
1977
|
-
);
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
async function generateReport(results, outputDir, options) {
|
|
1981
|
-
if (options.post) {
|
|
1982
|
-
const prNumber = findPrNumber();
|
|
1983
|
-
if (!prNumber) {
|
|
1984
|
-
logger.warn(
|
|
1985
|
-
"No open PR found for this branch. Push your branch and open a PR first, then run `npx afterbefore --post` again."
|
|
1986
|
-
);
|
|
1987
|
-
return;
|
|
1988
|
-
}
|
|
1989
|
-
const prSummary = generateSummaryMd(results, void 0, { includeFilePaths: false });
|
|
1990
|
-
postOrUpdateComment(prNumber, prSummary);
|
|
1991
|
-
logger.success(`Posted results to PR #${prNumber}`);
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
// src/templates/report.html.ts
|
|
1996
|
-
function escapeHtml(str) {
|
|
1997
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1998
|
-
}
|
|
1999
|
-
function generateReportHtml(results, sessionName) {
|
|
2000
|
-
const changed = results.filter((r) => r.changed);
|
|
2001
|
-
const unchanged = results.filter((r) => !r.changed);
|
|
2002
|
-
const totalChecked = results.length;
|
|
2003
|
-
const changedCount = changed.length;
|
|
2004
|
-
const changedCards = changed.map((r) => {
|
|
2005
|
-
const diffPct = r.diffPercentage.toFixed(2);
|
|
2006
|
-
const beforeFile = r.beforePath.split("/").pop() || "";
|
|
2007
|
-
const afterFile = r.afterPath.split("/").pop() || "";
|
|
2008
|
-
const compareFile = r.comparePath ? r.comparePath.split("/").pop() || "" : "";
|
|
2009
|
-
return `
|
|
2010
|
-
<div class="card">
|
|
2011
|
-
<div class="card-header">
|
|
2012
|
-
<span class="route">${escapeHtml(r.route)}</span>
|
|
2013
|
-
<span class="badge">${diffPct}% changed</span>
|
|
2014
|
-
</div>
|
|
2015
|
-
${compareFile ? `
|
|
2016
|
-
<div class="compare-wrap">
|
|
2017
|
-
<img src="${escapeHtml(compareFile)}" alt="Side-by-side comparison of ${escapeHtml(r.route)}" class="compare-img" loading="lazy">
|
|
2018
|
-
</div>
|
|
2019
|
-
` : ""}
|
|
2020
|
-
<div class="slider-section">
|
|
2021
|
-
<p class="slider-label">Interactive comparison \u2014 drag the divider</p>
|
|
2022
|
-
<div class="slider" style="--pos: 50%">
|
|
2023
|
-
<img src="${escapeHtml(beforeFile)}" alt="Before" class="slider-img">
|
|
2024
|
-
<img src="${escapeHtml(afterFile)}" alt="After" class="slider-img slider-after" style="clip-path: inset(0 0 0 var(--pos))">
|
|
2025
|
-
<input type="range" min="0" max="100" value="50" class="slider-range"
|
|
2026
|
-
oninput="this.parentElement.style.setProperty('--pos', this.value + '%')">
|
|
2027
|
-
<div class="slider-labels">
|
|
2028
|
-
<span>Before</span>
|
|
2029
|
-
<span>After</span>
|
|
2030
|
-
</div>
|
|
2031
|
-
</div>
|
|
2032
|
-
</div>
|
|
2033
|
-
</div>`;
|
|
2034
|
-
}).join("\n");
|
|
2035
|
-
const unchangedSection = unchanged.length > 0 ? `
|
|
2036
|
-
<details class="unchanged-section">
|
|
2037
|
-
<summary>${unchanged.length} route${unchanged.length === 1 ? "" : "s"} unchanged</summary>
|
|
2038
|
-
<ul>
|
|
2039
|
-
${unchanged.map((r) => `<li><code>${escapeHtml(r.route)}</code></li>`).join("\n ")}
|
|
2040
|
-
</ul>
|
|
2041
|
-
</details>` : "";
|
|
2042
|
-
return `<!DOCTYPE html>
|
|
2043
|
-
<html lang="en">
|
|
2044
|
-
<head>
|
|
2045
|
-
<meta charset="UTF-8">
|
|
2046
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2047
|
-
<title>afterbefore report \u2014 ${escapeHtml(sessionName)}</title>
|
|
2048
|
-
<style>
|
|
2049
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2050
|
-
|
|
2051
|
-
body {
|
|
2052
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2053
|
-
background: #0f0f17;
|
|
2054
|
-
color: #e0e0e0;
|
|
2055
|
-
padding: 2rem;
|
|
2056
|
-
max-width: 1400px;
|
|
2057
|
-
margin: 0 auto;
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
h1 {
|
|
2061
|
-
font-size: 1.5rem;
|
|
2062
|
-
font-weight: 600;
|
|
2063
|
-
margin-bottom: 0.25rem;
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
.subtitle {
|
|
2067
|
-
color: #888;
|
|
2068
|
-
font-size: 0.9rem;
|
|
2069
|
-
margin-bottom: 2rem;
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
.summary {
|
|
2073
|
-
display: flex;
|
|
2074
|
-
gap: 1.5rem;
|
|
2075
|
-
margin-bottom: 2rem;
|
|
2076
|
-
flex-wrap: wrap;
|
|
2077
|
-
}
|
|
2078
|
-
|
|
2079
|
-
.stat {
|
|
2080
|
-
background: #1a1a2e;
|
|
2081
|
-
border-radius: 8px;
|
|
2082
|
-
padding: 1rem 1.5rem;
|
|
2083
|
-
min-width: 140px;
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
.stat-value {
|
|
2087
|
-
font-size: 1.8rem;
|
|
2088
|
-
font-weight: 700;
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
.stat-value.changed { color: #f59e0b; }
|
|
2092
|
-
.stat-value.total { color: #3b82f6; }
|
|
2093
|
-
.stat-value.unchanged { color: #22c55e; }
|
|
2094
|
-
|
|
2095
|
-
.stat-label {
|
|
2096
|
-
color: #888;
|
|
2097
|
-
font-size: 0.8rem;
|
|
2098
|
-
margin-top: 0.25rem;
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
.card {
|
|
2102
|
-
background: #1a1a2e;
|
|
2103
|
-
border-radius: 12px;
|
|
2104
|
-
margin-bottom: 2rem;
|
|
2105
|
-
overflow: hidden;
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
.card-header {
|
|
2109
|
-
display: flex;
|
|
2110
|
-
align-items: center;
|
|
2111
|
-
justify-content: space-between;
|
|
2112
|
-
padding: 1rem 1.5rem;
|
|
2113
|
-
border-bottom: 1px solid #2a2a3e;
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
.route {
|
|
2117
|
-
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
2118
|
-
font-size: 1.1rem;
|
|
2119
|
-
font-weight: 600;
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
.badge {
|
|
2123
|
-
background: #f59e0b20;
|
|
2124
|
-
color: #f59e0b;
|
|
2125
|
-
padding: 0.25rem 0.75rem;
|
|
2126
|
-
border-radius: 999px;
|
|
2127
|
-
font-size: 0.8rem;
|
|
2128
|
-
font-weight: 600;
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
.compare-wrap {
|
|
2132
|
-
padding: 1rem;
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
.compare-img {
|
|
2136
|
-
width: 100%;
|
|
2137
|
-
border-radius: 8px;
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
.slider-section {
|
|
2141
|
-
padding: 1rem 1.5rem 1.5rem;
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
.slider-label {
|
|
2145
|
-
color: #666;
|
|
2146
|
-
font-size: 0.75rem;
|
|
2147
|
-
margin-bottom: 0.75rem;
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
.slider {
|
|
2151
|
-
position: relative;
|
|
2152
|
-
overflow: hidden;
|
|
2153
|
-
border-radius: 8px;
|
|
2154
|
-
background: #111;
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
.slider-img {
|
|
2158
|
-
display: block;
|
|
2159
|
-
width: 100%;
|
|
2160
|
-
height: auto;
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
.slider-after {
|
|
2164
|
-
position: absolute;
|
|
2165
|
-
top: 0;
|
|
2166
|
-
left: 0;
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
.slider-range {
|
|
2170
|
-
position: absolute;
|
|
2171
|
-
top: 0;
|
|
2172
|
-
left: 0;
|
|
2173
|
-
width: 100%;
|
|
2174
|
-
height: 100%;
|
|
2175
|
-
opacity: 0;
|
|
2176
|
-
cursor: col-resize;
|
|
2177
|
-
z-index: 2;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
.slider-labels {
|
|
2181
|
-
display: flex;
|
|
2182
|
-
justify-content: space-between;
|
|
2183
|
-
padding: 0.5rem 0;
|
|
2184
|
-
color: #666;
|
|
2185
|
-
font-size: 0.75rem;
|
|
2186
|
-
}
|
|
2187
|
-
|
|
2188
|
-
.unchanged-section {
|
|
2189
|
-
background: #1a1a2e;
|
|
2190
|
-
border-radius: 12px;
|
|
2191
|
-
padding: 1rem 1.5rem;
|
|
2192
|
-
margin-bottom: 2rem;
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
.unchanged-section summary {
|
|
2196
|
-
cursor: pointer;
|
|
2197
|
-
color: #888;
|
|
2198
|
-
font-size: 0.9rem;
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
.unchanged-section ul {
|
|
2202
|
-
margin-top: 0.75rem;
|
|
2203
|
-
padding-left: 1.5rem;
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
.unchanged-section li {
|
|
2207
|
-
color: #666;
|
|
2208
|
-
margin-bottom: 0.25rem;
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
.unchanged-section code {
|
|
2212
|
-
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
2213
|
-
font-size: 0.85rem;
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
|
-
.summary-table {
|
|
2217
|
-
width: 100%;
|
|
2218
|
-
border-collapse: collapse;
|
|
2219
|
-
margin-bottom: 2rem;
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
.summary-table th {
|
|
2223
|
-
text-align: left;
|
|
2224
|
-
padding: 0.5rem 1rem;
|
|
2225
|
-
border-bottom: 2px solid #2a2a3e;
|
|
2226
|
-
color: #888;
|
|
2227
|
-
font-size: 0.8rem;
|
|
2228
|
-
font-weight: 600;
|
|
2229
|
-
text-transform: uppercase;
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
.summary-table td {
|
|
2233
|
-
padding: 0.5rem 1rem;
|
|
2234
|
-
border-bottom: 1px solid #1a1a2e;
|
|
2235
|
-
font-size: 0.9rem;
|
|
2236
|
-
}
|
|
2237
|
-
|
|
2238
|
-
.summary-table code {
|
|
2239
|
-
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
2240
|
-
font-size: 0.85rem;
|
|
2241
|
-
}
|
|
2242
|
-
|
|
2243
|
-
.status-changed { color: #f59e0b; }
|
|
2244
|
-
.status-unchanged { color: #22c55e; }
|
|
2245
|
-
|
|
2246
|
-
footer {
|
|
2247
|
-
text-align: center;
|
|
2248
|
-
padding: 2rem 0;
|
|
2249
|
-
color: #444;
|
|
2250
|
-
font-size: 0.75rem;
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
footer a {
|
|
2254
|
-
color: #555;
|
|
2255
|
-
text-decoration: none;
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
footer a:hover {
|
|
2259
|
-
color: #888;
|
|
2260
|
-
}
|
|
2261
|
-
</style>
|
|
2262
|
-
</head>
|
|
2263
|
-
<body>
|
|
2264
|
-
<h1>afterbefore report</h1>
|
|
2265
|
-
<p class="subtitle">${escapeHtml(sessionName)}</p>
|
|
2266
|
-
|
|
2267
|
-
<div class="summary">
|
|
2268
|
-
<div class="stat">
|
|
2269
|
-
<div class="stat-value total">${totalChecked}</div>
|
|
2270
|
-
<div class="stat-label">Routes checked</div>
|
|
2271
|
-
</div>
|
|
2272
|
-
<div class="stat">
|
|
2273
|
-
<div class="stat-value changed">${changedCount}</div>
|
|
2274
|
-
<div class="stat-label">With visual changes</div>
|
|
2275
|
-
</div>
|
|
2276
|
-
<div class="stat">
|
|
2277
|
-
<div class="stat-value unchanged">${unchanged.length}</div>
|
|
2278
|
-
<div class="stat-label">Unchanged</div>
|
|
2279
|
-
</div>
|
|
2280
|
-
</div>
|
|
2281
|
-
|
|
2282
|
-
<table class="summary-table">
|
|
2283
|
-
<thead>
|
|
2284
|
-
<tr><th>Route</th><th>Diff %</th><th>Status</th></tr>
|
|
2285
|
-
</thead>
|
|
2286
|
-
<tbody>
|
|
2287
|
-
${results.map((r) => {
|
|
2288
|
-
const status = r.changed ? "Changed" : "Unchanged";
|
|
2289
|
-
const statusClass = r.changed ? "status-changed" : "status-unchanged";
|
|
2290
|
-
const pct = r.changed ? `${r.diffPercentage.toFixed(2)}%` : "0%";
|
|
2291
|
-
return `<tr><td><code>${escapeHtml(r.route)}</code></td><td>${pct}</td><td class="${statusClass}">${status}</td></tr>`;
|
|
2292
|
-
}).join("\n ")}
|
|
2293
|
-
</tbody>
|
|
2294
|
-
</table>
|
|
2295
|
-
|
|
2296
|
-
${changedCards}
|
|
2297
|
-
${unchangedSection}
|
|
2298
|
-
|
|
2299
|
-
<footer>
|
|
2300
|
-
Generated by <a href="https://github.com/kairevicius/afterbefore">afterbefore</a>
|
|
2301
|
-
</footer>
|
|
2302
|
-
</body>
|
|
2303
|
-
</html>`;
|
|
2304
|
-
}
|
|
2305
|
-
|
|
2306
|
-
// src/pipeline.ts
|
|
2307
|
-
function generateSessionName(cwd) {
|
|
2308
|
-
const branch = getCurrentBranch(cwd);
|
|
2309
|
-
const name = branch.replace(/^(feat|fix|perf|chore|refactor|docs|style|test|ci|build)\//, "");
|
|
2310
|
-
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2311
|
-
return `${name}_${date}`;
|
|
2312
|
-
}
|
|
2313
|
-
function routeToPrefix(route) {
|
|
2314
|
-
if (route === "/") return "_root";
|
|
2315
|
-
return route.replace(/^\//, "").replace(/\//g, "-");
|
|
2316
|
-
}
|
|
2317
|
-
function mapRouteToChangedComponents(changedComponentFiles, graph) {
|
|
2318
|
-
const routeMap = /* @__PURE__ */ new Map();
|
|
2319
|
-
for (const componentPath of changedComponentFiles) {
|
|
2320
|
-
const routes = findRoutesForFile(componentPath, graph);
|
|
2321
|
-
for (const route of routes) {
|
|
2322
|
-
const next = routeMap.get(route) ?? [];
|
|
2323
|
-
next.push(componentPath);
|
|
2324
|
-
routeMap.set(route, next);
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
for (const [route, files] of routeMap.entries()) {
|
|
2328
|
-
routeMap.set(route, Array.from(new Set(files)).sort((a, b) => a.localeCompare(b)));
|
|
2329
|
-
}
|
|
2330
|
-
return routeMap;
|
|
2331
|
-
}
|
|
2332
|
-
function expandRoutes(routes, config, routeComponentMap) {
|
|
2333
|
-
const tasks = [];
|
|
2334
|
-
for (const r of routes) {
|
|
2335
|
-
const scenarios = config?.scenarios?.[r.route] ?? [];
|
|
2336
|
-
const changedComponents = routeComponentMap.get(r.route) ?? [];
|
|
2337
|
-
tasks.push({
|
|
2338
|
-
route: r.route,
|
|
2339
|
-
label: r.route,
|
|
2340
|
-
prefix: routeToPrefix(r.route),
|
|
2341
|
-
skipAutoTabs: scenarios.length > 0,
|
|
2342
|
-
skipAutoSections: scenarios.length > 0,
|
|
2343
|
-
changedComponents
|
|
2344
|
-
});
|
|
2345
|
-
for (const s of scenarios) {
|
|
2346
|
-
tasks.push({
|
|
2347
|
-
route: r.route,
|
|
2348
|
-
label: `${r.route} [${s.name}]`,
|
|
2349
|
-
prefix: `${routeToPrefix(r.route)}~${s.name}`,
|
|
2350
|
-
actions: s.actions,
|
|
2351
|
-
selector: s.selector
|
|
2352
|
-
});
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
return tasks;
|
|
2356
|
-
}
|
|
2357
|
-
function applyConfigDefaults(options, config) {
|
|
2358
|
-
if (!config?.defaults) return;
|
|
2359
|
-
const defaults = config.defaults;
|
|
2360
|
-
const cliDefaults = {
|
|
2361
|
-
base: "main",
|
|
2362
|
-
output: ".afterbefore",
|
|
2363
|
-
post: false,
|
|
2364
|
-
threshold: 0.1,
|
|
2365
|
-
maxRoutes: 6,
|
|
2366
|
-
width: 1280,
|
|
2367
|
-
height: 720,
|
|
2368
|
-
delay: 0,
|
|
2369
|
-
autoTabs: true,
|
|
2370
|
-
maxTabsPerRoute: 5,
|
|
2371
|
-
autoSections: true,
|
|
2372
|
-
maxSectionsPerRoute: 10,
|
|
2373
|
-
maxDepth: 10,
|
|
2374
|
-
dryRun: false,
|
|
2375
|
-
verbose: false,
|
|
2376
|
-
open: false
|
|
2377
|
-
};
|
|
2378
|
-
const opts = options;
|
|
2379
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
2380
|
-
if (key === "cwd" || value === void 0) continue;
|
|
2381
|
-
if (key in cliDefaults && opts[key] === cliDefaults[key]) {
|
|
2382
|
-
opts[key] = value;
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
async function runPipeline(options) {
|
|
2387
|
-
const { base, output, post, cwd } = options;
|
|
2388
|
-
const sessionName = generateSessionName(cwd);
|
|
2389
|
-
const outputDir = resolve4(cwd, output, sessionName);
|
|
2390
|
-
const startTime = Date.now();
|
|
2391
|
-
try {
|
|
2392
|
-
const version = true ? "0.1.18" : "dev";
|
|
2393
|
-
const mode = options.dryRun ? "Dry run" : "Comparing";
|
|
2394
|
-
console.log(`
|
|
2395
|
-
afterbefore v${version} \xB7 ${mode} against ${base}
|
|
2396
|
-
`);
|
|
2397
|
-
const config = await loadConfig(cwd);
|
|
2398
|
-
applyConfigDefaults(options, config);
|
|
2399
|
-
logger.startPipeline(options.dryRun ? 3 : 8);
|
|
2400
|
-
const t1 = Date.now();
|
|
2401
|
-
logger.pipeline(1, "Analyzing diff...");
|
|
2402
|
-
const diffFiles = getChangedFiles(base, cwd);
|
|
2403
|
-
const gitDiff = options.dryRun ? "" : getGitDiff(base, cwd);
|
|
2404
|
-
logger.stageComplete("Diff", `${diffFiles.length} files changed`, Date.now() - t1);
|
|
2405
|
-
if (diffFiles.length === 0) {
|
|
2406
|
-
logger.completePipeline();
|
|
2407
|
-
logger.success("No changed files detected. Nothing to do.");
|
|
2408
|
-
return;
|
|
2409
|
-
}
|
|
2410
|
-
const t2 = Date.now();
|
|
2411
|
-
const classified = classifyFiles(diffFiles);
|
|
2412
|
-
const impactfulFiles = classified.filter(
|
|
2413
|
-
(f) => f.category !== "test" && f.category !== "other"
|
|
2414
|
-
);
|
|
2415
|
-
if (impactfulFiles.length === 0) {
|
|
2416
|
-
logger.completePipeline();
|
|
2417
|
-
logger.success(
|
|
2418
|
-
"No visually relevant changes detected (only test/other files changed)."
|
|
2419
|
-
);
|
|
2420
|
-
return;
|
|
2421
|
-
}
|
|
2422
|
-
logger.pipeline(2, "Building import graph...");
|
|
2423
|
-
const worktreePromise = options.dryRun ? null : createWorktree(base, cwd);
|
|
2424
|
-
const graph = await buildImportGraph(cwd);
|
|
2425
|
-
const graphEdges = Array.from(graph.forward.values()).reduce((sum, deps) => sum + deps.size, 0);
|
|
2426
|
-
logger.stageComplete("Graph", `${graph.forward.size} modules, ${graphEdges} edges`, Date.now() - t2);
|
|
2427
|
-
const t3 = Date.now();
|
|
2428
|
-
logger.pipeline(3, "Finding affected routes...");
|
|
2429
|
-
const changedPaths = impactfulFiles.map((f) => f.path);
|
|
2430
|
-
let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes, options.maxDepth);
|
|
2431
|
-
const changedComponentFiles = impactfulFiles.filter((f) => f.category === "component").map((f) => f.path);
|
|
2432
|
-
const routeComponentMap = mapRouteToChangedComponents(changedComponentFiles, graph);
|
|
2433
|
-
if (affectedRoutes.length === 0) {
|
|
2434
|
-
const hasGlobalChanges = impactfulFiles.some((f) => isGlobalVisualFile(f.path));
|
|
2435
|
-
if (hasGlobalChanges) {
|
|
2436
|
-
const allRoutes = [];
|
|
2437
|
-
for (const file of graph.forward.keys()) {
|
|
2438
|
-
if (!isPageFile(file)) continue;
|
|
2439
|
-
const route = pagePathToRoute(file);
|
|
2440
|
-
if (route === null) continue;
|
|
2441
|
-
allRoutes.push({
|
|
2442
|
-
pagePath: file,
|
|
2443
|
-
route,
|
|
2444
|
-
reason: "transitive",
|
|
2445
|
-
depth: 0,
|
|
2446
|
-
triggerChain: [file]
|
|
2447
|
-
});
|
|
2448
|
-
}
|
|
2449
|
-
allRoutes.sort((a, b) => a.route.localeCompare(b.route));
|
|
2450
|
-
if (options.maxRoutes > 0 && allRoutes.length > options.maxRoutes) {
|
|
2451
|
-
affectedRoutes = allRoutes.slice(0, options.maxRoutes);
|
|
2452
|
-
} else {
|
|
2453
|
-
affectedRoutes = allRoutes;
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
const directCount = affectedRoutes.filter((r) => r.reason === "direct").length;
|
|
2458
|
-
const transitiveCount = affectedRoutes.length - directCount;
|
|
2459
|
-
const impactDetail = directCount > 0 && transitiveCount > 0 ? `${affectedRoutes.length} routes (${directCount} direct, ${transitiveCount} transitive)` : `${affectedRoutes.length} routes`;
|
|
2460
|
-
logger.stageComplete("Impact", impactDetail, Date.now() - t3);
|
|
2461
|
-
if (options.verbose) {
|
|
2462
|
-
console.log("");
|
|
2463
|
-
for (const r of affectedRoutes) {
|
|
2464
|
-
const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
|
|
2465
|
-
const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
|
|
2466
|
-
console.log(chalk2.dim(` ${r.route.padEnd(24)} ${depthLabel.padEnd(10)} ${chain}`));
|
|
2467
|
-
}
|
|
2468
|
-
console.log("");
|
|
2469
|
-
}
|
|
2470
|
-
if (affectedRoutes.length === 0) {
|
|
2471
|
-
worktreePromise?.then((w) => w.cleanup()).catch(() => {
|
|
2472
|
-
});
|
|
2473
|
-
logger.completePipeline();
|
|
2474
|
-
logger.success(
|
|
2475
|
-
"No affected routes found. Changed files don't impact any pages."
|
|
2476
|
-
);
|
|
2477
|
-
return;
|
|
2478
|
-
}
|
|
2479
|
-
if (options.dryRun) {
|
|
2480
|
-
worktreePromise?.then((w) => w.cleanup()).catch(() => {
|
|
2481
|
-
});
|
|
2482
|
-
logger.completePipeline();
|
|
2483
|
-
console.log(`
|
|
2484
|
-
${affectedRoutes.length} route(s) would be captured:
|
|
2485
|
-
`);
|
|
2486
|
-
for (const r of affectedRoutes) {
|
|
2487
|
-
const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
|
|
2488
|
-
const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
|
|
2489
|
-
console.log(` ${r.route.padEnd(24)} ${chalk2.dim(depthLabel.padEnd(10))} ${chalk2.dim(chain)}`);
|
|
2490
|
-
}
|
|
2491
|
-
const elapsed2 = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2492
|
-
console.log(chalk2.dim(`
|
|
2493
|
-
Completed in ${elapsed2}s (dry run \u2014 no screenshots captured)
|
|
2494
|
-
`));
|
|
2495
|
-
return;
|
|
2496
|
-
}
|
|
2497
|
-
const t4 = Date.now();
|
|
2498
|
-
logger.pipeline(4, "Setting up worktree...");
|
|
2499
|
-
const worktree = await worktreePromise;
|
|
2500
|
-
logger.stageComplete("Worktree", "created + dependencies installed", Date.now() - t4);
|
|
2501
|
-
const t5 = Date.now();
|
|
2502
|
-
logger.pipeline(5, "Starting servers...");
|
|
2503
|
-
await ensureDir(outputDir);
|
|
2504
|
-
const beforePort = await findAvailablePort();
|
|
2505
|
-
const afterPort = await findAvailablePort(/* @__PURE__ */ new Set([beforePort]));
|
|
2506
|
-
const [beforeServer, afterServer, browser] = await Promise.all([
|
|
2507
|
-
startServer(worktree.path, beforePort),
|
|
2508
|
-
startServer(cwd, afterPort),
|
|
2509
|
-
launchBrowser()
|
|
2510
|
-
]);
|
|
2511
|
-
cleanupRegistry.register(() => stopServer(beforeServer));
|
|
2512
|
-
cleanupRegistry.register(() => stopServer(afterServer));
|
|
2513
|
-
cleanupRegistry.register(() => browser.close());
|
|
2514
|
-
logger.stageComplete("Servers", `ready on :${beforePort} and :${afterPort}`, Date.now() - t5);
|
|
2515
|
-
const t6 = Date.now();
|
|
2516
|
-
logger.pipeline(6, "Capturing screenshots...");
|
|
2517
|
-
const tasks = expandRoutes(affectedRoutes, config, routeComponentMap);
|
|
2518
|
-
const captures = await captureRoutes(
|
|
2519
|
-
tasks,
|
|
2520
|
-
beforeServer.url,
|
|
2521
|
-
afterServer.url,
|
|
2522
|
-
outputDir,
|
|
2523
|
-
{
|
|
2524
|
-
browser,
|
|
2525
|
-
width: options.width,
|
|
2526
|
-
height: options.height,
|
|
2527
|
-
device: options.device,
|
|
2528
|
-
delay: options.delay,
|
|
2529
|
-
autoTabs: options.autoTabs,
|
|
2530
|
-
maxTabsPerRoute: options.maxTabsPerRoute,
|
|
2531
|
-
autoSections: options.autoSections,
|
|
2532
|
-
maxSectionsPerRoute: options.maxSectionsPerRoute,
|
|
2533
|
-
onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
|
|
2534
|
-
}
|
|
2535
|
-
);
|
|
2536
|
-
logger.stageComplete("Capture", `${captures.length} screenshots from ${tasks.length} routes`, Date.now() - t6);
|
|
2537
|
-
const t7 = Date.now();
|
|
2538
|
-
logger.pipeline(7, "Comparing screenshots...");
|
|
2539
|
-
const bgColor = detectBgColor(cwd);
|
|
2540
|
-
const allResults = await compareScreenshots(captures, outputDir, options.threshold, { bgColor });
|
|
2541
|
-
const results = allResults.filter((r) => {
|
|
2542
|
-
const isSubCapture = r.prefix.includes("~");
|
|
2543
|
-
if (isSubCapture && !r.changed) {
|
|
2544
|
-
if (r.comparePath) {
|
|
2545
|
-
try {
|
|
2546
|
-
unlinkSync2(r.comparePath);
|
|
2547
|
-
} catch {
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
return false;
|
|
2551
|
-
}
|
|
2552
|
-
return true;
|
|
2553
|
-
});
|
|
2554
|
-
const changedCount = results.filter((r) => r.changed).length;
|
|
2555
|
-
const unchangedCount = results.length - changedCount;
|
|
2556
|
-
logger.stageComplete("Compare", `${changedCount} changed, ${unchangedCount} unchanged`, Date.now() - t7);
|
|
2557
|
-
const t8 = Date.now();
|
|
2558
|
-
logger.pipeline(8, "Generating report...");
|
|
2559
|
-
await generateReport(results, outputDir, { post });
|
|
2560
|
-
const reportHtml = generateReportHtml(results, sessionName);
|
|
2561
|
-
const reportPath = resolve4(outputDir, "report.html");
|
|
2562
|
-
writeFileSync2(reportPath, reportHtml, "utf-8");
|
|
2563
|
-
logger.stageComplete("Report", reportPath.replace(cwd + "/", ""), Date.now() - t8);
|
|
2564
|
-
const summary = generateSummaryMd(results, gitDiff);
|
|
2565
|
-
logger.completePipeline(true);
|
|
2566
|
-
console.log("\n" + summary);
|
|
2567
|
-
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2568
|
-
logger.success(
|
|
2569
|
-
`Done in ${elapsed}s \u2014 ${results.length} route(s) checked, ${changedCount} with visual changes`
|
|
2570
|
-
);
|
|
2571
|
-
const changedResults = results.filter((r) => r.changed);
|
|
2572
|
-
if (changedResults.length > 0) {
|
|
2573
|
-
const hero = changedResults.reduce((best, r) => r.diffPercentage > best.diffPercentage ? r : best);
|
|
2574
|
-
if (hero.comparePath) {
|
|
2575
|
-
console.log(chalk2.dim(`
|
|
2576
|
-
Biggest change: ${hero.route} (${hero.diffPercentage.toFixed(1)}%) \u2014 ${hero.comparePath.replace(cwd + "/", "")}`));
|
|
2577
|
-
}
|
|
2578
|
-
}
|
|
2579
|
-
if (!post) {
|
|
2580
|
-
console.log(chalk2.dim(` View report: open ${reportPath.replace(cwd + "/", "")}`));
|
|
2581
|
-
console.log(chalk2.dim(" Post to your PR: npx afterbefore --post\n"));
|
|
2582
|
-
}
|
|
2583
|
-
if (options.open) {
|
|
2584
|
-
try {
|
|
2585
|
-
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
2586
|
-
execSync5(`${openCmd} "${reportPath}"`, { stdio: "ignore" });
|
|
2587
|
-
} catch {
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
} finally {
|
|
2591
|
-
try {
|
|
2592
|
-
logger.writeLogFile(resolve4(outputDir, "debug.log"));
|
|
2593
|
-
} catch {
|
|
2594
|
-
}
|
|
2595
|
-
await cleanupRegistry.runAll();
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
export {
|
|
2599
|
-
loadConfig,
|
|
2600
|
-
runPipeline
|
|
2601
|
-
};
|
|
2602
|
-
//# sourceMappingURL=index.js.map
|