apex-auditor 0.2.9 → 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,18 +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;
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;
34
580
  for (let i = 2; i < argv.length; i += 1) {
35
581
  const arg = argv[i];
36
582
  if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
@@ -40,11 +586,14 @@ function parseArgs(argv) {
40
586
  else if (arg === "--ci") {
41
587
  ci = true;
42
588
  }
589
+ else if (arg === "--fail-on-budget") {
590
+ failOnBudget = true;
591
+ }
43
592
  else if (arg === "--no-color") {
44
- colorMode = "off";
593
+ colorMode = "never";
45
594
  }
46
595
  else if (arg === "--color") {
47
- colorMode = "on";
596
+ colorMode = "always";
48
597
  }
49
598
  else if (arg === "--log-level" && i + 1 < argv.length) {
50
599
  const value = argv[i + 1];
@@ -52,7 +601,7 @@ function parseArgs(argv) {
52
601
  logLevelOverride = value;
53
602
  }
54
603
  else {
55
- throw new Error(`Invalid --log-level value: ${value}`);
604
+ throw new Error(`Unknown argument: ${arg}`);
56
605
  }
57
606
  i += 1;
58
607
  }
@@ -94,41 +643,490 @@ function parseArgs(argv) {
94
643
  parallelOverride = value;
95
644
  i += 1;
96
645
  }
97
- 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") {
98
705
  openReport = true;
99
706
  }
100
707
  else if (arg === "--warm-up") {
101
708
  warmUp = true;
102
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
+ }
103
740
  else if (arg === "--json") {
104
741
  jsonOutput = true;
105
742
  }
743
+ else if (arg === "--show-parallel") {
744
+ showParallel = true;
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");
106
756
  }
107
757
  const finalConfigPath = configPath ?? "apex.config.json";
108
- return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter, throttlingMethodOverride, cpuSlowdownOverride, parallelOverride, openReport, warmUp, jsonOutput };
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);
109
1057
  }
110
1058
  /**
111
1059
  * Runs the ApexAuditor audit CLI.
112
1060
  *
113
1061
  * @param argv - The process arguments array.
114
1062
  */
115
- export async function runAuditCli(argv) {
1063
+ export async function runAuditCli(argv, options) {
116
1064
  const args = parseArgs(argv);
117
1065
  const startTimeMs = Date.now();
118
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;
119
1073
  const effectiveLogLevel = args.logLevelOverride ?? config.logLevel;
120
- const effectiveThrottling = args.throttlingMethodOverride ?? config.throttlingMethod;
1074
+ const effectiveThrottling = args.fast ? "simulate" : args.throttlingMethodOverride ?? presetThrottling ?? config.throttlingMethod;
121
1075
  const effectiveCpuSlowdown = args.cpuSlowdownOverride ?? config.cpuSlowdownMultiplier;
122
- const effectiveParallel = args.parallelOverride ?? config.parallel;
123
- const effectiveWarmUp = args.warmUp || config.warmUp === true;
124
- 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 = {
125
1098
  ...config,
1099
+ buildId: effectiveBuildId,
126
1100
  logLevel: effectiveLogLevel,
127
1101
  throttlingMethod: effectiveThrottling,
128
1102
  cpuSlowdownMultiplier: effectiveCpuSlowdown,
129
1103
  parallel: effectiveParallel,
1104
+ auditTimeoutMs: effectiveAuditTimeoutMs,
130
1105
  warmUp: effectiveWarmUp,
1106
+ incremental: finalIncremental,
1107
+ runs: effectiveRuns,
131
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
+ }
132
1130
  const filteredConfig = filterConfigDevices(effectiveConfig, args.deviceFilter);
133
1131
  if (filteredConfig.pages.length === 0) {
134
1132
  // eslint-disable-next-line no-console
@@ -136,15 +1134,170 @@ export async function runAuditCli(argv) {
136
1134
  process.exitCode = 1;
137
1135
  return;
138
1136
  }
139
- const summary = await runAuditsForConfig({ config: filteredConfig, configPath });
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
+ }
140
1275
  const outputDir = resolve(".apex-auditor");
141
1276
  await mkdir(outputDir, { recursive: true });
142
1277
  await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
143
- const markdown = buildMarkdown(summary.results);
1278
+ const markdown = buildMarkdown(summary);
144
1279
  await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
145
- const html = buildHtmlReport(summary.results, summary.configPath);
1280
+ const html = buildHtmlReport(summary);
146
1281
  const reportPath = resolve(outputDir, "report.html");
147
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);
148
1301
  // Open HTML report in browser if requested
149
1302
  if (args.openReport) {
150
1303
  openInBrowser(reportPath);
@@ -155,23 +1308,110 @@ export async function runAuditCli(argv) {
155
1308
  console.log(JSON.stringify(summary, null, 2));
156
1309
  return;
157
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
+ }
158
1318
  // Also echo a compact, colourised table to stdout for quick viewing.
159
- const useColor = shouldUseColor(args.ci, args.colorMode);
160
- 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);
161
1340
  // eslint-disable-next-line no-console
162
- console.log(consoleTable);
163
- printSummaryStats(summary.results, useColor);
164
- printRedIssues(summary.results);
165
- printCiSummary(args, summary.results, effectiveConfig.budgets);
166
- printLowestPerformancePages(summary.results, useColor);
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);
1386
+ // eslint-disable-next-line no-console
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 }));
167
1391
  const elapsedMs = Date.now() - startTimeMs;
168
1392
  const elapsedText = formatElapsedTime(elapsedMs);
169
1393
  const elapsedDisplay = useColor ? `${ANSI_CYAN}${elapsedText}${ANSI_RESET}` : elapsedText;
170
1394
  const runsPerTarget = effectiveConfig.runs ?? 1;
171
1395
  const comboCount = summary.results.length;
172
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
+ : "";
173
1400
  // eslint-disable-next-line no-console
174
- 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
+ }
175
1415
  }
176
1416
  function filterConfigDevices(config, deviceFilter) {
177
1417
  if (deviceFilter === undefined) {
@@ -185,6 +1425,39 @@ function filterConfigDevices(config, deviceFilter) {
185
1425
  pages: filteredPages,
186
1426
  };
187
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
+ }
188
1461
  function filterPageDevices(page, deviceFilter) {
189
1462
  const devices = page.devices.filter((device) => device === deviceFilter);
190
1463
  if (devices.length === 0) {
@@ -195,17 +1468,45 @@ function filterPageDevices(page, deviceFilter) {
195
1468
  devices,
196
1469
  };
197
1470
  }
198
- function buildMarkdown(results) {
1471
+ function buildMarkdown(summary) {
1472
+ const meta = summary.meta;
1473
+ const metaTable = [
1474
+ "| Field | Value |",
1475
+ "|-------|-------|",
1476
+ `| Config | ${meta.configPath} |`,
1477
+ `| Build ID | ${meta.buildId ?? "-"} |`,
1478
+ `| Incremental | ${meta.incremental ? "yes" : "no"} |`,
1479
+ `| Resolved parallel | ${meta.resolvedParallel} |`,
1480
+ `| Warm-up | ${meta.warmUp ? "yes" : "no"} |`,
1481
+ `| Throttling | ${meta.throttlingMethod} |`,
1482
+ `| CPU slowdown | ${meta.cpuSlowdownMultiplier} |`,
1483
+ `| Combos | ${meta.comboCount} |`,
1484
+ `| Executed combos | ${meta.executedCombos} |`,
1485
+ `| Cached combos | ${meta.cachedCombos} |`,
1486
+ `| Runs per combo | ${meta.runsPerCombo} |`,
1487
+ `| Total steps | ${meta.totalSteps} |`,
1488
+ `| Executed steps | ${meta.executedSteps} |`,
1489
+ `| Cached steps | ${meta.cachedSteps} |`,
1490
+ `| Started | ${meta.startedAt} |`,
1491
+ `| Completed | ${meta.completedAt} |`,
1492
+ `| Elapsed | ${formatElapsedTime(meta.elapsedMs)} |`,
1493
+ `| Avg per step | ${formatElapsedTime(meta.averageStepMs)} |`,
1494
+ ].join("\n");
199
1495
  const header = [
200
1496
  "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | INP (ms) | Error | Top issues |",
201
1497
  "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|----------|-------|-----------|",
202
1498
  ].join("\n");
203
- const lines = results.map((result) => buildRow(result));
204
- return `${header}\n${lines.join("\n")}`;
1499
+ const lines = summary.results.map((result) => buildRow(result));
1500
+ return `${metaTable}\n\n${header}\n${lines.join("\n")}`;
205
1501
  }
206
- function buildHtmlReport(results, configPath) {
1502
+ function buildHtmlReport(summary) {
1503
+ const results = summary.results;
1504
+ const meta = summary.meta;
207
1505
  const timestamp = new Date().toISOString();
208
1506
  const rows = results.map((result) => buildHtmlRow(result)).join("\n");
1507
+ const cacheSummary = meta.incremental
1508
+ ? `${meta.executedCombos} executed / ${meta.cachedCombos} cached`
1509
+ : "disabled";
209
1510
  return `<!DOCTYPE html>
210
1511
  <html lang="en">
211
1512
  <head>
@@ -213,40 +1514,140 @@ function buildHtmlReport(results, configPath) {
213
1514
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
214
1515
  <title>ApexAuditor Report</title>
215
1516
  <style>
216
- :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
+ }
217
1529
  * { box-sizing: border-box; margin: 0; padding: 0; }
218
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); padding: 2rem; }
219
- h1 { margin-bottom: 0.5rem; }
220
- .meta { color: #888; margin-bottom: 2rem; font-size: 0.9rem; }
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); }
221
1554
  .cards { display: grid; gap: 1.5rem; }
222
- .card { background: var(--card); border-radius: 12px; padding: 1.5rem; }
223
- .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 1px solid #333; padding-bottom: 1rem; }
224
- .card-title { font-size: 1.1rem; font-weight: 600; }
225
- .device-badge { font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; background: #333; }
226
- .device-badge.mobile { background: #0891b2; }
227
- .device-badge.desktop { background: #7c3aed; }
228
- .scores { display: flex; gap: 1rem; margin-bottom: 1rem; }
229
- .score-item { text-align: center; flex: 1; }
230
- .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; }
231
- .score-circle.green { border-color: var(--green); color: var(--green); }
232
- .score-circle.yellow { border-color: var(--yellow); color: var(--yellow); }
233
- .score-circle.red { border-color: var(--red); color: var(--red); }
234
- .score-label { font-size: 0.75rem; color: #888; }
235
- .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 1rem; }
236
- .metric { background: #1a1a2e; padding: 0.75rem; border-radius: 8px; text-align: center; }
237
- .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; }
238
1613
  .metric-value.green { color: var(--green); }
239
1614
  .metric-value.yellow { color: var(--yellow); }
240
1615
  .metric-value.red { color: var(--red); }
241
- .metric-label { font-size: 0.7rem; color: #888; margin-top: 0.25rem; }
242
- .issues { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #333; }
243
- .issues-title { font-size: 0.8rem; color: #888; margin-bottom: 0.5rem; }
244
- .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; }
245
1633
  </style>
246
1634
  </head>
247
1635
  <body>
248
1636
  <h1>ApexAuditor Report</h1>
249
- <p class="meta">Generated: ${timestamp} | Config: ${escapeHtml(configPath)}</p>
1637
+ <p class="meta">Generated: ${timestamp}</p>
1638
+ <div class="meta-grid">
1639
+ ${buildMetaCard("Build ID", meta.buildId ?? "-")}
1640
+ ${buildMetaCard("Incremental", meta.incremental ? "Yes" : "No")}
1641
+ ${buildMetaCard("Cache", cacheSummary)}
1642
+ ${buildMetaCard("Resolved parallel", meta.resolvedParallel.toString())}
1643
+ ${buildMetaCard("Elapsed", formatElapsedTime(meta.elapsedMs))}
1644
+ ${buildMetaCard("Avg / step", formatElapsedTime(meta.averageStepMs))}
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")}
1650
+ </div>
250
1651
  <div class="cards">
251
1652
  ${rows}
252
1653
  </div>
@@ -291,6 +1692,36 @@ function buildScoreCircle(label, score) {
291
1692
  function buildMetricBox(label, value, colorClass) {
292
1693
  return `<div class="metric"><div class="metric-value ${colorClass}">${value}</div><div class="metric-label">${label}</div></div>`;
293
1694
  }
1695
+ function buildMetaCard(label, value) {
1696
+ return `<div class="meta-card"><div class="meta-label">${escapeHtml(label)}</div><div class="meta-value">${escapeHtml(value)}</div></div>`;
1697
+ }
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";
1702
+ const rows = [
1703
+ { label: "Build ID", value: meta.buildId ?? "-" },
1704
+ { label: "Incremental", value: meta.incremental ? "Yes" : "No" },
1705
+ { label: "Resolved parallel", value: meta.resolvedParallel.toString() },
1706
+ { label: "Warm-up", value: meta.warmUp ? "Yes" : "No" },
1707
+ { label: "Throttling", value: meta.throttlingMethod },
1708
+ { label: "CPU slowdown", value: meta.cpuSlowdownMultiplier.toString() },
1709
+ { label: "Combos", value: meta.comboCount.toString() },
1710
+ { label: "Cache", value: incrementalSummary },
1711
+ { label: "Runs per combo", value: meta.runsPerCombo.toString() },
1712
+ { label: "Total steps", value: meta.totalSteps.toString() },
1713
+ { label: "Elapsed", value: formatElapsedTime(meta.elapsedMs) },
1714
+ { label: "Avg / step", value: formatElapsedTime(meta.averageStepMs) },
1715
+ ];
1716
+ const padLabel = (label) => label.padEnd(16, " ");
1717
+ // eslint-disable-next-line no-console
1718
+ console.log("\nMeta:");
1719
+ for (const row of rows) {
1720
+ const value = useColor ? `${ANSI_CYAN}${row.value}${ANSI_RESET}` : row.value;
1721
+ // eslint-disable-next-line no-console
1722
+ console.log(` ${padLabel(row.label)} ${value}`);
1723
+ }
1724
+ }
294
1725
  function getMetricClass(value, good, warn) {
295
1726
  if (value === undefined)
296
1727
  return "";
@@ -414,6 +1845,52 @@ function formatOpportunityLabel(opportunity) {
414
1845
  const suffix = parts.length > 0 ? ` (${parts.join(", ")})` : "";
415
1846
  return `${opportunity.id}${suffix}`;
416
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
+ }
417
1894
  function formatMetricSeconds(valueMs, goodThresholdMs, warnThresholdMs, useColor) {
418
1895
  if (valueMs === undefined) {
419
1896
  return "-";
@@ -533,10 +2010,11 @@ function printRedIssues(results) {
533
2010
  isRedScore(scores.seo));
534
2011
  });
535
2012
  if (redResults.length === 0) {
2013
+ // eslint-disable-next-line no-console
2014
+ console.log(boxify(["No red issues."]));
536
2015
  return;
537
2016
  }
538
- // eslint-disable-next-line no-console
539
- console.log("\nRed issues (scores below 50):");
2017
+ const lines = ["Red issues (scores below 50):"];
540
2018
  for (const result of redResults) {
541
2019
  const scores = result.scores;
542
2020
  const badParts = [];
@@ -553,15 +2031,16 @@ function printRedIssues(results) {
553
2031
  badParts.push(`SEO:${scores.seo}`);
554
2032
  }
555
2033
  const issues = formatTopIssues(result.opportunities);
556
- // eslint-disable-next-line no-console
557
- console.log(`- ${result.label} ${result.path} [${result.device}] – ${badParts.join(", ")} – ${issues}`);
2034
+ lines.push(`- ${result.label} ${result.path} [${result.device}] – ${badParts.join(", ")} – ${issues}`);
558
2035
  }
2036
+ // eslint-disable-next-line no-console
2037
+ console.log(boxifyWithSeparators(lines));
559
2038
  }
560
2039
  function shouldUseColor(ci, colorMode) {
561
- if (colorMode === "on") {
2040
+ if (colorMode === "always") {
562
2041
  return true;
563
2042
  }
564
- if (colorMode === "off") {
2043
+ if (colorMode === "never") {
565
2044
  return false;
566
2045
  }
567
2046
  if (ci) {
@@ -569,24 +2048,20 @@ function shouldUseColor(ci, colorMode) {
569
2048
  }
570
2049
  return typeof process !== "undefined" && Boolean(process.stdout && process.stdout.isTTY);
571
2050
  }
572
- function printCiSummary(args, results, budgets) {
573
- if (!args.ci) {
2051
+ function printCiSummary(params) {
2052
+ if (!params.isCi && !params.failOnBudget) {
574
2053
  return;
575
2054
  }
576
- if (!budgets) {
577
- // eslint-disable-next-line no-console
578
- console.log("\nCI mode: no budgets configured. Skipping threshold checks.");
579
- return;
580
- }
581
- const violations = collectBudgetViolations(results, budgets);
582
- if (violations.length === 0) {
583
- // eslint-disable-next-line no-console
584
- 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
+ }
585
2060
  return;
586
2061
  }
587
2062
  // eslint-disable-next-line no-console
588
- console.log(`\nCI budgets FAILED (${violations.length} violations):`);
589
- for (const violation of violations) {
2063
+ console.log(`\nBudgets FAILED (${params.violations.length} violations):`);
2064
+ for (const violation of params.violations) {
590
2065
  // eslint-disable-next-line no-console
591
2066
  console.log(`- ${violation.pageLabel} ${violation.path} [${violation.device}] – ${violation.kind} ${violation.id}: ${violation.value} vs limit ${violation.limit}`);
592
2067
  }
@@ -655,16 +2130,16 @@ function printLowestPerformancePages(results, useColor) {
655
2130
  if (worst.length === 0) {
656
2131
  return;
657
2132
  }
658
- // eslint-disable-next-line no-console
659
- console.log("\nLowest Performance pages:");
2133
+ const lines = ["Lowest Performance pages:"];
660
2134
  for (const entry of worst) {
661
2135
  const perfText = colourScore(entry.performance, useColor);
662
2136
  const label = entry.result.label;
663
2137
  const path = entry.result.path;
664
2138
  const device = entry.result.device;
665
- // eslint-disable-next-line no-console
666
- console.log(`- ${label} ${path} [${device}] P:${perfText}`);
2139
+ lines.push(`- ${label} ${path} [${device}] P:${perfText}`);
667
2140
  }
2141
+ // eslint-disable-next-line no-console
2142
+ console.log(boxifyWithSeparators(lines));
668
2143
  }
669
2144
  function collectMetricViolations(result, metricsBudgets, allViolations) {
670
2145
  const metrics = result.metrics;
@@ -710,3 +2185,61 @@ function openInBrowser(filePath) {
710
2185
  }
711
2186
  });
712
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
+ }