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.
- package/README.md +49 -100
- package/dist/accessibility-types.js +1 -0
- package/dist/accessibility.js +152 -0
- package/dist/axe-script.js +26 -0
- package/dist/bin.js +183 -9
- package/dist/cdp-client.js +264 -0
- package/dist/cli.js +1549 -82
- package/dist/config.js +11 -0
- package/dist/lighthouse-runner.js +524 -54
- package/dist/lighthouse-worker.js +248 -0
- package/dist/measure-cli.js +139 -0
- package/dist/measure-runner.js +447 -0
- package/dist/measure-types.js +1 -0
- package/dist/shell-cli.js +566 -0
- package/dist/spinner.js +37 -0
- package/dist/ui/render-panel.js +46 -0
- package/dist/ui/render-table.js +61 -0
- package/dist/ui/ui-theme.js +47 -0
- package/dist/url.js +6 -0
- package/dist/webhooks.js +29 -0
- package/dist/wizard-cli.js +14 -22
- package/package.json +4 -2
|
@@ -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
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
153
|
-
const
|
|
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
|
|
161
|
-
if (
|
|
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
|
-
|
|
528
|
+
resultsFromRunner = await runSequential(tasksToRun, config.chromePort, auditTimeoutMs, signal, updateProgress);
|
|
164
529
|
}
|
|
165
530
|
else {
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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((
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
433
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|