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/README.md +49 -100
- package/dist/accessibility-types.js +1 -0
- package/dist/accessibility.js +152 -0
- package/dist/axe-script.js +26 -0
- package/dist/bin.js +183 -9
- package/dist/cdp-client.js +264 -0
- package/dist/cli.js +1549 -82
- package/dist/config.js +11 -0
- package/dist/lighthouse-runner.js +524 -54
- package/dist/lighthouse-worker.js +248 -0
- package/dist/measure-cli.js +139 -0
- package/dist/measure-runner.js +447 -0
- package/dist/measure-types.js +1 -0
- package/dist/shell-cli.js +566 -0
- package/dist/spinner.js +37 -0
- package/dist/ui/render-panel.js +46 -0
- package/dist/ui/render-table.js +61 -0
- package/dist/ui/ui-theme.js +47 -0
- package/dist/url.js +6 -0
- package/dist/webhooks.js +29 -0
- package/dist/wizard-cli.js +14 -22
- package/package.json +4 -2
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 = "
|
|
593
|
+
colorMode = "never";
|
|
46
594
|
}
|
|
47
595
|
else if (arg === "--color") {
|
|
48
|
-
colorMode = "
|
|
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(`
|
|
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 === "--
|
|
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 {
|
|
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
|
|
128
|
-
const
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 {
|
|
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 {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
.
|
|
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.
|
|
270
|
-
.issues {
|
|
271
|
-
|
|
272
|
-
|
|
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("
|
|
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("
|
|
290
|
-
${buildMetaCard("
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
2040
|
+
if (colorMode === "always") {
|
|
628
2041
|
return true;
|
|
629
2042
|
}
|
|
630
|
-
if (colorMode === "
|
|
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(
|
|
639
|
-
if (!
|
|
2051
|
+
function printCiSummary(params) {
|
|
2052
|
+
if (!params.isCi && !params.failOnBudget) {
|
|
640
2053
|
return;
|
|
641
2054
|
}
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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(`\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|