apex-auditor 0.2.7 → 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 CHANGED
@@ -2,144 +2,151 @@
2
2
 
3
3
  ApexAuditor is a small, framework-agnostic Lighthouse runner that gives you **fast, structured insights** across multiple pages and devices.
4
4
 
5
- It is designed to:
5
+ It focuses on:
6
6
 
7
- - **Run anywhere**: attach to an existing Chrome instance (remote debugging) on Windows, macOS, or Linux.
8
- - **Work with any web stack**: Next.js, Vite, Rails, static sites, etc. – as long as there is an HTTP server.
9
- - **Summarize multiple pages at once**: homepage, blog, auth, search, and more.
10
- - **Output developer-friendly reports**: one Markdown table + JSON, ready to paste into PRs or chat.
11
-
12
- > V1 focuses on a solid, single-project core. Route auto-detection and monorepo orchestration will land in later versions.
7
+ - **Multi-page, multi-device audits**: run Lighthouse across your key flows in one shot.
8
+ - **Framework flexibility**: works with any stack that serves HTTP (Next.js, Remix, Vite/React, SvelteKit, Rails, static sites, etc.).
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, 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.
13
13
 
14
14
  ---
15
15
 
16
- ## Quick start (single project)
16
+ ## Example output
17
17
 
18
- ### 1. Install dependencies
18
+ Terminal summary:
19
19
 
20
- From the `apex-auditor` directory:
20
+ ![Example terminal output 1](./public/example_output_1.png)
21
21
 
22
- ```bash
23
- pnpm install
24
- ```
22
+ ![Example terminal output 2](./public/example_output_2.png)
25
23
 
26
- ### 2. Start your web app
24
+ Wizard route selection:
27
25
 
28
- In your application repo (for example, a Next.js app running on port 3000):
26
+ ![Wizard screenshot](./public/wizard_1.png)
29
27
 
30
- ```bash
31
- pnpm start
32
- # or: pnpm dev, npm run dev, etc.
33
- ```
28
+ ---
29
+
30
+ ## Installation
34
31
 
35
- Make sure the app is reachable at the `baseUrl` you will configure (default example: `http://localhost:3000`).
32
+ Install as a dev dependency (recommended):
36
33
 
37
- ### 3. Start Chrome with remote debugging
34
+ ```bash
35
+ pnpm add -D apex-auditor
36
+ # or
37
+ npm install --save-dev apex-auditor
38
+ ```
38
39
 
39
- ApexAuditor connects to an existing Chrome instance instead of launching its own. Start Chrome once with a debugging port (example for Windows):
40
+ You can also run it without installing by using your package manager's "dlx"/"npx" style command, for example:
40
41
 
41
42
  ```bash
42
- "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" \
43
- --remote-debugging-port=9222 \
44
- --user-data-dir="%LOCALAPPDATA%\\ChromeApex"
43
+ pnpm dlx apex-auditor@latest wizard
45
44
  ```
46
45
 
47
- On macOS or Linux the flags are the same; only the Chrome path changes.
46
+ ---
47
+
48
+ ## Common commands
48
49
 
49
- ### 4. Configure pages (wizard-friendly)
50
+ All commands are available as a CLI named `apex-auditor` once installed.
50
51
 
51
- Run the guided wizard to scaffold `apex.config.json` and optionally auto-discover routes:
52
+ ### Quickstart (auto-detect routes and run a one-off audit)
52
53
 
53
54
  ```bash
54
- pnpm wizard
55
+ apex-auditor quickstart --base-url http://localhost:3000
55
56
  ```
56
57
 
57
- The wizard asks for the base URL, optional query string, desired Chrome port, run count, and can crawl popular frameworks (Next.js app/pages) to prefill routes before you fine-tune the list. You can still edit the file manually afterwards:
58
+ ### Wizard (interactive config with route auto-detection)
58
59
 
59
- ```jsonc
60
- {
61
- "baseUrl": "http://localhost:3000",
62
- "query": "?lhci=1",
63
- "chromePort": 9222,
64
- "runs": 1,
65
- "pages": [
66
- { "path": "/", "label": "home", "devices": ["mobile", "desktop"] },
67
- { "path": "/blog", "label": "blog", "devices": ["mobile", "desktop"] },
68
- { "path": "/contact", "label": "contact", "devices": ["mobile"] }
69
- ]
70
- }
60
+ ```bash
61
+ apex-auditor wizard
71
62
  ```
72
63
 
73
- > Tip: rerun `pnpm wizard -- --config custom/path.json` to regenerate configs for multiple projects, or pass a different `--project-root` when prompted to detect routes from another app.
64
+ The wizard can detect routes for:
74
65
 
75
- - `baseUrl`: root URL of your running app.
76
- - `query` (optional): query string appended to every URL (for example `?lhci=1` to disable analytics).
77
- - `chromePort` (optional): remote debugging port (defaults to `9222`).
78
- - `runs` (optional): how many times to run Lighthouse per page/device (results are averaged).
79
- - `pages`: list of paths and devices to audit.
66
+ - Next.js (App Router / Pages Router)
67
+ - Remix
68
+ - SvelteKit
69
+ - Single Page Apps (Vite/CRA/etc., via HTML crawl)
80
70
 
81
- ### 5. Run an audit
71
+ ### Audit (run using an existing config)
82
72
 
83
73
  ```bash
84
- pnpm audit
74
+ apex-auditor audit --config apex.config.json
85
75
  ```
86
76
 
87
- This will:
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 |
88
92
 
89
- - Run Lighthouse for every `page × device` defined in `apex.config.json`.
90
- - Write structured results to `.apex-auditor/summary.json`.
91
- - Write a human-readable table to `.apex-auditor/summary.md`.
92
- - Print the same table to the terminal.
93
+ ---
93
94
 
94
- Example output:
95
+ ## Output files
95
96
 
96
- ```text
97
- | Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | Top issues |
98
- |-------|------|---------|----|----|----|-----|---------|---------|----------|-------|-----------|
99
- | home | / | mobile | 95 |100 |100 |100 | 2.9 | 0.9 | 160 | 0.002 | render-blocking-resources (140ms); unused-javascript (55KB) |
100
- | home | / | desktop |100 |100 |100 |100 | 0.6 | 0.4 | 0 | 0.016 | unused-javascript (55KB) |
101
- ```
97
+ After each audit, results are saved to `.apex-auditor/`:
102
98
 
103
- You can paste this table directly into PRs, tickets, or chat to discuss optimizations.
99
+ - `summary.json` structured JSON results
100
+ - `summary.md` – Markdown table
101
+ - `report.html` – visual HTML report with score circles and metrics
104
102
 
105
103
  ---
106
104
 
107
- ## Configuration reference (V1)
105
+ ## Configuration
108
106
 
109
- ```ts
110
- // apex.config.json (TypeScript shape)
111
- interface ApexPageConfig {
112
- path: string; // URL path, must start with "/"
113
- label: string; // short label for reports
114
- devices: ("mobile" | "desktop")[];
115
- }
107
+ Example `apex.config.json`:
116
108
 
117
- interface ApexConfig {
118
- baseUrl: string; // e.g. "http://localhost:3000"
119
- query?: string; // e.g. "?lhci=1"
120
- chromePort?: number; // default: 9222
121
- runs?: number; // default: 1
122
- pages: ApexPageConfig[];
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
+ }
123
125
  }
124
126
  ```
125
127
 
126
- Future versions will add:
128
+ ---
127
129
 
128
- - Automatic route discovery (for example, from Next.js `app/` routes or a crawler).
129
- - Workspace-level configs for monorepos.
130
- - CI integration recipes and HTML dashboards.
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
131
137
 
132
138
  ---
133
139
 
134
- ## Code structure (V1)
140
+ ## Further documentation
141
+
142
+ For detailed guides, configuration options, and CI examples, see the `docs/` directory:
135
143
 
136
- The codebase is intentionally small and modular:
144
+ - `docs/getting-started.md` installation, quickstart, wizard, and audit flows.
145
+ - `docs/configuration-and-routes.md` – `apex.config.json` schema and route detection details.
146
+ - `docs/cli-and-ci.md` – CLI flags, CI mode, budgets, and example workflows.
137
147
 
138
- - `src/types.ts` – shared type definitions for config and results.
139
- - `src/config.ts` – loads and validates `apex.config.json`.
140
- - `src/lighthouse-runner.ts` – runs Lighthouse for each page/device and normalises results.
141
- - `src/cli.ts` – CLI entry point; orchestrates config + runner, writes JSON/Markdown.
148
+ ---
142
149
 
143
- All public modules use explicit TypeScript types and are written to be reusable in future integrations (route detectors, monorepo orchestration, CI adapters).
150
+ ## License
144
151
 
145
- See `ROADMAP.md` for planned features and phases.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 parts = [`LCP ${lcpText}`, `FCP ${fcpText}`, `TBT ${tbtText}`, `CLS ${clsText}`];
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
- 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) {
@@ -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 results = [];
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
- 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 totalSteps = tasks.length * runs;
72
146
  let completedSteps = 0;
73
- const session = await createChromeSession(config.chromePort);
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 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));
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 { configPath, results };
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
- emulatedFormFactor: params.device,
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apex-auditor",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "private": false,
5
5
  "description": "CLI to run structured Lighthouse audits (Performance, Accessibility, Best Practices, SEO) across routes.",
6
6
  "type": "module",