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.
@@ -0,0 +1,248 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import lighthouse from "lighthouse";
5
+ import { launch as launchChrome } from "chrome-launcher";
6
+ function isTransientLighthouseError(error) {
7
+ const message = error instanceof Error && typeof error.message === "string" ? error.message : "";
8
+ return (message.includes("performance mark has not been set") ||
9
+ message.includes("TargetCloseError") ||
10
+ message.includes("Target closed") ||
11
+ message.includes("setAutoAttach") ||
12
+ message.includes("LanternError") ||
13
+ message.includes("top level events") ||
14
+ message.includes("CDP") ||
15
+ message.includes("disconnected") ||
16
+ message.includes("ApexAuditor timeout"));
17
+ }
18
+ async function createChromeSession() {
19
+ const userDataDir = await mkdtemp(join(tmpdir(), "apex-auditor-chrome-"));
20
+ const chrome = await launchChrome({
21
+ chromeFlags: [
22
+ "--headless=new",
23
+ "--disable-gpu",
24
+ "--no-sandbox",
25
+ "--disable-dev-shm-usage",
26
+ "--disable-extensions",
27
+ "--disable-default-apps",
28
+ "--no-first-run",
29
+ "--no-default-browser-check",
30
+ `--user-data-dir=${userDataDir}`,
31
+ "--disable-background-networking",
32
+ "--disable-background-timer-throttling",
33
+ "--disable-backgrounding-occluded-windows",
34
+ "--disable-renderer-backgrounding",
35
+ "--disable-client-side-phishing-detection",
36
+ "--disable-sync",
37
+ "--disable-translate",
38
+ "--metrics-recording-only",
39
+ "--safebrowsing-disable-auto-update",
40
+ "--password-store=basic",
41
+ "--use-mock-keychain",
42
+ ],
43
+ });
44
+ return {
45
+ port: chrome.port,
46
+ close: async () => {
47
+ try {
48
+ await chrome.kill();
49
+ await rm(userDataDir, { recursive: true, force: true });
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ },
55
+ };
56
+ }
57
+ async function delayMs(durationMs) {
58
+ await new Promise((resolve) => {
59
+ setTimeout(resolve, durationMs);
60
+ });
61
+ }
62
+ function computeRetryDelayMs(params) {
63
+ const baseDelayMs = 250;
64
+ const maxDelayMs = 5000;
65
+ const exp = Math.min(5, params.attempt);
66
+ const candidate = baseDelayMs * Math.pow(2, exp);
67
+ const jitter = Math.floor(Math.random() * 200);
68
+ return Math.min(maxDelayMs, candidate + jitter);
69
+ }
70
+ async function withTimeout(promise, timeoutMs) {
71
+ const timeoutPromise = new Promise((_, reject) => {
72
+ setTimeout(() => {
73
+ reject(new Error(`ApexAuditor timeout after ${timeoutMs}ms`));
74
+ }, timeoutMs);
75
+ });
76
+ return Promise.race([promise, timeoutPromise]);
77
+ }
78
+ async function runTaskWithRetry(task, sessionRef, maxRetries) {
79
+ let attempt = 0;
80
+ let lastError;
81
+ while (attempt <= maxRetries) {
82
+ try {
83
+ return await withTimeout(runSingleAudit({
84
+ url: task.url,
85
+ path: task.path,
86
+ label: task.label,
87
+ device: task.device,
88
+ port: sessionRef.session.port,
89
+ logLevel: task.logLevel,
90
+ throttlingMethod: task.throttlingMethod,
91
+ cpuSlowdownMultiplier: task.cpuSlowdownMultiplier,
92
+ onlyCategories: task.onlyCategories,
93
+ }), task.timeoutMs);
94
+ }
95
+ catch (error) {
96
+ lastError = error;
97
+ const shouldRetry = isTransientLighthouseError(error) && attempt < maxRetries;
98
+ if (!shouldRetry) {
99
+ throw error instanceof Error ? error : new Error("Lighthouse failed");
100
+ }
101
+ await sessionRef.session.close();
102
+ await delayMs(computeRetryDelayMs({ attempt }));
103
+ sessionRef.session = await createChromeSession();
104
+ attempt += 1;
105
+ }
106
+ }
107
+ throw lastError instanceof Error ? lastError : new Error("Lighthouse failed after retries");
108
+ }
109
+ async function runSingleAudit(params) {
110
+ const onlyCategories = params.onlyCategories ?? ["performance", "accessibility", "best-practices", "seo"];
111
+ const throttling = params.throttlingMethod === "simulate"
112
+ ? {
113
+ cpuSlowdownMultiplier: params.cpuSlowdownMultiplier,
114
+ rttMs: 150,
115
+ throughputKbps: 1638.4,
116
+ requestLatencyMs: 150 * 3.75,
117
+ downloadThroughputKbps: 1638.4,
118
+ uploadThroughputKbps: 750,
119
+ }
120
+ : { cpuSlowdownMultiplier: params.cpuSlowdownMultiplier };
121
+ const options = {
122
+ port: params.port,
123
+ output: "json",
124
+ logLevel: params.logLevel,
125
+ onlyCategories,
126
+ formFactor: params.device,
127
+ throttlingMethod: params.throttlingMethod,
128
+ throttling,
129
+ screenEmulation: params.device === "mobile"
130
+ ? { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false }
131
+ : { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false },
132
+ };
133
+ const runnerResult = await lighthouse(params.url, options);
134
+ const lhrUnknown = runnerResult.lhr;
135
+ if (!lhrUnknown || typeof lhrUnknown !== "object") {
136
+ throw new Error("Lighthouse did not return a valid result");
137
+ }
138
+ const lhr = lhrUnknown;
139
+ const scores = extractScores(lhr);
140
+ const metrics = extractMetrics(lhr);
141
+ const opportunities = extractTopOpportunities(lhr, 3);
142
+ return {
143
+ url: lhr.finalDisplayedUrl ?? params.url,
144
+ path: params.path,
145
+ label: params.label,
146
+ device: params.device,
147
+ scores,
148
+ metrics,
149
+ opportunities,
150
+ runtimeErrorCode: typeof lhr.runtimeError?.code === "string" ? lhr.runtimeError.code : undefined,
151
+ runtimeErrorMessage: typeof lhr.runtimeError?.message === "string" ? lhr.runtimeError.message : undefined,
152
+ };
153
+ }
154
+ function extractScores(lhr) {
155
+ const performanceScore = lhr.categories.performance?.score;
156
+ const accessibilityScore = lhr.categories.accessibility?.score;
157
+ const bestPracticesScore = lhr.categories["best-practices"]?.score;
158
+ const seoScore = lhr.categories.seo?.score;
159
+ return {
160
+ performance: normaliseScore(performanceScore),
161
+ accessibility: normaliseScore(accessibilityScore),
162
+ bestPractices: normaliseScore(bestPracticesScore),
163
+ seo: normaliseScore(seoScore),
164
+ };
165
+ }
166
+ function normaliseScore(score) {
167
+ if (typeof score !== "number") {
168
+ return undefined;
169
+ }
170
+ return Math.round(score * 100);
171
+ }
172
+ function extractMetrics(lhr) {
173
+ const audits = lhr.audits;
174
+ const lcpAudit = audits["largest-contentful-paint"];
175
+ const fcpAudit = audits["first-contentful-paint"];
176
+ const tbtAudit = audits["total-blocking-time"];
177
+ const clsAudit = audits["cumulative-layout-shift"];
178
+ const inpAudit = audits["interaction-to-next-paint"];
179
+ const lcpMs = typeof lcpAudit?.numericValue === "number" ? lcpAudit.numericValue : undefined;
180
+ const fcpMs = typeof fcpAudit?.numericValue === "number" ? fcpAudit.numericValue : undefined;
181
+ const tbtMs = typeof tbtAudit?.numericValue === "number" ? tbtAudit.numericValue : undefined;
182
+ const cls = typeof clsAudit?.numericValue === "number" ? clsAudit.numericValue : undefined;
183
+ const inpMs = typeof inpAudit?.numericValue === "number" ? inpAudit.numericValue : undefined;
184
+ return {
185
+ lcpMs,
186
+ fcpMs,
187
+ tbtMs,
188
+ cls,
189
+ inpMs,
190
+ };
191
+ }
192
+ function extractTopOpportunities(lhr, limit) {
193
+ const audits = Object.values(lhr.audits);
194
+ const candidates = audits
195
+ .filter((audit) => audit.details?.type === "opportunity")
196
+ .map((audit) => {
197
+ const savingsMs = audit.details?.overallSavingsMs;
198
+ const savingsBytes = audit.details?.overallSavingsBytes;
199
+ return {
200
+ id: audit.id ?? "unknown",
201
+ title: audit.title ?? (audit.id ?? "Unknown"),
202
+ estimatedSavingsMs: typeof savingsMs === "number" ? savingsMs : undefined,
203
+ estimatedSavingsBytes: typeof savingsBytes === "number" ? savingsBytes : undefined,
204
+ };
205
+ });
206
+ candidates.sort((a, b) => (b.estimatedSavingsMs ?? 0) - (a.estimatedSavingsMs ?? 0));
207
+ return candidates.slice(0, limit);
208
+ }
209
+ function send(message) {
210
+ if (typeof process.send === "function") {
211
+ process.send(message);
212
+ }
213
+ }
214
+ async function main() {
215
+ const maxRetries = 2;
216
+ const maxTasksPerChrome = 20;
217
+ const sessionRef = { session: await createChromeSession() };
218
+ let tasksSinceChromeStart = 0;
219
+ process.on("message", async (raw) => {
220
+ const message = raw && typeof raw === "object" ? raw : undefined;
221
+ if (!message || message.type !== "run") {
222
+ return;
223
+ }
224
+ try {
225
+ const result = await runTaskWithRetry(message.task, sessionRef, maxRetries);
226
+ send({ type: "result", id: message.id, result });
227
+ tasksSinceChromeStart += 1;
228
+ if (tasksSinceChromeStart >= maxTasksPerChrome) {
229
+ tasksSinceChromeStart = 0;
230
+ await sessionRef.session.close();
231
+ sessionRef.session = await createChromeSession();
232
+ }
233
+ }
234
+ catch (error) {
235
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
236
+ send({ type: "error", id: message.id, errorMessage });
237
+ try {
238
+ await sessionRef.session.close();
239
+ }
240
+ catch {
241
+ return;
242
+ }
243
+ sessionRef.session = await createChromeSession();
244
+ tasksSinceChromeStart = 0;
245
+ }
246
+ });
247
+ }
248
+ void main();
@@ -0,0 +1,139 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { loadConfig } from "./config.js";
4
+ import { runMeasureForConfig } from "./measure-runner.js";
5
+ function printSummary(summary) {
6
+ const combos = summary.meta.comboCount;
7
+ if (combos === 0) {
8
+ // eslint-disable-next-line no-console
9
+ console.log("No measure results.");
10
+ return;
11
+ }
12
+ let longTaskCount = 0;
13
+ let longTaskTotalMs = 0;
14
+ let longTaskMaxMs = 0;
15
+ let scriptingTotalMs = 0;
16
+ let scriptingCount = 0;
17
+ let totalRequests = 0;
18
+ let totalBytes = 0;
19
+ let thirdPartyRequests = 0;
20
+ let thirdPartyBytes = 0;
21
+ let cacheHitsApprox = 0;
22
+ let lateScripts = 0;
23
+ for (const r of summary.results) {
24
+ longTaskCount += r.longTasks.count;
25
+ longTaskTotalMs += r.longTasks.totalMs;
26
+ longTaskMaxMs = Math.max(longTaskMaxMs, r.longTasks.maxMs);
27
+ if (r.scriptingDurationMs !== undefined) {
28
+ scriptingTotalMs += r.scriptingDurationMs;
29
+ scriptingCount += 1;
30
+ }
31
+ totalRequests += r.network.totalRequests;
32
+ totalBytes += r.network.totalBytes;
33
+ thirdPartyRequests += r.network.thirdPartyRequests;
34
+ thirdPartyBytes += r.network.thirdPartyBytes;
35
+ cacheHitsApprox += Math.round(r.network.cacheHitRatio * r.network.totalRequests);
36
+ lateScripts += r.network.lateScriptRequests;
37
+ }
38
+ const avgScriptingMs = scriptingCount > 0 ? Math.round(scriptingTotalMs / scriptingCount) : 0;
39
+ const cacheHitRatio = totalRequests > 0 ? cacheHitsApprox / totalRequests : 0;
40
+ const thirdPartyShare = totalRequests > 0 ? thirdPartyRequests / totalRequests : 0;
41
+ // eslint-disable-next-line no-console
42
+ console.log([
43
+ "Summary:",
44
+ ` Combos: ${combos}`,
45
+ ` Long tasks: ${longTaskCount} tasks, total ${Math.round(longTaskTotalMs)}ms, max ${Math.round(longTaskMaxMs)}ms`,
46
+ ` Scripting: avg ${avgScriptingMs}ms`,
47
+ ` Network: ${totalRequests} requests, ${Math.round(totalBytes / 1024)} KB; 3P ${thirdPartyRequests} (${Math.round(thirdPartyShare * 100)}%), cache-hit ${Math.round(cacheHitRatio * 100)}%, late scripts ${lateScripts}`,
48
+ ].join("\n"));
49
+ }
50
+ function parseArgs(argv) {
51
+ let configPath;
52
+ let deviceFilter;
53
+ let parallelOverride;
54
+ let timeoutMs;
55
+ let jsonOutput = false;
56
+ for (let i = 2; i < argv.length; i += 1) {
57
+ const arg = argv[i];
58
+ if ((arg === "--config" || arg === "-c") && i + 1 < argv.length) {
59
+ configPath = argv[i + 1];
60
+ i += 1;
61
+ }
62
+ else if (arg === "--mobile-only") {
63
+ deviceFilter = "mobile";
64
+ }
65
+ else if (arg === "--desktop-only") {
66
+ deviceFilter = "desktop";
67
+ }
68
+ else if (arg === "--parallel" && i + 1 < argv.length) {
69
+ const value = parseInt(argv[i + 1], 10);
70
+ if (Number.isNaN(value) || value < 1 || value > 10) {
71
+ throw new Error(`Invalid --parallel value: ${argv[i + 1]}. Expected integer between 1 and 10.`);
72
+ }
73
+ parallelOverride = value;
74
+ i += 1;
75
+ }
76
+ else if (arg === "--timeout-ms" && i + 1 < argv.length) {
77
+ const value = parseInt(argv[i + 1], 10);
78
+ if (Number.isNaN(value) || value <= 0) {
79
+ throw new Error(`Invalid --timeout-ms value: ${argv[i + 1]}. Expected a positive integer.`);
80
+ }
81
+ timeoutMs = value;
82
+ i += 1;
83
+ }
84
+ else if (arg === "--json") {
85
+ jsonOutput = true;
86
+ }
87
+ }
88
+ return { configPath: configPath ?? "apex.config.json", deviceFilter, parallelOverride, timeoutMs, jsonOutput };
89
+ }
90
+ function filterConfigDevices(config, deviceFilter) {
91
+ if (!deviceFilter) {
92
+ return config;
93
+ }
94
+ const pages = config.pages
95
+ .map((page) => {
96
+ const devices = page.devices.filter((d) => d === deviceFilter);
97
+ return { path: page.path, label: page.label, devices };
98
+ })
99
+ .filter((p) => p.devices.length > 0);
100
+ return { ...config, pages };
101
+ }
102
+ /**
103
+ * Runs the ApexAuditor measure CLI (fast batch metrics, non-Lighthouse).
104
+ *
105
+ * @param argv - The process arguments array.
106
+ */
107
+ export async function runMeasureCli(argv) {
108
+ const args = parseArgs(argv);
109
+ const { configPath, config } = await loadConfig({ configPath: args.configPath });
110
+ const filteredConfig = filterConfigDevices(config, args.deviceFilter);
111
+ if (filteredConfig.pages.length === 0) {
112
+ // eslint-disable-next-line no-console
113
+ console.error("No pages remain after applying device filter.");
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+ const outputDir = resolve(".apex-auditor");
118
+ const artifactsDir = resolve(outputDir, "measure");
119
+ await mkdir(outputDir, { recursive: true });
120
+ await mkdir(artifactsDir, { recursive: true });
121
+ const summary = await runMeasureForConfig({
122
+ config: filteredConfig,
123
+ configPath,
124
+ parallelOverride: args.parallelOverride,
125
+ timeoutMs: args.timeoutMs,
126
+ artifactsDir,
127
+ });
128
+ await writeFile(resolve(outputDir, "measure-summary.json"), JSON.stringify(summary, null, 2), "utf8");
129
+ if (args.jsonOutput) {
130
+ // eslint-disable-next-line no-console
131
+ console.log(JSON.stringify(summary, null, 2));
132
+ return;
133
+ }
134
+ // eslint-disable-next-line no-console
135
+ console.log(`Measured ${summary.meta.comboCount} combos in ${Math.round(summary.meta.elapsedMs / 1000)}s (avg ${summary.meta.averageComboMs}ms/combo).`);
136
+ // eslint-disable-next-line no-console
137
+ console.log(`Wrote .apex-auditor/measure-summary.json`);
138
+ printSummary(summary);
139
+ }