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.
- package/README.md +38 -4
- package/dist/ai/interpreter.js +1 -331
- package/dist/browser/agent.js +1 -485
- package/dist/cli.js +1 -1553
- package/dist/config/loader.js +1 -305
- package/dist/index.js +1 -262
- package/dist/parser/flow.js +1 -117
- package/dist/perf/audit.js +1 -635
- package/dist/report/generator.js +1 -641
- package/dist/runner/index.js +1 -744
- package/dist/task/index.js +1 -4
- package/dist/task/report.js +1 -740
- package/dist/task/runner.js +1 -1362
- package/dist/task/session.js +1 -153
- package/dist/task/tools.d.ts +12 -0
- package/dist/task/tools.js +1 -258
- package/dist/task/types.d.ts +18 -0
- package/dist/task/types.js +1 -2
- package/dist/types.js +1 -2
- package/package.json +6 -3
- package/dist/ai/interpreter.d.ts.map +0 -1
- package/dist/ai/interpreter.js.map +0 -1
- package/dist/browser/agent.d.ts.map +0 -1
- package/dist/browser/agent.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config/loader.d.ts.map +0 -1
- package/dist/config/loader.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/parser/flow.d.ts.map +0 -1
- package/dist/parser/flow.js.map +0 -1
- package/dist/perf/audit.d.ts.map +0 -1
- package/dist/perf/audit.js.map +0 -1
- package/dist/report/generator.d.ts.map +0 -1
- package/dist/report/generator.js.map +0 -1
- package/dist/runner/index.d.ts.map +0 -1
- package/dist/runner/index.js.map +0 -1
- package/dist/task/index.d.ts.map +0 -1
- package/dist/task/index.js.map +0 -1
- package/dist/task/report.d.ts.map +0 -1
- package/dist/task/report.js.map +0 -1
- package/dist/task/runner.d.ts.map +0 -1
- package/dist/task/runner.js.map +0 -1
- package/dist/task/session.d.ts.map +0 -1
- package/dist/task/session.js.map +0 -1
- package/dist/task/tools.d.ts.map +0 -1
- package/dist/task/tools.js.map +0 -1
- package/dist/task/types.d.ts.map +0 -1
- package/dist/task/types.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/perf/audit.js
CHANGED
|
@@ -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}
|