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/README.md +49 -99
- package/dist/accessibility-types.js +1 -0
- package/dist/accessibility.js +152 -0
- package/dist/axe-script.js +26 -0
- package/dist/bin.js +185 -9
- package/dist/cdp-client.js +264 -0
- package/dist/cli.js +1608 -75
- package/dist/config.js +11 -0
- package/dist/lighthouse-runner.js +580 -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 +15 -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,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 = "
|
|
593
|
+
colorMode = "never";
|
|
45
594
|
}
|
|
46
595
|
else if (arg === "--color") {
|
|
47
|
-
colorMode = "
|
|
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(`
|
|
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 === "--
|
|
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 {
|
|
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
|
|
124
|
-
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 = {
|
|
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
|
|
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
|
|
1278
|
+
const markdown = buildMarkdown(summary);
|
|
144
1279
|
await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
|
|
145
|
-
const html = buildHtmlReport(summary
|
|
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
|
-
|
|
160
|
-
|
|
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(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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 {
|
|
219
|
-
|
|
220
|
-
|
|
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 {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
.
|
|
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.
|
|
242
|
-
.issues {
|
|
243
|
-
|
|
244
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
2040
|
+
if (colorMode === "always") {
|
|
562
2041
|
return true;
|
|
563
2042
|
}
|
|
564
|
-
if (colorMode === "
|
|
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(
|
|
573
|
-
if (!
|
|
2051
|
+
function printCiSummary(params) {
|
|
2052
|
+
if (!params.isCi && !params.failOnBudget) {
|
|
574
2053
|
return;
|
|
575
2054
|
}
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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(`\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|