argusqa-os 9.5.1 → 9.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * ARGUS Web Vitals Analyzer (Sprint 9 — Advanced Performance Metrics)
3
+ *
4
+ * Captures Core Web Vitals and performance metrics directly via the browser
5
+ * Performance API. Unlike Lighthouse, this works in headless Chrome — metrics
6
+ * are always available in CI without requiring a non-headless browser.
7
+ *
8
+ * Metrics captured:
9
+ * LCP — Largest Contentful Paint (PerformanceObserver, buffered)
10
+ * CLS — Cumulative Layout Shift (PerformanceObserver, buffered)
11
+ * FCP — First Contentful Paint (getEntriesByType('paint'))
12
+ * TTI — Time to Interactive (NavigationTiming.domInteractive)
13
+ * TTFB — Time to First Byte (NavigationTiming.responseStart)
14
+ *
15
+ * Bundle / resource monitoring:
16
+ * perf_bundle_large — JS file > 500 KB (warning) or > 2 MB (critical)
17
+ * perf_bundle_large_css — CSS file > 150 KB (warning)
18
+ *
19
+ * Findings emitted:
20
+ * perf_lcp — warning ≥2500ms, critical ≥4000ms
21
+ * perf_cls — warning ≥0.1, critical ≥0.25
22
+ * perf_fcp — warning ≥1800ms, critical ≥3000ms
23
+ * perf_tti — warning ≥3500ms, critical ≥7300ms
24
+ * perf_bundle_large — warning / critical per JS/CSS size thresholds
25
+ * perf_vitals_summary — info, always emitted when analysis runs
26
+ */
27
+
28
+ import { registerExpensive } from '../registry.js';
29
+ import { unwrapEval } from './mcp-client.js';
30
+ import { childLogger } from './logger.js';
31
+ import { thresholds } from '../config/targets.js';
32
+
33
+ const logger = childLogger('web-vitals');
34
+
35
+ // ── Thresholds ────────────────────────────────────────────────────────────────
36
+ // Core Web Vitals "Needs Improvement" / "Poor" boundaries (Google 2024)
37
+ const LCP_WARN = thresholds.perf?.LCP ?? 2500; // ms
38
+ const LCP_CRIT = 4000; // ms
39
+ const CLS_WARN = thresholds.perf?.CLS ?? 0.1;
40
+ const CLS_CRIT = 0.25;
41
+ const FCP_WARN = 1800; // ms
42
+ const FCP_CRIT = 3000; // ms
43
+ const TTI_WARN = 3500; // ms (domInteractive)
44
+ const TTI_CRIT = 7300; // ms
45
+
46
+ const JS_WARN_BYTES = 500 * 1024; // 500 KB
47
+ const JS_CRIT_BYTES = 2000 * 1024; // 2 MB
48
+ const CSS_WARN_BYTES = 150 * 1024; // 150 KB
49
+
50
+ // ── In-browser measurement script ────────────────────────────────────────────
51
+ // Async — awaits PerformanceObserver callbacks for LCP/CLS (buffered entries
52
+ // are delivered synchronously on observe(), so the setTimeout fallbacks are
53
+ // safety nets only).
54
+ const VITALS_SCRIPT = `async () => {
55
+ var result = {
56
+ lcp: null, cls: null, fcp: null,
57
+ tti: null, ttfb: null, domComplete: null,
58
+ resources: [],
59
+ };
60
+
61
+ // ── Navigation Timing ───────────────────────────────────────────────────────
62
+ var navEntries = performance.getEntriesByType('navigation');
63
+ if (navEntries.length > 0) {
64
+ var nav = navEntries[0];
65
+ result.ttfb = Math.round(nav.responseStart);
66
+ result.tti = Math.round(nav.domInteractive);
67
+ result.domComplete = Math.round(nav.domComplete);
68
+ }
69
+
70
+ // ── FCP (synchronous — available in paint entries buffer) ──────────────────
71
+ var paintEntries = performance.getEntriesByType('paint');
72
+ for (var i = 0; i < paintEntries.length; i++) {
73
+ if (paintEntries[i].name === 'first-contentful-paint') {
74
+ result.fcp = Math.round(paintEntries[i].startTime);
75
+ break;
76
+ }
77
+ }
78
+
79
+ // ── LCP (PerformanceObserver with buffered:true) ───────────────────────────
80
+ await new Promise(function(resolve) {
81
+ try {
82
+ var lcpObs = new PerformanceObserver(function(list) {
83
+ var entries = list.getEntries();
84
+ if (entries.length > 0) {
85
+ result.lcp = Math.round(entries[entries.length - 1].startTime);
86
+ }
87
+ lcpObs.disconnect();
88
+ resolve();
89
+ });
90
+ lcpObs.observe({ type: 'largest-contentful-paint', buffered: true });
91
+ setTimeout(resolve, 150); // fallback if no LCP entries
92
+ } catch (e) { resolve(); }
93
+ });
94
+
95
+ // ── CLS (PerformanceObserver with buffered:true) ───────────────────────────
96
+ var clsScore = 0;
97
+ await new Promise(function(resolve) {
98
+ try {
99
+ var clsObs = new PerformanceObserver(function(list) {
100
+ var entries = list.getEntries();
101
+ for (var j = 0; j < entries.length; j++) {
102
+ if (!entries[j].hadRecentInput) clsScore += entries[j].value;
103
+ }
104
+ clsObs.disconnect();
105
+ resolve();
106
+ });
107
+ clsObs.observe({ type: 'layout-shift', buffered: true });
108
+ setTimeout(resolve, 150);
109
+ } catch (e) { resolve(); }
110
+ });
111
+ result.cls = Math.round(clsScore * 1000) / 1000;
112
+
113
+ // ── Resource Timing — JS / CSS bundles ────────────────────────────────────
114
+ var pageOrigin;
115
+ try { pageOrigin = new URL(window.location.href).origin; } catch (e) { pageOrigin = ''; }
116
+ var resEntries = performance.getEntriesByType('resource');
117
+ for (var k = 0; k < resEntries.length; k++) {
118
+ var r = resEntries[k];
119
+ if (!r.name) continue;
120
+ var pathname = r.name.split('?')[0];
121
+ var ext = pathname.split('.').pop().toLowerCase();
122
+ if (ext !== 'js' && ext !== 'css') continue;
123
+ var size = r.transferSize || r.encodedBodySize || r.decodedBodySize || 0;
124
+ // BFcache / memory-cache restores set all three size fields to 0.
125
+ // Fall back to a HEAD request to get Content-Length for same-origin resources.
126
+ if (size === 0) {
127
+ try {
128
+ var isLocal = new URL(r.name).origin === pageOrigin;
129
+ if (isLocal) {
130
+ var head = await fetch(r.name, { method: 'HEAD', cache: 'no-store' });
131
+ var cl = parseInt(head.headers.get('content-length') || '0', 10);
132
+ if (cl > 0) size = cl;
133
+ }
134
+ } catch (e) {}
135
+ }
136
+ if (size === 0) continue; // CORS opaque — skip
137
+ var isThirdParty = false;
138
+ try { isThirdParty = new URL(r.name).origin !== pageOrigin; } catch (e) {}
139
+ result.resources.push({
140
+ url: r.name,
141
+ ext: ext,
142
+ sizeBytes: size,
143
+ durationMs: Math.round(r.duration),
144
+ isThirdParty: isThirdParty,
145
+ });
146
+ }
147
+
148
+ return JSON.stringify(result);
149
+ }`;
150
+
151
+ // ── JSON parse helper ─────────────────────────────────────────────────────────
152
+ function parseJson(raw) {
153
+ try {
154
+ const str = unwrapEval(raw);
155
+ if (typeof str === 'object' && str !== null) return str;
156
+ return JSON.parse(str);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ // ── Public API ────────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Capture Web Vitals and performance metrics for a single page.
166
+ *
167
+ * Navigates fresh so timing data starts from scratch on each run.
168
+ *
169
+ * @param {object} browser - CdpBrowserAdapter
170
+ * @param {string} url - Fully-qualified URL to analyse
171
+ * @returns {Promise<object[]>} Array of performance finding objects
172
+ */
173
+ export async function analyzeWebVitals(browser, url) {
174
+ const findings = [];
175
+
176
+ // Navigate fresh — timing APIs measure from navigation start
177
+ try {
178
+ await browser.navigate(url);
179
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
180
+ // Let PerformanceObserver callbacks settle after networkidle
181
+ await new Promise(r => setTimeout(r, 1200));
182
+ } catch {
183
+ return findings;
184
+ }
185
+
186
+ let data;
187
+ try {
188
+ const raw = await browser.evaluate(VITALS_SCRIPT);
189
+ data = parseJson(raw);
190
+ } catch (err) {
191
+ logger.warn(`[ARGUS] web-vitals: measurement script failed for ${url}: ${err.message}`);
192
+ return findings;
193
+ }
194
+ if (!data) return findings;
195
+
196
+ const { lcp, cls, fcp, tti, ttfb, resources = [] } = data;
197
+
198
+ // ── LCP ───────────────────────────────────────────────────────────────────
199
+ if (lcp !== null) {
200
+ const sev = lcp >= LCP_CRIT ? 'critical' : lcp >= LCP_WARN ? 'warning' : 'info';
201
+ if (sev !== 'info') {
202
+ findings.push({
203
+ type: 'perf_lcp', value: lcp, threshold: LCP_WARN,
204
+ message: `LCP ${lcp}ms — threshold ${LCP_WARN}ms (warning) / ${LCP_CRIT}ms (critical)`,
205
+ severity: sev, url,
206
+ });
207
+ }
208
+ }
209
+
210
+ // ── CLS ───────────────────────────────────────────────────────────────────
211
+ if (cls !== null && cls >= CLS_WARN) {
212
+ const sev = cls >= CLS_CRIT ? 'critical' : 'warning';
213
+ findings.push({
214
+ type: 'perf_cls', value: cls, threshold: CLS_WARN,
215
+ message: `CLS ${cls} — threshold ${CLS_WARN} (warning) / ${CLS_CRIT} (critical)`,
216
+ severity: sev, url,
217
+ });
218
+ }
219
+
220
+ // ── FCP ───────────────────────────────────────────────────────────────────
221
+ if (fcp !== null && fcp >= FCP_WARN) {
222
+ const sev = fcp >= FCP_CRIT ? 'critical' : 'warning';
223
+ findings.push({
224
+ type: 'perf_fcp', value: fcp, threshold: FCP_WARN,
225
+ message: `FCP ${fcp}ms — threshold ${FCP_WARN}ms (warning) / ${FCP_CRIT}ms (critical)`,
226
+ severity: sev, url,
227
+ });
228
+ }
229
+
230
+ // ── TTI ───────────────────────────────────────────────────────────────────
231
+ if (tti !== null && tti >= TTI_WARN) {
232
+ const sev = tti >= TTI_CRIT ? 'critical' : 'warning';
233
+ findings.push({
234
+ type: 'perf_tti', value: tti, threshold: TTI_WARN,
235
+ message: `TTI (domInteractive) ${tti}ms — threshold ${TTI_WARN}ms (warning) / ${TTI_CRIT}ms (critical)`,
236
+ severity: sev, url,
237
+ });
238
+ }
239
+
240
+ // ── Bundle sizes ──────────────────────────────────────────────────────────
241
+ for (const r of resources) {
242
+ const kb = Math.round(r.sizeBytes / 1024);
243
+ if (r.ext === 'js') {
244
+ if (r.sizeBytes >= JS_WARN_BYTES) {
245
+ const sev = r.sizeBytes >= JS_CRIT_BYTES ? 'critical' : 'warning';
246
+ findings.push({
247
+ type: 'perf_bundle_large', ext: 'js', sizeKb: kb,
248
+ resourceUrl: r.url, durationMs: r.durationMs, isThirdParty: r.isThirdParty,
249
+ message: `JS bundle ${kb}KB — threshold ${JS_WARN_BYTES / 1024}KB (warning) / ${JS_CRIT_BYTES / 1024}KB (critical): ${r.url}`,
250
+ severity: sev, url,
251
+ });
252
+ }
253
+ } else if (r.ext === 'css' && r.sizeBytes >= CSS_WARN_BYTES) {
254
+ findings.push({
255
+ type: 'perf_bundle_large', ext: 'css', sizeKb: kb,
256
+ resourceUrl: r.url, durationMs: r.durationMs, isThirdParty: r.isThirdParty,
257
+ message: `CSS bundle ${kb}KB — threshold ${CSS_WARN_BYTES / 1024}KB (warning): ${r.url}`,
258
+ severity: 'warning', url,
259
+ });
260
+ }
261
+ }
262
+
263
+ // ── Summary — always emitted ──────────────────────────────────────────────
264
+ findings.push({
265
+ type: 'perf_vitals_summary',
266
+ lcp: lcp ?? null,
267
+ cls: cls ?? null,
268
+ fcp: fcp ?? null,
269
+ tti: tti ?? null,
270
+ ttfb: ttfb ?? null,
271
+ bundleCount: resources.length,
272
+ message: `Web Vitals: LCP=${lcp ?? 'N/A'}ms CLS=${cls ?? 'N/A'} FCP=${fcp ?? 'N/A'}ms TTI=${tti ?? 'N/A'}ms TTFB=${ttfb ?? 'N/A'}ms`,
273
+ severity: 'info',
274
+ url,
275
+ });
276
+
277
+ return findings;
278
+ }
279
+
280
+ // ── Self-registration ─────────────────────────────────────────────────────────
281
+ registerExpensive({
282
+ name: 'web-vitals',
283
+ analyze: (browser, url) => analyzeWebVitals(browser, url),
284
+ });