apex-auditor 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,8 +1,507 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
3
  import { exec } from "node:child_process";
4
4
  import { loadConfig } from "./config.js";
5
+ import { runAccessibilityAudit } from "./accessibility.js";
6
+ import { startSpinner, stopSpinner, updateSpinnerMessage } from "./spinner.js";
5
7
  import { runAuditsForConfig } from "./lighthouse-runner.js";
8
+ import { postJsonWebhook } from "./webhooks.js";
9
+ import { renderPanel } from "./ui/render-panel.js";
10
+ import { renderTable } from "./ui/render-table.js";
11
+ import { UiTheme } from "./ui/ui-theme.js";
12
+ async function runCommand(command, cwd) {
13
+ return await new Promise((resolveCommand, rejectCommand) => {
14
+ exec(command, { cwd }, (error, stdout, stderr) => {
15
+ if (error) {
16
+ rejectCommand(new Error(stderr.trim() || error.message));
17
+ return;
18
+ }
19
+ resolveCommand(stdout);
20
+ });
21
+ });
22
+ }
23
+ function colorScore(score, theme) {
24
+ if (score === undefined) {
25
+ return "-";
26
+ }
27
+ if (score >= 90) {
28
+ return theme.green(score.toString());
29
+ }
30
+ if (score >= 50) {
31
+ return theme.yellow(score.toString());
32
+ }
33
+ return theme.red(score.toString());
34
+ }
35
+ function colorDevice(device, theme) {
36
+ return device === "mobile" ? theme.cyan("mobile") : theme.magenta("desktop");
37
+ }
38
+ function buildWebhookPayload(params) {
39
+ const regressions = collectRegressions(params.previous, params.current);
40
+ const budgetPassed = params.budgetViolations.length === 0;
41
+ return {
42
+ type: "apex-auditor",
43
+ buildId: params.current.meta.buildId,
44
+ elapsedMs: params.current.meta.elapsedMs,
45
+ regressions,
46
+ budget: {
47
+ passed: budgetPassed,
48
+ violations: params.budgetViolations.length,
49
+ },
50
+ accessibility: params.accessibility === undefined
51
+ ? undefined
52
+ : {
53
+ critical: params.accessibility.impactCounts.critical,
54
+ serious: params.accessibility.impactCounts.serious,
55
+ moderate: params.accessibility.impactCounts.moderate,
56
+ minor: params.accessibility.impactCounts.minor,
57
+ errored: params.accessibility.errored,
58
+ total: params.accessibility.total,
59
+ },
60
+ links: {
61
+ reportHtml: params.reportPath,
62
+ exportJson: params.exportPath,
63
+ accessibilitySummary: params.accessibilityPath,
64
+ },
65
+ };
66
+ }
67
+ function shouldSendWebhook(regressions, budgetViolations) {
68
+ return regressions.length > 0 || budgetViolations.length > 0;
69
+ }
70
+ function summariseAccessibility(results) {
71
+ const counts = {
72
+ critical: 0,
73
+ serious: 0,
74
+ moderate: 0,
75
+ minor: 0,
76
+ };
77
+ let errored = 0;
78
+ for (const result of results.results) {
79
+ if (result.runtimeErrorMessage) {
80
+ errored += 1;
81
+ continue;
82
+ }
83
+ for (const violation of result.violations) {
84
+ const impact = violation.impact;
85
+ if (impact === "critical")
86
+ counts.critical += 1;
87
+ else if (impact === "serious")
88
+ counts.serious += 1;
89
+ else if (impact === "moderate")
90
+ counts.moderate += 1;
91
+ else if (impact === "minor")
92
+ counts.minor += 1;
93
+ }
94
+ }
95
+ return { impactCounts: { ...counts }, errored, total: results.results.length };
96
+ }
97
+ function buildAccessibilityPanel(summary, useColor) {
98
+ const theme = new UiTheme({ noColor: !useColor });
99
+ const headers = ["Impact", "Count"];
100
+ const rows = [
101
+ ["critical", summary.impactCounts.critical.toString()],
102
+ ["serious", summary.impactCounts.serious.toString()],
103
+ ["moderate", summary.impactCounts.moderate.toString()],
104
+ ["minor", summary.impactCounts.minor.toString()],
105
+ ];
106
+ const table = renderTable({ headers, rows });
107
+ const metaLines = [
108
+ `${theme.bold("Total combos")}: ${summary.total}`,
109
+ `${theme.bold("Errored combos")}: ${summary.errored}`,
110
+ ];
111
+ return `${table}\n${metaLines.join("\n")}`;
112
+ }
113
+ function buildSectionIndex(useColor) {
114
+ const theme = new UiTheme({ noColor: !useColor });
115
+ const lines = [
116
+ `${theme.bold("Sections")}:`,
117
+ ` 1) Effective settings`,
118
+ ` 2) Meta`,
119
+ ` 3) Stats`,
120
+ ` 4) Changes`,
121
+ ` 5) Summary`,
122
+ ` 6) Issues`,
123
+ ` 7) Top fixes`,
124
+ ` 8) Lowest performance`,
125
+ ` 9) Export (regressions/issues)`,
126
+ ];
127
+ return lines.join("\n");
128
+ }
129
+ function selectTopViolations(result, limit) {
130
+ const impactRank = { critical: 1, serious: 2, moderate: 3, minor: 4 };
131
+ return [...result.violations]
132
+ .sort((a, b) => {
133
+ const rankA = impactRank[a.impact ?? ""] ?? 5;
134
+ const rankB = impactRank[b.impact ?? ""] ?? 5;
135
+ if (rankA !== rankB) {
136
+ return rankA - rankB;
137
+ }
138
+ const nodesA = a.nodes.length;
139
+ const nodesB = b.nodes.length;
140
+ return nodesB - nodesA;
141
+ })
142
+ .slice(0, limit);
143
+ }
144
+ function buildAccessibilityIssuesPanel(results, useColor) {
145
+ const theme = new UiTheme({ noColor: !useColor });
146
+ if (results.length === 0) {
147
+ return renderPanel({ title: theme.bold("Accessibility (top issues)"), lines: [theme.dim("No accessibility results.")] });
148
+ }
149
+ const lines = [];
150
+ for (const result of results) {
151
+ lines.push(`${theme.bold(`${result.label} ${result.path} [${result.device}]`)}`);
152
+ if (result.runtimeErrorMessage) {
153
+ lines.push(`- ${theme.red("Error")}: ${result.runtimeErrorMessage}`);
154
+ continue;
155
+ }
156
+ const tops = selectTopViolations(result, 3);
157
+ if (tops.length === 0) {
158
+ lines.push(theme.dim("- No violations found"));
159
+ continue;
160
+ }
161
+ for (const violation of tops) {
162
+ const impact = violation.impact ? `${violation.impact}: ` : "";
163
+ const title = violation.help ?? violation.id;
164
+ const targetSample = violation.nodes[0]?.target?.[0] ?? "";
165
+ const detail = targetSample ? ` (${targetSample})` : "";
166
+ lines.push(`- ${impact}${title}${detail}`);
167
+ }
168
+ }
169
+ return renderPanel({ title: theme.bold("Accessibility (top issues)"), lines });
170
+ }
171
+ function severityBackground(score) {
172
+ if (score === undefined) {
173
+ return "";
174
+ }
175
+ if (score >= 90) {
176
+ return "";
177
+ }
178
+ if (score >= 50) {
179
+ return "";
180
+ }
181
+ return "";
182
+ }
183
+ function applyRowBackground(row, score, useColor) {
184
+ const bg = severityBackground(score);
185
+ if (!useColor || bg === "") {
186
+ return row;
187
+ }
188
+ return row;
189
+ }
190
+ function formatDelta(curr, prev, theme) {
191
+ if (curr === undefined || prev === undefined) {
192
+ return "-";
193
+ }
194
+ const delta = curr - prev;
195
+ if (delta === 0) {
196
+ return theme.dim("0");
197
+ }
198
+ const text = delta > 0 ? `+${delta}` : `${delta}`;
199
+ return delta > 0 ? theme.green(text) : theme.red(text);
200
+ }
201
+ function buildSummaryPanel(params) {
202
+ const theme = new UiTheme({ noColor: !params.useColor });
203
+ const hasPrev = params.previousSummary !== undefined;
204
+ const headers = params.useColor
205
+ ? [
206
+ theme.bold("Label"),
207
+ theme.bold("Path"),
208
+ theme.bold("Device"),
209
+ theme.green("P"),
210
+ hasPrev ? theme.cyan("ΔP") : "",
211
+ theme.cyan("A"),
212
+ theme.magenta("BP"),
213
+ theme.yellow("SEO"),
214
+ ].filter((h) => h !== "")
215
+ : ["Label", "Path", "Device", "P", ...(hasPrev ? ["ΔP"] : []), "A", "BP", "SEO"];
216
+ const prevMap = params.previousSummary !== undefined
217
+ ? new Map(params.previousSummary.results.map((r) => [`${r.label}:::${r.path}:::${r.device}`, r]))
218
+ : undefined;
219
+ const filtered = params.regressionsOnly && prevMap !== undefined
220
+ ? params.results.filter((r) => {
221
+ const key = `${r.label}:::${r.path}:::${r.device}`;
222
+ const prev = prevMap.get(key);
223
+ const prevScore = prev?.scores.performance;
224
+ const currScore = r.scores.performance;
225
+ return prevScore !== undefined && currScore !== undefined && currScore < prevScore;
226
+ })
227
+ : params.results;
228
+ const rows = filtered.map((r) => {
229
+ const scores = r.scores;
230
+ const prevScore = prevMap?.get(`${r.label}:::${r.path}:::${r.device}`)?.scores.performance;
231
+ const baseRow = [
232
+ r.label,
233
+ r.path,
234
+ colorDevice(r.device, theme),
235
+ colorScore(scores.performance, theme),
236
+ ...(hasPrev ? [formatDelta(scores.performance, prevScore, theme)] : []),
237
+ colorScore(scores.accessibility, theme),
238
+ colorScore(scores.bestPractices, theme),
239
+ colorScore(scores.seo, theme),
240
+ ];
241
+ return applyRowBackground(baseRow, scores.performance, params.useColor);
242
+ });
243
+ return renderTable({ headers, rows });
244
+ }
245
+ function collectRegressions(previous, current) {
246
+ if (previous === undefined) {
247
+ return [];
248
+ }
249
+ const prevMap = new Map(previous.results.map((r) => [`${r.label}:::${r.path}:::${r.device}`, r]));
250
+ const lines = [];
251
+ for (const r of current.results) {
252
+ const key = `${r.label}:::${r.path}:::${r.device}`;
253
+ const prev = prevMap.get(key);
254
+ if (prev?.scores.performance !== undefined && r.scores.performance !== undefined) {
255
+ const delta = r.scores.performance - prev.scores.performance;
256
+ if (delta < 0) {
257
+ lines.push({
258
+ label: r.label,
259
+ path: r.path,
260
+ device: r.device,
261
+ previousP: prev.scores.performance,
262
+ currentP: r.scores.performance,
263
+ deltaP: delta,
264
+ });
265
+ }
266
+ }
267
+ }
268
+ return lines.sort((a, b) => a.deltaP - b.deltaP).slice(0, 10);
269
+ }
270
+ function collectDeepAuditTargets(results) {
271
+ return [...results]
272
+ .sort((a, b) => (a.scores.performance ?? 101) - (b.scores.performance ?? 101))
273
+ .slice(0, 5)
274
+ .map((r) => ({
275
+ label: r.label,
276
+ path: r.path,
277
+ device: r.device,
278
+ score: r.scores.performance ?? 0,
279
+ }));
280
+ }
281
+ function collectTopIssues(results) {
282
+ const counts = new Map();
283
+ for (const r of results) {
284
+ for (const opp of r.opportunities) {
285
+ const existing = counts.get(opp.title);
286
+ if (existing) {
287
+ existing.count += 1;
288
+ existing.totalMs += opp.estimatedSavingsMs ?? 0;
289
+ }
290
+ else {
291
+ counts.set(opp.title, { title: opp.title, count: 1, totalMs: opp.estimatedSavingsMs ?? 0 });
292
+ }
293
+ }
294
+ }
295
+ return [...counts.values()].sort((a, b) => b.totalMs - a.totalMs).slice(0, 5);
296
+ }
297
+ function buildSuggestedCommands(configPath, targets) {
298
+ return targets.map((t) => `pnpm tsx src/bin.ts --config ${configPath} --${t.device}-only --open-report # focus on ${t.path}`);
299
+ }
300
+ function buildShareableExport(params) {
301
+ const regressions = collectRegressions(params.previousSummary, params.current);
302
+ const deepAuditTargets = collectDeepAuditTargets(params.current.results);
303
+ const suggestedCommands = buildSuggestedCommands(params.configPath, deepAuditTargets.map((t) => ({ path: t.path, device: t.device })));
304
+ const budgetViolations = params.budgets === undefined
305
+ ? []
306
+ : collectBudgetViolations(params.current.results, params.budgets).map((v) => ({
307
+ pageLabel: v.pageLabel,
308
+ path: v.path,
309
+ device: v.device,
310
+ kind: v.kind,
311
+ id: v.id,
312
+ value: v.value,
313
+ limit: v.limit,
314
+ }));
315
+ return {
316
+ generatedAt: new Date().toISOString(),
317
+ regressions,
318
+ topIssues: collectTopIssues(params.current.results),
319
+ deepAuditTargets,
320
+ suggestedCommands,
321
+ budgets: params.budgets,
322
+ budgetViolations,
323
+ budgetPassed: budgetViolations.length === 0,
324
+ };
325
+ }
326
+ function buildExportPanel(params) {
327
+ const theme = new UiTheme({ noColor: !params.useColor });
328
+ const lines = [];
329
+ const width = 80;
330
+ const divider = () => {
331
+ lines.push(theme.dim("─".repeat(width)));
332
+ };
333
+ const formBorder = (label) => {
334
+ const labelText = ` ${label} `;
335
+ const remaining = Math.max(width - labelText.length - 2, 0);
336
+ const bar = "─".repeat(remaining);
337
+ return {
338
+ top: theme.dim(`┌${labelText}${bar}`),
339
+ bottom: theme.dim(`└${"─".repeat(width - 1)}`),
340
+ };
341
+ };
342
+ lines.push(theme.bold("Export"));
343
+ lines.push(theme.dim(`Path: ${params.exportPath}`));
344
+ lines.push(theme.dim(`Generated: ${params.share.generatedAt}`));
345
+ divider();
346
+ // Budgets
347
+ if (params.share.budgets !== undefined) {
348
+ const statusText = params.share.budgetPassed ? theme.green("passed") : theme.red("failed");
349
+ lines.push(`${theme.bold("Budgets")} ${statusText}`);
350
+ const thresholdRows = [];
351
+ const categories = params.share.budgets.categories;
352
+ if (categories !== undefined) {
353
+ if (categories.performance !== undefined)
354
+ thresholdRows.push(["category", "performance", `${categories.performance}`]);
355
+ if (categories.accessibility !== undefined)
356
+ thresholdRows.push(["category", "accessibility", `${categories.accessibility}`]);
357
+ if (categories.bestPractices !== undefined)
358
+ thresholdRows.push(["category", "bestPractices", `${categories.bestPractices}`]);
359
+ if (categories.seo !== undefined)
360
+ thresholdRows.push(["category", "seo", `${categories.seo}`]);
361
+ }
362
+ const metrics = params.share.budgets.metrics;
363
+ if (metrics !== undefined) {
364
+ if (metrics.lcpMs !== undefined)
365
+ thresholdRows.push(["metric", "lcpMs", `${metrics.lcpMs}ms`]);
366
+ if (metrics.fcpMs !== undefined)
367
+ thresholdRows.push(["metric", "fcpMs", `${metrics.fcpMs}ms`]);
368
+ if (metrics.tbtMs !== undefined)
369
+ thresholdRows.push(["metric", "tbtMs", `${metrics.tbtMs}ms`]);
370
+ if (metrics.cls !== undefined)
371
+ thresholdRows.push(["metric", "cls", `${metrics.cls}`]);
372
+ if (metrics.inpMs !== undefined)
373
+ thresholdRows.push(["metric", "inpMs", `${metrics.inpMs}ms`]);
374
+ }
375
+ if (thresholdRows.length > 0) {
376
+ lines.push(renderTable({
377
+ headers: ["Type", "Id", "Limit"],
378
+ rows: thresholdRows,
379
+ }));
380
+ }
381
+ if (params.share.budgetViolations.length > 0) {
382
+ lines.push(theme.bold("Violations"));
383
+ params.share.budgetViolations.forEach((v) => {
384
+ const valueText = v.kind === "category" ? `${Math.round(v.value)}` : `${Math.round(v.value)}ms`;
385
+ const limitText = v.kind === "category" ? `${Math.round(v.limit)}` : `${Math.round(v.limit)}ms`;
386
+ lines.push(`${v.pageLabel} ${v.path} [${colorDevice(v.device, theme)}] – ${v.kind} ${v.id}: ${valueText} vs limit ${limitText}`);
387
+ });
388
+ }
389
+ else {
390
+ lines.push(theme.dim("No violations."));
391
+ }
392
+ divider();
393
+ }
394
+ // Regressions
395
+ lines.push(`${theme.bold("Regressions")} ${theme.dim("(top 10 by ΔP)")}`);
396
+ if (params.share.regressions.length === 0) {
397
+ lines.push(theme.green("No regressions detected."));
398
+ }
399
+ else {
400
+ const regressionRows = params.share.regressions.map((r) => [
401
+ r.label,
402
+ r.path,
403
+ colorDevice(r.device, theme),
404
+ colorScore(r.currentP, theme),
405
+ r.deltaP >= 0 ? theme.green(`+${r.deltaP}`) : theme.red(String(r.deltaP)),
406
+ String(r.previousP),
407
+ ]);
408
+ lines.push(renderTable({
409
+ headers: ["Label", "Path", "Device", "P", "ΔP", "Prev P"],
410
+ rows: regressionRows,
411
+ }));
412
+ }
413
+ divider();
414
+ // Deep audit targets
415
+ lines.push(`${theme.bold("Deep audit targets")} ${theme.dim("(worst 5 by P)")}`);
416
+ const deepRows = params.share.deepAuditTargets.map((t) => [
417
+ t.label,
418
+ t.path,
419
+ colorDevice(t.device, theme),
420
+ colorScore(t.score, theme),
421
+ ]);
422
+ lines.push(renderTable({
423
+ headers: ["Label", "Path", "Device", "P"],
424
+ rows: deepRows,
425
+ }));
426
+ divider();
427
+ // Suggested commands
428
+ lines.push(`${theme.bold("Suggested commands")} ${theme.dim("(copy/paste ready)")}`);
429
+ if (params.share.suggestedCommands.length === 0) {
430
+ lines.push(theme.dim("No suggestions available."));
431
+ }
432
+ else {
433
+ const box = formBorder("Copy/paste");
434
+ lines.push(box.top);
435
+ params.share.suggestedCommands.forEach((cmd, index) => {
436
+ const prefix = `${String(index + 1).padStart(2, "0")}.`;
437
+ lines.push(`${theme.dim("│")} ${theme.dim(prefix)} ${cmd}`);
438
+ });
439
+ lines.push(box.bottom);
440
+ }
441
+ return lines.join("\n");
442
+ }
443
+ function buildIssuesPanel(results, useColor) {
444
+ const theme = new UiTheme({ noColor: !useColor });
445
+ const reds = results.filter((r) => (r.scores.performance ?? 100) < 50);
446
+ if (reds.length === 0) {
447
+ return renderPanel({ title: theme.bold("Issues"), lines: [theme.green("No red issues.")] });
448
+ }
449
+ const lines = reds.map((r) => {
450
+ const top = r.opportunities[0];
451
+ const issue = top ? `${top.title}${top.estimatedSavingsMs ? ` (${Math.round(top.estimatedSavingsMs)}ms)` : ""}` : "No top issue reported";
452
+ const perfText = colorScore(r.scores.performance, theme);
453
+ const deviceText = colorDevice(r.device, theme);
454
+ return `${r.label} ${r.path} [${deviceText}] – P:${perfText} – ${issue}`;
455
+ });
456
+ return renderPanel({ title: theme.bold("Issues"), lines });
457
+ }
458
+ function buildTopFixesPanel(results, useColor) {
459
+ const theme = new UiTheme({ noColor: !useColor });
460
+ const opportunityCounts = new Map();
461
+ for (const r of results) {
462
+ for (const opp of r.opportunities) {
463
+ const existing = opportunityCounts.get(opp.title);
464
+ if (existing) {
465
+ existing.count += 1;
466
+ existing.totalMs += opp.estimatedSavingsMs ?? 0;
467
+ }
468
+ else {
469
+ opportunityCounts.set(opp.title, { title: opp.title, count: 1, totalMs: opp.estimatedSavingsMs ?? 0 });
470
+ }
471
+ }
472
+ }
473
+ const sorted = [...opportunityCounts.values()].sort((a, b) => b.totalMs - a.totalMs).slice(0, 5);
474
+ if (sorted.length === 0) {
475
+ return renderPanel({ title: theme.bold("Top fixes"), lines: [theme.dim("No opportunities collected.")] });
476
+ }
477
+ const lines = sorted.map((o) => `- ${theme.cyan(o.title)} (seen on ${o.count} pages) (${Math.round(o.totalMs)}ms)`);
478
+ return renderPanel({ title: theme.bold("Top fixes"), lines });
479
+ }
480
+ function buildLowestPerformancePanel(results, useColor) {
481
+ const theme = new UiTheme({ noColor: !useColor });
482
+ const sorted = [...results].sort((a, b) => (a.scores.performance ?? 101) - (b.scores.performance ?? 101)).slice(0, 5);
483
+ const lines = sorted.map((r) => {
484
+ const perfText = colorScore(r.scores.performance, theme);
485
+ const deviceText = colorDevice(r.device, theme);
486
+ return `${r.label} ${r.path} [${deviceText}] P:${perfText}`;
487
+ });
488
+ return renderPanel({ title: theme.bold("Lowest performance"), lines });
489
+ }
490
+ function buildBudgetsPanel(params) {
491
+ if (params.budgets === undefined) {
492
+ return undefined;
493
+ }
494
+ const theme = new UiTheme({ noColor: !params.useColor });
495
+ if (params.violations.length === 0) {
496
+ return renderPanel({ title: theme.bold("Budgets"), lines: [theme.green("All budgets passed.")] });
497
+ }
498
+ const lines = params.violations.map((v) => {
499
+ const valueText = v.kind === "category" ? `${Math.round(v.value)}` : `${Math.round(v.value)}ms`;
500
+ const limitText = v.kind === "category" ? `${Math.round(v.limit)}` : `${Math.round(v.limit)}ms`;
501
+ return `${v.pageLabel} ${v.path} [${v.device}] – ${v.kind} ${v.id}: ${valueText} vs limit ${limitText}`;
502
+ });
503
+ return renderPanel({ title: theme.bold("Budgets"), lines });
504
+ }
6
505
  const ANSI_RESET = "\u001B[0m";
7
506
  const ANSI_RED = "\u001B[31m";
8
507
  const ANSI_YELLOW = "\u001B[33m";
@@ -19,19 +518,65 @@ const CLS_GOOD = 0.1;
19
518
  const CLS_WARN = 0.25;
20
519
  const INP_GOOD_MS = 200;
21
520
  const INP_WARN_MS = 500;
521
+ const DEFAULT_MAX_STEPS = 120;
522
+ const DEFAULT_MAX_COMBOS = 60;
523
+ const DEFAULT_OVERVIEW_MAX_COMBOS = 10;
524
+ async function confirmLargeRun(message) {
525
+ if (!process.stdin.isTTY) {
526
+ return false;
527
+ }
528
+ const wasRaw = process.stdin.isRaw;
529
+ process.stdin.setRawMode?.(true);
530
+ process.stdin.setEncoding("utf8");
531
+ process.stdout.write(message);
532
+ return await new Promise((resolve) => {
533
+ const onData = (chunk) => {
534
+ process.stdin.setRawMode?.(Boolean(wasRaw));
535
+ process.stdin.pause();
536
+ const raw = typeof chunk === "string" ? chunk : chunk.toString("utf8");
537
+ const first = raw.trim().charAt(0);
538
+ const yes = first === "y" || first === "Y";
539
+ process.stdout.write("\n");
540
+ resolve(yes);
541
+ };
542
+ process.stdin.once("data", onData);
543
+ process.stdin.resume();
544
+ });
545
+ }
22
546
  function parseArgs(argv) {
23
547
  let configPath;
24
548
  let ci = false;
549
+ let failOnBudget = false;
25
550
  let colorMode = "auto";
26
551
  let logLevelOverride;
27
552
  let deviceFilter;
28
553
  let throttlingMethodOverride;
29
554
  let cpuSlowdownOverride;
30
555
  let parallelOverride;
556
+ let auditTimeoutMsOverride;
557
+ let plan = false;
558
+ let yes = false;
559
+ let maxSteps;
560
+ let maxCombos;
561
+ let stable = false;
31
562
  let openReport = false;
32
563
  let warmUp = false;
564
+ let incremental = false;
565
+ let buildId;
566
+ let runsOverride;
567
+ let quick = false;
568
+ let accurate = false;
33
569
  let jsonOutput = false;
34
570
  let showParallel = false;
571
+ let fast = false;
572
+ let overview = false;
573
+ let overviewCombos;
574
+ let regressionsOnly = false;
575
+ let changedOnly = false;
576
+ let rerunFailing = false;
577
+ let accessibilityPass = false;
578
+ let webhookUrl;
579
+ let webhookAlways = false;
35
580
  for (let i = 2; i < argv.length; i += 1) {
36
581
  const arg = argv[i];
37
582
  if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
@@ -41,11 +586,14 @@ function parseArgs(argv) {
41
586
  else if (arg === "--ci") {
42
587
  ci = true;
43
588
  }
589
+ else if (arg === "--fail-on-budget") {
590
+ failOnBudget = true;
591
+ }
44
592
  else if (arg === "--no-color") {
45
- colorMode = "off";
593
+ colorMode = "never";
46
594
  }
47
595
  else if (arg === "--color") {
48
- colorMode = "on";
596
+ colorMode = "always";
49
597
  }
50
598
  else if (arg === "--log-level" && i + 1 < argv.length) {
51
599
  const value = argv[i + 1];
@@ -53,7 +601,7 @@ function parseArgs(argv) {
53
601
  logLevelOverride = value;
54
602
  }
55
603
  else {
56
- throw new Error(`Invalid --log-level value: ${value}`);
604
+ throw new Error(`Unknown argument: ${arg}`);
57
605
  }
58
606
  i += 1;
59
607
  }
@@ -95,44 +643,490 @@ function parseArgs(argv) {
95
643
  parallelOverride = value;
96
644
  i += 1;
97
645
  }
98
- else if (arg === "--open") {
646
+ else if (arg === "--audit-timeout-ms" && i + 1 < argv.length) {
647
+ const value = parseInt(argv[i + 1], 10);
648
+ if (Number.isNaN(value) || value <= 0) {
649
+ throw new Error(`Invalid --audit-timeout-ms value: ${argv[i + 1]}. Expected a positive integer (milliseconds).`);
650
+ }
651
+ auditTimeoutMsOverride = value;
652
+ i += 1;
653
+ }
654
+ else if (arg === "--plan") {
655
+ plan = true;
656
+ }
657
+ else if (arg === "--regressions-only") {
658
+ regressionsOnly = true;
659
+ }
660
+ else if (arg === "--changed-only") {
661
+ changedOnly = true;
662
+ }
663
+ else if (arg === "--rerun-failing") {
664
+ rerunFailing = true;
665
+ }
666
+ else if (arg === "--accessibility-pass") {
667
+ accessibilityPass = true;
668
+ }
669
+ else if (arg === "--webhook-url" && i + 1 < argv.length) {
670
+ webhookUrl = argv[i + 1];
671
+ i += 1;
672
+ }
673
+ else if (arg === "--webhook-always") {
674
+ webhookAlways = true;
675
+ }
676
+ else if (arg === "--max-steps" && i + 1 < argv.length) {
677
+ const value = parseInt(argv[i + 1], 10);
678
+ if (Number.isNaN(value) || value <= 0) {
679
+ throw new Error(`Invalid --max-steps value: ${argv[i + 1]}. Expected a positive integer.`);
680
+ }
681
+ maxSteps = value;
682
+ i += 1;
683
+ }
684
+ else if (arg === "--max-combos" && i + 1 < argv.length) {
685
+ const value = parseInt(argv[i + 1], 10);
686
+ if (Number.isNaN(value) || value <= 0) {
687
+ throw new Error(`Invalid --max-combos value: ${argv[i + 1]}. Expected a positive integer.`);
688
+ }
689
+ maxCombos = value;
690
+ i += 1;
691
+ }
692
+ else if (arg === "--yes" || arg === "-y") {
693
+ yes = true;
694
+ }
695
+ else if (arg.startsWith("--parallel=")) {
696
+ parallelOverride = Number(arg.split("=")[1]);
697
+ if (Number.isNaN(parallelOverride)) {
698
+ parallelOverride = undefined;
699
+ }
700
+ }
701
+ else if (arg === "--stable") {
702
+ stable = true;
703
+ }
704
+ else if (arg === "--open" || arg === "--open-report") {
99
705
  openReport = true;
100
706
  }
101
707
  else if (arg === "--warm-up") {
102
708
  warmUp = true;
103
709
  }
710
+ else if (arg === "--incremental") {
711
+ incremental = true;
712
+ }
713
+ else if (arg === "--build-id" && i + 1 < argv.length) {
714
+ buildId = argv[i + 1];
715
+ i += 1;
716
+ }
717
+ else if (arg === "--runs" && i + 1 < argv.length) {
718
+ const value = parseInt(argv[i + 1], 10);
719
+ if (Number.isNaN(value) || value !== 1) {
720
+ throw new Error(`Multi-run mode is no longer supported. Received --runs ${argv[i + 1]}. ` +
721
+ "Run the same command multiple times instead (more stable).");
722
+ }
723
+ runsOverride = value;
724
+ i += 1;
725
+ }
726
+ else if (arg === "--overview-combos" && i + 1 < argv.length) {
727
+ const value = parseInt(argv[i + 1], 10);
728
+ if (Number.isNaN(value) || value <= 0 || value > 200) {
729
+ throw new Error(`Invalid --overview-combos value: ${argv[i + 1]}. Expected integer between 1 and 200.`);
730
+ }
731
+ overviewCombos = value;
732
+ i += 1;
733
+ }
734
+ else if (arg === "--quick") {
735
+ quick = true;
736
+ }
737
+ else if (arg === "--accurate") {
738
+ accurate = true;
739
+ }
104
740
  else if (arg === "--json") {
105
741
  jsonOutput = true;
106
742
  }
107
743
  else if (arg === "--show-parallel") {
108
744
  showParallel = true;
109
745
  }
746
+ else if (arg === "--fast") {
747
+ fast = true;
748
+ }
749
+ else if (arg === "--overview") {
750
+ overview = true;
751
+ }
752
+ }
753
+ const presetCount = [fast, quick, accurate, overview].filter((flag) => flag).length;
754
+ if (presetCount > 1) {
755
+ throw new Error("Choose only one preset: --overview, --fast, --quick, or --accurate");
110
756
  }
111
757
  const finalConfigPath = configPath ?? "apex.config.json";
112
- return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter, throttlingMethodOverride, cpuSlowdownOverride, parallelOverride, openReport, warmUp, jsonOutput, showParallel };
758
+ return {
759
+ configPath: finalConfigPath,
760
+ ci,
761
+ failOnBudget,
762
+ colorMode,
763
+ logLevelOverride,
764
+ deviceFilter,
765
+ throttlingMethodOverride,
766
+ cpuSlowdownOverride,
767
+ parallelOverride,
768
+ auditTimeoutMsOverride,
769
+ plan,
770
+ yes,
771
+ maxSteps,
772
+ maxCombos,
773
+ stable,
774
+ openReport,
775
+ warmUp,
776
+ incremental,
777
+ buildId,
778
+ runsOverride,
779
+ quick,
780
+ accurate,
781
+ jsonOutput,
782
+ showParallel,
783
+ fast,
784
+ overview,
785
+ overviewCombos,
786
+ regressionsOnly,
787
+ changedOnly,
788
+ rerunFailing,
789
+ accessibilityPass,
790
+ webhookUrl,
791
+ webhookAlways,
792
+ };
793
+ }
794
+ function printPlan(params) {
795
+ const lines = [];
796
+ lines.push(`Config: ${params.configPath}`);
797
+ lines.push(`Parallel: ${params.resolvedConfig.parallel ?? "auto"}`);
798
+ lines.push(`Throttling: ${params.resolvedConfig.throttlingMethod ?? "simulate"}`);
799
+ lines.push(`CPU slowdown: ${params.resolvedConfig.cpuSlowdownMultiplier ?? 4}`);
800
+ lines.push(`Warm-up: ${params.resolvedConfig.warmUp === true ? "yes" : "no"}`);
801
+ lines.push(`Incremental: ${params.resolvedConfig.incremental === true ? "yes" : "no"}`);
802
+ lines.push(`Build ID: ${params.resolvedConfig.buildId ?? "-"}`);
803
+ lines.push(`Runs per combo: 1`);
804
+ lines.push(`Planned combos: ${params.plannedCombos}`);
805
+ lines.push(`Planned Lighthouse runs (steps): ${params.plannedSteps}`);
806
+ if (params.sampled) {
807
+ lines.push(`Overview sampling: yes (${params.sampledCombos} combos)`);
808
+ }
809
+ lines.push(`Guardrails: combos<=${params.maxCombos}, steps<=${params.maxSteps}`);
810
+ printSectionHeader("Plan", params.useColor);
811
+ printDivider();
812
+ // eslint-disable-next-line no-console
813
+ console.log(boxifyWithSeparators(lines));
814
+ printDivider();
815
+ }
816
+ function sampleConfigForOverview(config, maxCombos) {
817
+ const nextPages = [];
818
+ let combos = 0;
819
+ for (const page of config.pages) {
820
+ if (combos >= maxCombos) {
821
+ break;
822
+ }
823
+ const remaining = maxCombos - combos;
824
+ const devices = page.devices.slice(0, remaining);
825
+ if (devices.length === 0) {
826
+ continue;
827
+ }
828
+ nextPages.push({ ...page, devices });
829
+ combos += devices.length;
830
+ }
831
+ const sampled = combos < config.pages.reduce((acc, p) => acc + p.devices.length, 0);
832
+ return { config: { ...config, pages: nextPages }, sampled, sampledCombos: combos };
833
+ }
834
+ async function resolveAutoBuildId(configPath) {
835
+ const startDir = dirname(configPath);
836
+ const tryReadText = async (absolutePath) => {
837
+ try {
838
+ const raw = await readFile(absolutePath, "utf8");
839
+ const trimmed = raw.trim();
840
+ return trimmed.length > 0 ? trimmed : undefined;
841
+ }
842
+ catch {
843
+ return undefined;
844
+ }
845
+ };
846
+ const findUp = async (relativePath) => {
847
+ let currentDir = startDir;
848
+ while (true) {
849
+ const candidate = resolve(currentDir, relativePath);
850
+ const value = await tryReadText(candidate);
851
+ if (value !== undefined) {
852
+ return value;
853
+ }
854
+ const parent = dirname(currentDir);
855
+ if (parent === currentDir) {
856
+ return undefined;
857
+ }
858
+ currentDir = parent;
859
+ }
860
+ };
861
+ const nextBuildId = await findUp(".next/BUILD_ID");
862
+ if (nextBuildId !== undefined) {
863
+ return `next:${nextBuildId}`;
864
+ }
865
+ const gitHead = await findUp(".git/HEAD");
866
+ if (gitHead === undefined) {
867
+ return undefined;
868
+ }
869
+ if (gitHead.startsWith("ref:")) {
870
+ const refPath = gitHead.replace("ref:", "").trim();
871
+ const refValue = await findUp(`.git/${refPath}`);
872
+ return refValue !== undefined ? `git:${refValue}` : undefined;
873
+ }
874
+ return `git:${gitHead}`;
875
+ }
876
+ async function loadPreviousSummary() {
877
+ const previousPath = resolve(".apex-auditor", "summary.json");
878
+ try {
879
+ const raw = await readFile(previousPath, "utf8");
880
+ const parsed = JSON.parse(raw);
881
+ if (!parsed || typeof parsed !== "object") {
882
+ return undefined;
883
+ }
884
+ return parsed;
885
+ }
886
+ catch {
887
+ return undefined;
888
+ }
889
+ }
890
+ function computeAvgScores(results) {
891
+ const sums = results.reduce((acc, r) => {
892
+ return {
893
+ performance: acc.performance + (r.scores.performance ?? 0),
894
+ accessibility: acc.accessibility + (r.scores.accessibility ?? 0),
895
+ bestPractices: acc.bestPractices + (r.scores.bestPractices ?? 0),
896
+ seo: acc.seo + (r.scores.seo ?? 0),
897
+ count: acc.count + 1,
898
+ };
899
+ }, { performance: 0, accessibility: 0, bestPractices: 0, seo: 0, count: 0 });
900
+ const count = Math.max(1, sums.count);
901
+ return {
902
+ performance: Math.round(sums.performance / count),
903
+ accessibility: Math.round(sums.accessibility / count),
904
+ bestPractices: Math.round(sums.bestPractices / count),
905
+ seo: Math.round(sums.seo / count),
906
+ };
907
+ }
908
+ function buildEffectiveSettingsPanel(params) {
909
+ const theme = new UiTheme({ noColor: !params.useColor });
910
+ const buildIdText = params.config.buildId ?? "-";
911
+ const throttlingText = params.config.throttlingMethod ?? "simulate";
912
+ const cpuText = String(params.config.cpuSlowdownMultiplier ?? 4);
913
+ const parallelText = String(params.config.parallel ?? 4);
914
+ const warmUpText = params.config.warmUp ? "yes" : "no";
915
+ const incrementalText = params.config.incremental ? "on" : "off";
916
+ const lines = [];
917
+ lines.push(`${theme.dim("Config")}: ${params.configPath}`);
918
+ lines.push(`${theme.dim("Build ID")}: ${buildIdText}`);
919
+ lines.push(`${theme.dim("Incremental")}: ${incrementalText}`);
920
+ lines.push(`${theme.dim("Warm-up")}: ${warmUpText}`);
921
+ lines.push(`${theme.dim("Throttling")}: ${throttlingText}`);
922
+ lines.push(`${theme.dim("CPU slowdown")}: ${cpuText}`);
923
+ lines.push(`${theme.dim("Parallel")}: ${parallelText}`);
924
+ return renderPanel({ title: theme.bold("Effective settings"), lines });
925
+ }
926
+ function buildMetaPanel(meta, useColor) {
927
+ const theme = new UiTheme({ noColor: !useColor });
928
+ const cacheSummary = meta.incremental
929
+ ? `${meta.executedCombos} executed / ${meta.cachedCombos} cached (steps: ${meta.executedSteps}/${meta.cachedSteps})`
930
+ : "No";
931
+ const lines = [
932
+ `${theme.dim("Build ID")}: ${meta.buildId ?? "-"}`,
933
+ `${theme.dim("Incremental")}: ${meta.incremental ? "Yes" : "No"}`,
934
+ `${theme.dim("Resolved parallel")}: ${meta.resolvedParallel}`,
935
+ `${theme.dim("Warm-up")}: ${meta.warmUp ? "Yes" : "No"}`,
936
+ `${theme.dim("Throttling")}: ${meta.throttlingMethod}`,
937
+ `${theme.dim("CPU slowdown")}: ${meta.cpuSlowdownMultiplier}`,
938
+ `${theme.dim("Combos")}: ${meta.comboCount}`,
939
+ `${theme.dim("Cache")}: ${cacheSummary}`,
940
+ `${theme.dim("Runs per combo")}: ${meta.runsPerCombo}`,
941
+ `${theme.dim("Total steps")}: ${meta.totalSteps}`,
942
+ `${theme.dim("Elapsed")}: ${formatElapsedTime(meta.elapsedMs)}`,
943
+ `${theme.dim("Avg / step")}: ${formatElapsedTime(meta.averageStepMs)}`,
944
+ ];
945
+ return renderPanel({ title: theme.bold("Meta"), lines });
946
+ }
947
+ function buildStatsPanel(results, useColor) {
948
+ const theme = new UiTheme({ noColor: !useColor });
949
+ let pSum = 0;
950
+ let aSum = 0;
951
+ let bpSum = 0;
952
+ let seoSum = 0;
953
+ let count = 0;
954
+ let green = 0;
955
+ let yellow = 0;
956
+ let red = 0;
957
+ for (const r of results) {
958
+ const p = r.scores.performance;
959
+ if (p !== undefined) {
960
+ count += 1;
961
+ pSum += p;
962
+ if (p >= 90) {
963
+ green += 1;
964
+ }
965
+ else if (p >= 50) {
966
+ yellow += 1;
967
+ }
968
+ else {
969
+ red += 1;
970
+ }
971
+ }
972
+ if (r.scores.accessibility !== undefined)
973
+ aSum += r.scores.accessibility;
974
+ if (r.scores.bestPractices !== undefined)
975
+ bpSum += r.scores.bestPractices;
976
+ if (r.scores.seo !== undefined)
977
+ seoSum += r.scores.seo;
978
+ }
979
+ const avgP = count > 0 ? Math.round(pSum / count) : 0;
980
+ const avgA = results.length > 0 ? Math.round(aSum / results.length) : 0;
981
+ const avgBP = results.length > 0 ? Math.round(bpSum / results.length) : 0;
982
+ const avgSEO = results.length > 0 ? Math.round(seoSum / results.length) : 0;
983
+ const lines = [
984
+ `${theme.dim("Summary")}: Avg P:${avgP} A:${avgA} BP:${avgBP} SEO:${avgSEO}`,
985
+ `${theme.dim("Scores")}: ${theme.green(`${green} green (90+)`)} | ${theme.yellow(`${yellow} yellow (50-89)`)} | ${theme.red(`${red} red (<50)`)} of ${count} total`,
986
+ ];
987
+ return renderPanel({ title: theme.bold("Stats"), lines });
988
+ }
989
+ function buildChangesBox(previous, current, useColor) {
990
+ const prevAvg = computeAvgScores(previous.results);
991
+ const currAvg = computeAvgScores(current.results);
992
+ const avgDelta = {
993
+ performance: currAvg.performance - prevAvg.performance,
994
+ accessibility: currAvg.accessibility - prevAvg.accessibility,
995
+ bestPractices: currAvg.bestPractices - prevAvg.bestPractices,
996
+ seo: currAvg.seo - prevAvg.seo,
997
+ };
998
+ const prevMap = new Map(previous.results.map((r) => [`${r.label}:::${r.path}:::${r.device}`, r]));
999
+ const currMap = new Map(current.results.map((r) => [`${r.label}:::${r.path}:::${r.device}`, r]));
1000
+ const allKeys = new Set([...prevMap.keys(), ...currMap.keys()]);
1001
+ const deltas = [];
1002
+ let added = 0;
1003
+ let removed = 0;
1004
+ for (const key of allKeys) {
1005
+ const prev = prevMap.get(key);
1006
+ const curr = currMap.get(key);
1007
+ if (!prev && curr) {
1008
+ added += 1;
1009
+ continue;
1010
+ }
1011
+ if (prev && !curr) {
1012
+ removed += 1;
1013
+ continue;
1014
+ }
1015
+ if (!prev || !curr) {
1016
+ continue;
1017
+ }
1018
+ const deltaP = (curr.scores.performance ?? 0) - (prev.scores.performance ?? 0);
1019
+ deltas.push({
1020
+ key,
1021
+ label: curr.label,
1022
+ path: curr.path,
1023
+ device: curr.device,
1024
+ deltaP,
1025
+ });
1026
+ }
1027
+ deltas.sort((a, b) => a.deltaP - b.deltaP);
1028
+ const regressions = deltas.slice(0, 5);
1029
+ const improvements = [...deltas].reverse().slice(0, 5);
1030
+ const formatDelta = (value) => {
1031
+ const sign = value > 0 ? "+" : "";
1032
+ if (!useColor) {
1033
+ return `${sign}${value}`;
1034
+ }
1035
+ if (value > 0) {
1036
+ return `${ANSI_GREEN}${sign}${value}${ANSI_RESET}`;
1037
+ }
1038
+ if (value < 0) {
1039
+ return `${ANSI_RED}${sign}${value}${ANSI_RESET}`;
1040
+ }
1041
+ return `${ANSI_CYAN}${sign}${value}${ANSI_RESET}`;
1042
+ };
1043
+ const lines = [];
1044
+ lines.push(`Avg deltas: P ${formatDelta(avgDelta.performance)} | A ${formatDelta(avgDelta.accessibility)} | BP ${formatDelta(avgDelta.bestPractices)} | SEO ${formatDelta(avgDelta.seo)}`);
1045
+ lines.push(`Combos: +${added} added, -${removed} removed`);
1046
+ lines.push("");
1047
+ lines.push("Top regressions (Performance):");
1048
+ for (const r of regressions) {
1049
+ lines.push(`- ${r.label} ${r.path} [${r.device}] ΔP:${formatDelta(r.deltaP)}`);
1050
+ }
1051
+ lines.push("");
1052
+ lines.push("Top improvements (Performance):");
1053
+ for (const r of improvements) {
1054
+ lines.push(`- ${r.label} ${r.path} [${r.device}] ΔP:${formatDelta(r.deltaP)}`);
1055
+ }
1056
+ return boxifyWithSeparators(lines);
113
1057
  }
114
1058
  /**
115
1059
  * Runs the ApexAuditor audit CLI.
116
1060
  *
117
1061
  * @param argv - The process arguments array.
118
1062
  */
119
- export async function runAuditCli(argv) {
1063
+ export async function runAuditCli(argv, options) {
120
1064
  const args = parseArgs(argv);
121
1065
  const startTimeMs = Date.now();
122
1066
  const { configPath, config } = await loadConfig({ configPath: args.configPath });
1067
+ const previousSummary = await loadPreviousSummary();
1068
+ const presetThrottling = args.accurate ? "devtools" : undefined;
1069
+ const presetWarmUp = args.accurate ? true : args.overview ? false : undefined;
1070
+ const presetParallel = args.accurate ? 1 : undefined;
1071
+ const DEFAULT_PARALLEL = 4;
1072
+ const DEFAULT_WARM_UP = true;
123
1073
  const effectiveLogLevel = args.logLevelOverride ?? config.logLevel;
124
- const effectiveThrottling = args.throttlingMethodOverride ?? config.throttlingMethod;
1074
+ const effectiveThrottling = args.fast ? "simulate" : args.throttlingMethodOverride ?? presetThrottling ?? config.throttlingMethod;
125
1075
  const effectiveCpuSlowdown = args.cpuSlowdownOverride ?? config.cpuSlowdownMultiplier;
126
- const effectiveParallel = args.parallelOverride ?? config.parallel;
127
- const effectiveWarmUp = args.warmUp || config.warmUp === true;
128
- const effectiveConfig = {
1076
+ const effectiveParallel = args.stable ? 1 : args.parallelOverride ?? presetParallel ?? config.parallel ?? DEFAULT_PARALLEL;
1077
+ const effectiveAuditTimeoutMs = args.auditTimeoutMsOverride ?? config.auditTimeoutMs;
1078
+ const warmUpDefaulted = presetWarmUp ?? config.warmUp ?? DEFAULT_WARM_UP;
1079
+ const effectiveWarmUp = args.warmUp ? true : warmUpDefaulted;
1080
+ const effectiveIncremental = args.incremental;
1081
+ if (!effectiveIncremental && config.incremental === true) {
1082
+ // eslint-disable-next-line no-console
1083
+ console.log("Note: incremental caching is now opt-in. Pass --incremental to enable it for this run.");
1084
+ }
1085
+ const candidateBuildId = args.buildId ?? config.buildId;
1086
+ const autoBuildId = effectiveIncremental && candidateBuildId === undefined
1087
+ ? await resolveAutoBuildId(configPath)
1088
+ : undefined;
1089
+ const effectiveBuildId = candidateBuildId ?? autoBuildId;
1090
+ const finalIncremental = effectiveIncremental && effectiveBuildId !== undefined;
1091
+ if (effectiveIncremental && !finalIncremental) {
1092
+ // eslint-disable-next-line no-console
1093
+ console.log("Incremental mode requested, but no buildId could be resolved. Running a full audit. Tip: pass --build-id or set buildId in apex.config.json");
1094
+ }
1095
+ const effectiveRuns = 1;
1096
+ const onlyCategories = args.fast ? ["performance"] : undefined;
1097
+ let effectiveConfig = {
129
1098
  ...config,
1099
+ buildId: effectiveBuildId,
130
1100
  logLevel: effectiveLogLevel,
131
1101
  throttlingMethod: effectiveThrottling,
132
1102
  cpuSlowdownMultiplier: effectiveCpuSlowdown,
133
1103
  parallel: effectiveParallel,
1104
+ auditTimeoutMs: effectiveAuditTimeoutMs,
134
1105
  warmUp: effectiveWarmUp,
1106
+ incremental: finalIncremental,
1107
+ runs: effectiveRuns,
135
1108
  };
1109
+ if (args.changedOnly) {
1110
+ const changedFiles = await getChangedFiles();
1111
+ const changedConfig = filterConfigChanged(effectiveConfig, changedFiles);
1112
+ if (changedConfig.pages.length === 0) {
1113
+ // eslint-disable-next-line no-console
1114
+ console.error("Changed-only mode: no pages matched git diff. Nothing to run.");
1115
+ process.exitCode = 0;
1116
+ return;
1117
+ }
1118
+ effectiveConfig = changedConfig;
1119
+ }
1120
+ if (args.rerunFailing) {
1121
+ const rerunConfig = filterConfigFailing(previousSummary, effectiveConfig);
1122
+ if (rerunConfig.pages.length === 0) {
1123
+ // eslint-disable-next-line no-console
1124
+ console.log("Rerun-failing mode: no failing combos found in previous summary. Nothing to run.");
1125
+ process.exitCode = 0;
1126
+ return;
1127
+ }
1128
+ effectiveConfig = rerunConfig;
1129
+ }
136
1130
  const filteredConfig = filterConfigDevices(effectiveConfig, args.deviceFilter);
137
1131
  if (filteredConfig.pages.length === 0) {
138
1132
  // eslint-disable-next-line no-console
@@ -140,7 +1134,144 @@ export async function runAuditCli(argv) {
140
1134
  process.exitCode = 1;
141
1135
  return;
142
1136
  }
143
- const summary = await runAuditsForConfig({ config: filteredConfig, configPath, showParallel: args.showParallel });
1137
+ const overviewMaxCombos = args.overviewCombos ?? DEFAULT_OVERVIEW_MAX_COMBOS;
1138
+ const overviewSample = args.overview && !args.yes ? sampleConfigForOverview(filteredConfig, overviewMaxCombos) : { config: filteredConfig, sampled: false, sampledCombos: 0 };
1139
+ const plannedCombos = overviewSample.config.pages.reduce((acc, p) => acc + p.devices.length, 0);
1140
+ const plannedRuns = overviewSample.config.runs ?? 1;
1141
+ const plannedSteps = plannedCombos * plannedRuns;
1142
+ const maxSteps = args.maxSteps ?? DEFAULT_MAX_STEPS;
1143
+ const maxCombos = args.maxCombos ?? DEFAULT_MAX_COMBOS;
1144
+ const isTty = typeof process !== "undefined" && process.stdout?.isTTY === true;
1145
+ const exceeds = plannedSteps > maxSteps || plannedCombos > maxCombos;
1146
+ const useColor = shouldUseColor(args.ci, args.colorMode);
1147
+ if (args.plan) {
1148
+ printPlan({
1149
+ configPath,
1150
+ resolvedConfig: overviewSample.config,
1151
+ plannedCombos,
1152
+ plannedSteps,
1153
+ sampled: overviewSample.sampled,
1154
+ sampledCombos: overviewSample.sampledCombos,
1155
+ maxCombos,
1156
+ maxSteps,
1157
+ useColor,
1158
+ });
1159
+ return;
1160
+ }
1161
+ if (isTty && !args.ci && !args.jsonOutput) {
1162
+ const tipLines = [
1163
+ "Tip: use --plan to preview run size before starting.",
1164
+ "Note: runs-per-combo is always 1; rerun the same command to compare results.",
1165
+ "If parallel mode flakes (worker disconnects), retry with --stable (forces parallel=1).",
1166
+ ];
1167
+ // eslint-disable-next-line no-console
1168
+ console.log(boxifyWithSeparators(tipLines));
1169
+ printDivider();
1170
+ }
1171
+ if (overviewSample.sampled) {
1172
+ // eslint-disable-next-line no-console
1173
+ console.log(`Overview mode: sampling ${plannedCombos} combos (pass --yes for full suite or --overview-combos <n> to adjust).`);
1174
+ }
1175
+ if (exceeds && !args.yes) {
1176
+ const limitText = `limits combos<=${maxCombos}, steps<=${maxSteps}`;
1177
+ const planText = `Planned run: ${plannedCombos} combos x ${plannedRuns} runs = ${plannedSteps} Lighthouse runs.`;
1178
+ if (args.ci || !isTty) {
1179
+ // eslint-disable-next-line no-console
1180
+ console.error(`${planText} Refusing to start because it exceeds default ${limitText}. Use --yes to proceed or adjust with --max-steps/--max-combos.`);
1181
+ process.exitCode = 1;
1182
+ return;
1183
+ }
1184
+ // eslint-disable-next-line no-console
1185
+ console.log(`${planText} This exceeds default ${limitText}.`);
1186
+ const ok = await confirmLargeRun("Continue? (y/N) ");
1187
+ if (!ok) {
1188
+ // eslint-disable-next-line no-console
1189
+ console.log("Cancelled.");
1190
+ return;
1191
+ }
1192
+ }
1193
+ let summary;
1194
+ const abortController = new AbortController();
1195
+ if (options?.signal?.aborted === true) {
1196
+ abortController.abort();
1197
+ }
1198
+ else if (options?.signal) {
1199
+ const externalSignal = options.signal;
1200
+ const onExternalAbort = () => abortController.abort();
1201
+ externalSignal.addEventListener("abort", onExternalAbort, { once: true });
1202
+ abortController.signal.addEventListener("abort", () => {
1203
+ externalSignal.removeEventListener("abort", onExternalAbort);
1204
+ });
1205
+ }
1206
+ const onSigInt = () => {
1207
+ abortController.abort();
1208
+ };
1209
+ process.once("SIGINT", onSigInt);
1210
+ let spinnerStarted = false;
1211
+ let lastProgressLine;
1212
+ const formatEtaText = (etaMs) => {
1213
+ if (etaMs === undefined) {
1214
+ return "";
1215
+ }
1216
+ const seconds = Math.max(0, Math.round(etaMs / 1000));
1217
+ const minutes = Math.floor(seconds / 60);
1218
+ const remainingSeconds = seconds % 60;
1219
+ if (minutes === 0) {
1220
+ return `${remainingSeconds}s`;
1221
+ }
1222
+ return `${minutes}m ${remainingSeconds}s`;
1223
+ };
1224
+ const startAuditSpinner = () => {
1225
+ if (spinnerStarted) {
1226
+ return;
1227
+ }
1228
+ startSpinner("Running audit (Lighthouse)");
1229
+ spinnerStarted = true;
1230
+ };
1231
+ if (!filteredConfig.warmUp) {
1232
+ startAuditSpinner();
1233
+ }
1234
+ try {
1235
+ summary = await runAuditsForConfig({
1236
+ config: overviewSample.config,
1237
+ configPath,
1238
+ showParallel: args.showParallel,
1239
+ onlyCategories,
1240
+ signal: abortController.signal,
1241
+ onAfterWarmUp: startAuditSpinner,
1242
+ onProgress: ({ completed, total, path, device, etaMs }) => {
1243
+ if (!process.stdout.isTTY) {
1244
+ return;
1245
+ }
1246
+ const etaText = etaMs !== undefined ? ` | ETA ${formatEtaText(etaMs)}` : "";
1247
+ const message = `* Running audit (Lighthouse) page ${completed}/${total} — ${path} [${device}]${etaText}`;
1248
+ const padded = message.padEnd(lastProgressLine?.length ?? message.length, " ");
1249
+ process.stdout.write(`\r${padded}`);
1250
+ lastProgressLine = padded;
1251
+ updateSpinnerMessage(`Running audit (Lighthouse) page ${completed}/${total}`);
1252
+ },
1253
+ });
1254
+ }
1255
+ catch (error) {
1256
+ const message = error instanceof Error ? error.message : "Unknown error";
1257
+ if (abortController.signal.aborted || message.includes("Aborted")) {
1258
+ // eslint-disable-next-line no-console
1259
+ console.log("Audit cancelled.");
1260
+ process.exitCode = 130;
1261
+ return;
1262
+ }
1263
+ handleFriendlyError(error);
1264
+ process.exitCode = 1;
1265
+ return;
1266
+ }
1267
+ finally {
1268
+ process.removeListener("SIGINT", onSigInt);
1269
+ if (lastProgressLine !== undefined) {
1270
+ process.stdout.write("\n");
1271
+ lastProgressLine = undefined;
1272
+ }
1273
+ stopSpinner();
1274
+ }
144
1275
  const outputDir = resolve(".apex-auditor");
145
1276
  await mkdir(outputDir, { recursive: true });
146
1277
  await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
@@ -149,6 +1280,24 @@ export async function runAuditCli(argv) {
149
1280
  const html = buildHtmlReport(summary);
150
1281
  const reportPath = resolve(outputDir, "report.html");
151
1282
  await writeFile(reportPath, html, "utf8");
1283
+ const budgetViolations = effectiveConfig.budgets === undefined ? [] : collectBudgetViolations(summary.results, effectiveConfig.budgets);
1284
+ const shareable = buildShareableExport({
1285
+ configPath,
1286
+ previousSummary,
1287
+ current: summary,
1288
+ budgets: effectiveConfig.budgets,
1289
+ });
1290
+ const exportPath = resolve(outputDir, "export.json");
1291
+ await writeFile(exportPath, JSON.stringify(shareable, null, 2), "utf8");
1292
+ const accessibilityArtifacts = resolve(outputDir, "accessibility");
1293
+ const accessibilitySummary = await runAccessibilityAudit({
1294
+ config: filteredConfig,
1295
+ configPath,
1296
+ artifactsDir: accessibilityArtifacts,
1297
+ });
1298
+ const accessibilitySummaryPath = resolve(outputDir, "accessibility-summary.json");
1299
+ await writeFile(accessibilitySummaryPath, JSON.stringify(accessibilitySummary, null, 2), "utf8");
1300
+ const accessibilityAggregated = accessibilitySummary === undefined ? undefined : summariseAccessibility(accessibilitySummary);
152
1301
  // Open HTML report in browser if requested
153
1302
  if (args.openReport) {
154
1303
  openInBrowser(reportPath);
@@ -159,24 +1308,110 @@ export async function runAuditCli(argv) {
159
1308
  console.log(JSON.stringify(summary, null, 2));
160
1309
  return;
161
1310
  }
1311
+ printReportLink(reportPath);
1312
+ if (isTty && !args.ci) {
1313
+ // eslint-disable-next-line no-console
1314
+ console.log(buildEffectiveSettingsPanel({ configPath, config: effectiveConfig, useColor }));
1315
+ // eslint-disable-next-line no-console
1316
+ console.log(buildSectionIndex(useColor));
1317
+ }
162
1318
  // Also echo a compact, colourised table to stdout for quick viewing.
163
- const useColor = shouldUseColor(args.ci, args.colorMode);
164
- const consoleTable = buildConsoleTable(summary.results, useColor);
1319
+ // Structured panels
1320
+ // eslint-disable-next-line no-console
1321
+ console.log(buildMetaPanel(summary.meta, useColor));
1322
+ // eslint-disable-next-line no-console
1323
+ console.log(buildStatsPanel(summary.results, useColor));
1324
+ if (previousSummary !== undefined) {
1325
+ printSectionHeader("Changes", useColor);
1326
+ printDivider();
1327
+ // eslint-disable-next-line no-console
1328
+ console.log(buildChangesBox(previousSummary, summary, useColor));
1329
+ printDivider();
1330
+ }
1331
+ printSectionHeader("Summary", useColor);
1332
+ // eslint-disable-next-line no-console
1333
+ console.log(buildSummaryPanel({
1334
+ results: summary.results,
1335
+ useColor,
1336
+ regressionsOnly: args.regressionsOnly,
1337
+ previousSummary,
1338
+ }));
1339
+ printSectionHeader("Issues", useColor);
1340
+ // eslint-disable-next-line no-console
1341
+ console.log(buildIssuesPanel(summary.results, useColor));
1342
+ printSectionHeader("Top fixes", useColor);
1343
+ // eslint-disable-next-line no-console
1344
+ console.log(buildTopFixesPanel(summary.results, useColor));
1345
+ const budgetsPanel = buildBudgetsPanel({
1346
+ budgets: effectiveConfig.budgets,
1347
+ violations: budgetViolations,
1348
+ useColor,
1349
+ });
1350
+ if (budgetsPanel !== undefined) {
1351
+ printSectionHeader("Budgets", useColor);
1352
+ // eslint-disable-next-line no-console
1353
+ console.log(budgetsPanel);
1354
+ }
1355
+ printSectionHeader("Accessibility (fast pass)", useColor);
1356
+ // eslint-disable-next-line no-console
1357
+ console.log(buildAccessibilityPanel(summariseAccessibility(accessibilitySummary), useColor));
1358
+ // eslint-disable-next-line no-console
1359
+ console.log(buildAccessibilityIssuesPanel(accessibilitySummary.results, useColor));
1360
+ if (args.webhookUrl) {
1361
+ const regressions = collectRegressions(previousSummary, summary);
1362
+ if (args.webhookAlways || shouldSendWebhook(regressions, budgetViolations)) {
1363
+ const payload = buildWebhookPayload({
1364
+ current: summary,
1365
+ previous: previousSummary,
1366
+ budgetViolations,
1367
+ accessibility: accessibilityAggregated,
1368
+ reportPath,
1369
+ exportPath,
1370
+ accessibilityPath: accessibilitySummaryPath,
1371
+ });
1372
+ try {
1373
+ await postJsonWebhook({ url: args.webhookUrl, payload });
1374
+ // eslint-disable-next-line no-console
1375
+ console.log(`Sent webhook to ${args.webhookUrl}`);
1376
+ }
1377
+ catch (error) {
1378
+ const message = error instanceof Error ? error.message : String(error);
1379
+ // eslint-disable-next-line no-console
1380
+ console.error(`Failed to send webhook: ${message}`);
1381
+ }
1382
+ }
1383
+ }
1384
+ printCiSummary({ isCi: args.ci, failOnBudget: args.failOnBudget, violations: budgetViolations });
1385
+ printSectionHeader("Lowest performance", useColor);
165
1386
  // eslint-disable-next-line no-console
166
- console.log(consoleTable);
167
- printRunMeta(summary.meta, useColor);
168
- printSummaryStats(summary.results, useColor);
169
- printRedIssues(summary.results);
170
- printCiSummary(args, summary.results, effectiveConfig.budgets);
171
- printLowestPerformancePages(summary.results, useColor);
1387
+ console.log(buildLowestPerformancePanel(summary.results, useColor));
1388
+ printSectionHeader("Export", useColor);
1389
+ // eslint-disable-next-line no-console
1390
+ console.log(buildExportPanel({ exportPath, useColor, share: shareable }));
172
1391
  const elapsedMs = Date.now() - startTimeMs;
173
1392
  const elapsedText = formatElapsedTime(elapsedMs);
174
1393
  const elapsedDisplay = useColor ? `${ANSI_CYAN}${elapsedText}${ANSI_RESET}` : elapsedText;
175
1394
  const runsPerTarget = effectiveConfig.runs ?? 1;
176
1395
  const comboCount = summary.results.length;
177
1396
  const totalRuns = comboCount * runsPerTarget;
1397
+ const cacheNote = summary.meta.incremental
1398
+ ? ` Cache: ${summary.meta.executedCombos} executed / ${summary.meta.cachedCombos} cached (steps: ${summary.meta.executedSteps} executed, ${summary.meta.cachedSteps} cached).`
1399
+ : "";
178
1400
  // eslint-disable-next-line no-console
179
- console.log(`\nCompleted in ${elapsedDisplay} (${comboCount} page/device combinations x ${runsPerTarget} runs = ${totalRuns} Lighthouse runs).`);
1401
+ console.log(`\nCompleted in ${elapsedDisplay} (${comboCount} page/device combinations x ${runsPerTarget} runs = ${totalRuns} Lighthouse runs).${cacheNote}`);
1402
+ }
1403
+ async function getChangedFiles() {
1404
+ try {
1405
+ const diffOutput = await runCommand("git diff --name-only", process.cwd());
1406
+ const files = diffOutput
1407
+ .split("\n")
1408
+ .map((line) => line.trim())
1409
+ .filter((line) => line.length > 0);
1410
+ return files;
1411
+ }
1412
+ catch {
1413
+ return [];
1414
+ }
180
1415
  }
181
1416
  function filterConfigDevices(config, deviceFilter) {
182
1417
  if (deviceFilter === undefined) {
@@ -190,6 +1425,39 @@ function filterConfigDevices(config, deviceFilter) {
190
1425
  pages: filteredPages,
191
1426
  };
192
1427
  }
1428
+ function filterConfigChanged(config, changedFiles) {
1429
+ if (changedFiles.length === 0) {
1430
+ return config;
1431
+ }
1432
+ const pageMatches = (pagePath) => {
1433
+ const segment = pagePath.replace(/^\//, "");
1434
+ return changedFiles.some((file) => file.includes(segment));
1435
+ };
1436
+ const pages = config.pages.filter((page) => pageMatches(page.path));
1437
+ return { ...config, pages };
1438
+ }
1439
+ function filterConfigFailing(previous, config) {
1440
+ if (previous === undefined) {
1441
+ return config;
1442
+ }
1443
+ const failing = new Set();
1444
+ for (const result of previous.results) {
1445
+ const runtimeFailed = Boolean(result.runtimeErrorMessage);
1446
+ const perfScore = result.scores.performance;
1447
+ const failedScore = typeof perfScore === "number" && perfScore < 90;
1448
+ if (runtimeFailed || failedScore) {
1449
+ failing.add(`${result.label}:::${result.path}:::${result.device}`);
1450
+ }
1451
+ }
1452
+ const pages = config.pages.flatMap((page) => {
1453
+ const devices = page.devices.filter((device) => failing.has(`${page.label}:::${page.path}:::${device}`));
1454
+ if (devices.length === 0) {
1455
+ return [];
1456
+ }
1457
+ return [{ ...page, devices }];
1458
+ });
1459
+ return { ...config, pages };
1460
+ }
193
1461
  function filterPageDevices(page, deviceFilter) {
194
1462
  const devices = page.devices.filter((device) => device === deviceFilter);
195
1463
  if (devices.length === 0) {
@@ -206,13 +1474,19 @@ function buildMarkdown(summary) {
206
1474
  "| Field | Value |",
207
1475
  "|-------|-------|",
208
1476
  `| Config | ${meta.configPath} |`,
1477
+ `| Build ID | ${meta.buildId ?? "-"} |`,
1478
+ `| Incremental | ${meta.incremental ? "yes" : "no"} |`,
209
1479
  `| Resolved parallel | ${meta.resolvedParallel} |`,
210
1480
  `| Warm-up | ${meta.warmUp ? "yes" : "no"} |`,
211
1481
  `| Throttling | ${meta.throttlingMethod} |`,
212
1482
  `| CPU slowdown | ${meta.cpuSlowdownMultiplier} |`,
213
1483
  `| Combos | ${meta.comboCount} |`,
1484
+ `| Executed combos | ${meta.executedCombos} |`,
1485
+ `| Cached combos | ${meta.cachedCombos} |`,
214
1486
  `| Runs per combo | ${meta.runsPerCombo} |`,
215
1487
  `| Total steps | ${meta.totalSteps} |`,
1488
+ `| Executed steps | ${meta.executedSteps} |`,
1489
+ `| Cached steps | ${meta.cachedSteps} |`,
216
1490
  `| Started | ${meta.startedAt} |`,
217
1491
  `| Completed | ${meta.completedAt} |`,
218
1492
  `| Elapsed | ${formatElapsedTime(meta.elapsedMs)} |`,
@@ -230,6 +1504,9 @@ function buildHtmlReport(summary) {
230
1504
  const meta = summary.meta;
231
1505
  const timestamp = new Date().toISOString();
232
1506
  const rows = results.map((result) => buildHtmlRow(result)).join("\n");
1507
+ const cacheSummary = meta.incremental
1508
+ ? `${meta.executedCombos} executed / ${meta.cachedCombos} cached`
1509
+ : "disabled";
233
1510
  return `<!DOCTYPE html>
234
1511
  <html lang="en">
235
1512
  <head>
@@ -237,57 +1514,139 @@ function buildHtmlReport(summary) {
237
1514
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
238
1515
  <title>ApexAuditor Report</title>
239
1516
  <style>
240
- :root { --green: #0cce6b; --yellow: #ffa400; --red: #ff4e42; --bg: #1a1a2e; --card: #16213e; --text: #eee; }
1517
+ :root {
1518
+ --green: #0cce6b;
1519
+ --yellow: #ffa400;
1520
+ --red: #ff4e42;
1521
+ --bg: #0f172a;
1522
+ --panel: #0b1224;
1523
+ --card: #111a33;
1524
+ --border: #27324d;
1525
+ --text: #e8edf7;
1526
+ --muted: #93a4c3;
1527
+ --accent: #7c3aed;
1528
+ }
241
1529
  * { box-sizing: border-box; margin: 0; padding: 0; }
242
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); padding: 2rem; }
243
- h1 { margin-bottom: 0.5rem; }
244
- .meta { color: #888; margin-bottom: 2rem; font-size: 0.9rem; }
245
- .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
246
- .meta-card { background: #16213e; border-radius: 10px; padding: 1rem; border: 1px solid #23304f; }
247
- .meta-label { font-size: 0.8rem; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.04em; }
248
- .meta-value { font-size: 1rem; font-weight: 600; color: #e5e7eb; }
1530
+ body {
1531
+ font-family: "Inter", "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
1532
+ background: radial-gradient(circle at 20% 20%, #122042, #0a1020 45%), #0a0f1f;
1533
+ color: var(--text);
1534
+ padding: 2rem;
1535
+ line-height: 1.5;
1536
+ }
1537
+ h1 { margin-bottom: 0.5rem; letter-spacing: 0.02em; }
1538
+ .meta { color: var(--muted); margin-bottom: 2rem; font-size: 0.95rem; }
1539
+ .meta-grid {
1540
+ display: grid;
1541
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1542
+ gap: 1rem;
1543
+ margin-bottom: 2rem;
1544
+ }
1545
+ .meta-card {
1546
+ background: linear-gradient(135deg, var(--panel), #0f1a33);
1547
+ border-radius: 12px;
1548
+ padding: 1rem;
1549
+ border: 1px solid var(--border);
1550
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
1551
+ }
1552
+ .meta-label { font-size: 0.78rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
1553
+ .meta-value { font-size: 1.05rem; font-weight: 650; color: var(--text); }
249
1554
  .cards { display: grid; gap: 1.5rem; }
250
- .card { background: var(--card); border-radius: 12px; padding: 1.5rem; }
251
- .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 1px solid #333; padding-bottom: 1rem; }
252
- .card-title { font-size: 1.1rem; font-weight: 600; }
253
- .device-badge { font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; background: #333; }
254
- .device-badge.mobile { background: #0891b2; }
255
- .device-badge.desktop { background: #7c3aed; }
256
- .scores { display: flex; gap: 1rem; margin-bottom: 1rem; }
257
- .score-item { text-align: center; flex: 1; }
258
- .score-circle { width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: bold; margin: 0 auto 0.5rem; border: 3px solid; }
259
- .score-circle.green { border-color: var(--green); color: var(--green); }
260
- .score-circle.yellow { border-color: var(--yellow); color: var(--yellow); }
261
- .score-circle.red { border-color: var(--red); color: var(--red); }
262
- .score-label { font-size: 0.75rem; color: #888; }
263
- .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 1rem; }
264
- .metric { background: #1a1a2e; padding: 0.75rem; border-radius: 8px; text-align: center; }
265
- .metric-value { font-size: 1.1rem; font-weight: 600; }
1555
+ .card {
1556
+ background: linear-gradient(180deg, var(--card), #0e1a31);
1557
+ border-radius: 14px;
1558
+ padding: 1.5rem;
1559
+ border: 1px solid var(--border);
1560
+ box-shadow: 0 14px 45px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
1561
+ }
1562
+ .card-header {
1563
+ display: flex;
1564
+ justify-content: space-between;
1565
+ align-items: center;
1566
+ margin-bottom: 1rem;
1567
+ border-bottom: 1px solid var(--border);
1568
+ padding-bottom: 1rem;
1569
+ }
1570
+ .card-title { font-size: 1.1rem; font-weight: 650; }
1571
+ .card-title span { color: var(--muted); font-weight: 500; }
1572
+ .device-badge {
1573
+ font-size: 0.78rem;
1574
+ padding: 0.35rem 0.65rem;
1575
+ border-radius: 999px;
1576
+ background: #1f2937;
1577
+ border: 1px solid var(--border);
1578
+ text-transform: uppercase;
1579
+ letter-spacing: 0.08em;
1580
+ }
1581
+ .device-badge.mobile { background: linear-gradient(135deg, #0ea5e9, #0891b2); color: #e6f6ff; border-color: #0ea5e9; }
1582
+ .device-badge.desktop { background: linear-gradient(135deg, #8b5cf6, #7c3aed); color: #f5efff; border-color: #8b5cf6; }
1583
+ .scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
1584
+ .score-item { text-align: center; }
1585
+ .score-circle {
1586
+ width: 64px;
1587
+ height: 64px;
1588
+ border-radius: 12px;
1589
+ display: flex;
1590
+ align-items: center;
1591
+ justify-content: center;
1592
+ font-size: 1.25rem;
1593
+ font-weight: 700;
1594
+ margin: 0 auto 0.35rem;
1595
+ border: 2px solid var(--border);
1596
+ background: #0c152a;
1597
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
1598
+ }
1599
+ .score-circle.green { border-color: var(--green); color: var(--green); box-shadow: 0 0 0 1px rgba(12, 206, 107, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05); }
1600
+ .score-circle.yellow { border-color: var(--yellow); color: var(--yellow); box-shadow: 0 0 0 1px rgba(255, 164, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05); }
1601
+ .score-circle.red { border-color: var(--red); color: var(--red); box-shadow: 0 0 0 1px rgba(255, 78, 66, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05); }
1602
+ .score-label { font-size: 0.78rem; color: var(--muted); }
1603
+ .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.85rem; }
1604
+ .metric {
1605
+ background: #0c152a;
1606
+ padding: 0.85rem;
1607
+ border-radius: 10px;
1608
+ text-align: center;
1609
+ border: 1px solid var(--border);
1610
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
1611
+ }
1612
+ .metric-value { font-size: 1.05rem; font-weight: 650; }
266
1613
  .metric-value.green { color: var(--green); }
267
1614
  .metric-value.yellow { color: var(--yellow); }
268
1615
  .metric-value.red { color: var(--red); }
269
- .metric-label { font-size: 0.7rem; color: #888; margin-top: 0.25rem; }
270
- .issues { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #333; }
271
- .issues-title { font-size: 0.8rem; color: #888; margin-bottom: 0.5rem; }
272
- .issue { font-size: 0.85rem; color: #ccc; padding: 0.25rem 0; }
1616
+ .metric-label { font-size: 0.72rem; color: var(--muted); margin-top: 0.25rem; letter-spacing: 0.04em; }
1617
+ .issues {
1618
+ margin-top: 1rem;
1619
+ padding: 1rem;
1620
+ border-radius: 10px;
1621
+ border: 1px solid var(--border);
1622
+ background: #0c152a;
1623
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
1624
+ }
1625
+ .issues-title { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.5rem; letter-spacing: 0.05em; text-transform: uppercase; }
1626
+ .issue {
1627
+ font-size: 0.88rem;
1628
+ color: var(--text);
1629
+ padding: 0.35rem 0.25rem;
1630
+ border-bottom: 1px dashed var(--border);
1631
+ }
1632
+ .issue:last-child { border-bottom: none; }
273
1633
  </style>
274
1634
  </head>
275
1635
  <body>
276
1636
  <h1>ApexAuditor Report</h1>
277
1637
  <p class="meta">Generated: ${timestamp}</p>
278
1638
  <div class="meta-grid">
279
- ${buildMetaCard("Config", escapeHtml(meta.configPath))}
1639
+ ${buildMetaCard("Build ID", meta.buildId ?? "-")}
1640
+ ${buildMetaCard("Incremental", meta.incremental ? "Yes" : "No")}
1641
+ ${buildMetaCard("Cache", cacheSummary)}
280
1642
  ${buildMetaCard("Resolved parallel", meta.resolvedParallel.toString())}
281
- ${buildMetaCard("Warm-up", meta.warmUp ? "Yes" : "No")}
282
- ${buildMetaCard("Throttling", meta.throttlingMethod)}
283
- ${buildMetaCard("CPU slowdown", meta.cpuSlowdownMultiplier.toString())}
284
- ${buildMetaCard("Combos", meta.comboCount.toString())}
285
- ${buildMetaCard("Runs per combo", meta.runsPerCombo.toString())}
286
- ${buildMetaCard("Total steps", meta.totalSteps.toString())}
287
1643
  ${buildMetaCard("Elapsed", formatElapsedTime(meta.elapsedMs))}
288
1644
  ${buildMetaCard("Avg / step", formatElapsedTime(meta.averageStepMs))}
289
- ${buildMetaCard("Started", meta.startedAt)}
290
- ${buildMetaCard("Completed", meta.completedAt)}
1645
+ ${buildMetaCard("Combos", meta.comboCount.toString())}
1646
+ ${buildMetaCard("Runs per combo", meta.runsPerCombo.toString())}
1647
+ ${buildMetaCard("Throttling", meta.throttlingMethod)}
1648
+ ${buildMetaCard("CPU slowdown", meta.cpuSlowdownMultiplier.toString())}
1649
+ ${buildMetaCard("Warm-up", meta.warmUp ? "Yes" : "No")}
291
1650
  </div>
292
1651
  <div class="cards">
293
1652
  ${rows}
@@ -337,12 +1696,18 @@ function buildMetaCard(label, value) {
337
1696
  return `<div class="meta-card"><div class="meta-label">${escapeHtml(label)}</div><div class="meta-value">${escapeHtml(value)}</div></div>`;
338
1697
  }
339
1698
  function printRunMeta(meta, useColor) {
1699
+ const incrementalSummary = meta.incremental
1700
+ ? `${meta.executedCombos} executed / ${meta.cachedCombos} cached (${meta.executedSteps} executed steps, ${meta.cachedSteps} cached steps)`
1701
+ : "No";
340
1702
  const rows = [
1703
+ { label: "Build ID", value: meta.buildId ?? "-" },
1704
+ { label: "Incremental", value: meta.incremental ? "Yes" : "No" },
341
1705
  { label: "Resolved parallel", value: meta.resolvedParallel.toString() },
342
1706
  { label: "Warm-up", value: meta.warmUp ? "Yes" : "No" },
343
1707
  { label: "Throttling", value: meta.throttlingMethod },
344
1708
  { label: "CPU slowdown", value: meta.cpuSlowdownMultiplier.toString() },
345
1709
  { label: "Combos", value: meta.comboCount.toString() },
1710
+ { label: "Cache", value: incrementalSummary },
346
1711
  { label: "Runs per combo", value: meta.runsPerCombo.toString() },
347
1712
  { label: "Total steps", value: meta.totalSteps.toString() },
348
1713
  { label: "Elapsed", value: formatElapsedTime(meta.elapsedMs) },
@@ -480,6 +1845,52 @@ function formatOpportunityLabel(opportunity) {
480
1845
  const suffix = parts.length > 0 ? ` (${parts.join(", ")})` : "";
481
1846
  return `${opportunity.id}${suffix}`;
482
1847
  }
1848
+ function printTopFixes(results) {
1849
+ const map = new Map();
1850
+ for (const result of results) {
1851
+ for (const opp of result.opportunities) {
1852
+ if (!hasMeaningfulSavings(opp)) {
1853
+ continue;
1854
+ }
1855
+ const key = opp.id;
1856
+ const previous = map.get(key);
1857
+ map.set(key, {
1858
+ title: previous?.title ?? opp.title,
1859
+ count: (previous?.count ?? 0) + 1,
1860
+ totalMs: (previous?.totalMs ?? 0) + (opp.estimatedSavingsMs ?? 0),
1861
+ totalBytes: (previous?.totalBytes ?? 0) + (opp.estimatedSavingsBytes ?? 0),
1862
+ });
1863
+ }
1864
+ }
1865
+ const aggregates = Array.from(map.entries()).map(([id, v]) => {
1866
+ return { id, title: v.title, count: v.count, totalMs: v.totalMs, totalBytes: v.totalBytes };
1867
+ });
1868
+ if (aggregates.length === 0) {
1869
+ // eslint-disable-next-line no-console
1870
+ console.log(boxifyWithSeparators(["No actionable opportunities found in this sample."]));
1871
+ return;
1872
+ }
1873
+ aggregates.sort((a, b) => {
1874
+ if (a.count !== b.count) {
1875
+ return b.count - a.count;
1876
+ }
1877
+ if (a.totalMs !== b.totalMs) {
1878
+ return b.totalMs - a.totalMs;
1879
+ }
1880
+ return b.totalBytes - a.totalBytes;
1881
+ });
1882
+ const top = aggregates.slice(0, 8);
1883
+ const lines = ["Most common opportunities:"];
1884
+ for (const entry of top) {
1885
+ const ms = entry.totalMs > 0 ? `${Math.round(entry.totalMs)}ms` : "";
1886
+ const kb = entry.totalBytes > 0 ? `${Math.round(entry.totalBytes / 1024)}KB` : "";
1887
+ const parts = [ms, kb].filter((p) => p.length > 0);
1888
+ const suffix = parts.length > 0 ? ` (${parts.join(", ")})` : "";
1889
+ lines.push(`- ${entry.id} – ${entry.title} (seen on ${entry.count} pages)${suffix}`);
1890
+ }
1891
+ // eslint-disable-next-line no-console
1892
+ console.log(boxifyWithSeparators(lines));
1893
+ }
483
1894
  function formatMetricSeconds(valueMs, goodThresholdMs, warnThresholdMs, useColor) {
484
1895
  if (valueMs === undefined) {
485
1896
  return "-";
@@ -599,10 +2010,11 @@ function printRedIssues(results) {
599
2010
  isRedScore(scores.seo));
600
2011
  });
601
2012
  if (redResults.length === 0) {
2013
+ // eslint-disable-next-line no-console
2014
+ console.log(boxify(["No red issues."]));
602
2015
  return;
603
2016
  }
604
- // eslint-disable-next-line no-console
605
- console.log("\nRed issues (scores below 50):");
2017
+ const lines = ["Red issues (scores below 50):"];
606
2018
  for (const result of redResults) {
607
2019
  const scores = result.scores;
608
2020
  const badParts = [];
@@ -619,15 +2031,16 @@ function printRedIssues(results) {
619
2031
  badParts.push(`SEO:${scores.seo}`);
620
2032
  }
621
2033
  const issues = formatTopIssues(result.opportunities);
622
- // eslint-disable-next-line no-console
623
- console.log(`- ${result.label} ${result.path} [${result.device}] – ${badParts.join(", ")} – ${issues}`);
2034
+ lines.push(`- ${result.label} ${result.path} [${result.device}] – ${badParts.join(", ")} – ${issues}`);
624
2035
  }
2036
+ // eslint-disable-next-line no-console
2037
+ console.log(boxifyWithSeparators(lines));
625
2038
  }
626
2039
  function shouldUseColor(ci, colorMode) {
627
- if (colorMode === "on") {
2040
+ if (colorMode === "always") {
628
2041
  return true;
629
2042
  }
630
- if (colorMode === "off") {
2043
+ if (colorMode === "never") {
631
2044
  return false;
632
2045
  }
633
2046
  if (ci) {
@@ -635,24 +2048,20 @@ function shouldUseColor(ci, colorMode) {
635
2048
  }
636
2049
  return typeof process !== "undefined" && Boolean(process.stdout && process.stdout.isTTY);
637
2050
  }
638
- function printCiSummary(args, results, budgets) {
639
- if (!args.ci) {
2051
+ function printCiSummary(params) {
2052
+ if (!params.isCi && !params.failOnBudget) {
640
2053
  return;
641
2054
  }
642
- if (!budgets) {
643
- // eslint-disable-next-line no-console
644
- console.log("\nCI mode: no budgets configured. Skipping threshold checks.");
645
- return;
646
- }
647
- const violations = collectBudgetViolations(results, budgets);
648
- if (violations.length === 0) {
649
- // eslint-disable-next-line no-console
650
- console.log("\nCI budgets PASSED.");
2055
+ if (params.violations.length === 0) {
2056
+ if (params.isCi) {
2057
+ // eslint-disable-next-line no-console
2058
+ console.log("\nCI budgets PASSED.");
2059
+ }
651
2060
  return;
652
2061
  }
653
2062
  // eslint-disable-next-line no-console
654
- console.log(`\nCI budgets FAILED (${violations.length} violations):`);
655
- for (const violation of violations) {
2063
+ console.log(`\nBudgets FAILED (${params.violations.length} violations):`);
2064
+ for (const violation of params.violations) {
656
2065
  // eslint-disable-next-line no-console
657
2066
  console.log(`- ${violation.pageLabel} ${violation.path} [${violation.device}] – ${violation.kind} ${violation.id}: ${violation.value} vs limit ${violation.limit}`);
658
2067
  }
@@ -721,16 +2130,16 @@ function printLowestPerformancePages(results, useColor) {
721
2130
  if (worst.length === 0) {
722
2131
  return;
723
2132
  }
724
- // eslint-disable-next-line no-console
725
- console.log("\nLowest Performance pages:");
2133
+ const lines = ["Lowest Performance pages:"];
726
2134
  for (const entry of worst) {
727
2135
  const perfText = colourScore(entry.performance, useColor);
728
2136
  const label = entry.result.label;
729
2137
  const path = entry.result.path;
730
2138
  const device = entry.result.device;
731
- // eslint-disable-next-line no-console
732
- console.log(`- ${label} ${path} [${device}] P:${perfText}`);
2139
+ lines.push(`- ${label} ${path} [${device}] P:${perfText}`);
733
2140
  }
2141
+ // eslint-disable-next-line no-console
2142
+ console.log(boxifyWithSeparators(lines));
734
2143
  }
735
2144
  function collectMetricViolations(result, metricsBudgets, allViolations) {
736
2145
  const metrics = result.metrics;
@@ -776,3 +2185,61 @@ function openInBrowser(filePath) {
776
2185
  }
777
2186
  });
778
2187
  }
2188
+ function printReportLink(reportPath) {
2189
+ const fileUrl = `file://${reportPath.replace(/\\/g, "/")}`;
2190
+ // eslint-disable-next-line no-console
2191
+ console.log(`\nReport saved to: ${reportPath}`);
2192
+ // eslint-disable-next-line no-console
2193
+ console.log(`Open report: ${fileUrl}`);
2194
+ }
2195
+ function printSectionHeader(label, useColor) {
2196
+ const decorated = useColor ? `${ANSI_BLUE}${label}${ANSI_RESET}` : label;
2197
+ // eslint-disable-next-line no-console
2198
+ console.log(`\n┌─ ${decorated} ${"─".repeat(Math.max(0, 30 - label.length))}`);
2199
+ }
2200
+ function printDivider() {
2201
+ // eslint-disable-next-line no-console
2202
+ console.log("├" + "─".repeat(40));
2203
+ }
2204
+ function boxify(lines) {
2205
+ if (lines.length === 0) {
2206
+ return "";
2207
+ }
2208
+ const maxWidth = Math.max(...lines.map((line) => line.length));
2209
+ const top = `┌${"─".repeat(maxWidth + 2)}┐`;
2210
+ const bottom = `└${"─".repeat(maxWidth + 2)}┘`;
2211
+ const body = lines.map((line) => `│ ${line.padEnd(maxWidth, " ")} │`);
2212
+ return [top, ...body, bottom].join("\n");
2213
+ }
2214
+ function boxifyWithSeparators(lines) {
2215
+ if (lines.length === 0) {
2216
+ return "";
2217
+ }
2218
+ const maxWidth = Math.max(...lines.map((line) => line.length));
2219
+ const top = `┌${"─".repeat(maxWidth + 2)}┐`;
2220
+ const bottom = `└${"─".repeat(maxWidth + 2)}┘`;
2221
+ const sep = `├${"─".repeat(maxWidth + 2)}┤`;
2222
+ const body = lines.flatMap((line, index) => {
2223
+ const row = `│ ${line.padEnd(maxWidth, " ")} │`;
2224
+ if (index === lines.length - 1) {
2225
+ return [row];
2226
+ }
2227
+ return [row, sep];
2228
+ });
2229
+ return [top, ...body, bottom].join("\n");
2230
+ }
2231
+ function handleFriendlyError(error) {
2232
+ const message = error instanceof Error ? error.message : String(error);
2233
+ if (message.includes("Could not reach")) {
2234
+ // eslint-disable-next-line no-console
2235
+ console.error("Cannot reach the target URL. Is your dev server running and accessible from this machine?");
2236
+ return;
2237
+ }
2238
+ if (message.includes("LanternError")) {
2239
+ // eslint-disable-next-line no-console
2240
+ console.error("Lighthouse trace analysis failed (Lantern). Try: reduce parallelism, set --throttling devtools, or rerun with fewer pages.");
2241
+ return;
2242
+ }
2243
+ // eslint-disable-next-line no-console
2244
+ console.error(message);
2245
+ }