apex-auditor 0.2.8 → 0.3.0
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 +68 -6
- package/dist/bin.js +2 -0
- package/dist/cli.js +295 -10
- package/dist/config.js +20 -1
- package/dist/lighthouse-runner.js +237 -31
- package/dist/wizard-cli.js +1 -0
- 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,67 @@ 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
|
+
| `--show-parallel` | Print the resolved parallel worker count before running |
|
|
91
|
+
| `--mobile-only` | Only audit mobile device configurations |
|
|
92
|
+
| `--desktop-only` | Only audit desktop device configurations |
|
|
76
93
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Output files
|
|
97
|
+
|
|
98
|
+
After each audit, results are saved to `.apex-auditor/`:
|
|
99
|
+
|
|
100
|
+
- `summary.json` – structured JSON results
|
|
101
|
+
- `summary.md` – Markdown table plus a structured meta section (parallel, timings, throttling)
|
|
102
|
+
- `report.html` – visual HTML report with score circles, metrics, and a meta grid (resolved parallel, elapsed, avg/step)
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
Example `apex.config.json`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"baseUrl": "http://localhost:3000",
|
|
113
|
+
"runs": 3,
|
|
114
|
+
"throttlingMethod": "devtools",
|
|
115
|
+
"cpuSlowdownMultiplier": 4,
|
|
116
|
+
"parallel": 2,
|
|
117
|
+
"warmUp": true,
|
|
118
|
+
"pages": [
|
|
119
|
+
{ "path": "/", "label": "home", "devices": ["mobile", "desktop"] },
|
|
120
|
+
{ "path": "/docs", "label": "docs", "devices": ["mobile"] }
|
|
121
|
+
],
|
|
122
|
+
"budgets": {
|
|
123
|
+
"categories": { "performance": 80, "accessibility": 90 },
|
|
124
|
+
"metrics": { "lcpMs": 2500, "inpMs": 200 }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Metrics tracked
|
|
132
|
+
|
|
133
|
+
- **LCP** (Largest Contentful Paint)
|
|
134
|
+
- **FCP** (First Contentful Paint)
|
|
135
|
+
- **TBT** (Total Blocking Time)
|
|
136
|
+
- **CLS** (Cumulative Layout Shift)
|
|
137
|
+
- **INP** (Interaction to Next Paint) - Core Web Vital
|
|
80
138
|
|
|
81
139
|
---
|
|
82
140
|
|
|
@@ -88,4 +146,8 @@ For detailed guides, configuration options, and CI examples, see the `docs/` dir
|
|
|
88
146
|
- `docs/configuration-and-routes.md` – `apex.config.json` schema and route detection details.
|
|
89
147
|
- `docs/cli-and-ci.md` – CLI flags, CI mode, budgets, and example workflows.
|
|
90
148
|
|
|
91
|
-
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
package/dist/bin.js
CHANGED
|
@@ -35,6 +35,8 @@ function printHelp() {
|
|
|
35
35
|
" --log-level <lvl> Override Lighthouse log level: silent|error|info|verbose",
|
|
36
36
|
" --mobile-only Run audits only for 'mobile' devices defined in the config",
|
|
37
37
|
" --desktop-only Run audits only for 'desktop' devices defined in the config",
|
|
38
|
+
" --parallel <n> Override parallel workers (1-10). Default auto-tunes from CPU/memory.",
|
|
39
|
+
" --show-parallel Print the resolved parallel workers before running.",
|
|
38
40
|
].join("\n"));
|
|
39
41
|
}
|
|
40
42
|
export async function runBin(argv) {
|
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,21 @@ 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;
|
|
34
|
+
let showParallel = false;
|
|
25
35
|
for (let i = 2; i < argv.length; i += 1) {
|
|
26
36
|
const arg = argv[i];
|
|
27
37
|
if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
|
|
@@ -59,9 +69,47 @@ function parseArgs(argv) {
|
|
|
59
69
|
}
|
|
60
70
|
deviceFilter = "desktop";
|
|
61
71
|
}
|
|
72
|
+
else if (arg === "--throttling" && i + 1 < argv.length) {
|
|
73
|
+
const value = argv[i + 1];
|
|
74
|
+
if (value === "simulate" || value === "devtools") {
|
|
75
|
+
throttlingMethodOverride = value;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
throw new Error(`Invalid --throttling value: ${value}. Expected "simulate" or "devtools".`);
|
|
79
|
+
}
|
|
80
|
+
i += 1;
|
|
81
|
+
}
|
|
82
|
+
else if (arg === "--cpu-slowdown" && i + 1 < argv.length) {
|
|
83
|
+
const value = parseFloat(argv[i + 1]);
|
|
84
|
+
if (Number.isNaN(value) || value <= 0 || value > 20) {
|
|
85
|
+
throw new Error(`Invalid --cpu-slowdown value: ${argv[i + 1]}. Expected number between 0 and 20.`);
|
|
86
|
+
}
|
|
87
|
+
cpuSlowdownOverride = value;
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
else if (arg === "--parallel" && i + 1 < argv.length) {
|
|
91
|
+
const value = parseInt(argv[i + 1], 10);
|
|
92
|
+
if (Number.isNaN(value) || value < 1 || value > 10) {
|
|
93
|
+
throw new Error(`Invalid --parallel value: ${argv[i + 1]}. Expected integer between 1 and 10.`);
|
|
94
|
+
}
|
|
95
|
+
parallelOverride = value;
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
else if (arg === "--open") {
|
|
99
|
+
openReport = true;
|
|
100
|
+
}
|
|
101
|
+
else if (arg === "--warm-up") {
|
|
102
|
+
warmUp = true;
|
|
103
|
+
}
|
|
104
|
+
else if (arg === "--json") {
|
|
105
|
+
jsonOutput = true;
|
|
106
|
+
}
|
|
107
|
+
else if (arg === "--show-parallel") {
|
|
108
|
+
showParallel = true;
|
|
109
|
+
}
|
|
62
110
|
}
|
|
63
111
|
const finalConfigPath = configPath ?? "apex.config.json";
|
|
64
|
-
return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter };
|
|
112
|
+
return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter, throttlingMethodOverride, cpuSlowdownOverride, parallelOverride, openReport, warmUp, jsonOutput, showParallel };
|
|
65
113
|
}
|
|
66
114
|
/**
|
|
67
115
|
* Runs the ApexAuditor audit CLI.
|
|
@@ -73,9 +121,17 @@ export async function runAuditCli(argv) {
|
|
|
73
121
|
const startTimeMs = Date.now();
|
|
74
122
|
const { configPath, config } = await loadConfig({ configPath: args.configPath });
|
|
75
123
|
const effectiveLogLevel = args.logLevelOverride ?? config.logLevel;
|
|
124
|
+
const effectiveThrottling = args.throttlingMethodOverride ?? config.throttlingMethod;
|
|
125
|
+
const effectiveCpuSlowdown = args.cpuSlowdownOverride ?? config.cpuSlowdownMultiplier;
|
|
126
|
+
const effectiveParallel = args.parallelOverride ?? config.parallel;
|
|
127
|
+
const effectiveWarmUp = args.warmUp || config.warmUp === true;
|
|
76
128
|
const effectiveConfig = {
|
|
77
129
|
...config,
|
|
78
130
|
logLevel: effectiveLogLevel,
|
|
131
|
+
throttlingMethod: effectiveThrottling,
|
|
132
|
+
cpuSlowdownMultiplier: effectiveCpuSlowdown,
|
|
133
|
+
parallel: effectiveParallel,
|
|
134
|
+
warmUp: effectiveWarmUp,
|
|
79
135
|
};
|
|
80
136
|
const filteredConfig = filterConfigDevices(effectiveConfig, args.deviceFilter);
|
|
81
137
|
if (filteredConfig.pages.length === 0) {
|
|
@@ -84,17 +140,32 @@ export async function runAuditCli(argv) {
|
|
|
84
140
|
process.exitCode = 1;
|
|
85
141
|
return;
|
|
86
142
|
}
|
|
87
|
-
const summary = await runAuditsForConfig({ config: filteredConfig, configPath });
|
|
143
|
+
const summary = await runAuditsForConfig({ config: filteredConfig, configPath, showParallel: args.showParallel });
|
|
88
144
|
const outputDir = resolve(".apex-auditor");
|
|
89
145
|
await mkdir(outputDir, { recursive: true });
|
|
90
146
|
await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
|
|
91
|
-
const markdown = buildMarkdown(summary
|
|
147
|
+
const markdown = buildMarkdown(summary);
|
|
92
148
|
await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
|
|
149
|
+
const html = buildHtmlReport(summary);
|
|
150
|
+
const reportPath = resolve(outputDir, "report.html");
|
|
151
|
+
await writeFile(reportPath, html, "utf8");
|
|
152
|
+
// Open HTML report in browser if requested
|
|
153
|
+
if (args.openReport) {
|
|
154
|
+
openInBrowser(reportPath);
|
|
155
|
+
}
|
|
156
|
+
// If JSON output requested, print JSON and exit early
|
|
157
|
+
if (args.jsonOutput) {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
93
162
|
// Also echo a compact, colourised table to stdout for quick viewing.
|
|
94
163
|
const useColor = shouldUseColor(args.ci, args.colorMode);
|
|
95
164
|
const consoleTable = buildConsoleTable(summary.results, useColor);
|
|
96
165
|
// eslint-disable-next-line no-console
|
|
97
166
|
console.log(consoleTable);
|
|
167
|
+
printRunMeta(summary.meta, useColor);
|
|
168
|
+
printSummaryStats(summary.results, useColor);
|
|
98
169
|
printRedIssues(summary.results);
|
|
99
170
|
printCiSummary(args, summary.results, effectiveConfig.budgets);
|
|
100
171
|
printLowestPerformancePages(summary.results, useColor);
|
|
@@ -129,13 +200,170 @@ function filterPageDevices(page, deviceFilter) {
|
|
|
129
200
|
devices,
|
|
130
201
|
};
|
|
131
202
|
}
|
|
132
|
-
function buildMarkdown(
|
|
203
|
+
function buildMarkdown(summary) {
|
|
204
|
+
const meta = summary.meta;
|
|
205
|
+
const metaTable = [
|
|
206
|
+
"| Field | Value |",
|
|
207
|
+
"|-------|-------|",
|
|
208
|
+
`| Config | ${meta.configPath} |`,
|
|
209
|
+
`| Resolved parallel | ${meta.resolvedParallel} |`,
|
|
210
|
+
`| Warm-up | ${meta.warmUp ? "yes" : "no"} |`,
|
|
211
|
+
`| Throttling | ${meta.throttlingMethod} |`,
|
|
212
|
+
`| CPU slowdown | ${meta.cpuSlowdownMultiplier} |`,
|
|
213
|
+
`| Combos | ${meta.comboCount} |`,
|
|
214
|
+
`| Runs per combo | ${meta.runsPerCombo} |`,
|
|
215
|
+
`| Total steps | ${meta.totalSteps} |`,
|
|
216
|
+
`| Started | ${meta.startedAt} |`,
|
|
217
|
+
`| Completed | ${meta.completedAt} |`,
|
|
218
|
+
`| Elapsed | ${formatElapsedTime(meta.elapsedMs)} |`,
|
|
219
|
+
`| Avg per step | ${formatElapsedTime(meta.averageStepMs)} |`,
|
|
220
|
+
].join("\n");
|
|
133
221
|
const header = [
|
|
134
|
-
"| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Error | Top issues |",
|
|
135
|
-
"
|
|
222
|
+
"| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | INP (ms) | Error | Top issues |",
|
|
223
|
+
"|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|----------|-------|-----------|",
|
|
136
224
|
].join("\n");
|
|
137
|
-
const lines = results.map((result) => buildRow(result));
|
|
138
|
-
return `${header}\n${lines.join("\n")}`;
|
|
225
|
+
const lines = summary.results.map((result) => buildRow(result));
|
|
226
|
+
return `${metaTable}\n\n${header}\n${lines.join("\n")}`;
|
|
227
|
+
}
|
|
228
|
+
function buildHtmlReport(summary) {
|
|
229
|
+
const results = summary.results;
|
|
230
|
+
const meta = summary.meta;
|
|
231
|
+
const timestamp = new Date().toISOString();
|
|
232
|
+
const rows = results.map((result) => buildHtmlRow(result)).join("\n");
|
|
233
|
+
return `<!DOCTYPE html>
|
|
234
|
+
<html lang="en">
|
|
235
|
+
<head>
|
|
236
|
+
<meta charset="UTF-8">
|
|
237
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
238
|
+
<title>ApexAuditor Report</title>
|
|
239
|
+
<style>
|
|
240
|
+
:root { --green: #0cce6b; --yellow: #ffa400; --red: #ff4e42; --bg: #1a1a2e; --card: #16213e; --text: #eee; }
|
|
241
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
242
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); padding: 2rem; }
|
|
243
|
+
h1 { margin-bottom: 0.5rem; }
|
|
244
|
+
.meta { color: #888; margin-bottom: 2rem; font-size: 0.9rem; }
|
|
245
|
+
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
246
|
+
.meta-card { background: #16213e; border-radius: 10px; padding: 1rem; border: 1px solid #23304f; }
|
|
247
|
+
.meta-label { font-size: 0.8rem; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
248
|
+
.meta-value { font-size: 1rem; font-weight: 600; color: #e5e7eb; }
|
|
249
|
+
.cards { display: grid; gap: 1.5rem; }
|
|
250
|
+
.card { background: var(--card); border-radius: 12px; padding: 1.5rem; }
|
|
251
|
+
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 1px solid #333; padding-bottom: 1rem; }
|
|
252
|
+
.card-title { font-size: 1.1rem; font-weight: 600; }
|
|
253
|
+
.device-badge { font-size: 0.75rem; padding: 0.25rem 0.5rem; border-radius: 4px; background: #333; }
|
|
254
|
+
.device-badge.mobile { background: #0891b2; }
|
|
255
|
+
.device-badge.desktop { background: #7c3aed; }
|
|
256
|
+
.scores { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
|
257
|
+
.score-item { text-align: center; flex: 1; }
|
|
258
|
+
.score-circle { width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: bold; margin: 0 auto 0.5rem; border: 3px solid; }
|
|
259
|
+
.score-circle.green { border-color: var(--green); color: var(--green); }
|
|
260
|
+
.score-circle.yellow { border-color: var(--yellow); color: var(--yellow); }
|
|
261
|
+
.score-circle.red { border-color: var(--red); color: var(--red); }
|
|
262
|
+
.score-label { font-size: 0.75rem; color: #888; }
|
|
263
|
+
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 1rem; }
|
|
264
|
+
.metric { background: #1a1a2e; padding: 0.75rem; border-radius: 8px; text-align: center; }
|
|
265
|
+
.metric-value { font-size: 1.1rem; font-weight: 600; }
|
|
266
|
+
.metric-value.green { color: var(--green); }
|
|
267
|
+
.metric-value.yellow { color: var(--yellow); }
|
|
268
|
+
.metric-value.red { color: var(--red); }
|
|
269
|
+
.metric-label { font-size: 0.7rem; color: #888; margin-top: 0.25rem; }
|
|
270
|
+
.issues { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #333; }
|
|
271
|
+
.issues-title { font-size: 0.8rem; color: #888; margin-bottom: 0.5rem; }
|
|
272
|
+
.issue { font-size: 0.85rem; color: #ccc; padding: 0.25rem 0; }
|
|
273
|
+
</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<h1>ApexAuditor Report</h1>
|
|
277
|
+
<p class="meta">Generated: ${timestamp}</p>
|
|
278
|
+
<div class="meta-grid">
|
|
279
|
+
${buildMetaCard("Config", escapeHtml(meta.configPath))}
|
|
280
|
+
${buildMetaCard("Resolved parallel", meta.resolvedParallel.toString())}
|
|
281
|
+
${buildMetaCard("Warm-up", meta.warmUp ? "Yes" : "No")}
|
|
282
|
+
${buildMetaCard("Throttling", meta.throttlingMethod)}
|
|
283
|
+
${buildMetaCard("CPU slowdown", meta.cpuSlowdownMultiplier.toString())}
|
|
284
|
+
${buildMetaCard("Combos", meta.comboCount.toString())}
|
|
285
|
+
${buildMetaCard("Runs per combo", meta.runsPerCombo.toString())}
|
|
286
|
+
${buildMetaCard("Total steps", meta.totalSteps.toString())}
|
|
287
|
+
${buildMetaCard("Elapsed", formatElapsedTime(meta.elapsedMs))}
|
|
288
|
+
${buildMetaCard("Avg / step", formatElapsedTime(meta.averageStepMs))}
|
|
289
|
+
${buildMetaCard("Started", meta.startedAt)}
|
|
290
|
+
${buildMetaCard("Completed", meta.completedAt)}
|
|
291
|
+
</div>
|
|
292
|
+
<div class="cards">
|
|
293
|
+
${rows}
|
|
294
|
+
</div>
|
|
295
|
+
</body>
|
|
296
|
+
</html>`;
|
|
297
|
+
}
|
|
298
|
+
function buildHtmlRow(result) {
|
|
299
|
+
const scores = result.scores;
|
|
300
|
+
const metrics = result.metrics;
|
|
301
|
+
const lcpSeconds = metrics.lcpMs !== undefined ? (metrics.lcpMs / 1000).toFixed(1) + "s" : "-";
|
|
302
|
+
const fcpSeconds = metrics.fcpMs !== undefined ? (metrics.fcpMs / 1000).toFixed(1) + "s" : "-";
|
|
303
|
+
const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs) + "ms" : "-";
|
|
304
|
+
const clsVal = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
|
|
305
|
+
const inpMs = metrics.inpMs !== undefined ? Math.round(metrics.inpMs) + "ms" : "-";
|
|
306
|
+
const issues = result.opportunities.slice(0, 3).map((o) => `<div class="issue">${escapeHtml(o.title)}${o.estimatedSavingsMs ? ` (${Math.round(o.estimatedSavingsMs)}ms)` : ""}</div>`).join("");
|
|
307
|
+
return ` <div class="card">
|
|
308
|
+
<div class="card-header">
|
|
309
|
+
<div class="card-title">${escapeHtml(result.label)} <span style="color:#888">${escapeHtml(result.path)}</span></div>
|
|
310
|
+
<span class="device-badge ${result.device}">${result.device}</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="scores">
|
|
313
|
+
${buildScoreCircle("P", scores.performance)}
|
|
314
|
+
${buildScoreCircle("A", scores.accessibility)}
|
|
315
|
+
${buildScoreCircle("BP", scores.bestPractices)}
|
|
316
|
+
${buildScoreCircle("SEO", scores.seo)}
|
|
317
|
+
</div>
|
|
318
|
+
<div class="metrics">
|
|
319
|
+
${buildMetricBox("LCP", lcpSeconds, getMetricClass(metrics.lcpMs, 2500, 4000))}
|
|
320
|
+
${buildMetricBox("FCP", fcpSeconds, getMetricClass(metrics.fcpMs, 1800, 3000))}
|
|
321
|
+
${buildMetricBox("TBT", tbtMs, getMetricClass(metrics.tbtMs, 200, 600))}
|
|
322
|
+
${buildMetricBox("CLS", clsVal, getMetricClass(metrics.cls, 0.1, 0.25))}
|
|
323
|
+
${buildMetricBox("INP", inpMs, getMetricClass(metrics.inpMs, 200, 500))}
|
|
324
|
+
</div>
|
|
325
|
+
${issues ? `<div class="issues"><div class="issues-title">Top Issues</div>${issues}</div>` : ""}
|
|
326
|
+
</div>`;
|
|
327
|
+
}
|
|
328
|
+
function buildScoreCircle(label, score) {
|
|
329
|
+
const value = score !== undefined ? score.toString() : "-";
|
|
330
|
+
const colorClass = score === undefined ? "" : score >= 90 ? "green" : score >= 50 ? "yellow" : "red";
|
|
331
|
+
return `<div class="score-item"><div class="score-circle ${colorClass}">${value}</div><div class="score-label">${label}</div></div>`;
|
|
332
|
+
}
|
|
333
|
+
function buildMetricBox(label, value, colorClass) {
|
|
334
|
+
return `<div class="metric"><div class="metric-value ${colorClass}">${value}</div><div class="metric-label">${label}</div></div>`;
|
|
335
|
+
}
|
|
336
|
+
function buildMetaCard(label, value) {
|
|
337
|
+
return `<div class="meta-card"><div class="meta-label">${escapeHtml(label)}</div><div class="meta-value">${escapeHtml(value)}</div></div>`;
|
|
338
|
+
}
|
|
339
|
+
function printRunMeta(meta, useColor) {
|
|
340
|
+
const rows = [
|
|
341
|
+
{ label: "Resolved parallel", value: meta.resolvedParallel.toString() },
|
|
342
|
+
{ label: "Warm-up", value: meta.warmUp ? "Yes" : "No" },
|
|
343
|
+
{ label: "Throttling", value: meta.throttlingMethod },
|
|
344
|
+
{ label: "CPU slowdown", value: meta.cpuSlowdownMultiplier.toString() },
|
|
345
|
+
{ label: "Combos", value: meta.comboCount.toString() },
|
|
346
|
+
{ label: "Runs per combo", value: meta.runsPerCombo.toString() },
|
|
347
|
+
{ label: "Total steps", value: meta.totalSteps.toString() },
|
|
348
|
+
{ label: "Elapsed", value: formatElapsedTime(meta.elapsedMs) },
|
|
349
|
+
{ label: "Avg / step", value: formatElapsedTime(meta.averageStepMs) },
|
|
350
|
+
];
|
|
351
|
+
const padLabel = (label) => label.padEnd(16, " ");
|
|
352
|
+
// eslint-disable-next-line no-console
|
|
353
|
+
console.log("\nMeta:");
|
|
354
|
+
for (const row of rows) {
|
|
355
|
+
const value = useColor ? `${ANSI_CYAN}${row.value}${ANSI_RESET}` : row.value;
|
|
356
|
+
// eslint-disable-next-line no-console
|
|
357
|
+
console.log(` ${padLabel(row.label)} ${value}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function getMetricClass(value, good, warn) {
|
|
361
|
+
if (value === undefined)
|
|
362
|
+
return "";
|
|
363
|
+
return value <= good ? "green" : value <= warn ? "yellow" : "red";
|
|
364
|
+
}
|
|
365
|
+
function escapeHtml(text) {
|
|
366
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
139
367
|
}
|
|
140
368
|
function buildConsoleTable(results, useColor) {
|
|
141
369
|
const header = [
|
|
@@ -161,9 +389,10 @@ function buildRow(result) {
|
|
|
161
389
|
const fcpSeconds = metrics.fcpMs !== undefined ? (metrics.fcpMs / 1000).toFixed(1) : "-";
|
|
162
390
|
const tbtMs = metrics.tbtMs !== undefined ? Math.round(metrics.tbtMs).toString() : "-";
|
|
163
391
|
const cls = metrics.cls !== undefined ? metrics.cls.toFixed(3) : "-";
|
|
392
|
+
const inpMs = metrics.inpMs !== undefined ? Math.round(metrics.inpMs).toString() : "-";
|
|
164
393
|
const issues = formatTopIssues(result.opportunities);
|
|
165
394
|
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} |`;
|
|
395
|
+
return `| ${result.label} | ${result.path} | ${result.device} | ${scores.performance ?? "-"} | ${scores.accessibility ?? "-"} | ${scores.bestPractices ?? "-"} | ${scores.seo ?? "-"} | ${lcpSeconds} | ${fcpSeconds} | ${tbtMs} | ${cls} | ${inpMs} | ${error} | ${issues} |`;
|
|
167
396
|
}
|
|
168
397
|
function buildConsoleRow(result, useColor) {
|
|
169
398
|
const scoreLine = buildConsoleScoreLine(result, useColor);
|
|
@@ -194,7 +423,8 @@ function buildConsoleMetricsLine(result, useColor) {
|
|
|
194
423
|
const fcpText = formatMetricSeconds(metrics.fcpMs, FCP_GOOD_MS, FCP_WARN_MS, useColor);
|
|
195
424
|
const tbtText = formatMetricMilliseconds(metrics.tbtMs, TBT_GOOD_MS, TBT_WARN_MS, useColor);
|
|
196
425
|
const clsText = formatMetricRatio(metrics.cls, CLS_GOOD, CLS_WARN, useColor);
|
|
197
|
-
const
|
|
426
|
+
const inpText = formatMetricMilliseconds(metrics.inpMs, INP_GOOD_MS, INP_WARN_MS, useColor);
|
|
427
|
+
const parts = [`LCP ${lcpText}`, `FCP ${fcpText}`, `TBT ${tbtText}`, `CLS ${clsText}`, `INP ${inpText}`];
|
|
198
428
|
return ` ↳ Metrics: ${parts.join(" | ")}`;
|
|
199
429
|
}
|
|
200
430
|
function buildConsoleErrorLine(result, useColor) {
|
|
@@ -325,6 +555,41 @@ function colourScore(score, useColor) {
|
|
|
325
555
|
function isRedScore(score) {
|
|
326
556
|
return typeof score === "number" && score < 50;
|
|
327
557
|
}
|
|
558
|
+
function printSummaryStats(results, useColor) {
|
|
559
|
+
if (results.length === 0)
|
|
560
|
+
return;
|
|
561
|
+
const scores = {
|
|
562
|
+
performance: results.map((r) => r.scores.performance).filter((s) => s !== undefined),
|
|
563
|
+
accessibility: results.map((r) => r.scores.accessibility).filter((s) => s !== undefined),
|
|
564
|
+
bestPractices: results.map((r) => r.scores.bestPractices).filter((s) => s !== undefined),
|
|
565
|
+
seo: results.map((r) => r.scores.seo).filter((s) => s !== undefined),
|
|
566
|
+
};
|
|
567
|
+
const avg = (arr) => arr.length > 0 ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
|
|
568
|
+
const countGreen = (arr) => arr.filter((s) => s >= 90).length;
|
|
569
|
+
const countYellow = (arr) => arr.filter((s) => s >= 50 && s < 90).length;
|
|
570
|
+
const countRed = (arr) => arr.filter((s) => s < 50).length;
|
|
571
|
+
const avgP = avg(scores.performance);
|
|
572
|
+
const avgA = avg(scores.accessibility);
|
|
573
|
+
const avgBP = avg(scores.bestPractices);
|
|
574
|
+
const avgSEO = avg(scores.seo);
|
|
575
|
+
const greenCount = countGreen(scores.performance) + countGreen(scores.accessibility) + countGreen(scores.bestPractices) + countGreen(scores.seo);
|
|
576
|
+
const yellowCount = countYellow(scores.performance) + countYellow(scores.accessibility) + countYellow(scores.bestPractices) + countYellow(scores.seo);
|
|
577
|
+
const redCount = countRed(scores.performance) + countRed(scores.accessibility) + countRed(scores.bestPractices) + countRed(scores.seo);
|
|
578
|
+
const totalScores = greenCount + yellowCount + redCount;
|
|
579
|
+
const formatAvg = (val) => {
|
|
580
|
+
if (!useColor)
|
|
581
|
+
return val.toString();
|
|
582
|
+
const color = val >= 90 ? ANSI_GREEN : val >= 50 ? ANSI_YELLOW : ANSI_RED;
|
|
583
|
+
return `${color}${val}${ANSI_RESET}`;
|
|
584
|
+
};
|
|
585
|
+
// eslint-disable-next-line no-console
|
|
586
|
+
console.log(`\n📊 Summary: Avg P:${formatAvg(avgP)} A:${formatAvg(avgA)} BP:${formatAvg(avgBP)} SEO:${formatAvg(avgSEO)}`);
|
|
587
|
+
const greenText = useColor ? `${ANSI_GREEN}${greenCount}${ANSI_RESET}` : greenCount.toString();
|
|
588
|
+
const yellowText = useColor ? `${ANSI_YELLOW}${yellowCount}${ANSI_RESET}` : yellowCount.toString();
|
|
589
|
+
const redText = useColor ? `${ANSI_RED}${redCount}${ANSI_RESET}` : redCount.toString();
|
|
590
|
+
// eslint-disable-next-line no-console
|
|
591
|
+
console.log(` Scores: ${greenText} green (90+) | ${yellowText} yellow (50-89) | ${redText} red (<50) of ${totalScores} total`);
|
|
592
|
+
}
|
|
328
593
|
function printRedIssues(results) {
|
|
329
594
|
const redResults = results.filter((result) => {
|
|
330
595
|
const scores = result.scores;
|
|
@@ -473,6 +738,7 @@ function collectMetricViolations(result, metricsBudgets, allViolations) {
|
|
|
473
738
|
addMetricViolation("fcpMs", metrics.fcpMs, metricsBudgets.fcpMs, result, allViolations);
|
|
474
739
|
addMetricViolation("tbtMs", metrics.tbtMs, metricsBudgets.tbtMs, result, allViolations);
|
|
475
740
|
addMetricViolation("cls", metrics.cls, metricsBudgets.cls, result, allViolations);
|
|
741
|
+
addMetricViolation("inpMs", metrics.inpMs, metricsBudgets.inpMs, result, allViolations);
|
|
476
742
|
}
|
|
477
743
|
function addMetricViolation(id, actual, limit, result, allViolations) {
|
|
478
744
|
if (limit === undefined || actual === undefined) {
|
|
@@ -491,3 +757,22 @@ function addMetricViolation(id, actual, limit, result, allViolations) {
|
|
|
491
757
|
limit,
|
|
492
758
|
});
|
|
493
759
|
}
|
|
760
|
+
function openInBrowser(filePath) {
|
|
761
|
+
const platform = process.platform;
|
|
762
|
+
let command;
|
|
763
|
+
if (platform === "win32") {
|
|
764
|
+
command = `start "" "${filePath}"`;
|
|
765
|
+
}
|
|
766
|
+
else if (platform === "darwin") {
|
|
767
|
+
command = `open "${filePath}"`;
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
command = `xdg-open "${filePath}"`;
|
|
771
|
+
}
|
|
772
|
+
exec(command, (error) => {
|
|
773
|
+
if (error) {
|
|
774
|
+
// eslint-disable-next-line no-console
|
|
775
|
+
console.error(`Could not open report: ${error.message}`);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
}
|
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) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { request as httpRequest } from "node:http";
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { cpus, freemem } from "node:os";
|
|
3
4
|
import lighthouse from "lighthouse";
|
|
4
5
|
import { launch as launchChrome } from "chrome-launcher";
|
|
5
6
|
async function createChromeSession(chromePort) {
|
|
@@ -16,6 +17,18 @@ async function createChromeSession(chromePort) {
|
|
|
16
17
|
"--disable-default-apps",
|
|
17
18
|
"--no-first-run",
|
|
18
19
|
"--no-default-browser-check",
|
|
20
|
+
// Additional flags for more consistent and accurate results
|
|
21
|
+
"--disable-background-networking",
|
|
22
|
+
"--disable-background-timer-throttling",
|
|
23
|
+
"--disable-backgrounding-occluded-windows",
|
|
24
|
+
"--disable-renderer-backgrounding",
|
|
25
|
+
"--disable-client-side-phishing-detection",
|
|
26
|
+
"--disable-sync",
|
|
27
|
+
"--disable-translate",
|
|
28
|
+
"--metrics-recording-only",
|
|
29
|
+
"--safebrowsing-disable-auto-update",
|
|
30
|
+
"--password-store=basic",
|
|
31
|
+
"--use-mock-keychain",
|
|
19
32
|
],
|
|
20
33
|
});
|
|
21
34
|
return {
|
|
@@ -59,43 +72,142 @@ async function ensureUrlReachable(url) {
|
|
|
59
72
|
throw error instanceof Error ? error : new Error(`URL not reachable: ${url}`);
|
|
60
73
|
});
|
|
61
74
|
}
|
|
75
|
+
async function performWarmUp(config) {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.log("Performing warm-up requests...");
|
|
78
|
+
const uniqueUrls = new Set();
|
|
79
|
+
for (const page of config.pages) {
|
|
80
|
+
const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
|
|
81
|
+
uniqueUrls.add(url);
|
|
82
|
+
}
|
|
83
|
+
// Make parallel warm-up requests to all unique URLs
|
|
84
|
+
const warmUpPromises = Array.from(uniqueUrls).map(async (url) => {
|
|
85
|
+
try {
|
|
86
|
+
await fetchUrl(url);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Ignore warm-up errors, the actual audit will catch real issues
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
await Promise.all(warmUpPromises);
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.log(`Warm-up complete (${uniqueUrls.size} pages).`);
|
|
95
|
+
}
|
|
96
|
+
async function fetchUrl(url) {
|
|
97
|
+
const parsed = new URL(url);
|
|
98
|
+
const client = parsed.protocol === "https:" ? httpsRequest : httpRequest;
|
|
99
|
+
await new Promise((resolve, reject) => {
|
|
100
|
+
const request = client({
|
|
101
|
+
hostname: parsed.hostname,
|
|
102
|
+
port: parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80,
|
|
103
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
104
|
+
method: "GET",
|
|
105
|
+
}, (response) => {
|
|
106
|
+
response.resume();
|
|
107
|
+
resolve();
|
|
108
|
+
});
|
|
109
|
+
request.on("error", reject);
|
|
110
|
+
request.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
62
113
|
/**
|
|
63
114
|
* Run audits for all pages defined in the config and return a structured summary.
|
|
64
115
|
*/
|
|
65
|
-
export async function runAuditsForConfig({ config, configPath, }) {
|
|
116
|
+
export async function runAuditsForConfig({ config, configPath, showParallel, }) {
|
|
66
117
|
const runs = config.runs ?? 1;
|
|
67
|
-
const results = [];
|
|
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 startedAtMs = Date.now();
|
|
146
|
+
const parallelCount = resolveParallelCount({ requested: config.parallel, chromePort: config.chromePort, taskCount: tasks.length });
|
|
147
|
+
if (showParallel === true) {
|
|
148
|
+
// eslint-disable-next-line no-console
|
|
149
|
+
console.log(`Resolved parallel workers: ${parallelCount}`);
|
|
150
|
+
}
|
|
151
|
+
const totalSteps = tasks.length * runs;
|
|
72
152
|
let completedSteps = 0;
|
|
73
|
-
const
|
|
153
|
+
const progressLock = { count: 0 };
|
|
154
|
+
const updateProgress = (path, device) => {
|
|
155
|
+
progressLock.count += 1;
|
|
156
|
+
completedSteps = progressLock.count;
|
|
157
|
+
const etaMs = computeEtaMs({ startedAtMs, completed: completedSteps, total: totalSteps });
|
|
158
|
+
logProgress({ completed: completedSteps, total: totalSteps, path, device, etaMs });
|
|
159
|
+
};
|
|
160
|
+
let results;
|
|
161
|
+
if (parallelCount <= 1 || config.chromePort !== undefined) {
|
|
162
|
+
// Sequential execution (original behavior) or using external Chrome
|
|
163
|
+
results = await runSequential(tasks, config.chromePort, updateProgress);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Parallel execution with multiple Chrome instances
|
|
167
|
+
results = await runParallel(tasks, parallelCount, updateProgress);
|
|
168
|
+
}
|
|
169
|
+
const completedAtMs = Date.now();
|
|
170
|
+
const elapsedMs = completedAtMs - startedAtMs;
|
|
171
|
+
const averageStepMs = totalSteps > 0 ? elapsedMs / totalSteps : 0;
|
|
172
|
+
return {
|
|
173
|
+
meta: {
|
|
174
|
+
configPath,
|
|
175
|
+
resolvedParallel: parallelCount,
|
|
176
|
+
totalSteps,
|
|
177
|
+
comboCount: tasks.length,
|
|
178
|
+
runsPerCombo: runs,
|
|
179
|
+
warmUp: config.warmUp === true,
|
|
180
|
+
throttlingMethod,
|
|
181
|
+
cpuSlowdownMultiplier,
|
|
182
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
183
|
+
completedAt: new Date(completedAtMs).toISOString(),
|
|
184
|
+
elapsedMs,
|
|
185
|
+
averageStepMs,
|
|
186
|
+
},
|
|
187
|
+
results,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function runSequential(tasks, chromePort, updateProgress) {
|
|
191
|
+
const results = [];
|
|
192
|
+
const session = await createChromeSession(chromePort);
|
|
74
193
|
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));
|
|
194
|
+
for (const task of tasks) {
|
|
195
|
+
const summaries = [];
|
|
196
|
+
for (let index = 0; index < task.runs; index += 1) {
|
|
197
|
+
const summary = await runSingleAudit({
|
|
198
|
+
url: task.url,
|
|
199
|
+
path: task.path,
|
|
200
|
+
label: task.label,
|
|
201
|
+
device: task.device,
|
|
202
|
+
port: session.port,
|
|
203
|
+
logLevel: task.logLevel,
|
|
204
|
+
throttlingMethod: task.throttlingMethod,
|
|
205
|
+
cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
|
|
206
|
+
});
|
|
207
|
+
summaries.push(summary);
|
|
208
|
+
updateProgress(task.path, task.device);
|
|
98
209
|
}
|
|
210
|
+
results.push(aggregateSummaries(summaries));
|
|
99
211
|
}
|
|
100
212
|
}
|
|
101
213
|
finally {
|
|
@@ -103,7 +215,52 @@ export async function runAuditsForConfig({ config, configPath, }) {
|
|
|
103
215
|
await session.close();
|
|
104
216
|
}
|
|
105
217
|
}
|
|
106
|
-
return
|
|
218
|
+
return results;
|
|
219
|
+
}
|
|
220
|
+
async function runParallel(tasks, parallelCount, updateProgress) {
|
|
221
|
+
// Create a pool of Chrome sessions
|
|
222
|
+
const sessions = [];
|
|
223
|
+
const effectiveParallel = Math.min(parallelCount, tasks.length);
|
|
224
|
+
for (let i = 0; i < effectiveParallel; i += 1) {
|
|
225
|
+
sessions.push(await createChromeSession());
|
|
226
|
+
}
|
|
227
|
+
const results = new Array(tasks.length);
|
|
228
|
+
let taskIndex = 0;
|
|
229
|
+
const runWorker = async (session, workerIndex) => {
|
|
230
|
+
while (taskIndex < tasks.length) {
|
|
231
|
+
const currentIndex = taskIndex;
|
|
232
|
+
taskIndex += 1;
|
|
233
|
+
const task = tasks[currentIndex];
|
|
234
|
+
const summaries = [];
|
|
235
|
+
for (let run = 0; run < task.runs; run += 1) {
|
|
236
|
+
const summary = await runSingleAudit({
|
|
237
|
+
url: task.url,
|
|
238
|
+
path: task.path,
|
|
239
|
+
label: task.label,
|
|
240
|
+
device: task.device,
|
|
241
|
+
port: session.port,
|
|
242
|
+
logLevel: task.logLevel,
|
|
243
|
+
throttlingMethod: task.throttlingMethod,
|
|
244
|
+
cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
|
|
245
|
+
});
|
|
246
|
+
summaries.push(summary);
|
|
247
|
+
updateProgress(task.path, task.device);
|
|
248
|
+
}
|
|
249
|
+
results[currentIndex] = aggregateSummaries(summaries);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
await Promise.all(sessions.map((session, index) => runWorker(session, index)));
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
// Close all Chrome sessions
|
|
257
|
+
await Promise.all(sessions.map(async (session) => {
|
|
258
|
+
if (session.close) {
|
|
259
|
+
await session.close();
|
|
260
|
+
}
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
return results;
|
|
107
264
|
}
|
|
108
265
|
function buildUrl({ baseUrl, path, query }) {
|
|
109
266
|
const cleanBase = baseUrl.replace(/\/$/, "");
|
|
@@ -111,9 +268,10 @@ function buildUrl({ baseUrl, path, query }) {
|
|
|
111
268
|
const queryPart = query && query.length > 0 ? query : "";
|
|
112
269
|
return `${cleanBase}${cleanPath}${queryPart}`;
|
|
113
270
|
}
|
|
114
|
-
function logProgress({ completed, total, path, device, }) {
|
|
271
|
+
function logProgress({ completed, total, path, device, etaMs, }) {
|
|
115
272
|
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
116
|
-
const
|
|
273
|
+
const etaText = etaMs !== undefined ? ` | ETA ${formatEta(etaMs)}` : "";
|
|
274
|
+
const message = `Running audits ${completed}/${total} (${percentage}%) – ${path} [${device}]${etaText}`;
|
|
117
275
|
if (typeof process !== "undefined" && process.stdout && typeof process.stdout.write === "function" && process.stdout.isTTY) {
|
|
118
276
|
const padded = message.padEnd(80, " ");
|
|
119
277
|
process.stdout.write(`\r${padded}`);
|
|
@@ -131,7 +289,22 @@ async function runSingleAudit(params) {
|
|
|
131
289
|
output: "json",
|
|
132
290
|
logLevel: params.logLevel,
|
|
133
291
|
onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
|
|
134
|
-
|
|
292
|
+
formFactor: params.device,
|
|
293
|
+
// Throttling configuration for more accurate results
|
|
294
|
+
throttlingMethod: params.throttlingMethod,
|
|
295
|
+
throttling: {
|
|
296
|
+
// CPU throttling - adjustable via config
|
|
297
|
+
cpuSlowdownMultiplier: params.cpuSlowdownMultiplier,
|
|
298
|
+
// Network throttling (Slow 4G / Fast 3G preset - Lighthouse default)
|
|
299
|
+
rttMs: 150,
|
|
300
|
+
throughputKbps: 1638.4,
|
|
301
|
+
requestLatencyMs: 150 * 3.75,
|
|
302
|
+
downloadThroughputKbps: 1638.4,
|
|
303
|
+
uploadThroughputKbps: 750,
|
|
304
|
+
},
|
|
305
|
+
screenEmulation: params.device === "mobile"
|
|
306
|
+
? { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false }
|
|
307
|
+
: { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
|
|
135
308
|
};
|
|
136
309
|
const runnerResult = await lighthouse(params.url, options);
|
|
137
310
|
const lhrUnknown = runnerResult.lhr;
|
|
@@ -178,15 +351,18 @@ function extractMetrics(lhr) {
|
|
|
178
351
|
const fcpAudit = audits["first-contentful-paint"];
|
|
179
352
|
const tbtAudit = audits["total-blocking-time"];
|
|
180
353
|
const clsAudit = audits["cumulative-layout-shift"];
|
|
354
|
+
const inpAudit = audits["interaction-to-next-paint"];
|
|
181
355
|
const lcpMs = typeof lcpAudit?.numericValue === "number" ? lcpAudit.numericValue : undefined;
|
|
182
356
|
const fcpMs = typeof fcpAudit?.numericValue === "number" ? fcpAudit.numericValue : undefined;
|
|
183
357
|
const tbtMs = typeof tbtAudit?.numericValue === "number" ? tbtAudit.numericValue : undefined;
|
|
184
358
|
const cls = typeof clsAudit?.numericValue === "number" ? clsAudit.numericValue : undefined;
|
|
359
|
+
const inpMs = typeof inpAudit?.numericValue === "number" ? inpAudit.numericValue : undefined;
|
|
185
360
|
return {
|
|
186
361
|
lcpMs,
|
|
187
362
|
fcpMs,
|
|
188
363
|
tbtMs,
|
|
189
364
|
cls,
|
|
365
|
+
inpMs,
|
|
190
366
|
};
|
|
191
367
|
}
|
|
192
368
|
function extractTopOpportunities(lhr, limit) {
|
|
@@ -223,6 +399,7 @@ function aggregateSummaries(summaries) {
|
|
|
223
399
|
fcpMs: averageOf(summaries.map((s) => s.metrics.fcpMs)),
|
|
224
400
|
tbtMs: averageOf(summaries.map((s) => s.metrics.tbtMs)),
|
|
225
401
|
cls: averageOf(summaries.map((s) => s.metrics.cls)),
|
|
402
|
+
inpMs: averageOf(summaries.map((s) => s.metrics.inpMs)),
|
|
226
403
|
};
|
|
227
404
|
const opportunities = summaries[0].opportunities;
|
|
228
405
|
return {
|
|
@@ -245,3 +422,32 @@ function averageOf(values) {
|
|
|
245
422
|
const total = defined.reduce((sum, value) => sum + value, 0);
|
|
246
423
|
return total / defined.length;
|
|
247
424
|
}
|
|
425
|
+
function resolveParallelCount({ requested, chromePort, taskCount, }) {
|
|
426
|
+
if (chromePort !== undefined) {
|
|
427
|
+
return 1;
|
|
428
|
+
}
|
|
429
|
+
if (requested !== undefined) {
|
|
430
|
+
return requested;
|
|
431
|
+
}
|
|
432
|
+
const cpuBased = Math.max(1, Math.min(6, Math.floor(cpus().length / 2)));
|
|
433
|
+
const memoryBased = Math.max(1, Math.min(6, Math.floor(freemem() / 1_500_000_000)));
|
|
434
|
+
const suggested = Math.max(1, Math.min(cpuBased, memoryBased));
|
|
435
|
+
return Math.max(1, Math.min(10, Math.min(taskCount, suggested || 1)));
|
|
436
|
+
}
|
|
437
|
+
function computeEtaMs({ startedAtMs, completed, total, }) {
|
|
438
|
+
if (completed === 0 || total === 0 || completed > total) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
const elapsedMs = Date.now() - startedAtMs;
|
|
442
|
+
const averagePerStep = elapsedMs / completed;
|
|
443
|
+
const remainingSteps = total - completed;
|
|
444
|
+
return Math.max(0, Math.round(averagePerStep * remainingSteps));
|
|
445
|
+
}
|
|
446
|
+
function formatEta(etaMs) {
|
|
447
|
+
const totalSeconds = Math.ceil(etaMs / 1000);
|
|
448
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
449
|
+
const seconds = totalSeconds % 60;
|
|
450
|
+
const minutesPart = minutes > 0 ? `${minutes}m ` : "";
|
|
451
|
+
const secondsPart = `${seconds}s`;
|
|
452
|
+
return `${minutesPart}${secondsPart}`.trim();
|
|
453
|
+
}
|
package/dist/wizard-cli.js
CHANGED
|
@@ -278,6 +278,7 @@ function convertRouteToPage(route) {
|
|
|
278
278
|
async function buildConfig() {
|
|
279
279
|
const profileAnswer = await ask(profileQuestion);
|
|
280
280
|
const baseAnswers = await collectBaseAnswers();
|
|
281
|
+
console.log("Tip: parallel workers auto-tune from CPU/memory. Override later with --parallel <n> or inspect with --show-parallel.");
|
|
281
282
|
const detectedPages = await maybeDetectPages(profileAnswer.profile);
|
|
282
283
|
const pages = await collectPages(detectedPages);
|
|
283
284
|
return {
|