slapify 0.0.16 → 0.0.18

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.
Files changed (52) hide show
  1. package/README.md +38 -4
  2. package/dist/ai/interpreter.js +1 -331
  3. package/dist/browser/agent.js +1 -485
  4. package/dist/cli.js +1 -1553
  5. package/dist/config/loader.js +1 -305
  6. package/dist/index.js +1 -262
  7. package/dist/parser/flow.js +1 -117
  8. package/dist/perf/audit.js +1 -635
  9. package/dist/report/generator.js +1 -641
  10. package/dist/runner/index.js +1 -744
  11. package/dist/task/index.js +1 -4
  12. package/dist/task/report.js +1 -740
  13. package/dist/task/runner.js +1 -1362
  14. package/dist/task/session.js +1 -153
  15. package/dist/task/tools.d.ts +12 -0
  16. package/dist/task/tools.js +1 -258
  17. package/dist/task/types.d.ts +18 -0
  18. package/dist/task/types.js +1 -2
  19. package/dist/types.js +1 -2
  20. package/package.json +6 -3
  21. package/dist/ai/interpreter.d.ts.map +0 -1
  22. package/dist/ai/interpreter.js.map +0 -1
  23. package/dist/browser/agent.d.ts.map +0 -1
  24. package/dist/browser/agent.js.map +0 -1
  25. package/dist/cli.d.ts.map +0 -1
  26. package/dist/cli.js.map +0 -1
  27. package/dist/config/loader.d.ts.map +0 -1
  28. package/dist/config/loader.js.map +0 -1
  29. package/dist/index.d.ts.map +0 -1
  30. package/dist/index.js.map +0 -1
  31. package/dist/parser/flow.d.ts.map +0 -1
  32. package/dist/parser/flow.js.map +0 -1
  33. package/dist/perf/audit.d.ts.map +0 -1
  34. package/dist/perf/audit.js.map +0 -1
  35. package/dist/report/generator.d.ts.map +0 -1
  36. package/dist/report/generator.js.map +0 -1
  37. package/dist/runner/index.d.ts.map +0 -1
  38. package/dist/runner/index.js.map +0 -1
  39. package/dist/task/index.d.ts.map +0 -1
  40. package/dist/task/index.js.map +0 -1
  41. package/dist/task/report.d.ts.map +0 -1
  42. package/dist/task/report.js.map +0 -1
  43. package/dist/task/runner.d.ts.map +0 -1
  44. package/dist/task/runner.js.map +0 -1
  45. package/dist/task/session.d.ts.map +0 -1
  46. package/dist/task/session.js.map +0 -1
  47. package/dist/task/tools.d.ts.map +0 -1
  48. package/dist/task/tools.js.map +0 -1
  49. package/dist/task/types.d.ts.map +0 -1
  50. package/dist/task/types.js.map +0 -1
  51. package/dist/types.d.ts.map +0 -1
  52. package/dist/types.js.map +0 -1
@@ -1,635 +1 @@
1
- /**
2
- * Performance auditing module — four independent layers:
3
- * 1. Core Web Vitals — injected into running browser, zero extra deps
4
- * 2. Network analysis — resource timing + fetch/XHR interception + long tasks
5
- * 3. Framework — React/Next.js detection + re-render analysis with interactions
6
- * 4. Deep audit — isolated headless Chrome for cold-start throttled scores
7
- */
8
- // ─── Core Web Vitals injection ────────────────────────────────────────────────
9
- /**
10
- * Injects a PerformanceObserver into the current page that collects
11
- * FCP, LCP, CLS and also reads Navigation Timing for TTFB / DOM ready.
12
- * Call collectCoreWebVitals() after some settle time to harvest results.
13
- */
14
- export async function injectVitalsObserver(browser) {
15
- const script = `(function(){
16
- if(window.__slapifyVitals) return;
17
- window.__slapifyVitals = { fcp: null, lcp: null, cls: 0, observed: false };
18
- try {
19
- var fcpObs = new PerformanceObserver(function(list){
20
- var e = list.getEntriesByName('first-contentful-paint')[0];
21
- if(e) window.__slapifyVitals.fcp = Math.round(e.startTime);
22
- });
23
- fcpObs.observe({ type: 'paint', buffered: true });
24
-
25
- var lcpObs = new PerformanceObserver(function(list){
26
- var entries = list.getEntries();
27
- if(entries.length) window.__slapifyVitals.lcp = Math.round(entries[entries.length-1].startTime);
28
- });
29
- lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
30
-
31
- var clsObs = new PerformanceObserver(function(list){
32
- list.getEntries().forEach(function(e){
33
- if(!e.hadRecentInput) window.__slapifyVitals.cls += e.value;
34
- });
35
- });
36
- clsObs.observe({ type: 'layout-shift', buffered: true });
37
-
38
- window.__slapifyVitals.observed = true;
39
- } catch(err) {}
40
- })()`;
41
- try {
42
- await browser.evaluate(script);
43
- }
44
- catch {
45
- // CSP may block eval — skip gracefully
46
- }
47
- }
48
- /**
49
- * Reads vitals from the injected observer + Navigation Timing API.
50
- */
51
- export async function collectCoreWebVitals(browser) {
52
- const script = `(function(){
53
- var v = window.__slapifyVitals || {};
54
- var nav = performance.getEntriesByType('navigation')[0] || {};
55
- return JSON.stringify({
56
- fcp: v.fcp || null,
57
- lcp: v.lcp || null,
58
- cls: v.cls != null ? +v.cls.toFixed(4) : null,
59
- ttfb: nav.responseStart ? Math.round(nav.responseStart) : null,
60
- domContentLoaded: nav.domContentLoadedEventEnd ? Math.round(nav.domContentLoadedEventEnd) : null,
61
- loadComplete: nav.loadEventEnd ? Math.round(nav.loadEventEnd) : null
62
- });
63
- })()`;
64
- try {
65
- const raw = await browser.evaluate(script);
66
- // evaluate may wrap in quotes
67
- const str = raw.startsWith('"') ? JSON.parse(raw) : raw;
68
- const parsed = JSON.parse(str);
69
- const vitals = {};
70
- if (parsed.fcp != null)
71
- vitals.fcp = parsed.fcp;
72
- if (parsed.lcp != null)
73
- vitals.lcp = parsed.lcp;
74
- if (parsed.cls != null)
75
- vitals.cls = parsed.cls;
76
- if (parsed.ttfb != null)
77
- vitals.ttfb = parsed.ttfb;
78
- if (parsed.domContentLoaded != null)
79
- vitals.domContentLoaded = parsed.domContentLoaded;
80
- if (parsed.loadComplete != null)
81
- vitals.loadComplete = parsed.loadComplete;
82
- return vitals;
83
- }
84
- catch {
85
- return {};
86
- }
87
- }
88
- // ─── Network analysis ─────────────────────────────────────────────────────────
89
- /**
90
- * Injects fetch/XHR interceptors and a LongTask PerformanceObserver into the
91
- * page. Must be called BEFORE user interactions or lazy-loading happens so that
92
- * API calls during the session are captured.
93
- *
94
- * Already-completed load-time requests are captured separately via
95
- * performance.getEntriesByType('resource') in collectNetworkAnalysis().
96
- */
97
- export async function injectNetworkTrackers(browser) {
98
- const script = `(function(){
99
- if (window.__slapifyNet) return;
100
- window.__slapifyNet = { requests: [], longTasks: [] };
101
-
102
- // ── fetch interceptor ──────────────────────────────────────────────────
103
- var _fetch = window.fetch;
104
- window.fetch = function(input, init) {
105
- var url = '';
106
- if (typeof input === 'string') url = input;
107
- else if (input instanceof URL) url = input.href;
108
- else if (input && input.url) url = input.url;
109
- url = url.slice(0, 300);
110
- var method = (init && init.method) || (input && input.method) || 'GET';
111
- var t0 = performance.now();
112
- return _fetch.apply(this, arguments)
113
- .then(function(r) {
114
- window.__slapifyNet.requests.push({ url: url, method: method.toUpperCase(), status: r.status, duration: Math.round(performance.now()-t0), type: 'fetch', failed: r.status >= 400 });
115
- return r;
116
- })
117
- .catch(function(e) {
118
- window.__slapifyNet.requests.push({ url: url, method: method.toUpperCase(), status: 0, duration: Math.round(performance.now()-t0), type: 'fetch', failed: true });
119
- throw e;
120
- });
121
- };
122
-
123
- // ── XHR interceptor ───────────────────────────────────────────────────
124
- var _open = XMLHttpRequest.prototype.open;
125
- var _send = XMLHttpRequest.prototype.send;
126
- XMLHttpRequest.prototype.open = function(m, u) {
127
- this.__slapM = m; this.__slapU = (u||'').toString().slice(0,300);
128
- return _open.apply(this, arguments);
129
- };
130
- XMLHttpRequest.prototype.send = function() {
131
- var self = this, t0 = performance.now();
132
- this.addEventListener('loadend', function() {
133
- window.__slapifyNet.requests.push({ url: self.__slapU||'', method: (self.__slapM||'GET').toUpperCase(), status: self.status, duration: Math.round(performance.now()-t0), type: 'xhr', failed: self.status >= 400 || self.status === 0 });
134
- });
135
- return _send.apply(this, arguments);
136
- };
137
-
138
- // ── Long task observer ────────────────────────────────────────────────
139
- try {
140
- var obs = new PerformanceObserver(function(list) {
141
- list.getEntries().forEach(function(e) {
142
- window.__slapifyNet.longTasks.push({ duration: Math.round(e.duration), startTime: Math.round(e.startTime) });
143
- });
144
- });
145
- obs.observe({ type: 'longtask', buffered: true });
146
- } catch(e) {}
147
- })()`;
148
- try {
149
- await browser.evaluate(script);
150
- }
151
- catch {
152
- // Ignore CSP errors
153
- }
154
- }
155
- /**
156
- * Collects all network data from the page:
157
- * - Resource timing (all loaded files — JS, CSS, images, fonts)
158
- * - Captured fetch/XHR API calls (from injectNetworkTrackers)
159
- * - Long tasks
160
- * - JS heap memory if available
161
- */
162
- export async function collectNetworkAnalysis(browser) {
163
- const script = `(function(){
164
- // Resource timing — all loaded files
165
- var resources = [];
166
- try {
167
- performance.getEntriesByType('resource').forEach(function(e) {
168
- resources.push({
169
- url: e.name.slice(0, 300),
170
- type: e.initiatorType || 'other',
171
- size: e.transferSize || 0,
172
- encodedSize: e.encodedBodySize || 0,
173
- duration: Math.round(e.duration),
174
- renderBlocking: e.renderBlockingStatus === 'blocking'
175
- });
176
- });
177
- } catch(e) {}
178
-
179
- var net = window.__slapifyNet || { requests: [], longTasks: [] };
180
-
181
- // Memory
182
- var memMB = null;
183
- try { if (performance.memory) memMB = +(performance.memory.usedJSHeapSize / 1048576).toFixed(1); } catch(e){}
184
-
185
- return JSON.stringify({ resources: resources, requests: net.requests, longTasks: net.longTasks, memMB: memMB });
186
- })()`;
187
- try {
188
- const raw = await browser.evaluate(script);
189
- const str = raw.startsWith('"') ? JSON.parse(raw) : raw;
190
- const data = JSON.parse(str);
191
- const resources = data.resources || [];
192
- const apiCalls = data.requests || [];
193
- const longTasks = data.longTasks || [];
194
- // Aggregate resource bytes by type
195
- let totalBytes = 0, jsBytes = 0, cssBytes = 0, imageBytes = 0, renderBlockingCount = 0;
196
- for (const r of resources) {
197
- totalBytes += r.size || 0;
198
- if (r.type === "script")
199
- jsBytes += r.size || 0;
200
- else if (r.type === "css" || r.type === "link")
201
- cssBytes += r.size || 0;
202
- else if (r.type === "img" || r.type === "image")
203
- imageBytes += r.size || 0;
204
- if (r.renderBlocking)
205
- renderBlockingCount++;
206
- }
207
- const heaviestResources = [...resources]
208
- .sort((a, b) => (b.size || 0) - (a.size || 0))
209
- .slice(0, 10);
210
- const slowApiCalls = apiCalls.filter((r) => r.duration >= 500);
211
- const failedApiCalls = apiCalls.filter((r) => r.failed);
212
- const totalBlockingMs = longTasks.reduce((s, t) => s + t.duration, 0);
213
- const result = {
214
- totalRequests: resources.length,
215
- totalBytes,
216
- jsBytes,
217
- cssBytes,
218
- imageBytes,
219
- renderBlockingCount,
220
- heaviestResources,
221
- apiCalls,
222
- slowApiCalls,
223
- failedApiCalls,
224
- longTasks,
225
- totalBlockingMs,
226
- };
227
- if (data.memMB != null)
228
- result.memoryMB = data.memMB;
229
- return result;
230
- }
231
- catch {
232
- return null;
233
- }
234
- }
235
- // ─── React Scan ───────────────────────────────────────────────────────────────
236
- /**
237
- * Comprehensive React detection that works with:
238
- * - Next.js App Router (no __NEXT_DATA__, uses __next_f / _next/static scripts)
239
- * - Next.js Pages Router (__NEXT_DATA__ present)
240
- * - Create React App, Vite, Remix, Gatsby
241
- * - Any React 16–18 app regardless of bundle mode
242
- *
243
- * Detection priority:
244
- * 1. __reactFiber* / __reactProps* on DOM nodes — most reliable for ALL React builds
245
- * 2. Next.js-specific globals (__NEXT_DATA__, __next_f, next/router)
246
- * 3. _next/static script tags — present on every Next.js build
247
- * 4. __REACT_DEVTOOLS_GLOBAL_HOOK__ with active renderers — dev / devtools
248
- * 5. window.React — legacy / dev / CRA
249
- * 6. data-reactroot / data-reactid — legacy attributes
250
- */
251
- const REACT_DETECT_SCRIPT = `(function(){
252
- // 1. React fiber on DOM nodes — works for ALL production React builds including Next.js App Router.
253
- // Scan a broad set of elements; fibers are attached to every rendered node.
254
- var els = document.querySelectorAll('*');
255
- var limit = Math.min(els.length, 200);
256
- for(var i = 0; i < limit; i++) {
257
- var el = els[i];
258
- // Only check elements that likely have React attached (skip pure HTML/SVG with no properties)
259
- var keys;
260
- try { keys = Object.keys(el); } catch(e) { continue; }
261
- for(var j = 0; j < keys.length; j++) {
262
- var k = keys[j];
263
- if(k.startsWith('__reactFiber') || k.startsWith('__reactProps') || k.startsWith('__reactInternalInstance') || k.startsWith('__reactEventHandlers')) {
264
- // Detect Next.js via global markers while we're at it
265
- var framework = (window.__NEXT_DATA__ || window.__next_f || window.next) ? 'Next.js' : 'React';
266
- return JSON.stringify({ detected: true, framework: framework, version: null });
267
- }
268
- }
269
- }
270
-
271
- // 2. Next.js globals — Pages Router (__NEXT_DATA__), App Router (__next_f streaming chunks)
272
- if(window.__NEXT_DATA__) return JSON.stringify({ detected: true, framework: 'Next.js (Pages)', version: null });
273
- if(window.__next_f || (Array.isArray(window.nd) && window.nd.length)) return JSON.stringify({ detected: true, framework: 'Next.js (App Router)', version: null });
274
-
275
- // 3. _next/static script tags — present on every Next.js deployment
276
- var scripts = document.querySelectorAll('script[src*="/_next/static"]');
277
- if(scripts.length > 0) return JSON.stringify({ detected: true, framework: 'Next.js', version: null });
278
-
279
- // 4. DevTools hook with active renderers (dev mode or React DevTools extension)
280
- var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
281
- if(hook && hook.renderers && hook.renderers.size > 0)
282
- return JSON.stringify({ detected: true, framework: 'React', version: null });
283
-
284
- // 5. window.React (dev builds / CRA / explicit exposure)
285
- if(window.React) return JSON.stringify({ detected: true, framework: 'React', version: window.React.version || null });
286
-
287
- // 6. Legacy attributes
288
- if(document.querySelector('[data-reactroot],[data-reactid]'))
289
- return JSON.stringify({ detected: true, framework: 'React (legacy)', version: null });
290
-
291
- return JSON.stringify({ detected: false, framework: null, version: null });
292
- })()`;
293
- /**
294
- * Injects React Scan from unpkg into the current page.
295
- * Uses comprehensive detection so Next.js / production builds are caught.
296
- * Does NOT cache detection state — always re-detects to avoid stale negatives.
297
- */
298
- export async function injectReactScan(browser) {
299
- const script = `(function(){
300
- // Always re-detect; do not skip based on prior state — prior detection
301
- // might have been a false negative from an old browser session.
302
- var raw = ${REACT_DETECT_SCRIPT};
303
- var info = JSON.parse(typeof raw === 'string' ? raw : '{"detected":false}');
304
- window.__reactDetection = info;
305
- if(!info.detected) return;
306
-
307
- // Only inject react-scan once per page load
308
- if(window.__reactScanInjected) return;
309
- window.__reactScanInjected = true;
310
-
311
- var s = document.createElement('script');
312
- s.src = 'https://unpkg.com/react-scan/dist/auto.global.js';
313
- s.onload = function(){
314
- try {
315
- if(window.reactScan) window.reactScan.setOptions({ enabled: true, showToolbar: false });
316
- } catch(e) {}
317
- };
318
- document.head.appendChild(s);
319
- })()`;
320
- try {
321
- await browser.evaluate(script);
322
- }
323
- catch {
324
- // Ignore CSP errors
325
- }
326
- }
327
- /**
328
- * Simulates real user interactions on the page and measures DOM mutation
329
- * activity triggered by each action. This gives a practical signal for
330
- * "how much work does the UI do when a user clicks something" without
331
- * requiring framework-specific hooks.
332
- *
333
- * Finds visible interactive elements (buttons, tabs, toggles), clicks each
334
- * one, waits for mutations to settle, then reports a count. A high mutation
335
- * count (>50) is flagged as potentially excessive.
336
- */
337
- async function performInteractionTests(browser) {
338
- const results = [];
339
- // Setup MutationObserver and find visible interactive elements
340
- const setupScript = `(function(){
341
- window.__slapifyMutCount = 0;
342
- if (window.__slapifyMutObs) { try { window.__slapifyMutObs.disconnect(); } catch(e){} }
343
- window.__slapifyMutObs = new MutationObserver(function(records) {
344
- records.forEach(function(r) {
345
- window.__slapifyMutCount += r.addedNodes.length + r.removedNodes.length + (r.type === 'attributes' ? 1 : 0);
346
- });
347
- });
348
- window.__slapifyMutObs.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: false });
349
-
350
- var seen = {};
351
- var found = [];
352
- var selectors = 'button:not([type="submit"]):not([disabled]), [role="tab"], [role="button"]:not(a), [aria-expanded]';
353
- document.querySelectorAll(selectors).forEach(function(el) {
354
- var rect = el.getBoundingClientRect();
355
- if (el.offsetParent !== null && rect.width > 20 && rect.height > 10) {
356
- var label = ((el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '')).replace(/\\s+/g, ' ').trim().slice(0, 40);
357
- if (label && !seen[label]) {
358
- seen[label] = true;
359
- found.push(label);
360
- }
361
- }
362
- });
363
- window.__slapifyInteractables = found.slice(0, 6);
364
- return JSON.stringify(window.__slapifyInteractables);
365
- })()`;
366
- let buttons = [];
367
- try {
368
- const raw = await browser.evaluate(setupScript);
369
- const s = raw.startsWith('"') ? JSON.parse(raw) : raw;
370
- buttons = JSON.parse(s) || [];
371
- }
372
- catch {
373
- return results;
374
- }
375
- for (let i = 0; i < buttons.length; i++) {
376
- try {
377
- // Reset counter before each interaction
378
- await browser.evaluate(`(function(){ window.__slapifyMutCount = 0; return 'ok'; })()`);
379
- // Perform the click
380
- const clickResult = await browser.evaluate(`(function(){
381
- var label = window.__slapifyInteractables[${i}];
382
- var selectors = 'button:not([type="submit"]):not([disabled]), [role="tab"], [role="button"]:not(a), [aria-expanded]';
383
- var target = Array.from(document.querySelectorAll(selectors)).find(function(el) {
384
- var t = ((el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '')).replace(/\\s+/g, ' ').trim().slice(0, 40);
385
- return t === label;
386
- });
387
- if (!target) return 'not_found';
388
- try { target.click(); return 'clicked'; } catch(e) { return 'error'; }
389
- })()`);
390
- if (!clickResult.includes("clicked"))
391
- continue;
392
- // Wait for mutations to settle (Node.js side — eval is synchronous per call)
393
- await new Promise((r) => setTimeout(r, 800));
394
- // Collect mutation count
395
- const countRaw = await browser.evaluate(`(function(){ return String(window.__slapifyMutCount || 0); })()`);
396
- const mutations = parseInt(countRaw.replace(/\D/g, "")) || 0;
397
- results.push({
398
- action: `Clicked: ${buttons[i]}`,
399
- mutations,
400
- flagged: mutations > 50,
401
- });
402
- }
403
- catch {
404
- // Non-fatal — skip this interaction
405
- }
406
- }
407
- // Tear down observer
408
- try {
409
- await browser.evaluate(`(function(){ if(window.__slapifyMutObs){ window.__slapifyMutObs.disconnect(); window.__slapifyMutObs=null; } return 'ok'; })()`);
410
- }
411
- catch {
412
- // ignore
413
- }
414
- return results;
415
- }
416
- /**
417
- * Collects framework detection and re-render analysis results.
418
- * Always runs fresh detection — never relies on a cached window.__reactDetection
419
- * that might have been set to {detected:false} by an older code path.
420
- */
421
- export async function collectReactScanResults(browser) {
422
- // Run fresh detection every time for reliability
423
- const detectScript = `(function(){
424
- var raw = ${REACT_DETECT_SCRIPT};
425
- var detection = JSON.parse(typeof raw === 'string' ? raw : '{"detected":false}');
426
- return JSON.stringify({
427
- isReact: detection.detected,
428
- framework: detection.framework || null,
429
- reactVersion: detection.version || (window.React && window.React.version) || null,
430
- scanAvailable: !!(window.reactScan && window.reactScan.getReport)
431
- });
432
- })()`;
433
- try {
434
- const raw = await browser.evaluate(detectScript);
435
- const str = raw.startsWith('"') ? JSON.parse(raw) : raw;
436
- const info = JSON.parse(str);
437
- if (!info.isReact) {
438
- return { detected: false, issues: [] };
439
- }
440
- const result = {
441
- detected: true,
442
- version: info.reactVersion ||
443
- (info.framework ? `(${info.framework})` : undefined),
444
- issues: [],
445
- };
446
- if (info.scanAvailable) {
447
- const reportScript = `(function(){
448
- try {
449
- var report = window.reactScan.getReport();
450
- if(!report) return 'null';
451
- var issues = [];
452
- report.forEach(function(v, k){
453
- if(v.count > 2) {
454
- issues.push({ component: k, renderCount: v.count, avgMs: v.time ? +(v.time/v.count).toFixed(1) : null });
455
- }
456
- });
457
- issues.sort(function(a,b){ return b.renderCount - a.renderCount; });
458
- return JSON.stringify(issues.slice(0, 20));
459
- } catch(e) { return '[]'; }
460
- })()`;
461
- try {
462
- const rawIssues = await browser.evaluate(reportScript);
463
- const issuesStr = rawIssues.startsWith('"')
464
- ? JSON.parse(rawIssues)
465
- : rawIssues;
466
- result.issues = JSON.parse(issuesStr) || [];
467
- }
468
- catch {
469
- // passive scan report not available
470
- }
471
- }
472
- // Interaction-based testing — simulate user clicks and measure activity
473
- const interactionTests = await performInteractionTests(browser);
474
- if ((interactionTests ?? []).length > 0) {
475
- result.interactionTests = interactionTests;
476
- }
477
- return result;
478
- }
479
- catch {
480
- return null;
481
- }
482
- }
483
- // ─── Lighthouse ───────────────────────────────────────────────────────────────
484
- /**
485
- * Runs a full Lighthouse audit against a URL.
486
- *
487
- * Lighthouse ALWAYS needs its own fresh Chrome for accurate scores — it applies
488
- * controlled CPU/network throttling and does multiple cold-start page loads.
489
- * Reusing the agent-browser session would skew every metric.
490
- *
491
- * chrome-launcher is a transitive dependency of lighthouse — we don't need to
492
- * import it directly. Lighthouse manages Chrome internally.
493
- *
494
- * Returns null if lighthouse is unavailable or the audit fails.
495
- */
496
- export async function runLighthouseAudit(url, outputDir) {
497
- try {
498
- // Lighthouse manages its own Chrome lifecycle via its bundled chrome-launcher.
499
- // We only need the lighthouse package itself.
500
- const { default: lighthouse } = await import("lighthouse");
501
- // Access chrome-launcher through lighthouse's own node_modules to avoid
502
- // needing it as a direct dependency.
503
- const { launch: launchChrome } = await import(
504
- // @ts-ignore — chrome-launcher is bundled by lighthouse, not by us
505
- "chrome-launcher");
506
- const chrome = await launchChrome({
507
- chromeFlags: ["--headless=new", "--no-sandbox", "--disable-gpu"],
508
- });
509
- try {
510
- const runnerResult = await lighthouse(url, {
511
- port: chrome.port,
512
- output: outputDir ? ["json", "html"] : "json",
513
- logLevel: "error",
514
- onlyCategories: [
515
- "performance",
516
- "accessibility",
517
- "best-practices",
518
- "seo",
519
- ],
520
- skipAudits: ["screenshot-thumbnails", "final-screenshot"],
521
- });
522
- if (!runnerResult?.lhr)
523
- return null;
524
- const lhr = runnerResult.lhr;
525
- const cat = lhr.categories ?? {};
526
- const aud = lhr.audits ?? {};
527
- const scores = {
528
- performance: Math.round((cat["performance"]?.score ?? 0) * 100),
529
- accessibility: Math.round((cat["accessibility"]?.score ?? 0) * 100),
530
- bestPractices: Math.round((cat["best-practices"]?.score ?? 0) * 100),
531
- seo: Math.round((cat["seo"]?.score ?? 0) * 100),
532
- fcp: aud["first-contentful-paint"]?.numericValue != null
533
- ? Math.round(aud["first-contentful-paint"].numericValue)
534
- : undefined,
535
- lcp: aud["largest-contentful-paint"]?.numericValue != null
536
- ? Math.round(aud["largest-contentful-paint"].numericValue)
537
- : undefined,
538
- cls: aud["cumulative-layout-shift"]?.numericValue != null
539
- ? +aud["cumulative-layout-shift"].numericValue.toFixed(4)
540
- : undefined,
541
- tbt: aud["total-blocking-time"]?.numericValue != null
542
- ? Math.round(aud["total-blocking-time"].numericValue)
543
- : undefined,
544
- speedIndex: aud["speed-index"]?.numericValue != null
545
- ? Math.round(aud["speed-index"].numericValue)
546
- : undefined,
547
- tti: aud["interactive"]?.numericValue != null
548
- ? Math.round(aud["interactive"].numericValue)
549
- : undefined,
550
- };
551
- let reportPath;
552
- if (outputDir && runnerResult.report) {
553
- const { default: fs } = await import("fs");
554
- const { default: path } = await import("path");
555
- fs.mkdirSync(outputDir, { recursive: true });
556
- const reports = Array.isArray(runnerResult.report)
557
- ? runnerResult.report
558
- : [runnerResult.report];
559
- const htmlReport = reports.find((r) => r.startsWith("<!"));
560
- if (htmlReport) {
561
- const slug = new URL(url).hostname.replace(/\./g, "-");
562
- reportPath = path.join(outputDir, `lighthouse-${slug}-${Date.now()}.html`);
563
- fs.writeFileSync(reportPath, htmlReport);
564
- }
565
- }
566
- return { scores, reportPath };
567
- }
568
- finally {
569
- await chrome.kill();
570
- }
571
- }
572
- catch {
573
- // lighthouse not installed or audit failed — not fatal
574
- return null;
575
- }
576
- }
577
- // ─── Full audit orchestrator ──────────────────────────────────────────────────
578
- /**
579
- * Run a full performance audit while a browser session is already open.
580
- *
581
- * CWV + framework analysis are collected from the live browser session.
582
- * The deep performance audit spins up its own fresh headless Chrome for accurate
583
- * throttled scores — this is intentional: it needs controlled cold-start
584
- * conditions that the existing session cannot provide.
585
- *
586
- * In the flow runner we avoid the overlap by closing agent-browser first (see
587
- * runner/index.ts). In the task agent the overlap is brief and acceptable since
588
- * the user explicitly asked for a perf audit mid-session.
589
- */
590
- export async function runPerfAudit(url, browser, options = {}) {
591
- const { lighthouse: runLighthouse = true, reactScan: runReactScan = true, settleMs = 2000, lighthouseOutputDir, navigate: shouldNavigate = true, } = options;
592
- // Navigate to the target URL if requested (default for task agent).
593
- // The flow runner passes navigate:false because the browser is already there.
594
- if (shouldNavigate) {
595
- await browser.navigate(url);
596
- // Brief pause so the page starts loading before we inject observers
597
- await new Promise((r) => setTimeout(r, 500));
598
- }
599
- const result = {
600
- url,
601
- auditedAt: new Date().toISOString(),
602
- vitals: {},
603
- scores: null,
604
- react: null,
605
- };
606
- // 1. Inject all observers early so they capture everything from here on
607
- await injectVitalsObserver(browser);
608
- await injectNetworkTrackers(browser);
609
- if (runReactScan) {
610
- await injectReactScan(browser);
611
- }
612
- // Allow page to settle and observers to fire
613
- await new Promise((resolve) => setTimeout(resolve, settleMs));
614
- // 3. Collect Core Web Vitals
615
- result.vitals = await collectCoreWebVitals(browser);
616
- // 4. Collect framework & re-render analysis (includes interaction tests that
617
- // also generate real API calls captured by the network tracker)
618
- if (runReactScan) {
619
- result.react = await collectReactScanResults(browser);
620
- }
621
- // 5. Collect network analysis AFTER interactions so API calls are captured
622
- result.network = (await collectNetworkAnalysis(browser)) ?? undefined;
623
- // 5. Run deep performance audit (in separate Chrome, parallel-safe)
624
- if (runLighthouse) {
625
- const lhResult = await runLighthouseAudit(url, lighthouseOutputDir);
626
- if (lhResult) {
627
- result.scores = lhResult.scores;
628
- result.lighthouse = lhResult.scores; // backwards compat
629
- if (lhResult.reportPath)
630
- result.lighthouseReportPath = lhResult.reportPath;
631
- }
632
- }
633
- return result;
634
- }
635
- //# sourceMappingURL=audit.js.map
1
+ export async function injectVitalsObserver(e){try{await e.evaluate("(function(){\n if(window.__slapifyVitals) return;\n window.__slapifyVitals = { fcp: null, lcp: null, cls: 0, observed: false };\n try {\n var fcpObs = new PerformanceObserver(function(list){\n var e = list.getEntriesByName('first-contentful-paint')[0];\n if(e) window.__slapifyVitals.fcp = Math.round(e.startTime);\n });\n fcpObs.observe({ type: 'paint', buffered: true });\n\n var lcpObs = new PerformanceObserver(function(list){\n var entries = list.getEntries();\n if(entries.length) window.__slapifyVitals.lcp = Math.round(entries[entries.length-1].startTime);\n });\n lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });\n\n var clsObs = new PerformanceObserver(function(list){\n list.getEntries().forEach(function(e){\n if(!e.hadRecentInput) window.__slapifyVitals.cls += e.value;\n });\n });\n clsObs.observe({ type: 'layout-shift', buffered: true });\n\n window.__slapifyVitals.observed = true;\n } catch(err) {}\n })()")}catch{}}export async function collectCoreWebVitals(e){try{const t=await e.evaluate("(function(){\n var v = window.__slapifyVitals || {};\n var nav = performance.getEntriesByType('navigation')[0] || {};\n return JSON.stringify({\n fcp: v.fcp || null,\n lcp: v.lcp || null,\n cls: v.cls != null ? +v.cls.toFixed(4) : null,\n ttfb: nav.responseStart ? Math.round(nav.responseStart) : null,\n domContentLoaded: nav.domContentLoadedEventEnd ? Math.round(nav.domContentLoadedEventEnd) : null,\n loadComplete: nav.loadEventEnd ? Math.round(nav.loadEventEnd) : null\n });\n })()"),n=t.startsWith('"')?JSON.parse(t):t,r=JSON.parse(n),a={};return null!=r.fcp&&(a.fcp=r.fcp),null!=r.lcp&&(a.lcp=r.lcp),null!=r.cls&&(a.cls=r.cls),null!=r.ttfb&&(a.ttfb=r.ttfb),null!=r.domContentLoaded&&(a.domContentLoaded=r.domContentLoaded),null!=r.loadComplete&&(a.loadComplete=r.loadComplete),a}catch{return{}}}export async function injectNetworkTrackers(e){try{await e.evaluate("(function(){\n if (window.__slapifyNet) return;\n window.__slapifyNet = { requests: [], longTasks: [] };\n\n // ── fetch interceptor ──────────────────────────────────────────────────\n var _fetch = window.fetch;\n window.fetch = function(input, init) {\n var url = '';\n if (typeof input === 'string') url = input;\n else if (input instanceof URL) url = input.href;\n else if (input && input.url) url = input.url;\n url = url.slice(0, 300);\n var method = (init && init.method) || (input && input.method) || 'GET';\n var t0 = performance.now();\n return _fetch.apply(this, arguments)\n .then(function(r) {\n window.__slapifyNet.requests.push({ url: url, method: method.toUpperCase(), status: r.status, duration: Math.round(performance.now()-t0), type: 'fetch', failed: r.status >= 400 });\n return r;\n })\n .catch(function(e) {\n window.__slapifyNet.requests.push({ url: url, method: method.toUpperCase(), status: 0, duration: Math.round(performance.now()-t0), type: 'fetch', failed: true });\n throw e;\n });\n };\n\n // ── XHR interceptor ───────────────────────────────────────────────────\n var _open = XMLHttpRequest.prototype.open;\n var _send = XMLHttpRequest.prototype.send;\n XMLHttpRequest.prototype.open = function(m, u) {\n this.__slapM = m; this.__slapU = (u||'').toString().slice(0,300);\n return _open.apply(this, arguments);\n };\n XMLHttpRequest.prototype.send = function() {\n var self = this, t0 = performance.now();\n this.addEventListener('loadend', function() {\n window.__slapifyNet.requests.push({ url: self.__slapU||'', method: (self.__slapM||'GET').toUpperCase(), status: self.status, duration: Math.round(performance.now()-t0), type: 'xhr', failed: self.status >= 400 || self.status === 0 });\n });\n return _send.apply(this, arguments);\n };\n\n // ── Long task observer ────────────────────────────────────────────────\n try {\n var obs = new PerformanceObserver(function(list) {\n list.getEntries().forEach(function(e) {\n window.__slapifyNet.longTasks.push({ duration: Math.round(e.duration), startTime: Math.round(e.startTime) });\n });\n });\n obs.observe({ type: 'longtask', buffered: true });\n } catch(e) {}\n })()")}catch{}}export async function collectNetworkAnalysis(e){try{const t=await e.evaluate("(function(){\n // Resource timing — all loaded files\n var resources = [];\n try {\n performance.getEntriesByType('resource').forEach(function(e) {\n resources.push({\n url: e.name.slice(0, 300),\n type: e.initiatorType || 'other',\n size: e.transferSize || 0,\n encodedSize: e.encodedBodySize || 0,\n duration: Math.round(e.duration),\n renderBlocking: e.renderBlockingStatus === 'blocking'\n });\n });\n } catch(e) {}\n\n var net = window.__slapifyNet || { requests: [], longTasks: [] };\n\n // Memory\n var memMB = null;\n try { if (performance.memory) memMB = +(performance.memory.usedJSHeapSize / 1048576).toFixed(1); } catch(e){}\n\n return JSON.stringify({ resources: resources, requests: net.requests, longTasks: net.longTasks, memMB: memMB });\n })()"),n=t.startsWith('"')?JSON.parse(t):t,r=JSON.parse(n),a=r.resources||[],i=r.requests||[],s=r.longTasks||[];let o=0,l=0,c=0,u=0,d=0;for(const e of a)o+=e.size||0,"script"===e.type?l+=e.size||0:"css"===e.type||"link"===e.type?c+=e.size||0:"img"!==e.type&&"image"!==e.type||(u+=e.size||0),e.renderBlocking&&d++;const f=[...a].sort((e,t)=>(t.size||0)-(e.size||0)).slice(0,10),p=i.filter(e=>e.duration>=500),w=i.filter(e=>e.failed),h=s.reduce((e,t)=>e+t.duration,0),m={totalRequests:a.length,totalBytes:o,jsBytes:l,cssBytes:c,imageBytes:u,renderBlockingCount:d,heaviestResources:f,apiCalls:i,slowApiCalls:p,failedApiCalls:w,longTasks:s,totalBlockingMs:h};return null!=r.memMB&&(m.memoryMB=r.memMB),m}catch{return null}}const e="(function(){\n // 1. React fiber on DOM nodes — works for ALL production React builds including Next.js App Router.\n // Scan a broad set of elements; fibers are attached to every rendered node.\n var els = document.querySelectorAll('*');\n var limit = Math.min(els.length, 200);\n for(var i = 0; i < limit; i++) {\n var el = els[i];\n // Only check elements that likely have React attached (skip pure HTML/SVG with no properties)\n var keys;\n try { keys = Object.keys(el); } catch(e) { continue; }\n for(var j = 0; j < keys.length; j++) {\n var k = keys[j];\n if(k.startsWith('__reactFiber') || k.startsWith('__reactProps') || k.startsWith('__reactInternalInstance') || k.startsWith('__reactEventHandlers')) {\n // Detect Next.js via global markers while we're at it\n var framework = (window.__NEXT_DATA__ || window.__next_f || window.next) ? 'Next.js' : 'React';\n return JSON.stringify({ detected: true, framework: framework, version: null });\n }\n }\n }\n\n // 2. Next.js globals — Pages Router (__NEXT_DATA__), App Router (__next_f streaming chunks)\n if(window.__NEXT_DATA__) return JSON.stringify({ detected: true, framework: 'Next.js (Pages)', version: null });\n if(window.__next_f || (Array.isArray(window.nd) && window.nd.length)) return JSON.stringify({ detected: true, framework: 'Next.js (App Router)', version: null });\n\n // 3. _next/static script tags — present on every Next.js deployment\n var scripts = document.querySelectorAll('script[src*=\"/_next/static\"]');\n if(scripts.length > 0) return JSON.stringify({ detected: true, framework: 'Next.js', version: null });\n\n // 4. DevTools hook with active renderers (dev mode or React DevTools extension)\n var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;\n if(hook && hook.renderers && hook.renderers.size > 0)\n return JSON.stringify({ detected: true, framework: 'React', version: null });\n\n // 5. window.React (dev builds / CRA / explicit exposure)\n if(window.React) return JSON.stringify({ detected: true, framework: 'React', version: window.React.version || null });\n\n // 6. Legacy attributes\n if(document.querySelector('[data-reactroot],[data-reactid]'))\n return JSON.stringify({ detected: true, framework: 'React (legacy)', version: null });\n\n return JSON.stringify({ detected: false, framework: null, version: null });\n})()";export async function injectReactScan(t){const n=`(function(){\n // Always re-detect; do not skip based on prior state — prior detection\n // might have been a false negative from an old browser session.\n var raw = ${e};\n var info = JSON.parse(typeof raw === 'string' ? raw : '{"detected":false}');\n window.__reactDetection = info;\n if(!info.detected) return;\n\n // Only inject react-scan once per page load\n if(window.__reactScanInjected) return;\n window.__reactScanInjected = true;\n\n var s = document.createElement('script');\n s.src = 'https://unpkg.com/react-scan/dist/auto.global.js';\n s.onload = function(){\n try {\n if(window.reactScan) window.reactScan.setOptions({ enabled: true, showToolbar: false });\n } catch(e) {}\n };\n document.head.appendChild(s);\n })()`;try{await t.evaluate(n)}catch{}}export async function collectReactScanResults(t){const n=`(function(){\n var raw = ${e};\n var detection = JSON.parse(typeof raw === 'string' ? raw : '{"detected":false}');\n return JSON.stringify({\n isReact: detection.detected,\n framework: detection.framework || null,\n reactVersion: detection.version || (window.React && window.React.version) || null,\n scanAvailable: !!(window.reactScan && window.reactScan.getReport)\n });\n })()`;try{const e=await t.evaluate(n),r=e.startsWith('"')?JSON.parse(e):e,a=JSON.parse(r);if(!a.isReact)return{detected:!1,issues:[]};const i={detected:!0,version:a.reactVersion||(a.framework?`(${a.framework})`:void 0),issues:[]};if(a.scanAvailable){const e="(function(){\n try {\n var report = window.reactScan.getReport();\n if(!report) return 'null';\n var issues = [];\n report.forEach(function(v, k){\n if(v.count > 2) {\n issues.push({ component: k, renderCount: v.count, avgMs: v.time ? +(v.time/v.count).toFixed(1) : null });\n }\n });\n issues.sort(function(a,b){ return b.renderCount - a.renderCount; });\n return JSON.stringify(issues.slice(0, 20));\n } catch(e) { return '[]'; }\n })()";try{const n=await t.evaluate(e),r=n.startsWith('"')?JSON.parse(n):n;i.issues=JSON.parse(r)||[]}catch{}}const s=await async function(e){const t=[];let n=[];try{const t=await e.evaluate("(function(){\n window.__slapifyMutCount = 0;\n if (window.__slapifyMutObs) { try { window.__slapifyMutObs.disconnect(); } catch(e){} }\n window.__slapifyMutObs = new MutationObserver(function(records) {\n records.forEach(function(r) {\n window.__slapifyMutCount += r.addedNodes.length + r.removedNodes.length + (r.type === 'attributes' ? 1 : 0);\n });\n });\n window.__slapifyMutObs.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: false });\n\n var seen = {};\n var found = [];\n var selectors = 'button:not([type=\"submit\"]):not([disabled]), [role=\"tab\"], [role=\"button\"]:not(a), [aria-expanded]';\n document.querySelectorAll(selectors).forEach(function(el) {\n var rect = el.getBoundingClientRect();\n if (el.offsetParent !== null && rect.width > 20 && rect.height > 10) {\n var label = ((el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '')).replace(/\\s+/g, ' ').trim().slice(0, 40);\n if (label && !seen[label]) {\n seen[label] = true;\n found.push(label);\n }\n }\n });\n window.__slapifyInteractables = found.slice(0, 6);\n return JSON.stringify(window.__slapifyInteractables);\n })()"),r=t.startsWith('"')?JSON.parse(t):t;n=JSON.parse(r)||[]}catch{return t}for(let r=0;r<n.length;r++)try{if(await e.evaluate("(function(){ window.__slapifyMutCount = 0; return 'ok'; })()"),!(await e.evaluate(`(function(){\n var label = window.__slapifyInteractables[${r}];\n var selectors = 'button:not([type="submit"]):not([disabled]), [role="tab"], [role="button"]:not(a), [aria-expanded]';\n var target = Array.from(document.querySelectorAll(selectors)).find(function(el) {\n var t = ((el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '')).replace(/\\s+/g, ' ').trim().slice(0, 40);\n return t === label;\n });\n if (!target) return 'not_found';\n try { target.click(); return 'clicked'; } catch(e) { return 'error'; }\n })()`)).includes("clicked"))continue;await new Promise(e=>setTimeout(e,800));const a=await e.evaluate("(function(){ return String(window.__slapifyMutCount || 0); })()"),i=parseInt(a.replace(/\D/g,""))||0;t.push({action:`Clicked: ${n[r]}`,mutations:i,flagged:i>50})}catch{}try{await e.evaluate("(function(){ if(window.__slapifyMutObs){ window.__slapifyMutObs.disconnect(); window.__slapifyMutObs=null; } return 'ok'; })()")}catch{}return t}(t);return(s??[]).length>0&&(i.interactionTests=s),i}catch{return null}}export async function runLighthouseAudit(e,t){try{const{default:n}=await import("lighthouse"),{launch:r}=await import("chrome-launcher"),a=await r({chromeFlags:["--headless=new","--no-sandbox","--disable-gpu"]});try{const r=await n(e,{port:a.port,output:t?["json","html"]:"json",logLevel:"error",onlyCategories:["performance","accessibility","best-practices","seo"],skipAudits:["screenshot-thumbnails","final-screenshot"]});if(!r?.lhr)return null;const i=r.lhr,s=i.categories??{},o=i.audits??{},l={performance:Math.round(100*(s.performance?.score??0)),accessibility:Math.round(100*(s.accessibility?.score??0)),bestPractices:Math.round(100*(s["best-practices"]?.score??0)),seo:Math.round(100*(s.seo?.score??0)),fcp:null!=o["first-contentful-paint"]?.numericValue?Math.round(o["first-contentful-paint"].numericValue):void 0,lcp:null!=o["largest-contentful-paint"]?.numericValue?Math.round(o["largest-contentful-paint"].numericValue):void 0,cls:null!=o["cumulative-layout-shift"]?.numericValue?+o["cumulative-layout-shift"].numericValue.toFixed(4):void 0,tbt:null!=o["total-blocking-time"]?.numericValue?Math.round(o["total-blocking-time"].numericValue):void 0,speedIndex:null!=o["speed-index"]?.numericValue?Math.round(o["speed-index"].numericValue):void 0,tti:null!=o.interactive?.numericValue?Math.round(o.interactive.numericValue):void 0};let c;if(t&&r.report){const{default:n}=await import("fs"),{default:a}=await import("path");n.mkdirSync(t,{recursive:!0});const i=(Array.isArray(r.report)?r.report:[r.report]).find(e=>e.startsWith("<!"));if(i){const r=new URL(e).hostname.replace(/\./g,"-");c=a.join(t,`lighthouse-${r}-${Date.now()}.html`),n.writeFileSync(c,i)}}return{scores:l,reportPath:c}}finally{await a.kill()}}catch{return null}}export async function runPerfAudit(e,t,n={}){const{lighthouse:r=!0,reactScan:a=!0,settleMs:i=2e3,lighthouseOutputDir:s,navigate:o=!0}=n;o&&(await t.navigate(e),await new Promise(e=>setTimeout(e,500)));const l={url:e,auditedAt:(new Date).toISOString(),vitals:{},scores:null,react:null};if(await injectVitalsObserver(t),await injectNetworkTrackers(t),a&&await injectReactScan(t),await new Promise(e=>setTimeout(e,i)),l.vitals=await collectCoreWebVitals(t),a&&(l.react=await collectReactScanResults(t)),l.network=await collectNetworkAnalysis(t)??void 0,r){const t=await runLighthouseAudit(e,s);t&&(l.scores=t.scores,l.lighthouse=t.scores,t.reportPath&&(l.lighthouseReportPath=t.reportPath))}return l}