apex-auditor 0.2.9 → 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
@@ -87,6 +87,7 @@ apex-auditor audit --config apex.config.json
87
87
  | `--warm-up` | Perform warm-up requests before auditing |
88
88
  | `--open` | Auto-open HTML report in browser after audit |
89
89
  | `--json` | Output JSON to stdout (for piping) |
90
+ | `--show-parallel` | Print the resolved parallel worker count before running |
90
91
  | `--mobile-only` | Only audit mobile device configurations |
91
92
  | `--desktop-only` | Only audit desktop device configurations |
92
93
 
@@ -97,8 +98,8 @@ apex-auditor audit --config apex.config.json
97
98
  After each audit, results are saved to `.apex-auditor/`:
98
99
 
99
100
  - `summary.json` – structured JSON results
100
- - `summary.md` – Markdown table
101
- - `report.html` – visual HTML report with score circles and metrics
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)
102
103
 
103
104
  ---
104
105
 
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
@@ -31,6 +31,7 @@ function parseArgs(argv) {
31
31
  let openReport = false;
32
32
  let warmUp = false;
33
33
  let jsonOutput = false;
34
+ let showParallel = false;
34
35
  for (let i = 2; i < argv.length; i += 1) {
35
36
  const arg = argv[i];
36
37
  if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
@@ -103,9 +104,12 @@ function parseArgs(argv) {
103
104
  else if (arg === "--json") {
104
105
  jsonOutput = true;
105
106
  }
107
+ else if (arg === "--show-parallel") {
108
+ showParallel = true;
109
+ }
106
110
  }
107
111
  const finalConfigPath = configPath ?? "apex.config.json";
108
- return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter, throttlingMethodOverride, cpuSlowdownOverride, parallelOverride, openReport, warmUp, jsonOutput };
112
+ return { configPath: finalConfigPath, ci, colorMode, logLevelOverride, deviceFilter, throttlingMethodOverride, cpuSlowdownOverride, parallelOverride, openReport, warmUp, jsonOutput, showParallel };
109
113
  }
110
114
  /**
111
115
  * Runs the ApexAuditor audit CLI.
@@ -136,13 +140,13 @@ export async function runAuditCli(argv) {
136
140
  process.exitCode = 1;
137
141
  return;
138
142
  }
139
- const summary = await runAuditsForConfig({ config: filteredConfig, configPath });
143
+ const summary = await runAuditsForConfig({ config: filteredConfig, configPath, showParallel: args.showParallel });
140
144
  const outputDir = resolve(".apex-auditor");
141
145
  await mkdir(outputDir, { recursive: true });
142
146
  await writeFile(resolve(outputDir, "summary.json"), JSON.stringify(summary, null, 2), "utf8");
143
- const markdown = buildMarkdown(summary.results);
147
+ const markdown = buildMarkdown(summary);
144
148
  await writeFile(resolve(outputDir, "summary.md"), markdown, "utf8");
145
- const html = buildHtmlReport(summary.results, summary.configPath);
149
+ const html = buildHtmlReport(summary);
146
150
  const reportPath = resolve(outputDir, "report.html");
147
151
  await writeFile(reportPath, html, "utf8");
148
152
  // Open HTML report in browser if requested
@@ -160,6 +164,7 @@ export async function runAuditCli(argv) {
160
164
  const consoleTable = buildConsoleTable(summary.results, useColor);
161
165
  // eslint-disable-next-line no-console
162
166
  console.log(consoleTable);
167
+ printRunMeta(summary.meta, useColor);
163
168
  printSummaryStats(summary.results, useColor);
164
169
  printRedIssues(summary.results);
165
170
  printCiSummary(args, summary.results, effectiveConfig.budgets);
@@ -195,15 +200,34 @@ function filterPageDevices(page, deviceFilter) {
195
200
  devices,
196
201
  };
197
202
  }
198
- 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");
199
221
  const header = [
200
222
  "| Label | Path | Device | P | A | BP | SEO | LCP (s) | FCP (s) | TBT (ms) | CLS | INP (ms) | Error | Top issues |",
201
223
  "|-------|------|--------|---|---|----|-----|---------|---------|----------|-----|----------|-------|-----------|",
202
224
  ].join("\n");
203
- const lines = results.map((result) => buildRow(result));
204
- 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")}`;
205
227
  }
206
- function buildHtmlReport(results, configPath) {
228
+ function buildHtmlReport(summary) {
229
+ const results = summary.results;
230
+ const meta = summary.meta;
207
231
  const timestamp = new Date().toISOString();
208
232
  const rows = results.map((result) => buildHtmlRow(result)).join("\n");
209
233
  return `<!DOCTYPE html>
@@ -218,6 +242,10 @@ function buildHtmlReport(results, configPath) {
218
242
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); padding: 2rem; }
219
243
  h1 { margin-bottom: 0.5rem; }
220
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; }
221
249
  .cards { display: grid; gap: 1.5rem; }
222
250
  .card { background: var(--card); border-radius: 12px; padding: 1.5rem; }
223
251
  .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 1px solid #333; padding-bottom: 1rem; }
@@ -246,7 +274,21 @@ function buildHtmlReport(results, configPath) {
246
274
  </head>
247
275
  <body>
248
276
  <h1>ApexAuditor Report</h1>
249
- <p class="meta">Generated: ${timestamp} | Config: ${escapeHtml(configPath)}</p>
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>
250
292
  <div class="cards">
251
293
  ${rows}
252
294
  </div>
@@ -291,6 +333,30 @@ function buildScoreCircle(label, score) {
291
333
  function buildMetricBox(label, value, colorClass) {
292
334
  return `<div class="metric"><div class="metric-value ${colorClass}">${value}</div><div class="metric-label">${label}</div></div>`;
293
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
+ }
294
360
  function getMetricClass(value, good, warn) {
295
361
  if (value === undefined)
296
362
  return "";
@@ -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) {
@@ -112,9 +113,8 @@ async function fetchUrl(url) {
112
113
  /**
113
114
  * Run audits for all pages defined in the config and return a structured summary.
114
115
  */
115
- export async function runAuditsForConfig({ config, configPath, }) {
116
+ export async function runAuditsForConfig({ config, configPath, showParallel, }) {
116
117
  const runs = config.runs ?? 1;
117
- const parallelCount = config.parallel ?? 1;
118
118
  const firstPage = config.pages[0];
119
119
  const healthCheckUrl = buildUrl({ baseUrl: config.baseUrl, path: firstPage.path, query: config.query });
120
120
  await ensureUrlReachable(healthCheckUrl);
@@ -142,13 +142,20 @@ export async function runAuditsForConfig({ config, configPath, }) {
142
142
  });
143
143
  }
144
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
+ }
145
151
  const totalSteps = tasks.length * runs;
146
152
  let completedSteps = 0;
147
153
  const progressLock = { count: 0 };
148
154
  const updateProgress = (path, device) => {
149
155
  progressLock.count += 1;
150
156
  completedSteps = progressLock.count;
151
- logProgress({ completed: completedSteps, total: totalSteps, path, device });
157
+ const etaMs = computeEtaMs({ startedAtMs, completed: completedSteps, total: totalSteps });
158
+ logProgress({ completed: completedSteps, total: totalSteps, path, device, etaMs });
152
159
  };
153
160
  let results;
154
161
  if (parallelCount <= 1 || config.chromePort !== undefined) {
@@ -159,7 +166,26 @@ export async function runAuditsForConfig({ config, configPath, }) {
159
166
  // Parallel execution with multiple Chrome instances
160
167
  results = await runParallel(tasks, parallelCount, updateProgress);
161
168
  }
162
- return { configPath, results };
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
+ };
163
189
  }
164
190
  async function runSequential(tasks, chromePort, updateProgress) {
165
191
  const results = [];
@@ -242,9 +268,10 @@ function buildUrl({ baseUrl, path, query }) {
242
268
  const queryPart = query && query.length > 0 ? query : "";
243
269
  return `${cleanBase}${cleanPath}${queryPart}`;
244
270
  }
245
- function logProgress({ completed, total, path, device, }) {
271
+ function logProgress({ completed, total, path, device, etaMs, }) {
246
272
  const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
247
- 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}`;
248
275
  if (typeof process !== "undefined" && process.stdout && typeof process.stdout.write === "function" && process.stdout.isTTY) {
249
276
  const padded = message.padEnd(80, " ");
250
277
  process.stdout.write(`\r${padded}`);
@@ -395,3 +422,32 @@ function averageOf(values) {
395
422
  const total = defined.reduce((sum, value) => sum + value, 0);
396
423
  return total / defined.length;
397
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.9",
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",