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.
@@ -0,0 +1,447 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { request as httpRequest } from "node:http";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { launch as launchChrome } from "chrome-launcher";
7
+ import { CdpClient } from "./cdp-client.js";
8
+ const DEFAULT_NAVIGATION_TIMEOUT_MS = 60_000;
9
+ const DEFAULT_MAX_PARALLEL = 4;
10
+ const DEFAULT_ARTIFACTS_DIR = ".apex-auditor/measure";
11
+ const CHROME_FLAGS = [
12
+ "--headless=new",
13
+ "--disable-gpu",
14
+ "--no-sandbox",
15
+ "--disable-dev-shm-usage",
16
+ "--disable-extensions",
17
+ "--disable-default-apps",
18
+ "--no-first-run",
19
+ "--no-default-browser-check",
20
+ ];
21
+ const MOBILE_METRICS = { mobile: true, width: 412, height: 823, deviceScaleFactor: 2 };
22
+ const DESKTOP_METRICS = { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1 };
23
+ const MOBILE_UA = "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
24
+ function buildUrl({ baseUrl, path, query }) {
25
+ const cleanBase = baseUrl.replace(/\/$/, "");
26
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
27
+ const queryPart = query && query.length > 0 ? query : "";
28
+ return `${cleanBase}${cleanPath}${queryPart}`;
29
+ }
30
+ function slugify(value) {
31
+ const safe = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
32
+ return safe.length > 0 ? safe : "page";
33
+ }
34
+ function buildArtifactBaseName(task) {
35
+ const hash = createHash("sha1").update(`${task.url}::${task.device}`).digest("hex").slice(0, 10);
36
+ const pathPart = slugify(task.path);
37
+ return `${pathPart}-${task.device}-${hash}`;
38
+ }
39
+ function resolveParallelCount(params) {
40
+ const requested = params.requested;
41
+ if (requested !== undefined) {
42
+ return Math.max(1, Math.min(DEFAULT_MAX_PARALLEL, Math.min(params.taskCount, requested)));
43
+ }
44
+ return Math.max(1, Math.min(DEFAULT_MAX_PARALLEL, params.taskCount));
45
+ }
46
+ async function createChromeSession() {
47
+ const userDataDir = await mkdtemp(join(tmpdir(), "apex-auditor-measure-chrome-"));
48
+ const chrome = await launchChrome({ chromeFlags: [...CHROME_FLAGS, `--user-data-dir=${userDataDir}`] });
49
+ return {
50
+ port: chrome.port,
51
+ close: async () => {
52
+ try {
53
+ await chrome.kill();
54
+ await rm(userDataDir, { recursive: true, force: true });
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ },
60
+ };
61
+ }
62
+ async function fetchJsonVersion(port) {
63
+ const url = `http://127.0.0.1:${port}/json/version`;
64
+ return new Promise((resolve, reject) => {
65
+ const request = httpRequest(url, (response) => {
66
+ const statusCode = response.statusCode ?? 0;
67
+ if (statusCode < 200 || statusCode >= 300) {
68
+ response.resume();
69
+ reject(new Error(`CDP /json/version returned HTTP ${statusCode}`));
70
+ return;
71
+ }
72
+ const chunks = [];
73
+ response.on("data", (chunk) => {
74
+ chunks.push(chunk);
75
+ });
76
+ response.on("end", () => {
77
+ const raw = Buffer.concat(chunks).toString("utf8");
78
+ const parsed = JSON.parse(raw);
79
+ if (!parsed || typeof parsed !== "object") {
80
+ reject(new Error("Invalid /json/version response"));
81
+ return;
82
+ }
83
+ const record = parsed;
84
+ if (typeof record.webSocketDebuggerUrl !== "string" || record.webSocketDebuggerUrl.length === 0) {
85
+ reject(new Error("Missing webSocketDebuggerUrl in /json/version"));
86
+ return;
87
+ }
88
+ resolve({ webSocketDebuggerUrl: record.webSocketDebuggerUrl });
89
+ });
90
+ });
91
+ request.on("error", reject);
92
+ request.end();
93
+ });
94
+ }
95
+ function buildTasks(config) {
96
+ const tasks = [];
97
+ for (const page of config.pages) {
98
+ for (const device of page.devices) {
99
+ const url = buildUrl({ baseUrl: config.baseUrl, path: page.path, query: config.query });
100
+ tasks.push({ url, path: page.path, label: page.label, device });
101
+ }
102
+ }
103
+ return tasks;
104
+ }
105
+ function buildMeasureScript() {
106
+ return `(() => {
107
+ const state = {
108
+ cls: 0,
109
+ lcp: undefined,
110
+ inp: undefined,
111
+ };
112
+ const clsObserver = new PerformanceObserver((list) => {
113
+ for (const entry of list.getEntries()) {
114
+ const e = entry;
115
+ if (e.hadRecentInput) {
116
+ continue;
117
+ }
118
+ state.cls += e.value;
119
+ }
120
+ });
121
+ try { clsObserver.observe({ type: 'layout-shift', buffered: true }); } catch {}
122
+ const lcpObserver = new PerformanceObserver((list) => {
123
+ const entries = list.getEntries();
124
+ const last = entries[entries.length - 1];
125
+ if (last) {
126
+ state.lcp = last.startTime;
127
+ }
128
+ });
129
+ try { lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); } catch {}
130
+ const inpObserver = new PerformanceObserver((list) => {
131
+ for (const entry of list.getEntries()) {
132
+ if (typeof entry.interactionId !== 'number') {
133
+ continue;
134
+ }
135
+ const duration = entry.duration;
136
+ if (typeof duration === 'number') {
137
+ state.inp = state.inp === undefined ? duration : Math.max(state.inp, duration);
138
+ }
139
+ }
140
+ });
141
+ try { inpObserver.observe({ type: 'event', buffered: true, durationThreshold: 40 }); } catch {}
142
+ (globalThis).__apexMeasure = state;
143
+ })()`;
144
+ }
145
+ function applyDeviceEmulation(client, device, sessionId) {
146
+ if (device === "mobile") {
147
+ return Promise.all([
148
+ client.send("Emulation.setDeviceMetricsOverride", MOBILE_METRICS, sessionId),
149
+ client.send("Emulation.setUserAgentOverride", { userAgent: MOBILE_UA }, sessionId),
150
+ ]);
151
+ }
152
+ return client.send("Emulation.setDeviceMetricsOverride", DESKTOP_METRICS, sessionId);
153
+ }
154
+ function recordLogEvents(client, sessionId, bucket) {
155
+ const offException = client.onEvent("Runtime.exceptionThrown", sessionId, (payload) => {
156
+ const text = typeof payload === "object" && payload !== null ? JSON.stringify(payload) : String(payload);
157
+ bucket.push(`exception: ${text}`);
158
+ });
159
+ const offConsole = client.onEvent("Runtime.consoleAPICalled", sessionId, (payload) => {
160
+ const text = typeof payload === "object" && payload !== null ? JSON.stringify(payload) : String(payload);
161
+ if (text.toLowerCase().includes("error")) {
162
+ bucket.push(`console: ${text}`);
163
+ }
164
+ });
165
+ const offLog = client.onEvent("Log.entryAdded", sessionId, (payload) => {
166
+ const record = payload;
167
+ const entry = record.entry;
168
+ const level = typeof entry?.level === "string" ? entry.level : "";
169
+ const text = typeof entry?.text === "string" ? entry.text : "";
170
+ if (level === "error" && text.length > 0) {
171
+ bucket.push(text);
172
+ }
173
+ });
174
+ return () => {
175
+ offException();
176
+ offConsole();
177
+ offLog();
178
+ };
179
+ }
180
+ async function captureScreenshot(client, task, sessionId, artifactsDir) {
181
+ try {
182
+ const screenshot = await client.send("Page.captureScreenshot", { format: "png" }, sessionId);
183
+ const data = typeof screenshot.data === "string" ? screenshot.data : undefined;
184
+ if (!data) {
185
+ return undefined;
186
+ }
187
+ const baseName = buildArtifactBaseName(task);
188
+ const screenshotPath = resolve(artifactsDir, `${baseName}.png`);
189
+ await writeFile(screenshotPath, Buffer.from(data, "base64"));
190
+ return screenshotPath;
191
+ }
192
+ catch {
193
+ return undefined;
194
+ }
195
+ }
196
+ function parseMeasureResult(valueUnknown) {
197
+ const value = valueUnknown && typeof valueUnknown === "object" ? valueUnknown : {};
198
+ const ttfbMs = typeof value.navigation?.responseStart === "number" ? value.navigation.responseStart : undefined;
199
+ const domContentLoadedMs = typeof value.navigation?.domContentLoadedEventEnd === "number" ? value.navigation.domContentLoadedEventEnd : undefined;
200
+ const loadMs = typeof value.navigation?.loadEventEnd === "number" ? value.navigation.loadEventEnd : undefined;
201
+ const lcpMs = typeof value.vitals?.lcpMs === "number" ? value.vitals.lcpMs : undefined;
202
+ const cls = typeof value.vitals?.cls === "number" ? value.vitals.cls : undefined;
203
+ const inpMs = typeof value.vitals?.inpMs === "number" ? value.vitals.inpMs : undefined;
204
+ const longTasks = {
205
+ count: typeof value.longTasks?.count === "number" ? value.longTasks.count : 0,
206
+ totalMs: typeof value.longTasks?.totalMs === "number" ? value.longTasks.totalMs : 0,
207
+ maxMs: typeof value.longTasks?.maxMs === "number" ? value.longTasks.maxMs : 0,
208
+ };
209
+ const scriptingDurationMs = typeof value.scriptingDurationMs === "number" ? value.scriptingDurationMs : longTasks.totalMs;
210
+ const network = {
211
+ totalRequests: typeof value.network?.totalRequests === "number" ? value.network.totalRequests : 0,
212
+ totalBytes: typeof value.network?.totalBytes === "number" ? value.network.totalBytes : 0,
213
+ thirdPartyRequests: typeof value.network?.thirdPartyRequests === "number" ? value.network.thirdPartyRequests : 0,
214
+ thirdPartyBytes: typeof value.network?.thirdPartyBytes === "number" ? value.network.thirdPartyBytes : 0,
215
+ cacheHitRatio: typeof value.network?.cacheHitRatio === "number" ? value.network.cacheHitRatio : 0,
216
+ lateScriptRequests: typeof value.network?.lateScriptRequests === "number" ? value.network.lateScriptRequests : 0,
217
+ };
218
+ return { timings: { ttfbMs, domContentLoadedMs, loadMs }, vitals: { lcpMs, cls, inpMs }, longTasks, scriptingDurationMs, network };
219
+ }
220
+ async function createTargetSession(client) {
221
+ const created = await client.send("Target.createTarget", { url: "about:blank" });
222
+ const attached = await client.send("Target.attachToTarget", { targetId: created.targetId, flatten: true });
223
+ return { targetId: created.targetId, sessionId: attached.sessionId };
224
+ }
225
+ async function enableDomains(client, sessionId) {
226
+ await client.send("Page.enable", {}, sessionId);
227
+ await client.send("Log.enable", {}, sessionId);
228
+ await client.send("Runtime.enable", {}, sessionId);
229
+ await client.send("Performance.enable", {}, sessionId);
230
+ }
231
+ async function injectMeasurementScript(client, sessionId) {
232
+ const measurementScript = buildMeasureScript();
233
+ await client.send("Runtime.evaluate", { expression: measurementScript, awaitPromise: false }, sessionId);
234
+ }
235
+ async function navigateAndAwaitLoad(client, sessionId, url, timeoutMs) {
236
+ const response = await client.send("Page.navigate", { url }, sessionId);
237
+ if (response.errorText) {
238
+ throw new Error(response.errorText);
239
+ }
240
+ await client.waitForEventForSession("Page.loadEventFired", sessionId, timeoutMs);
241
+ }
242
+ async function evaluateMetrics(client, sessionId) {
243
+ const evaluateResult = await client.send("Runtime.evaluate", {
244
+ expression: `(() => {
245
+ const nav = performance.getEntriesByType('navigation')[0];
246
+ const state = (globalThis).__apexMeasure;
247
+ const resources = performance.getEntriesByType('resource');
248
+ const longTasks = performance.getEntriesByType('longtask');
249
+ let longTaskCount = 0;
250
+ let longTaskTotal = 0;
251
+ let longTaskMax = 0;
252
+ for (const lt of longTasks) {
253
+ const duration = lt.duration || 0;
254
+ longTaskCount += 1;
255
+ longTaskTotal += duration;
256
+ if (duration > longTaskMax) {
257
+ longTaskMax = duration;
258
+ }
259
+ }
260
+ let totalRequests = 0;
261
+ let totalBytes = 0;
262
+ let thirdPartyRequests = 0;
263
+ let thirdPartyBytes = 0;
264
+ let cacheHits = 0;
265
+ let lateScriptRequests = 0;
266
+ const pageHost = location.host;
267
+ const loadEnd = nav ? nav.loadEventEnd : 0;
268
+ for (const res of resources) {
269
+ totalRequests += 1;
270
+ const transfer = typeof res.transferSize === 'number' ? res.transferSize : 0;
271
+ const encoded = typeof res.encodedBodySize === 'number' ? res.encodedBodySize : 0;
272
+ totalBytes += transfer;
273
+ try {
274
+ const url = new URL(res.name, location.href);
275
+ const isThirdParty = url.host !== pageHost;
276
+ if (isThirdParty) {
277
+ thirdPartyRequests += 1;
278
+ thirdPartyBytes += transfer;
279
+ }
280
+ } catch {}
281
+ if ((transfer === 0 && encoded > 0) || (encoded > 0 && transfer < encoded)) {
282
+ cacheHits += 1;
283
+ }
284
+ if (res.initiatorType === 'script' && loadEnd && res.startTime >= loadEnd) {
285
+ lateScriptRequests += 1;
286
+ }
287
+ }
288
+ const cacheHitRatio = totalRequests > 0 ? cacheHits / totalRequests : 0;
289
+ return {
290
+ navigation: nav ? {
291
+ responseStart: nav.responseStart,
292
+ domContentLoadedEventEnd: nav.domContentLoadedEventEnd,
293
+ loadEventEnd: nav.loadEventEnd,
294
+ } : undefined,
295
+ vitals: state ? {
296
+ lcpMs: state.lcp,
297
+ cls: state.cls,
298
+ inpMs: state.inp,
299
+ } : undefined,
300
+ longTasks: {
301
+ count: longTaskCount,
302
+ totalMs: longTaskTotal,
303
+ maxMs: longTaskMax,
304
+ },
305
+ scriptingDurationMs: longTaskTotal,
306
+ network: {
307
+ totalRequests,
308
+ totalBytes,
309
+ thirdPartyRequests,
310
+ thirdPartyBytes,
311
+ cacheHitRatio,
312
+ lateScriptRequests,
313
+ },
314
+ };
315
+ })()`,
316
+ }, sessionId);
317
+ const valueUnknown = evaluateResult.result?.value;
318
+ return parseMeasureResult(valueUnknown);
319
+ }
320
+ async function detachAndClose(client, context, stopLogging) {
321
+ stopLogging();
322
+ await client.send("Target.closeTarget", { targetId: context.targetId });
323
+ await client.send("Target.detachFromTarget", { sessionId: context.sessionId });
324
+ }
325
+ async function collectMetrics(params) {
326
+ const { client, task, sessionId, timeoutMs, artifactsDir, consoleErrors } = params;
327
+ const stopLogging = recordLogEvents(client, sessionId, consoleErrors);
328
+ await applyDeviceEmulation(client, task.device, sessionId);
329
+ await injectMeasurementScript(client, sessionId);
330
+ await navigateAndAwaitLoad(client, sessionId, task.url, timeoutMs);
331
+ const screenshotPath = await captureScreenshot(client, task, sessionId, artifactsDir);
332
+ await client.send("Runtime.evaluate", { expression: "void 0", awaitPromise: false }, sessionId);
333
+ const parsed = await evaluateMetrics(client, sessionId);
334
+ return { parsed, screenshotPath, stopLogging };
335
+ }
336
+ async function runSingleMeasure(params) {
337
+ const { client, task, timeoutMs, artifactsDir } = params;
338
+ let finalUrl = task.url;
339
+ const consoleErrors = [];
340
+ let screenshotPath;
341
+ try {
342
+ const context = await createTargetSession(client);
343
+ await enableDomains(client, context.sessionId);
344
+ const { parsed, screenshotPath: shotPath, stopLogging } = await collectMetrics({ client, task, sessionId: context.sessionId, timeoutMs, artifactsDir, consoleErrors });
345
+ screenshotPath = shotPath;
346
+ finalUrl = task.url;
347
+ await detachAndClose(client, context, stopLogging);
348
+ return {
349
+ url: finalUrl,
350
+ path: task.path,
351
+ label: task.label,
352
+ device: task.device,
353
+ timings: parsed.timings,
354
+ vitals: parsed.vitals,
355
+ longTasks: parsed.longTasks,
356
+ scriptingDurationMs: parsed.scriptingDurationMs,
357
+ network: parsed.network,
358
+ artifacts: { screenshotPath, consoleErrors },
359
+ };
360
+ }
361
+ catch (error) {
362
+ const message = error instanceof Error ? error.message : "Unknown error";
363
+ return {
364
+ url: finalUrl,
365
+ path: task.path,
366
+ label: task.label,
367
+ device: task.device,
368
+ timings: {},
369
+ vitals: {},
370
+ longTasks: { count: 0, totalMs: 0, maxMs: 0 },
371
+ scriptingDurationMs: 0,
372
+ network: {
373
+ totalRequests: 0,
374
+ totalBytes: 0,
375
+ thirdPartyRequests: 0,
376
+ thirdPartyBytes: 0,
377
+ cacheHitRatio: 0,
378
+ lateScriptRequests: 0,
379
+ },
380
+ artifacts: { screenshotPath, consoleErrors },
381
+ runtimeErrorMessage: message,
382
+ };
383
+ }
384
+ }
385
+ async function runWithConcurrency(params) {
386
+ const results = new Array(params.tasks.length);
387
+ const nextIndex = { value: 0 };
388
+ const worker = async () => {
389
+ while (true) {
390
+ const index = nextIndex.value;
391
+ if (index >= params.tasks.length) {
392
+ return;
393
+ }
394
+ nextIndex.value += 1;
395
+ const task = params.tasks[index];
396
+ results[index] = await params.runner(task);
397
+ }
398
+ };
399
+ const workers = new Array(params.parallel).fill(0).map(async () => worker());
400
+ await Promise.all(workers);
401
+ return results;
402
+ }
403
+ /**
404
+ * Run fast, non-Lighthouse measurements across all page/device combos in the config.
405
+ */
406
+ export async function runMeasureForConfig(params) {
407
+ const startedAtMs = Date.now();
408
+ const tasks = buildTasks(params.config);
409
+ const parallel = resolveParallelCount({ requested: params.parallelOverride ?? params.config.parallel, taskCount: tasks.length });
410
+ const timeoutMs = params.timeoutMs ?? DEFAULT_NAVIGATION_TIMEOUT_MS;
411
+ const artifactsDir = params.artifactsDir ?? DEFAULT_ARTIFACTS_DIR;
412
+ await mkdir(artifactsDir, { recursive: true });
413
+ const chrome = await createChromeSession();
414
+ try {
415
+ const version = await fetchJsonVersion(chrome.port);
416
+ const client = new CdpClient(version.webSocketDebuggerUrl);
417
+ await client.connect();
418
+ try {
419
+ const results = await runWithConcurrency({
420
+ tasks,
421
+ parallel,
422
+ runner: async (task) => runSingleMeasure({ client, task, timeoutMs, artifactsDir }),
423
+ });
424
+ const completedAtMs = Date.now();
425
+ const elapsedMs = completedAtMs - startedAtMs;
426
+ const averageComboMs = tasks.length > 0 ? Math.round(elapsedMs / tasks.length) : 0;
427
+ return {
428
+ meta: {
429
+ configPath: params.configPath,
430
+ resolvedParallel: parallel,
431
+ comboCount: tasks.length,
432
+ startedAt: new Date(startedAtMs).toISOString(),
433
+ completedAt: new Date(completedAtMs).toISOString(),
434
+ elapsedMs,
435
+ averageComboMs,
436
+ },
437
+ results,
438
+ };
439
+ }
440
+ finally {
441
+ client.close();
442
+ }
443
+ }
444
+ finally {
445
+ await chrome.close();
446
+ }
447
+ }
@@ -0,0 +1 @@
1
+ export {};