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 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
- Useful flags:
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
- - `--ci` – enable CI mode with budgets and non-zero exit codes.
78
- - `--no-color` / `--color` – control ANSI colours in console output.
79
- - `--log-level <silent|error|info|verbose>` – override Lighthouse log level.
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
- For the longer-term vision and planned features, see `ROADMAP.md`.
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.results);
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(results) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 parts = [`LCP ${lcpText}`, `FCP ${fcpText}`, `TBT ${tbtText}`, `CLS ${clsText}`];
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
- if (lcpMs === undefined && fcpMs === undefined && tbtMs === undefined && cls === undefined) {
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
- const totalSteps = config.pages.reduce((sum, page) => sum + page.devices.length * runs, 0);
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 session = await createChromeSession(config.chromePort);
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 page of config.pages) {
76
- for (const device of page.devices) {
77
- const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
78
- const summaries = [];
79
- for (let index = 0; index < runs; index += 1) {
80
- const summary = await runSingleAudit({
81
- url,
82
- path: page.path,
83
- label: page.label,
84
- device,
85
- port: session.port,
86
- logLevel: config.logLevel ?? "error",
87
- });
88
- summaries.push(summary);
89
- completedSteps += 1;
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 { configPath, results };
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 message = `Running audits ${completed}/${total} (${percentage}%) ${path} [${device}]`;
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
- emulatedFormFactor: params.device,
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
+ }
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",