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.
- package/README.md +49 -99
- package/dist/accessibility-types.js +1 -0
- package/dist/accessibility.js +152 -0
- package/dist/axe-script.js +26 -0
- package/dist/bin.js +185 -9
- package/dist/cdp-client.js +264 -0
- package/dist/cli.js +1608 -75
- package/dist/config.js +11 -0
- package/dist/lighthouse-runner.js +580 -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 +15 -22
- package/package.json +4 -2
|
@@ -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
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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 =
|
|
117
|
-
|
|
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
|
-
|
|
147
|
-
const
|
|
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
|
-
|
|
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
|
|
154
|
-
if (
|
|
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
|
-
|
|
528
|
+
resultsFromRunner = await runSequential(tasksToRun, config.chromePort, auditTimeoutMs, signal, updateProgress);
|
|
157
529
|
}
|
|
158
530
|
else {
|
|
159
|
-
|
|
160
|
-
results = await runParallel(tasks, parallelCount, updateProgress);
|
|
531
|
+
resultsFromRunner = await runParallelInProcesses(tasksToRun, parallelCount, auditTimeoutMs, signal, updateProgress);
|
|
161
532
|
}
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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((
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
+
}
|