apex-auditor 0.3.0 → 0.3.3

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.
@@ -1,12 +1,311 @@
1
+ import { mkdtemp, rm, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
1
3
  import { request as httpRequest } from "node:http";
2
4
  import { request as httpsRequest } from "node:https";
3
- import { cpus, freemem } from "node:os";
5
+ import { cpus, freemem, tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
4
8
  import lighthouse from "lighthouse";
5
9
  import { launch as launchChrome } from "chrome-launcher";
10
+ const CACHE_VERSION = 1;
11
+ const CACHE_DIR = ".apex-auditor";
12
+ const CACHE_FILE = "cache.json";
13
+ const DEFAULT_WORKER_TASK_TIMEOUT_MS = 5 * 60 * 1000;
14
+ const WORKER_RESPONSE_TIMEOUT_GRACE_MS = 15 * 1000;
15
+ const MAX_PARENT_TASK_ATTEMPTS = 5;
16
+ const MAX_PARENT_BACKOFF_MS = 3000;
17
+ let lastProgressLine;
18
+ function logLinePreservingProgress(message) {
19
+ if (typeof process === "undefined" || !process.stdout || typeof process.stdout.write !== "function" || process.stdout.isTTY !== true) {
20
+ // eslint-disable-next-line no-console
21
+ console.warn(message);
22
+ return;
23
+ }
24
+ if (lastProgressLine !== undefined) {
25
+ const clear = " ".repeat(lastProgressLine.length);
26
+ process.stdout.write(`\r${clear}\r`);
27
+ }
28
+ process.stdout.write(`${message}\n`);
29
+ if (lastProgressLine !== undefined) {
30
+ process.stdout.write(`\r${lastProgressLine}`);
31
+ }
32
+ }
33
+ function buildFailureSummary(task, errorMessage) {
34
+ return {
35
+ url: task.url,
36
+ path: task.path,
37
+ label: task.label,
38
+ device: task.device,
39
+ scores: {},
40
+ metrics: {},
41
+ opportunities: [],
42
+ runtimeErrorMessage: errorMessage,
43
+ };
44
+ }
45
+ async function withTimeout(promise, timeoutMs) {
46
+ const timeoutPromise = new Promise((_, reject) => {
47
+ setTimeout(() => {
48
+ reject(new Error(`ApexAuditor timeout after ${timeoutMs}ms`));
49
+ }, timeoutMs);
50
+ });
51
+ return Promise.race([promise, timeoutPromise]);
52
+ }
53
+ function stableStringify(value) {
54
+ return JSON.stringify(value);
55
+ }
56
+ function computeParentRetryDelayMs(params) {
57
+ const base = 200;
58
+ const expFactor = Math.min(4, params.attempt);
59
+ const candidate = base * Math.pow(2, expFactor);
60
+ const jitter = Math.floor(Math.random() * 200);
61
+ return Math.min(MAX_PARENT_BACKOFF_MS, candidate + jitter);
62
+ }
63
+ function buildCacheKey(params) {
64
+ const onlyCategories = params.onlyCategories ?? [];
65
+ return stableStringify({
66
+ buildId: params.buildId,
67
+ url: params.url,
68
+ path: params.path,
69
+ label: params.label,
70
+ device: params.device,
71
+ runs: params.runs,
72
+ throttlingMethod: params.throttlingMethod,
73
+ cpuSlowdownMultiplier: params.cpuSlowdownMultiplier,
74
+ onlyCategories,
75
+ });
76
+ }
77
+ async function loadIncrementalCache() {
78
+ const cachePath = resolve(CACHE_DIR, CACHE_FILE);
79
+ try {
80
+ const raw = await readFile(cachePath, "utf8");
81
+ const parsed = JSON.parse(raw);
82
+ if (!parsed || typeof parsed !== "object") {
83
+ return undefined;
84
+ }
85
+ const maybe = parsed;
86
+ if (maybe.version !== CACHE_VERSION || !maybe.entries || typeof maybe.entries !== "object") {
87
+ return undefined;
88
+ }
89
+ return { version: CACHE_VERSION, entries: maybe.entries };
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ async function saveIncrementalCache(cache) {
96
+ await mkdir(resolve(CACHE_DIR), { recursive: true });
97
+ const cachePath = resolve(CACHE_DIR, CACHE_FILE);
98
+ await writeFile(cachePath, JSON.stringify(cache, null, 2), "utf8");
99
+ }
100
+ function resolveWorkerEntryUrl() {
101
+ const jsEntry = new URL("./lighthouse-worker.js", import.meta.url);
102
+ const tsEntry = new URL("./lighthouse-worker.ts", import.meta.url);
103
+ const isSourceRun = import.meta.url.endsWith(".ts");
104
+ return { entry: isSourceRun ? tsEntry : jsEntry, useTsx: isSourceRun };
105
+ }
106
+ function spawnWorker() {
107
+ const resolved = resolveWorkerEntryUrl();
108
+ const entryPath = fileURLToPath(resolved.entry);
109
+ const args = resolved.useTsx ? ["--import", "tsx", entryPath] : [entryPath];
110
+ return spawn(process.execPath, args, { stdio: ["pipe", "pipe", "pipe", "ipc"] });
111
+ }
112
+ async function runParallelInProcesses(tasks, parallelCount, auditTimeoutMs, signal, updateProgress) {
113
+ const effectiveParallel = Math.min(parallelCount, tasks.length);
114
+ const workers = [];
115
+ for (let i = 0; i < effectiveParallel; i += 1) {
116
+ if (i > 0) {
117
+ await delayMs(200 * i);
118
+ }
119
+ workers.push({ child: spawnWorker(), busy: false });
120
+ }
121
+ const results = new Array(tasks.length);
122
+ const pending = [];
123
+ for (let taskIndex = 0; taskIndex < tasks.length; taskIndex += 1) {
124
+ for (let runIndex = 0; runIndex < tasks[taskIndex].runs; runIndex += 1) {
125
+ pending.push({ taskIndex, runIndex, attempts: 0 });
126
+ }
127
+ }
128
+ const summariesByTask = tasks.map(() => []);
129
+ const inFlight = new Map();
130
+ const waiters = new Map();
131
+ const attachListeners = (child) => {
132
+ child.on("message", (raw) => {
133
+ const msg = raw && typeof raw === "object" ? raw : undefined;
134
+ if (!msg || typeof msg.id !== "string") {
135
+ return;
136
+ }
137
+ const waiter = waiters.get(msg.id);
138
+ if (!waiter) {
139
+ return;
140
+ }
141
+ waiters.delete(msg.id);
142
+ waiter.resolve(msg);
143
+ });
144
+ child.on("error", (error) => {
145
+ for (const [id, waiter] of waiters) {
146
+ waiter.reject(error);
147
+ waiters.delete(id);
148
+ }
149
+ });
150
+ child.on("exit", () => {
151
+ for (const [id, waiter] of waiters) {
152
+ waiter.reject(new Error("Worker exited"));
153
+ waiters.delete(id);
154
+ }
155
+ });
156
+ child.on("disconnect", () => {
157
+ for (const [id, waiter] of waiters) {
158
+ waiter.reject(new Error("Worker disconnected"));
159
+ waiters.delete(id);
160
+ }
161
+ });
162
+ };
163
+ for (const worker of workers) {
164
+ attachListeners(worker.child);
165
+ }
166
+ let consecutiveRetries = 0;
167
+ const runOnWorker = async (workerIndex, taskIndex) => {
168
+ const worker = workers[workerIndex];
169
+ if (signal?.aborted) {
170
+ return;
171
+ }
172
+ const next = pending.shift();
173
+ if (!next) {
174
+ return;
175
+ }
176
+ const task = tasks[next.taskIndex];
177
+ const id = `${workerIndex}-${next.taskIndex}-${next.runIndex}-${Date.now()}`;
178
+ const workerTask = {
179
+ url: task.url,
180
+ path: task.path,
181
+ label: task.label,
182
+ device: task.device,
183
+ logLevel: task.logLevel,
184
+ throttlingMethod: task.throttlingMethod,
185
+ cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
186
+ timeoutMs: auditTimeoutMs,
187
+ onlyCategories: task.onlyCategories,
188
+ };
189
+ worker.busy = true;
190
+ worker.inFlightId = id;
191
+ worker.inFlightTaskIndex = next.taskIndex;
192
+ inFlight.set(id, next);
193
+ const responseTimeoutMs = auditTimeoutMs + WORKER_RESPONSE_TIMEOUT_GRACE_MS;
194
+ const response = await new Promise((resolve, reject) => {
195
+ const timeoutHandle = setTimeout(() => {
196
+ waiters.delete(id);
197
+ reject(new Error(`Worker response timeout after ${responseTimeoutMs}ms`));
198
+ }, responseTimeoutMs);
199
+ const abortListener = () => {
200
+ clearTimeout(timeoutHandle);
201
+ waiters.delete(id);
202
+ reject(new Error("Aborted"));
203
+ };
204
+ signal?.addEventListener("abort", abortListener, { once: true });
205
+ waiters.set(id, {
206
+ resolve: (msg) => {
207
+ clearTimeout(timeoutHandle);
208
+ signal?.removeEventListener("abort", abortListener);
209
+ resolve(msg);
210
+ },
211
+ reject: (err) => {
212
+ clearTimeout(timeoutHandle);
213
+ signal?.removeEventListener("abort", abortListener);
214
+ reject(err);
215
+ },
216
+ });
217
+ const request = { type: "run", id, task: workerTask };
218
+ try {
219
+ worker.child.send(request);
220
+ }
221
+ catch (error) {
222
+ clearTimeout(timeoutHandle);
223
+ waiters.delete(id);
224
+ reject(error instanceof Error ? error : new Error("Worker send failed"));
225
+ }
226
+ }).catch((error) => {
227
+ return { type: "error", id, errorMessage: error instanceof Error ? error.message : "Worker failure" };
228
+ });
229
+ const flight = inFlight.get(id);
230
+ inFlight.delete(id);
231
+ worker.busy = false;
232
+ worker.inFlightId = undefined;
233
+ worker.inFlightTaskIndex = undefined;
234
+ if (response.type === "error") {
235
+ const isTimeout = response.errorMessage.includes("timeout") || response.errorMessage.includes("Timeout") || response.errorMessage.includes("ApexAuditor timeout");
236
+ const prefix = isTimeout ? "Timeout" : "Worker error";
237
+ logLinePreservingProgress(`${prefix}: ${task.path} [${task.device}] (run ${next.runIndex + 1}/${task.runs}). Retrying... (${response.errorMessage})`);
238
+ const retryItem = flight
239
+ ? { taskIndex: flight.taskIndex, runIndex: flight.runIndex, attempts: next.attempts + 1 }
240
+ : { taskIndex: next.taskIndex, runIndex: next.runIndex, attempts: next.attempts + 1 };
241
+ if (retryItem.attempts >= MAX_PARENT_TASK_ATTEMPTS) {
242
+ logLinePreservingProgress(`Giving up: ${task.path} [${task.device}] (run ${next.runIndex + 1}/${task.runs}) after ${retryItem.attempts} attempts.`);
243
+ summariesByTask[next.taskIndex].push(buildFailureSummary(task, response.errorMessage));
244
+ updateProgress(task.path, task.device);
245
+ if (summariesByTask[next.taskIndex].length === task.runs) {
246
+ results[next.taskIndex] = aggregateSummaries(summariesByTask[next.taskIndex]);
247
+ }
248
+ }
249
+ else {
250
+ pending.unshift(retryItem);
251
+ }
252
+ consecutiveRetries += 1;
253
+ try {
254
+ worker.child.kill();
255
+ }
256
+ catch {
257
+ return;
258
+ }
259
+ const replacement = spawnWorker();
260
+ await delayMs(computeParentRetryDelayMs({ attempt: retryItem.attempts }));
261
+ attachListeners(replacement);
262
+ workers[workerIndex] = { child: replacement, busy: false };
263
+ return;
264
+ }
265
+ consecutiveRetries = 0;
266
+ summariesByTask[next.taskIndex].push(response.result);
267
+ updateProgress(task.path, task.device);
268
+ if (summariesByTask[next.taskIndex].length === task.runs) {
269
+ results[next.taskIndex] = aggregateSummaries(summariesByTask[next.taskIndex]);
270
+ }
271
+ };
272
+ try {
273
+ while (pending.length > 0) {
274
+ if (signal?.aborted) {
275
+ throw new Error("Aborted");
276
+ }
277
+ if (consecutiveRetries >= 2) {
278
+ logLinePreservingProgress(`Cooling down after ${consecutiveRetries} retry attempts; pausing briefly before resuming...`);
279
+ await delayMs(computeParentRetryDelayMs({ attempt: consecutiveRetries }));
280
+ consecutiveRetries = 0;
281
+ }
282
+ const idle = workers.map((w, idx) => (w.busy ? -1 : idx)).filter((idx) => idx >= 0);
283
+ if (idle.length === 0) {
284
+ await delayMs(50);
285
+ continue;
286
+ }
287
+ await Promise.all(idle.map(async (workerIndex) => {
288
+ await runOnWorker(workerIndex, workerIndex);
289
+ }));
290
+ }
291
+ }
292
+ finally {
293
+ for (const worker of workers) {
294
+ try {
295
+ worker.child.kill();
296
+ }
297
+ catch {
298
+ continue;
299
+ }
300
+ }
301
+ }
302
+ return results;
303
+ }
6
304
  async function createChromeSession(chromePort) {
7
305
  if (typeof chromePort === "number") {
8
306
  return { port: chromePort };
9
307
  }
308
+ const userDataDir = await mkdtemp(join(tmpdir(), "apex-auditor-chrome-"));
10
309
  const chrome = await launchChrome({
11
310
  chromeFlags: [
12
311
  "--headless=new",
@@ -17,6 +316,7 @@ async function createChromeSession(chromePort) {
17
316
  "--disable-default-apps",
18
317
  "--no-first-run",
19
318
  "--no-default-browser-check",
319
+ `--user-data-dir=${userDataDir}`,
20
320
  // Additional flags for more consistent and accurate results
21
321
  "--disable-background-networking",
22
322
  "--disable-background-timer-throttling",
@@ -36,6 +336,7 @@ async function createChromeSession(chromePort) {
36
336
  close: async () => {
37
337
  try {
38
338
  await chrome.kill();
339
+ await rm(userDataDir, { recursive: true, force: true });
39
340
  }
40
341
  catch {
41
342
  return;
@@ -80,16 +381,23 @@ async function performWarmUp(config) {
80
381
  const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
81
382
  uniqueUrls.add(url);
82
383
  }
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
384
+ const urls = Array.from(uniqueUrls);
385
+ const warmUpConcurrency = Math.max(1, Math.min(4, config.parallel ?? 4));
386
+ const warmUpNextIndex = { value: 0 };
387
+ const warmWorker = async () => {
388
+ while (warmUpNextIndex.value < urls.length) {
389
+ const index = warmUpNextIndex.value;
390
+ warmUpNextIndex.value += 1;
391
+ const url = urls[index];
392
+ try {
393
+ await fetchUrl(url);
394
+ }
395
+ catch {
396
+ // Ignore warm-up errors, the actual audit will catch real issues
397
+ }
90
398
  }
91
- });
92
- await Promise.all(warmUpPromises);
399
+ };
400
+ await Promise.all(new Array(warmUpConcurrency).fill(0).map(async () => warmWorker()));
93
401
  // eslint-disable-next-line no-console
94
402
  console.log(`Warm-up complete (${uniqueUrls.size} pages).`);
95
403
  }
@@ -113,8 +421,11 @@ async function fetchUrl(url) {
113
421
  /**
114
422
  * Run audits for all pages defined in the config and return a structured summary.
115
423
  */
116
- export async function runAuditsForConfig({ config, configPath, showParallel, }) {
117
- const runs = config.runs ?? 1;
424
+ export async function runAuditsForConfig({ config, configPath, showParallel, onlyCategories, signal, onAfterWarmUp, onProgress, }) {
425
+ const runs = 1;
426
+ if (config.runs !== undefined && config.runs !== 1) {
427
+ throw new Error("Multi-run mode is no longer supported. Set runs=1 and rerun the command multiple times for comparisons.");
428
+ }
118
429
  const firstPage = config.pages[0];
119
430
  const healthCheckUrl = buildUrl({ baseUrl: config.baseUrl, path: firstPage.path, query: config.query });
120
431
  await ensureUrlReachable(healthCheckUrl);
@@ -122,9 +433,16 @@ export async function runAuditsForConfig({ config, configPath, showParallel, })
122
433
  if (config.warmUp) {
123
434
  await performWarmUp(config);
124
435
  }
436
+ if (typeof onAfterWarmUp === "function") {
437
+ onAfterWarmUp();
438
+ }
125
439
  const throttlingMethod = config.throttlingMethod ?? "simulate";
126
440
  const cpuSlowdownMultiplier = config.cpuSlowdownMultiplier ?? 4;
127
441
  const logLevel = config.logLevel ?? "error";
442
+ const auditTimeoutMs = config.auditTimeoutMs ?? DEFAULT_WORKER_TASK_TIMEOUT_MS;
443
+ const incrementalEnabled = config.incremental === true && typeof config.buildId === "string" && config.buildId.length > 0;
444
+ const cache = incrementalEnabled ? await loadIncrementalCache() : undefined;
445
+ const cacheEntries = cache?.entries ?? {};
128
446
  // Build list of all audit tasks
129
447
  const tasks = [];
130
448
  for (const page of config.pages) {
@@ -139,32 +457,101 @@ export async function runAuditsForConfig({ config, configPath, showParallel, })
139
457
  logLevel,
140
458
  throttlingMethod,
141
459
  cpuSlowdownMultiplier,
460
+ onlyCategories,
142
461
  });
143
462
  }
144
463
  }
464
+ const results = new Array(tasks.length);
465
+ const tasksToRun = [];
466
+ const taskIndexByRunIndex = [];
467
+ let cachedSteps = 0;
468
+ let cachedCombos = 0;
469
+ if (incrementalEnabled) {
470
+ for (let i = 0; i < tasks.length; i += 1) {
471
+ const task = tasks[i];
472
+ const key = buildCacheKey({
473
+ buildId: config.buildId,
474
+ url: task.url,
475
+ path: task.path,
476
+ label: task.label,
477
+ device: task.device,
478
+ runs: task.runs,
479
+ throttlingMethod: task.throttlingMethod,
480
+ cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
481
+ onlyCategories: task.onlyCategories,
482
+ });
483
+ const cached = cacheEntries[key];
484
+ if (cached) {
485
+ results[i] = cached;
486
+ cachedSteps += task.runs;
487
+ cachedCombos += 1;
488
+ }
489
+ else {
490
+ taskIndexByRunIndex.push(i);
491
+ tasksToRun.push(task);
492
+ }
493
+ }
494
+ }
495
+ else {
496
+ for (let i = 0; i < tasks.length; i += 1) {
497
+ taskIndexByRunIndex.push(i);
498
+ tasksToRun.push(tasks[i]);
499
+ }
500
+ }
145
501
  const startedAtMs = Date.now();
146
- const parallelCount = resolveParallelCount({ requested: config.parallel, chromePort: config.chromePort, taskCount: tasks.length });
502
+ const parallelCount = resolveParallelCount({ requested: config.parallel, chromePort: config.chromePort, taskCount: tasksToRun.length });
147
503
  if (showParallel === true) {
148
504
  // eslint-disable-next-line no-console
149
505
  console.log(`Resolved parallel workers: ${parallelCount}`);
150
506
  }
151
507
  const totalSteps = tasks.length * runs;
152
- let completedSteps = 0;
153
- const progressLock = { count: 0 };
508
+ const executedCombos = tasksToRun.length;
509
+ const executedSteps = executedCombos * runs;
510
+ const cachedComboCount = cachedCombos;
511
+ let completedSteps = cachedSteps;
512
+ const progressLock = { count: cachedSteps };
154
513
  const updateProgress = (path, device) => {
155
514
  progressLock.count += 1;
156
515
  completedSteps = progressLock.count;
157
516
  const etaMs = computeEtaMs({ startedAtMs, completed: completedSteps, total: totalSteps });
158
517
  logProgress({ completed: completedSteps, total: totalSteps, path, device, etaMs });
518
+ if (typeof onProgress === "function") {
519
+ onProgress({ completed: completedSteps, total: totalSteps, path, device, etaMs });
520
+ }
159
521
  };
160
- let results;
161
- if (parallelCount <= 1 || config.chromePort !== undefined) {
522
+ let resultsFromRunner;
523
+ if (tasksToRun.length === 0) {
524
+ resultsFromRunner = [];
525
+ }
526
+ else if (parallelCount <= 1 || config.chromePort !== undefined) {
162
527
  // Sequential execution (original behavior) or using external Chrome
163
- results = await runSequential(tasks, config.chromePort, updateProgress);
528
+ resultsFromRunner = await runSequential(tasksToRun, config.chromePort, auditTimeoutMs, signal, updateProgress);
164
529
  }
165
530
  else {
166
- // Parallel execution with multiple Chrome instances
167
- results = await runParallel(tasks, parallelCount, updateProgress);
531
+ resultsFromRunner = await runParallelInProcesses(tasksToRun, parallelCount, auditTimeoutMs, signal, updateProgress);
532
+ }
533
+ for (let runIndex = 0; runIndex < resultsFromRunner.length; runIndex += 1) {
534
+ const originalTaskIndex = taskIndexByRunIndex[runIndex];
535
+ results[originalTaskIndex] = resultsFromRunner[runIndex];
536
+ }
537
+ if (incrementalEnabled) {
538
+ const nextEntries = { ...cacheEntries };
539
+ for (let i = 0; i < tasks.length; i += 1) {
540
+ const task = tasks[i];
541
+ const key = buildCacheKey({
542
+ buildId: config.buildId,
543
+ url: task.url,
544
+ path: task.path,
545
+ label: task.label,
546
+ device: task.device,
547
+ runs: task.runs,
548
+ throttlingMethod: task.throttlingMethod,
549
+ cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
550
+ onlyCategories: task.onlyCategories,
551
+ });
552
+ nextEntries[key] = results[i];
553
+ }
554
+ await saveIncrementalCache({ version: CACHE_VERSION, entries: nextEntries });
168
555
  }
169
556
  const completedAtMs = Date.now();
170
557
  const elapsedMs = completedAtMs - startedAtMs;
@@ -172,10 +559,16 @@ export async function runAuditsForConfig({ config, configPath, showParallel, })
172
559
  return {
173
560
  meta: {
174
561
  configPath,
562
+ buildId: typeof config.buildId === "string" ? config.buildId : undefined,
563
+ incremental: incrementalEnabled,
175
564
  resolvedParallel: parallelCount,
176
565
  totalSteps,
177
566
  comboCount: tasks.length,
567
+ executedCombos,
568
+ cachedCombos: cachedComboCount,
178
569
  runsPerCombo: runs,
570
+ executedSteps,
571
+ cachedSteps,
179
572
  warmUp: config.warmUp === true,
180
573
  throttlingMethod,
181
574
  cpuSlowdownMultiplier,
@@ -187,22 +580,48 @@ export async function runAuditsForConfig({ config, configPath, showParallel, })
187
580
  results,
188
581
  };
189
582
  }
190
- async function runSequential(tasks, chromePort, updateProgress) {
583
+ async function runSequential(tasks, chromePort, auditTimeoutMs, signal, updateProgress) {
191
584
  const results = [];
192
- const session = await createChromeSession(chromePort);
585
+ const sessionRef = { session: await createChromeSession(chromePort) };
193
586
  try {
194
587
  for (const task of tasks) {
195
588
  const summaries = [];
196
589
  for (let index = 0; index < task.runs; index += 1) {
197
- const summary = await runSingleAudit({
590
+ if (signal?.aborted) {
591
+ throw new Error("Aborted");
592
+ }
593
+ const summary = await withTimeout(runSingleAudit({
198
594
  url: task.url,
199
595
  path: task.path,
200
596
  label: task.label,
201
597
  device: task.device,
202
- port: session.port,
598
+ port: sessionRef.session.port,
203
599
  logLevel: task.logLevel,
204
600
  throttlingMethod: task.throttlingMethod,
205
601
  cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
602
+ onlyCategories: task.onlyCategories,
603
+ }), auditTimeoutMs).catch(async (error) => {
604
+ const message = error instanceof Error ? error.message : "Unknown error";
605
+ logLinePreservingProgress(`Timeout: ${task.path} [${task.device}] (run ${index + 1}/${task.runs}). Retrying... (${message})`);
606
+ if (sessionRef.session.close) {
607
+ await sessionRef.session.close();
608
+ }
609
+ sessionRef.session = await createChromeSession(chromePort);
610
+ return withTimeout(runSingleAudit({
611
+ url: task.url,
612
+ path: task.path,
613
+ label: task.label,
614
+ device: task.device,
615
+ port: sessionRef.session.port,
616
+ logLevel: task.logLevel,
617
+ throttlingMethod: task.throttlingMethod,
618
+ cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
619
+ onlyCategories: task.onlyCategories,
620
+ }), auditTimeoutMs).catch((secondError) => {
621
+ const secondMessage = secondError instanceof Error ? secondError.message : "Unknown error";
622
+ logLinePreservingProgress(`Giving up: ${task.path} [${task.device}] (run ${index + 1}/${task.runs}) after timeout retry.`);
623
+ return buildFailureSummary(task, secondMessage);
624
+ });
206
625
  });
207
626
  summaries.push(summary);
208
627
  updateProgress(task.path, task.device);
@@ -211,8 +630,8 @@ async function runSequential(tasks, chromePort, updateProgress) {
211
630
  }
212
631
  }
213
632
  finally {
214
- if (session.close) {
215
- await session.close();
633
+ if (sessionRef.session.close) {
634
+ await sessionRef.session.close();
216
635
  }
217
636
  }
218
637
  return results;
@@ -222,26 +641,25 @@ async function runParallel(tasks, parallelCount, updateProgress) {
222
641
  const sessions = [];
223
642
  const effectiveParallel = Math.min(parallelCount, tasks.length);
224
643
  for (let i = 0; i < effectiveParallel; i += 1) {
225
- sessions.push(await createChromeSession());
644
+ if (i > 0) {
645
+ await delayMs(200 * i);
646
+ }
647
+ sessions.push({ session: await createChromeSession() });
226
648
  }
227
649
  const results = new Array(tasks.length);
228
650
  let taskIndex = 0;
229
- const runWorker = async (session, workerIndex) => {
651
+ const runWorker = async (sessionRef, workerIndex) => {
230
652
  while (taskIndex < tasks.length) {
231
653
  const currentIndex = taskIndex;
232
654
  taskIndex += 1;
233
655
  const task = tasks[currentIndex];
234
656
  const summaries = [];
235
657
  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,
658
+ const summary = await runSingleAuditWithRetry({
659
+ task,
660
+ sessionRef,
661
+ updateProgress,
662
+ maxRetries: 2,
245
663
  });
246
664
  summaries.push(summary);
247
665
  updateProgress(task.path, task.device);
@@ -250,18 +668,62 @@ async function runParallel(tasks, parallelCount, updateProgress) {
250
668
  }
251
669
  };
252
670
  try {
253
- await Promise.all(sessions.map((session, index) => runWorker(session, index)));
671
+ await Promise.all(sessions.map((sessionRef, index) => runWorker(sessionRef, index)));
254
672
  }
255
673
  finally {
256
674
  // Close all Chrome sessions
257
- await Promise.all(sessions.map(async (session) => {
258
- if (session.close) {
259
- await session.close();
675
+ await Promise.all(sessions.map(async (sessionRef) => {
676
+ if (sessionRef.session.close) {
677
+ await sessionRef.session.close();
260
678
  }
261
679
  }));
262
680
  }
263
681
  return results;
264
682
  }
683
+ function isTransientLighthouseError(error) {
684
+ const message = error instanceof Error && typeof error.message === "string" ? error.message : "";
685
+ return (message.includes("performance mark has not been set") ||
686
+ message.includes("TargetCloseError") ||
687
+ message.includes("Target closed") ||
688
+ message.includes("setAutoAttach") ||
689
+ message.includes("LanternError") ||
690
+ message.includes("top level events") ||
691
+ message.includes("CDP") ||
692
+ message.includes("disconnected"));
693
+ }
694
+ async function runSingleAuditWithRetry({ task, sessionRef, updateProgress, maxRetries, }) {
695
+ let attempt = 0;
696
+ let lastError;
697
+ while (attempt <= maxRetries) {
698
+ try {
699
+ return await runSingleAudit({
700
+ url: task.url,
701
+ path: task.path,
702
+ label: task.label,
703
+ device: task.device,
704
+ port: sessionRef.session.port,
705
+ logLevel: task.logLevel,
706
+ throttlingMethod: task.throttlingMethod,
707
+ cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
708
+ onlyCategories: task.onlyCategories,
709
+ });
710
+ }
711
+ catch (error) {
712
+ lastError = error;
713
+ const shouldRetry = isTransientLighthouseError(error) && attempt < maxRetries;
714
+ if (!shouldRetry) {
715
+ throw error instanceof Error ? error : new Error("Lighthouse failed");
716
+ }
717
+ if (sessionRef.session.close) {
718
+ await sessionRef.session.close();
719
+ }
720
+ await delayMs(300 * (attempt + 1));
721
+ sessionRef.session = await createChromeSession();
722
+ attempt += 1;
723
+ }
724
+ }
725
+ throw lastError instanceof Error ? lastError : new Error("Lighthouse failed after retries");
726
+ }
265
727
  function buildUrl({ baseUrl, path, query }) {
266
728
  const cleanBase = baseUrl.replace(/\/$/, "");
267
729
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
@@ -273,10 +735,12 @@ function logProgress({ completed, total, path, device, etaMs, }) {
273
735
  const etaText = etaMs !== undefined ? ` | ETA ${formatEta(etaMs)}` : "";
274
736
  const message = `Running audits ${completed}/${total} (${percentage}%) – ${path} [${device}]${etaText}`;
275
737
  if (typeof process !== "undefined" && process.stdout && typeof process.stdout.write === "function" && process.stdout.isTTY) {
276
- const padded = message.padEnd(80, " ");
738
+ const padded = message.padEnd(100, " ");
739
+ lastProgressLine = padded;
277
740
  process.stdout.write(`\r${padded}`);
278
741
  if (completed === total) {
279
742
  process.stdout.write("\n");
743
+ lastProgressLine = undefined;
280
744
  }
281
745
  return;
282
746
  }
@@ -290,22 +754,21 @@ async function runSingleAudit(params) {
290
754
  logLevel: params.logLevel,
291
755
  onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
292
756
  formFactor: params.device,
293
- // Throttling configuration for more accurate results
294
757
  throttlingMethod: params.throttlingMethod,
295
- throttling: {
296
- // CPU throttling - adjustable via config
758
+ screenEmulation: params.device === "mobile"
759
+ ? { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false }
760
+ : { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
761
+ };
762
+ if (params.throttlingMethod === "simulate") {
763
+ options.throttling = {
297
764
  cpuSlowdownMultiplier: params.cpuSlowdownMultiplier,
298
- // Network throttling (Slow 4G / Fast 3G preset - Lighthouse default)
299
765
  rttMs: 150,
300
766
  throughputKbps: 1638.4,
301
767
  requestLatencyMs: 150 * 3.75,
302
768
  downloadThroughputKbps: 1638.4,
303
769
  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 },
308
- };
770
+ };
771
+ }
309
772
  const runnerResult = await lighthouse(params.url, options);
310
773
  const lhrUnknown = runnerResult.lhr;
311
774
  if (!lhrUnknown || typeof lhrUnknown !== "object") {
@@ -429,10 +892,12 @@ function resolveParallelCount({ requested, chromePort, taskCount, }) {
429
892
  if (requested !== undefined) {
430
893
  return requested;
431
894
  }
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)));
895
+ const logicalCpus = cpus().length;
896
+ const cpuBased = Math.max(1, Math.min(10, Math.floor(logicalCpus * 0.75)));
897
+ const memoryBased = Math.max(1, Math.min(10, Math.floor(freemem() / 1_500_000_000)));
434
898
  const suggested = Math.max(1, Math.min(cpuBased, memoryBased));
435
- return Math.max(1, Math.min(10, Math.min(taskCount, suggested || 1)));
899
+ const cappedSuggested = Math.min(4, suggested || 1);
900
+ return Math.max(1, Math.min(10, Math.min(taskCount, cappedSuggested)));
436
901
  }
437
902
  function computeEtaMs({ startedAtMs, completed, total, }) {
438
903
  if (completed === 0 || total === 0 || completed > total) {
@@ -451,3 +916,8 @@ function formatEta(etaMs) {
451
916
  const secondsPart = `${seconds}s`;
452
917
  return `${minutesPart}${secondsPart}`.trim();
453
918
  }
919
+ function delayMs(duration) {
920
+ return new Promise((resolve) => {
921
+ setTimeout(resolve, duration);
922
+ });
923
+ }