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.
- package/README.md +1100 -1089
- package/glama.json +36 -32
- package/package.json +1 -1
- package/src/adapters/browser.js +5 -4
- package/src/adapters/figma.js +336 -0
- package/src/config/targets.js +4 -0
- package/src/domain/finding.js +16 -1
- package/src/mcp-server.js +54 -3
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/orchestrator.js +36 -24
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +2 -1
- package/src/registry.js +1 -1
- package/src/utils/css-analyzer.js +7 -0
- package/src/utils/design-fidelity-analyzer.js +685 -0
- package/src/utils/flow-runner.js +2 -0
- package/src/utils/mcp-client.js +2 -17
- package/src/utils/retry.js +1 -1
- package/src/utils/session-persistence.js +16 -4
- package/src/utils/theme-analyzer.js +173 -0
- package/src/utils/visual-diff-analyzer.js +207 -0
- package/src/utils/web-vitals-analyzer.js +284 -0
|
@@ -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
|
+
});
|