apex-auditor 0.2.8 → 0.2.9
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 +67 -6
- package/dist/cli.js +224 -5
- package/dist/config.js +20 -1
- package/dist/lighthouse-runner.js +178 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@ It focuses on:
|
|
|
7
7
|
- **Multi-page, multi-device audits**: run Lighthouse across your key flows in one shot.
|
|
8
8
|
- **Framework flexibility**: works with any stack that serves HTTP (Next.js, Remix, Vite/React, SvelteKit, Rails, static sites, etc.).
|
|
9
9
|
- **Smart route discovery**: auto-detects routes for Next.js (App/Pages), Remix, SvelteKit, and can crawl generic SPAs.
|
|
10
|
-
- **Developer-friendly reports**: readable console output, Markdown tables, and JSON summaries for CI.
|
|
10
|
+
- **Developer-friendly reports**: readable console output, Markdown tables, HTML reports, and JSON summaries for CI.
|
|
11
|
+
- **Configurable throttling**: tune CPU/network throttling for accuracy vs speed trade-offs.
|
|
12
|
+
- **Parallel execution**: speed up batch testing with multiple Chrome instances.
|
|
11
13
|
|
|
12
14
|
---
|
|
13
15
|
|
|
@@ -72,11 +74,66 @@ The wizard can detect routes for:
|
|
|
72
74
|
apex-auditor audit --config apex.config.json
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
**CLI flags:**
|
|
78
|
+
|
|
79
|
+
| Flag | Description |
|
|
80
|
+
|------|-------------|
|
|
81
|
+
| `--ci` | Enable CI mode with budgets and non-zero exit codes |
|
|
82
|
+
| `--no-color` / `--color` | Control ANSI colours in console output |
|
|
83
|
+
| `--log-level <level>` | Override Lighthouse log level (`silent`, `error`, `info`, `verbose`) |
|
|
84
|
+
| `--throttling <method>` | Throttling method: `simulate` (fast) or `devtools` (accurate) |
|
|
85
|
+
| `--cpu-slowdown <n>` | CPU slowdown multiplier (1-20, default: 4) |
|
|
86
|
+
| `--parallel <n>` | Number of pages to audit in parallel (1-10) |
|
|
87
|
+
| `--warm-up` | Perform warm-up requests before auditing |
|
|
88
|
+
| `--open` | Auto-open HTML report in browser after audit |
|
|
89
|
+
| `--json` | Output JSON to stdout (for piping) |
|
|
90
|
+
| `--mobile-only` | Only audit mobile device configurations |
|
|
91
|
+
| `--desktop-only` | Only audit desktop device configurations |
|
|
76
92
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Output files
|
|
96
|
+
|
|
97
|
+
After each audit, results are saved to `.apex-auditor/`:
|
|
98
|
+
|
|
99
|
+
- `summary.json` – structured JSON results
|
|
100
|
+
- `summary.md` – Markdown table
|
|
101
|
+
- `report.html` – visual HTML report with score circles and metrics
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
Example `apex.config.json`:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"baseUrl": "http://localhost:3000",
|
|
112
|
+
"runs": 3,
|
|
113
|
+
"throttlingMethod": "devtools",
|
|
114
|
+
"cpuSlowdownMultiplier": 4,
|
|
115
|
+
"parallel": 2,
|
|
116
|
+
"warmUp": true,
|
|
117
|
+
"pages": [
|
|
118
|
+
{ "path": "/", "label": "home", "devices": ["mobile", "desktop"] },
|
|
119
|
+
{ "path": "/docs", "label": "docs", "devices": ["mobile"] }
|
|
120
|
+
],
|
|
121
|
+
"budgets": {
|
|
122
|
+
"categories": { "performance": 80, "accessibility": 90 },
|
|
123
|
+
"metrics": { "lcpMs": 2500, "inpMs": 200 }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Metrics tracked
|
|
131
|
+
|
|
132
|
+
- **LCP** (Largest Contentful Paint)
|
|
133
|
+
- **FCP** (First Contentful Paint)
|
|
134
|
+
- **TBT** (Total Blocking Time)
|
|
135
|
+
- **CLS** (Cumulative Layout Shift)
|
|
136
|
+
- **INP** (Interaction to Next Paint) - Core Web Vital
|
|
80
137
|
|
|
81
138
|
---
|
|
82
139
|
|
|
@@ -88,4 +145,8 @@ For detailed guides, configuration options, and CI examples, see the `docs/` dir
|
|
|
88
145
|
- `docs/configuration-and-routes.md` – `apex.config.json` schema and route detection details.
|
|
89
146
|
- `docs/cli-and-ci.md` – CLI flags, CI mode, budgets, and example workflows.
|
|
90
147
|
|
|
91
|
-
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
3
4
|
import { loadConfig } from "./config.js";
|
|
4
5
|
import { runAuditsForConfig } from "./lighthouse-runner.js";
|
|
5
6
|
const ANSI_RESET = "\u001B[0m";
|
|
@@ -16,12 +17,20 @@ const TBT_GOOD_MS = 200;
|
|
|
16
17
|
const TBT_WARN_MS = 600;
|
|
17
18
|
const CLS_GOOD = 0.1;
|
|
18
19
|
const CLS_WARN = 0.25;
|
|
20
|
+
const INP_GOOD_MS = 200;
|
|
21
|
+
const INP_WARN_MS = 500;
|
|
19
22
|
function parseArgs(argv) {
|
|
20
23
|
let configPath;
|
|
21
24
|
let ci = false;
|
|
22
25
|
let colorMode = "auto";
|
|
23
26
|
let logLevelOverride;
|
|
24
27
|
let deviceFilter;
|
|
28
|
+
let throttlingMethodOverride;
|
|
29
|
+
let cpuSlowdownOverride;
|
|
30
|
+
let parallelOverride;
|
|
31
|
+
let openReport = false;
|
|
32
|
+
let warmUp = false;
|
|
33
|
+
let jsonOutput = false;
|
|
25
34
|
for (let i = 2; i < argv.length; i += 1) {
|
|
26
35
|
const arg = argv[i];
|
|
27
36
|
if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
|
|
@@ -59,9 +68,44 @@ function parseArgs(argv) {
|
|
|
59
68
|
}
|
|
60
69
|
deviceFilter = "desktop";
|
|
61
70
|
}
|
|
71
|
+
else if (arg === "--throttling" && i + 1 < argv.length) {
|
|
72
|
+
const value = argv[i + 1];
|
|
73
|
+
if (value === "simulate" || value === "devtools") {
|
|
74
|
+
throttlingMethodOverride = value;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
throw new Error(`Invalid --throttling value: ${value}. Expected "simulate" or "devtools".`);
|
|
78
|
+
}
|
|
79
|
+
i += 1;
|
|
80
|
+
}
|
|
81
|
+
else if (arg === "--cpu-slowdown" && i + 1 < argv.length) {
|
|
82
|
+
const value = parseFloat(argv[i + 1]);
|
|
83
|
+
if (Number.isNaN(value) || value <= 0 || value > 20) {
|
|
84
|
+
throw new Error(`Invalid --cpu-slowdown value: ${argv[i + 1]}. Expected number between 0 and 20.`);
|
|
85
|
+
}
|
|
86
|
+
cpuSlowdownOverride = value;
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
else if (arg === "--parallel" && i + 1 < argv.length) {
|
|
90
|
+
const value = parseInt(argv[i + 1], 10);
|
|
91
|
+
if (Number.isNaN(value) || value < 1 || value > 10) {
|
|
92
|
+
throw new Error(`Invalid --parallel value: ${argv[i + 1]}. Expected integer between 1 and 10.`);
|
|
93
|
+
}
|
|
94
|
+
parallelOverride = value;
|
|
95
|
+
i += 1;
|
|
96
|
+
}
|
|
97
|
+
else if (arg === "--open") {
|
|
98
|
+
openReport = true;
|
|
99
|
+
}
|
|
100
|
+
else if (arg === "--warm-up") {
|
|
101
|
+
warmUp = true;
|
|
102
|
+
}
|
|
103
|
+
else if (arg === "--json") {
|
|
104
|
+
jsonOutput = true;
|
|
105
|
+
}
|
|
62
106
|
}
|
|
63
107
|
const finalConfigPath = configPath ?? "apex.config.json";
|
|
64
|
-
return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter };
|
|
108
|
+
return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter, throttlingMethodOverride, cpuSlowdownOverride, parallelOverride, openReport, warmUp, jsonOutput };
|
|
65
109
|
}
|
|
66
110
|
/**
|
|
67
111
|
* Runs the ApexAuditor audit CLI.
|
|
@@ -73,9 +117,17 @@ export async function runAuditCli(argv) {
|
|
|
73
117
|
const startTimeMs = Date.now();
|
|
74
118
|
const { configPath, config } = await loadConfig({ configPath: args.configPath });
|
|
75
119
|
const effectiveLogLevel = args.logLevelOverride ?? config.logLevel;
|
|
120
|
+
const effectiveThrottling = args.throttlingMethodOverride ?? config.throttlingMethod;
|
|
121
|
+
const effectiveCpuSlowdown = args.cpuSlowdownOverride ?? config.cpuSlowdownMultiplier;
|
|
122
|
+
const effectiveParallel = args.parallelOverride ?? config.parallel;
|
|
123
|
+
const effectiveWarmUp = args.warmUp || config.warmUp === true;
|
|
76
124
|
const effectiveConfig = {
|
|
77
125
|
...config,
|
|
78
126
|
logLevel: effectiveLogLevel,
|
|
127
|
+
throttlingMethod: effectiveThrottling,
|
|
128
|
+
cpuSlowdownMultiplier: effectiveCpuSlowdown,
|
|
129
|
+
parallel: effectiveParallel,
|
|
130
|
+
warmUp: effectiveWarmUp,
|
|
79
131
|
};
|
|
80
132
|
const filteredConfig = filterConfigDevices(effectiveConfig, args.deviceFilter);
|
|
81
133
|
if (filteredConfig.pages.length === 0) {
|
|
@@ -90,11 +142,25 @@ export async function runAuditCli(argv) {
|
|
|
90
142
|
await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
|
|
91
143
|
const markdown = buildMarkdown(summary.results);
|
|
92
144
|
await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
|
|
145
|
+
const html = buildHtmlReport(summary.results, summary.configPath);
|
|
146
|
+
const reportPath = resolve(outputDir, "report.html");
|
|
147
|
+
await writeFile(reportPath, html, "utf8");
|
|
148
|
+
// Open HTML report in browser if requested
|
|
149
|
+
if (args.openReport) {
|
|
150
|
+
openInBrowser(reportPath);
|
|
151
|
+
}
|
|
152
|
+
// If JSON output requested, print JSON and exit early
|
|
153
|
+
if (args.jsonOutput) {
|
|
154
|
+
// eslint-disable-next-line no-console
|
|
155
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
93
158
|
// Also echo a compact, colourised table to stdout for quick viewing.
|
|
94
159
|
const useColor = shouldUseColor(args.ci, args.colorMode);
|
|
95
160
|
const consoleTable = buildConsoleTable(summary.results, useColor);
|
|
96
161
|
// eslint-disable-next-line no-console
|
|
97
162
|
console.log(consoleTable);
|
|
163
|
+
printSummaryStats(summary.results, useColor);
|
|
98
164
|
printRedIssues(summary.results);
|
|
99
165
|
printCiSummary(args, summary.results, effectiveConfig.budgets);
|
|
100
166
|
printLowestPerformancePages(summary.results, useColor);
|
|
@@ -131,12 +197,108 @@ function filterPageDevices(page, deviceFilter) {
|
|
|
131
197
|
}
|
|
132
198
|
function buildMarkdown(results) {
|
|
133
199
|
const header = [
|
|
134
|
-
"| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Error | Top issues |",
|
|
135
|
-
"
|
|
200
|
+
"| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | INP (ms) | Error | Top issues |",
|
|
201
|
+
"|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|----------|-------|-----------|",
|
|
136
202
|
].join("\n");
|
|
137
203
|
const lines = results.map((result) => buildRow(result));
|
|
138
204
|
return `${header}\n${lines.join("\n")}`;
|
|
139
205
|
}
|
|
206
|
+
function buildHtmlReport(results, configPath) {
|
|
207
|
+
const timestamp = new Date().toISOString();
|
|
208
|
+
const rows = results.map((result) => buildHtmlRow(result)).join("\n");
|
|
209
|
+
return `<!DOCTYPE html>
|
|
210
|
+
<html lang="en">
|
|
211
|
+
<head>
|
|
212
|
+
<meta charset="UTF-8">
|
|
213
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
214
|
+
<title>ApexAuditor Report</title>
|
|
215
|
+
<style>
|
|
216
|
+
:root { --green: #0cce6b; --yellow: #ffa400; --red: #ff4e42; --bg: #1a1a2e; --card: #16213e; --text: #eee; }
|
|
217
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
218
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); padding: 2rem; }
|
|
219
|
+
h1 { margin-bottom: 0.5rem; }
|
|
220
|
+
.meta { color: #888; margin-bottom: 2rem; font-size: 0.9rem; }
|
|
221
|
+
.cards { display: grid; gap: 1.5rem; }
|
|
222
|
+
.card { background: var(--card); border-radius: 12px; padding: 1.5rem; }
|
|
223
|
+
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 1px solid #333; padding-bottom: 1rem; }
|
|
224
|
+
.card-title { font-size: 1.1rem; font-weight: 600; }
|
|
225
|
+
.device-badge { font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; background: #333; }
|
|
226
|
+
.device-badge.mobile { background: #0891b2; }
|
|
227
|
+
.device-badge.desktop { background: #7c3aed; }
|
|
228
|
+
.scores { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
|
229
|
+
.score-item { text-align: center; flex: 1; }
|
|
230
|
+
.score-circle { width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: bold; margin: 0 auto 0.5rem; border: 3px solid; }
|
|
231
|
+
.score-circle.green { border-color: var(--green); color: var(--green); }
|
|
232
|
+
.score-circle.yellow { border-color: var(--yellow); color: var(--yellow); }
|
|
233
|
+
.score-circle.red { border-color: var(--red); color: var(--red); }
|
|
234
|
+
.score-label { font-size: 0.75rem; color: #888; }
|
|
235
|
+
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 1rem; }
|
|
236
|
+
.metric { background: #1a1a2e; padding: 0.75rem; border-radius: 8px; text-align: center; }
|
|
237
|
+
.metric-value { font-size: 1.1rem; font-weight: 600; }
|
|
238
|
+
.metric-value.green { color: var(--green); }
|
|
239
|
+
.metric-value.yellow { color: var(--yellow); }
|
|
240
|
+
.metric-value.red { color: var(--red); }
|
|
241
|
+
.metric-label { font-size: 0.7rem; color: #888; margin-top: 0.25rem; }
|
|
242
|
+
.issues { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #333; }
|
|
243
|
+
.issues-title { font-size: 0.8rem; color: #888; margin-bottom: 0.5rem; }
|
|
244
|
+
.issue { font-size: 0.85rem; color: #ccc; padding: 0.25rem 0; }
|
|
245
|
+
</style>
|
|
246
|
+
</head>
|
|
247
|
+
<body>
|
|
248
|
+
<h1>ApexAuditor Report</h1>
|
|
249
|
+
<p class="meta">Generated: ${timestamp} | Config: ${escapeHtml(configPath)}</p>
|
|
250
|
+
<div class="cards">
|
|
251
|
+
${rows}
|
|
252
|
+
</div>
|
|
253
|
+
</body>
|
|
254
|
+
</html>`;
|
|
255
|
+
}
|
|
256
|
+
function buildHtmlRow(result) {
|
|
257
|
+
const scores = result.scores;
|
|
258
|
+
const metrics = result.metrics;
|
|
259
|
+
const lcpSeconds = metrics.lcpMs !== undefined ? (metrics.lcpMs / 1000).toFixed(1) + "s" : "-";
|
|
260
|
+
const fcpSeconds = metrics.fcpMs !== undefined ? (metrics.fcpMs / 1000).toFixed(1) + "s" : "-";
|
|
261
|
+
const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs) + "ms" : "-";
|
|
262
|
+
const clsVal = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
|
|
263
|
+
const inpMs = metrics.inpMs !== undefined ? Math.round(metrics.inpMs) + "ms" : "-";
|
|
264
|
+
const issues = result.opportunities.slice(0, 3).map((o) => `<div class="issue">${escapeHtml(o.title)}${o.estimatedSavingsMs ? ` (${Math.round(o.estimatedSavingsMs)}ms)` : ""}</div>`).join("");
|
|
265
|
+
return ` <div class="card">
|
|
266
|
+
<div class="card-header">
|
|
267
|
+
<div class="card-title">${escapeHtml(result.label)} <span style="color:#888">${escapeHtml(result.path)}</span></div>
|
|
268
|
+
<span class="device-badge ${result.device}">${result.device}</span>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="scores">
|
|
271
|
+
${buildScoreCircle("P", scores.performance)}
|
|
272
|
+
${buildScoreCircle("A", scores.accessibility)}
|
|
273
|
+
${buildScoreCircle("BP", scores.bestPractices)}
|
|
274
|
+
${buildScoreCircle("SEO", scores.seo)}
|
|
275
|
+
</div>
|
|
276
|
+
<div class="metrics">
|
|
277
|
+
${buildMetricBox("LCP", lcpSeconds, getMetricClass(metrics.lcpMs, 2500, 4000))}
|
|
278
|
+
${buildMetricBox("FCP", fcpSeconds, getMetricClass(metrics.fcpMs, 1800, 3000))}
|
|
279
|
+
${buildMetricBox("TBT", tbtMs, getMetricClass(metrics.tbtMs, 200, 600))}
|
|
280
|
+
${buildMetricBox("CLS", clsVal, getMetricClass(metrics.cls, 0.1, 0.25))}
|
|
281
|
+
${buildMetricBox("INP", inpMs, getMetricClass(metrics.inpMs, 200, 500))}
|
|
282
|
+
</div>
|
|
283
|
+
${issues ? `<div class="issues"><div class="issues-title">Top Issues</div>${issues}</div>` : ""}
|
|
284
|
+
</div>`;
|
|
285
|
+
}
|
|
286
|
+
function buildScoreCircle(label, score) {
|
|
287
|
+
const value = score !== undefined ? score.toString() : "-";
|
|
288
|
+
const colorClass = score === undefined ? "" : score >= 90 ? "green" : score >= 50 ? "yellow" : "red";
|
|
289
|
+
return `<div class="score-item"><div class="score-circle ${colorClass}">${value}</div><div class="score-label">${label}</div></div>`;
|
|
290
|
+
}
|
|
291
|
+
function buildMetricBox(label, value, colorClass) {
|
|
292
|
+
return `<div class="metric"><div class="metric-value ${colorClass}">${value}</div><div class="metric-label">${label}</div></div>`;
|
|
293
|
+
}
|
|
294
|
+
function getMetricClass(value, good, warn) {
|
|
295
|
+
if (value === undefined)
|
|
296
|
+
return "";
|
|
297
|
+
return value <= good ? "green" : value <= warn ? "yellow" : "red";
|
|
298
|
+
}
|
|
299
|
+
function escapeHtml(text) {
|
|
300
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
301
|
+
}
|
|
140
302
|
function buildConsoleTable(results, useColor) {
|
|
141
303
|
const header = [
|
|
142
304
|
"| Label | Path | Device | P | A | BP | SEO |",
|
|
@@ -161,9 +323,10 @@ function buildRow(result) {
|
|
|
161
323
|
const fcpSeconds = metrics.fcpMs !== undefined ? (metrics.fcpMs / 1000).toFixed(1) : "-";
|
|
162
324
|
const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs).toString() : "-";
|
|
163
325
|
const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
|
|
326
|
+
const inpMs = metrics.inpMs !== undefined ? Math.round(metrics.inpMs).toString() : "-";
|
|
164
327
|
const issues = formatTopIssues(result.opportunities);
|
|
165
328
|
const error = result.runtimeErrorCode ?? (result.runtimeErrorMessage !== undefined ? result.runtimeErrorMessage : "");
|
|
166
|
-
return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${error} | ${issues} |`;
|
|
329
|
+
return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${inpMs} | ${error} | ${issues} |`;
|
|
167
330
|
}
|
|
168
331
|
function buildConsoleRow(result, useColor) {
|
|
169
332
|
const scoreLine = buildConsoleScoreLine(result, useColor);
|
|
@@ -194,7 +357,8 @@ function buildConsoleMetricsLine(result, useColor) {
|
|
|
194
357
|
const fcpText = formatMetricSeconds(metrics.fcpMs, FCP_GOOD_MS, FCP_WARN_MS, useColor);
|
|
195
358
|
const tbtText = formatMetricMilliseconds(metrics.tbtMs, TBT_GOOD_MS, TBT_WARN_MS, useColor);
|
|
196
359
|
const clsText = formatMetricRatio(metrics.cls, CLS_GOOD, CLS_WARN, useColor);
|
|
197
|
-
const
|
|
360
|
+
const inpText = formatMetricMilliseconds(metrics.inpMs, INP_GOOD_MS, INP_WARN_MS, useColor);
|
|
361
|
+
const parts = [`LCP ${lcpText}`, `FCP ${fcpText}`, `TBT ${tbtText}`, `CLS ${clsText}`, `INP ${inpText}`];
|
|
198
362
|
return ` ↳ Metrics: ${parts.join(" | ")}`;
|
|
199
363
|
}
|
|
200
364
|
function buildConsoleErrorLine(result, useColor) {
|
|
@@ -325,6 +489,41 @@ function colourScore(score, useColor) {
|
|
|
325
489
|
function isRedScore(score) {
|
|
326
490
|
return typeof score === "number" && score < 50;
|
|
327
491
|
}
|
|
492
|
+
function printSummaryStats(results, useColor) {
|
|
493
|
+
if (results.length === 0)
|
|
494
|
+
return;
|
|
495
|
+
const scores = {
|
|
496
|
+
performance: results.map((r) => r.scores.performance).filter((s) => s !== undefined),
|
|
497
|
+
accessibility: results.map((r) => r.scores.accessibility).filter((s) => s !== undefined),
|
|
498
|
+
bestPractices: results.map((r) => r.scores.bestPractices).filter((s) => s !== undefined),
|
|
499
|
+
seo: results.map((r) => r.scores.seo).filter((s) => s !== undefined),
|
|
500
|
+
};
|
|
501
|
+
const avg = (arr) => arr.length > 0 ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
|
|
502
|
+
const countGreen = (arr) => arr.filter((s) => s >= 90).length;
|
|
503
|
+
const countYellow = (arr) => arr.filter((s) => s >= 50 && s < 90).length;
|
|
504
|
+
const countRed = (arr) => arr.filter((s) => s < 50).length;
|
|
505
|
+
const avgP = avg(scores.performance);
|
|
506
|
+
const avgA = avg(scores.accessibility);
|
|
507
|
+
const avgBP = avg(scores.bestPractices);
|
|
508
|
+
const avgSEO = avg(scores.seo);
|
|
509
|
+
const greenCount = countGreen(scores.performance) + countGreen(scores.accessibility) + countGreen(scores.bestPractices) + countGreen(scores.seo);
|
|
510
|
+
const yellowCount = countYellow(scores.performance) + countYellow(scores.accessibility) + countYellow(scores.bestPractices) + countYellow(scores.seo);
|
|
511
|
+
const redCount = countRed(scores.performance) + countRed(scores.accessibility) + countRed(scores.bestPractices) + countRed(scores.seo);
|
|
512
|
+
const totalScores = greenCount + yellowCount + redCount;
|
|
513
|
+
const formatAvg = (val) => {
|
|
514
|
+
if (!useColor)
|
|
515
|
+
return val.toString();
|
|
516
|
+
const color = val >= 90 ? ANSI_GREEN : val >= 50 ? ANSI_YELLOW : ANSI_RED;
|
|
517
|
+
return `${color}${val}${ANSI_RESET}`;
|
|
518
|
+
};
|
|
519
|
+
// eslint-disable-next-line no-console
|
|
520
|
+
console.log(`\n📊 Summary: Avg P:${formatAvg(avgP)} A:${formatAvg(avgA)} BP:${formatAvg(avgBP)} SEO:${formatAvg(avgSEO)}`);
|
|
521
|
+
const greenText = useColor ? `${ANSI_GREEN}${greenCount}${ANSI_RESET}` : greenCount.toString();
|
|
522
|
+
const yellowText = useColor ? `${ANSI_YELLOW}${yellowCount}${ANSI_RESET}` : yellowCount.toString();
|
|
523
|
+
const redText = useColor ? `${ANSI_RED}${redCount}${ANSI_RESET}` : redCount.toString();
|
|
524
|
+
// eslint-disable-next-line no-console
|
|
525
|
+
console.log(` Scores: ${greenText} green (90+) | ${yellowText} yellow (50-89) | ${redText} red (<50) of ${totalScores} total`);
|
|
526
|
+
}
|
|
328
527
|
function printRedIssues(results) {
|
|
329
528
|
const redResults = results.filter((result) => {
|
|
330
529
|
const scores = result.scores;
|
|
@@ -473,6 +672,7 @@ function collectMetricViolations(result, metricsBudgets, allViolations) {
|
|
|
473
672
|
addMetricViolation("fcpMs", metrics.fcpMs, metricsBudgets.fcpMs, result, allViolations);
|
|
474
673
|
addMetricViolation("tbtMs", metrics.tbtMs, metricsBudgets.tbtMs, result, allViolations);
|
|
475
674
|
addMetricViolation("cls", metrics.cls, metricsBudgets.cls, result, allViolations);
|
|
675
|
+
addMetricViolation("inpMs", metrics.inpMs, metricsBudgets.inpMs, result, allViolations);
|
|
476
676
|
}
|
|
477
677
|
function addMetricViolation(id, actual, limit, result, allViolations) {
|
|
478
678
|
if (limit === undefined || actual === undefined) {
|
|
@@ -491,3 +691,22 @@ function addMetricViolation(id, actual, limit, result, allViolations) {
|
|
|
491
691
|
limit,
|
|
492
692
|
});
|
|
493
693
|
}
|
|
694
|
+
function openInBrowser(filePath) {
|
|
695
|
+
const platform = process.platform;
|
|
696
|
+
let command;
|
|
697
|
+
if (platform === "win32") {
|
|
698
|
+
command = `start "" "${filePath}"`;
|
|
699
|
+
}
|
|
700
|
+
else if (platform === "darwin") {
|
|
701
|
+
command = `open "${filePath}"`;
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
command = `xdg-open "${filePath}"`;
|
|
705
|
+
}
|
|
706
|
+
exec(command, (error) => {
|
|
707
|
+
if (error) {
|
|
708
|
+
// eslint-disable-next-line no-console
|
|
709
|
+
console.error(`Could not open report: ${error.message}`);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -31,6 +31,19 @@ function normaliseConfig(input, absolutePath) {
|
|
|
31
31
|
const logLevel = rawLogLevel === "silent" || rawLogLevel === "error" || rawLogLevel === "info" || rawLogLevel === "verbose"
|
|
32
32
|
? rawLogLevel
|
|
33
33
|
: undefined;
|
|
34
|
+
const rawThrottlingMethod = maybeConfig.throttlingMethod;
|
|
35
|
+
const throttlingMethod = rawThrottlingMethod === "simulate" || rawThrottlingMethod === "devtools"
|
|
36
|
+
? rawThrottlingMethod
|
|
37
|
+
: undefined;
|
|
38
|
+
const rawCpuSlowdown = maybeConfig.cpuSlowdownMultiplier;
|
|
39
|
+
const cpuSlowdownMultiplier = typeof rawCpuSlowdown === "number" && rawCpuSlowdown > 0 && rawCpuSlowdown <= 20
|
|
40
|
+
? rawCpuSlowdown
|
|
41
|
+
: undefined;
|
|
42
|
+
const rawParallel = maybeConfig.parallel;
|
|
43
|
+
const parallel = typeof rawParallel === "number" && Number.isInteger(rawParallel) && rawParallel >= 1 && rawParallel <= 10
|
|
44
|
+
? rawParallel
|
|
45
|
+
: undefined;
|
|
46
|
+
const warmUp = typeof maybeConfig.warmUp === "boolean" ? maybeConfig.warmUp : undefined;
|
|
34
47
|
const budgets = normaliseBudgets(maybeConfig.budgets, absolutePath);
|
|
35
48
|
return {
|
|
36
49
|
baseUrl,
|
|
@@ -38,6 +51,10 @@ function normaliseConfig(input, absolutePath) {
|
|
|
38
51
|
chromePort,
|
|
39
52
|
runs,
|
|
40
53
|
logLevel,
|
|
54
|
+
throttlingMethod,
|
|
55
|
+
cpuSlowdownMultiplier,
|
|
56
|
+
parallel,
|
|
57
|
+
warmUp,
|
|
41
58
|
pages,
|
|
42
59
|
budgets,
|
|
43
60
|
};
|
|
@@ -123,7 +140,8 @@ function normaliseMetricBudgets(input, absolutePath) {
|
|
|
123
140
|
const fcpMs = normaliseMetricBudget(maybeMetrics.fcpMs, "fcpMs", absolutePath);
|
|
124
141
|
const tbtMs = normaliseMetricBudget(maybeMetrics.tbtMs, "tbtMs", absolutePath);
|
|
125
142
|
const cls = normaliseMetricBudget(maybeMetrics.cls, "cls", absolutePath);
|
|
126
|
-
|
|
143
|
+
const inpMs = normaliseMetricBudget(maybeMetrics.inpMs, "inpMs", absolutePath);
|
|
144
|
+
if (lcpMs === undefined && fcpMs === undefined && tbtMs === undefined && cls === undefined && inpMs === undefined) {
|
|
127
145
|
return undefined;
|
|
128
146
|
}
|
|
129
147
|
return {
|
|
@@ -131,6 +149,7 @@ function normaliseMetricBudgets(input, absolutePath) {
|
|
|
131
149
|
fcpMs,
|
|
132
150
|
tbtMs,
|
|
133
151
|
cls,
|
|
152
|
+
inpMs,
|
|
134
153
|
};
|
|
135
154
|
}
|
|
136
155
|
function normaliseScoreBudget(value, key, absolutePath) {
|
|
@@ -16,6 +16,18 @@ async function createChromeSession(chromePort) {
|
|
|
16
16
|
"--disable-default-apps",
|
|
17
17
|
"--no-first-run",
|
|
18
18
|
"--no-default-browser-check",
|
|
19
|
+
// Additional flags for more consistent and accurate results
|
|
20
|
+
"--disable-background-networking",
|
|
21
|
+
"--disable-background-timer-throttling",
|
|
22
|
+
"--disable-backgrounding-occluded-windows",
|
|
23
|
+
"--disable-renderer-backgrounding",
|
|
24
|
+
"--disable-client-side-phishing-detection",
|
|
25
|
+
"--disable-sync",
|
|
26
|
+
"--disable-translate",
|
|
27
|
+
"--metrics-recording-only",
|
|
28
|
+
"--safebrowsing-disable-auto-update",
|
|
29
|
+
"--password-store=basic",
|
|
30
|
+
"--use-mock-keychain",
|
|
19
31
|
],
|
|
20
32
|
});
|
|
21
33
|
return {
|
|
@@ -59,43 +71,117 @@ async function ensureUrlReachable(url) {
|
|
|
59
71
|
throw error instanceof Error ? error : new Error(`URL not reachable: ${url}`);
|
|
60
72
|
});
|
|
61
73
|
}
|
|
74
|
+
async function performWarmUp(config) {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.log("Performing warm-up requests...");
|
|
77
|
+
const uniqueUrls = new Set();
|
|
78
|
+
for (const page of config.pages) {
|
|
79
|
+
const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
|
|
80
|
+
uniqueUrls.add(url);
|
|
81
|
+
}
|
|
82
|
+
// Make parallel warm-up requests to all unique URLs
|
|
83
|
+
const warmUpPromises = Array.from(uniqueUrls).map(async (url) => {
|
|
84
|
+
try {
|
|
85
|
+
await fetchUrl(url);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Ignore warm-up errors, the actual audit will catch real issues
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
await Promise.all(warmUpPromises);
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.log(`Warm-up complete (${uniqueUrls.size} pages).`);
|
|
94
|
+
}
|
|
95
|
+
async function fetchUrl(url) {
|
|
96
|
+
const parsed = new URL(url);
|
|
97
|
+
const client = parsed.protocol === "https:" ? httpsRequest : httpRequest;
|
|
98
|
+
await new Promise((resolve, reject) => {
|
|
99
|
+
const request = client({
|
|
100
|
+
hostname: parsed.hostname,
|
|
101
|
+
port: parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80,
|
|
102
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
103
|
+
method: "GET",
|
|
104
|
+
}, (response) => {
|
|
105
|
+
response.resume();
|
|
106
|
+
resolve();
|
|
107
|
+
});
|
|
108
|
+
request.on("error", reject);
|
|
109
|
+
request.end();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
62
112
|
/**
|
|
63
113
|
* Run audits for all pages defined in the config and return a structured summary.
|
|
64
114
|
*/
|
|
65
115
|
export async function runAuditsForConfig({ config, configPath, }) {
|
|
66
116
|
const runs = config.runs ?? 1;
|
|
67
|
-
const
|
|
117
|
+
const parallelCount = config.parallel ?? 1;
|
|
68
118
|
const firstPage = config.pages[0];
|
|
69
119
|
const healthCheckUrl = buildUrl({ baseUrl: config.baseUrl, path: firstPage.path, query: config.query });
|
|
70
120
|
await ensureUrlReachable(healthCheckUrl);
|
|
71
|
-
|
|
121
|
+
// Perform warm-up requests if enabled
|
|
122
|
+
if (config.warmUp) {
|
|
123
|
+
await performWarmUp(config);
|
|
124
|
+
}
|
|
125
|
+
const throttlingMethod = config.throttlingMethod ?? "simulate";
|
|
126
|
+
const cpuSlowdownMultiplier = config.cpuSlowdownMultiplier ?? 4;
|
|
127
|
+
const logLevel = config.logLevel ?? "error";
|
|
128
|
+
// Build list of all audit tasks
|
|
129
|
+
const tasks = [];
|
|
130
|
+
for (const page of config.pages) {
|
|
131
|
+
for (const device of page.devices) {
|
|
132
|
+
const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
|
|
133
|
+
tasks.push({
|
|
134
|
+
url,
|
|
135
|
+
path: page.path,
|
|
136
|
+
label: page.label,
|
|
137
|
+
device,
|
|
138
|
+
runs,
|
|
139
|
+
logLevel,
|
|
140
|
+
throttlingMethod,
|
|
141
|
+
cpuSlowdownMultiplier,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const totalSteps = tasks.length * runs;
|
|
72
146
|
let completedSteps = 0;
|
|
73
|
-
const
|
|
147
|
+
const progressLock = { count: 0 };
|
|
148
|
+
const updateProgress = (path, device) => {
|
|
149
|
+
progressLock.count += 1;
|
|
150
|
+
completedSteps = progressLock.count;
|
|
151
|
+
logProgress({ completed: completedSteps, total: totalSteps, path, device });
|
|
152
|
+
};
|
|
153
|
+
let results;
|
|
154
|
+
if (parallelCount <= 1 || config.chromePort !== undefined) {
|
|
155
|
+
// Sequential execution (original behavior) or using external Chrome
|
|
156
|
+
results = await runSequential(tasks, config.chromePort, updateProgress);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Parallel execution with multiple Chrome instances
|
|
160
|
+
results = await runParallel(tasks, parallelCount, updateProgress);
|
|
161
|
+
}
|
|
162
|
+
return { configPath, results };
|
|
163
|
+
}
|
|
164
|
+
async function runSequential(tasks, chromePort, updateProgress) {
|
|
165
|
+
const results = [];
|
|
166
|
+
const session = await createChromeSession(chromePort);
|
|
74
167
|
try {
|
|
75
|
-
for (const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
logProgress({
|
|
91
|
-
completed: completedSteps,
|
|
92
|
-
total: totalSteps,
|
|
93
|
-
path: page.path,
|
|
94
|
-
device,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
results.push(aggregateSummaries(summaries));
|
|
168
|
+
for (const task of tasks) {
|
|
169
|
+
const summaries = [];
|
|
170
|
+
for (let index = 0; index < task.runs; index += 1) {
|
|
171
|
+
const summary = await runSingleAudit({
|
|
172
|
+
url: task.url,
|
|
173
|
+
path: task.path,
|
|
174
|
+
label: task.label,
|
|
175
|
+
device: task.device,
|
|
176
|
+
port: session.port,
|
|
177
|
+
logLevel: task.logLevel,
|
|
178
|
+
throttlingMethod: task.throttlingMethod,
|
|
179
|
+
cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
|
|
180
|
+
});
|
|
181
|
+
summaries.push(summary);
|
|
182
|
+
updateProgress(task.path, task.device);
|
|
98
183
|
}
|
|
184
|
+
results.push(aggregateSummaries(summaries));
|
|
99
185
|
}
|
|
100
186
|
}
|
|
101
187
|
finally {
|
|
@@ -103,7 +189,52 @@ export async function runAuditsForConfig({ config, configPath, }) {
|
|
|
103
189
|
await session.close();
|
|
104
190
|
}
|
|
105
191
|
}
|
|
106
|
-
return
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
194
|
+
async function runParallel(tasks, parallelCount, updateProgress) {
|
|
195
|
+
// Create a pool of Chrome sessions
|
|
196
|
+
const sessions = [];
|
|
197
|
+
const effectiveParallel = Math.min(parallelCount, tasks.length);
|
|
198
|
+
for (let i = 0; i < effectiveParallel; i += 1) {
|
|
199
|
+
sessions.push(await createChromeSession());
|
|
200
|
+
}
|
|
201
|
+
const results = new Array(tasks.length);
|
|
202
|
+
let taskIndex = 0;
|
|
203
|
+
const runWorker = async (session, workerIndex) => {
|
|
204
|
+
while (taskIndex < tasks.length) {
|
|
205
|
+
const currentIndex = taskIndex;
|
|
206
|
+
taskIndex += 1;
|
|
207
|
+
const task = tasks[currentIndex];
|
|
208
|
+
const summaries = [];
|
|
209
|
+
for (let run = 0; run < task.runs; run += 1) {
|
|
210
|
+
const summary = await runSingleAudit({
|
|
211
|
+
url: task.url,
|
|
212
|
+
path: task.path,
|
|
213
|
+
label: task.label,
|
|
214
|
+
device: task.device,
|
|
215
|
+
port: session.port,
|
|
216
|
+
logLevel: task.logLevel,
|
|
217
|
+
throttlingMethod: task.throttlingMethod,
|
|
218
|
+
cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
|
|
219
|
+
});
|
|
220
|
+
summaries.push(summary);
|
|
221
|
+
updateProgress(task.path, task.device);
|
|
222
|
+
}
|
|
223
|
+
results[currentIndex] = aggregateSummaries(summaries);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
await Promise.all(sessions.map((session, index) => runWorker(session, index)));
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
// Close all Chrome sessions
|
|
231
|
+
await Promise.all(sessions.map(async (session) => {
|
|
232
|
+
if (session.close) {
|
|
233
|
+
await session.close();
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
return results;
|
|
107
238
|
}
|
|
108
239
|
function buildUrl({ baseUrl, path, query }) {
|
|
109
240
|
const cleanBase = baseUrl.replace(/\/$/, "");
|
|
@@ -131,7 +262,22 @@ async function runSingleAudit(params) {
|
|
|
131
262
|
output: "json",
|
|
132
263
|
logLevel: params.logLevel,
|
|
133
264
|
onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
|
|
134
|
-
|
|
265
|
+
formFactor: params.device,
|
|
266
|
+
// Throttling configuration for more accurate results
|
|
267
|
+
throttlingMethod: params.throttlingMethod,
|
|
268
|
+
throttling: {
|
|
269
|
+
// CPU throttling - adjustable via config
|
|
270
|
+
cpuSlowdownMultiplier: params.cpuSlowdownMultiplier,
|
|
271
|
+
// Network throttling (Slow 4G / Fast 3G preset - Lighthouse default)
|
|
272
|
+
rttMs: 150,
|
|
273
|
+
throughputKbps: 1638.4,
|
|
274
|
+
requestLatencyMs: 150 * 3.75,
|
|
275
|
+
downloadThroughputKbps: 1638.4,
|
|
276
|
+
uploadThroughputKbps: 750,
|
|
277
|
+
},
|
|
278
|
+
screenEmulation: params.device === "mobile"
|
|
279
|
+
? { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false }
|
|
280
|
+
: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
|
|
135
281
|
};
|
|
136
282
|
const runnerResult = await lighthouse(params.url, options);
|
|
137
283
|
const lhrUnknown = runnerResult.lhr;
|
|
@@ -178,15 +324,18 @@ function extractMetrics(lhr) {
|
|
|
178
324
|
const fcpAudit = audits["first-contentful-paint"];
|
|
179
325
|
const tbtAudit = audits["total-blocking-time"];
|
|
180
326
|
const clsAudit = audits["cumulative-layout-shift"];
|
|
327
|
+
const inpAudit = audits["interaction-to-next-paint"];
|
|
181
328
|
const lcpMs = typeof lcpAudit?.numericValue === "number" ? lcpAudit.numericValue : undefined;
|
|
182
329
|
const fcpMs = typeof fcpAudit?.numericValue === "number" ? fcpAudit.numericValue : undefined;
|
|
183
330
|
const tbtMs = typeof tbtAudit?.numericValue === "number" ? tbtAudit.numericValue : undefined;
|
|
184
331
|
const cls = typeof clsAudit?.numericValue === "number" ? clsAudit.numericValue : undefined;
|
|
332
|
+
const inpMs = typeof inpAudit?.numericValue === "number" ? inpAudit.numericValue : undefined;
|
|
185
333
|
return {
|
|
186
334
|
lcpMs,
|
|
187
335
|
fcpMs,
|
|
188
336
|
tbtMs,
|
|
189
337
|
cls,
|
|
338
|
+
inpMs,
|
|
190
339
|
};
|
|
191
340
|
}
|
|
192
341
|
function extractTopOpportunities(lhr, limit) {
|
|
@@ -223,6 +372,7 @@ function aggregateSummaries(summaries) {
|
|
|
223
372
|
fcpMs: averageOf(summaries.map((s) => s.metrics.fcpMs)),
|
|
224
373
|
tbtMs: averageOf(summaries.map((s) => s.metrics.tbtMs)),
|
|
225
374
|
cls: averageOf(summaries.map((s) => s.metrics.cls)),
|
|
375
|
+
inpMs: averageOf(summaries.map((s) => s.metrics.inpMs)),
|
|
226
376
|
};
|
|
227
377
|
const opportunities = summaries[0].opportunities;
|
|
228
378
|
return {
|