apex-auditor 0.2.9 → 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,11 +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";
5
+ import { cpus, freemem, tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
3
8
  import lighthouse from "lighthouse";
4
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
+ }
5
304
  async function createChromeSession(chromePort) {
6
305
  if (typeof chromePort === "number") {
7
306
  return { port: chromePort };
8
307
  }
308
+ const userDataDir = await mkdtemp(join(tmpdir(), "apex-auditor-chrome-"));
9
309
  const chrome = await launchChrome({
10
310
  chromeFlags: [
11
311
  "--headless=new",
@@ -16,6 +316,7 @@ async function createChromeSession(chromePort) {
16
316
  "--disable-default-apps",
17
317
  "--no-first-run",
18
318
  "--no-default-browser-check",
319
+ `--user-data-dir=${userDataDir}`,
19
320
  // Additional flags for more consistent and accurate results
20
321
  "--disable-background-networking",
21
322
  "--disable-background-timer-throttling",
@@ -35,6 +336,7 @@ async function createChromeSession(chromePort) {
35
336
  close: async () => {
36
337
  try {
37
338
  await chrome.kill();
339
+ await rm(userDataDir, { recursive: true, force: true });
38
340
  }
39
341
  catch {
40
342
  return;
@@ -79,16 +381,23 @@ async function performWarmUp(config) {
79
381
  const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
80
382
  uniqueUrls.add(url);
81
383
  }
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
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
+ }
89
398
  }
90
- });
91
- await Promise.all(warmUpPromises);
399
+ };
400
+ await Promise.all(new Array(warmUpConcurrency).fill(0).map(async () => warmWorker()));
92
401
  // eslint-disable-next-line no-console
93
402
  console.log(`Warm-up complete (${uniqueUrls.size} pages).`);
94
403
  }
@@ -112,9 +421,11 @@ async function fetchUrl(url) {
112
421
  /**
113
422
  * Run audits for all pages defined in the config and return a structured summary.
114
423
  */
115
- export async function runAuditsForConfig({ config, configPath, }) {
116
- const runs = config.runs ?? 1;
117
- const parallelCount = config.parallel ?? 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, }) {
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,44 +457,171 @@ export async function runAuditsForConfig({ config, configPath, }) {
139
457
  logLevel,
140
458
  throttlingMethod,
141
459
  cpuSlowdownMultiplier,
460
+ onlyCategories,
461
+ });
462
+ }
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,
142
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
+ }
143
493
  }
144
494
  }
495
+ else {
496
+ for (let i = 0; i < tasks.length; i += 1) {
497
+ taskIndexByRunIndex.push(i);
498
+ tasksToRun.push(tasks[i]);
499
+ }
500
+ }
501
+ const startedAtMs = Date.now();
502
+ const parallelCount = resolveParallelCount({ requested: config.parallel, chromePort: config.chromePort, taskCount: tasksToRun.length });
503
+ if (showParallel === true) {
504
+ // eslint-disable-next-line no-console
505
+ console.log(`Resolved parallel workers: ${parallelCount}`);
506
+ }
145
507
  const totalSteps = tasks.length * runs;
146
- let completedSteps = 0;
147
- 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 };
148
513
  const updateProgress = (path, device) => {
149
514
  progressLock.count += 1;
150
515
  completedSteps = progressLock.count;
151
- logProgress({ completed: completedSteps, total: totalSteps, path, device });
516
+ const etaMs = computeEtaMs({ startedAtMs, completed: completedSteps, total: totalSteps });
517
+ logProgress({ completed: completedSteps, total: totalSteps, path, device, etaMs });
518
+ if (typeof onProgress === "function") {
519
+ onProgress({ completed: completedSteps, total: totalSteps, path, device, etaMs });
520
+ }
152
521
  };
153
- let results;
154
- 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) {
155
527
  // Sequential execution (original behavior) or using external Chrome
156
- results = await runSequential(tasks, config.chromePort, updateProgress);
528
+ resultsFromRunner = await runSequential(tasksToRun, config.chromePort, auditTimeoutMs, signal, updateProgress);
157
529
  }
158
530
  else {
159
- // Parallel execution with multiple Chrome instances
160
- results = await runParallel(tasks, parallelCount, updateProgress);
531
+ resultsFromRunner = await runParallelInProcesses(tasksToRun, parallelCount, auditTimeoutMs, signal, updateProgress);
161
532
  }
162
- return { configPath, results };
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 });
555
+ }
556
+ const completedAtMs = Date.now();
557
+ const elapsedMs = completedAtMs - startedAtMs;
558
+ const averageStepMs = totalSteps > 0 ? elapsedMs / totalSteps : 0;
559
+ return {
560
+ meta: {
561
+ configPath,
562
+ buildId: typeof config.buildId === "string" ? config.buildId : undefined,
563
+ incremental: incrementalEnabled,
564
+ resolvedParallel: parallelCount,
565
+ totalSteps,
566
+ comboCount: tasks.length,
567
+ executedCombos,
568
+ cachedCombos: cachedComboCount,
569
+ runsPerCombo: runs,
570
+ executedSteps,
571
+ cachedSteps,
572
+ warmUp: config.warmUp === true,
573
+ throttlingMethod,
574
+ cpuSlowdownMultiplier,
575
+ startedAt: new Date(startedAtMs).toISOString(),
576
+ completedAt: new Date(completedAtMs).toISOString(),
577
+ elapsedMs,
578
+ averageStepMs,
579
+ },
580
+ results,
581
+ };
163
582
  }
164
- async function runSequential(tasks, chromePort, updateProgress) {
583
+ async function runSequential(tasks, chromePort, auditTimeoutMs, signal, updateProgress) {
165
584
  const results = [];
166
- const session = await createChromeSession(chromePort);
585
+ const sessionRef = { session: await createChromeSession(chromePort) };
167
586
  try {
168
587
  for (const task of tasks) {
169
588
  const summaries = [];
170
589
  for (let index = 0; index < task.runs; index += 1) {
171
- const summary = await runSingleAudit({
590
+ if (signal?.aborted) {
591
+ throw new Error("Aborted");
592
+ }
593
+ const summary = await withTimeout(runSingleAudit({
172
594
  url: task.url,
173
595
  path: task.path,
174
596
  label: task.label,
175
597
  device: task.device,
176
- port: session.port,
598
+ port: sessionRef.session.port,
177
599
  logLevel: task.logLevel,
178
600
  throttlingMethod: task.throttlingMethod,
179
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
+ });
180
625
  });
181
626
  summaries.push(summary);
182
627
  updateProgress(task.path, task.device);
@@ -185,8 +630,8 @@ async function runSequential(tasks, chromePort, updateProgress) {
185
630
  }
186
631
  }
187
632
  finally {
188
- if (session.close) {
189
- await session.close();
633
+ if (sessionRef.session.close) {
634
+ await sessionRef.session.close();
190
635
  }
191
636
  }
192
637
  return results;
@@ -196,26 +641,25 @@ async function runParallel(tasks, parallelCount, updateProgress) {
196
641
  const sessions = [];
197
642
  const effectiveParallel = Math.min(parallelCount, tasks.length);
198
643
  for (let i = 0; i < effectiveParallel; i += 1) {
199
- sessions.push(await createChromeSession());
644
+ if (i > 0) {
645
+ await delayMs(200 * i);
646
+ }
647
+ sessions.push({ session: await createChromeSession() });
200
648
  }
201
649
  const results = new Array(tasks.length);
202
650
  let taskIndex = 0;
203
- const runWorker = async (session, workerIndex) => {
651
+ const runWorker = async (sessionRef, workerIndex) => {
204
652
  while (taskIndex < tasks.length) {
205
653
  const currentIndex = taskIndex;
206
654
  taskIndex += 1;
207
655
  const task = tasks[currentIndex];
208
656
  const summaries = [];
209
657
  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,
658
+ const summary = await runSingleAuditWithRetry({
659
+ task,
660
+ sessionRef,
661
+ updateProgress,
662
+ maxRetries: 2,
219
663
  });
220
664
  summaries.push(summary);
221
665
  updateProgress(task.path, task.device);
@@ -224,32 +668,79 @@ async function runParallel(tasks, parallelCount, updateProgress) {
224
668
  }
225
669
  };
226
670
  try {
227
- await Promise.all(sessions.map((session, index) => runWorker(session, index)));
671
+ await Promise.all(sessions.map((sessionRef, index) => runWorker(sessionRef, index)));
228
672
  }
229
673
  finally {
230
674
  // Close all Chrome sessions
231
- await Promise.all(sessions.map(async (session) => {
232
- if (session.close) {
233
- await session.close();
675
+ await Promise.all(sessions.map(async (sessionRef) => {
676
+ if (sessionRef.session.close) {
677
+ await sessionRef.session.close();
234
678
  }
235
679
  }));
236
680
  }
237
681
  return results;
238
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
+ }
239
727
  function buildUrl({ baseUrl, path, query }) {
240
728
  const cleanBase = baseUrl.replace(/\/$/, "");
241
729
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
242
730
  const queryPart = query && query.length > 0 ? query : "";
243
731
  return `${cleanBase}${cleanPath}${queryPart}`;
244
732
  }
245
- function logProgress({ completed, total, path, device, }) {
733
+ function logProgress({ completed, total, path, device, etaMs, }) {
246
734
  const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
247
- const message = `Running audits ${completed}/${total} (${percentage}%) ${path} [${device}]`;
735
+ const etaText = etaMs !== undefined ? ` | ETA ${formatEta(etaMs)}` : "";
736
+ const message = `Running audits ${completed}/${total} (${percentage}%) – ${path} [${device}]${etaText}`;
248
737
  if (typeof process !== "undefined" && process.stdout && typeof process.stdout.write === "function" && process.stdout.isTTY) {
249
- const padded = message.padEnd(80, " ");
738
+ const padded = message.padEnd(100, " ");
739
+ lastProgressLine = padded;
250
740
  process.stdout.write(`\r${padded}`);
251
741
  if (completed === total) {
252
742
  process.stdout.write("\n");
743
+ lastProgressLine = undefined;
253
744
  }
254
745
  return;
255
746
  }
@@ -263,22 +754,21 @@ async function runSingleAudit(params) {
263
754
  logLevel: params.logLevel,
264
755
  onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
265
756
  formFactor: params.device,
266
- // Throttling configuration for more accurate results
267
757
  throttlingMethod: params.throttlingMethod,
268
- throttling: {
269
- // 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 = {
270
764
  cpuSlowdownMultiplier: params.cpuSlowdownMultiplier,
271
- // Network throttling (Slow 4G / Fast 3G preset - Lighthouse default)
272
765
  rttMs: 150,
273
766
  throughputKbps: 1638.4,
274
767
  requestLatencyMs: 150 * 3.75,
275
768
  downloadThroughputKbps: 1638.4,
276
769
  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 },
281
- };
770
+ };
771
+ }
282
772
  const runnerResult = await lighthouse(params.url, options);
283
773
  const lhrUnknown = runnerResult.lhr;
284
774
  if (!lhrUnknown || typeof lhrUnknown !== "object") {
@@ -395,3 +885,39 @@ function averageOf(values) {
395
885
  const total = defined.reduce((sum, value) => sum + value, 0);
396
886
  return total / defined.length;
397
887
  }
888
+ function resolveParallelCount({ requested, chromePort, taskCount, }) {
889
+ if (chromePort !== undefined) {
890
+ return 1;
891
+ }
892
+ if (requested !== undefined) {
893
+ return requested;
894
+ }
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)));
898
+ const suggested = Math.max(1, Math.min(cpuBased, memoryBased));
899
+ const cappedSuggested = Math.min(4, suggested || 1);
900
+ return Math.max(1, Math.min(10, Math.min(taskCount, cappedSuggested)));
901
+ }
902
+ function computeEtaMs({ startedAtMs, completed, total, }) {
903
+ if (completed === 0 || total === 0 || completed > total) {
904
+ return undefined;
905
+ }
906
+ const elapsedMs = Date.now() - startedAtMs;
907
+ const averagePerStep = elapsedMs / completed;
908
+ const remainingSteps = total - completed;
909
+ return Math.max(0, Math.round(averagePerStep * remainingSteps));
910
+ }
911
+ function formatEta(etaMs) {
912
+ const totalSeconds = Math.ceil(etaMs / 1000);
913
+ const minutes = Math.floor(totalSeconds / 60);
914
+ const seconds = totalSeconds % 60;
915
+ const minutesPart = minutes > 0 ? `${minutes}m ` : "";
916
+ const secondsPart = `${seconds}s`;
917
+ return `${minutesPart}${secondsPart}`.trim();
918
+ }
919
+ function delayMs(duration) {
920
+ return new Promise((resolve) => {
921
+ setTimeout(resolve, duration);
922
+ });
923
+ }