argusqa-os 9.5.3 → 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/glama.json CHANGED
@@ -1,36 +1,36 @@
1
- {
2
- "$schema": "https://glama.ai/mcp/schemas/server.json",
3
- "name": "argus",
4
- "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 7 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types). 128 test blocks, 565 hard assertions, 56 detection categories.",
5
- "maintainers": ["ironclawdevs27"],
6
- "tools": [
7
- {
8
- "name": "argus_audit",
9
- "description": "Fast QA audit — JS errors, network failures (4xx/5xx), API frequency loops, CSS cascade issues, SEO violations, security headers, accessibility, and content. Returns { findings, summary }. Supports cache: true to skip re-crawl on repeat calls."
10
- },
11
- {
12
- "name": "argus_audit_full",
13
- "description": "Deep QA audit — extends argus_audit with Lighthouse performance/accessibility scoring, responsive layout checks at 4 viewports, memory leak detection via heap snapshot, and accessibility tree analysis."
14
- },
15
- {
16
- "name": "argus_compare",
17
- "description": "Diffs dev vs staging environments side-by-side. Captures screenshots, runs all analyzers on each, and surfaces regressions — findings present in staging but not dev, or with changed severity."
18
- },
19
- {
20
- "name": "argus_last_report",
21
- "description": "Returns the most recent Argus JSON report from the reports/ directory without re-running a scan."
22
- },
23
- {
24
- "name": "argus_watch_snapshot",
25
- "description": "Snapshots the currently open Chrome tab without navigating — captures console errors, network failures, CORS blocks, and auth failures in one poll. Accepts optional tabId to inspect a specific tab."
26
- },
27
- {
28
- "name": "argus_get_context",
29
- "description": "LLM-optimized diagnostic context for the open Chrome tab. Returns snapshot_id for fix-loop diffing: pass it back on the next call to get resolved/new_issues/persisting arrays. Accepts optional tabId for multi-tab workflows."
30
- },
31
- {
32
- "name": "argus_design_audit",
33
- "description": "Full Figma design-to-implementation fidelity audit. Fetches design spec from a Figma frame URL (requires FIGMA_API_TOKEN) and compares every extracted property against live DOM computed styles. Detects 13 mismatch finding types: CSS token values, component presence, fill/text color (RGB distance), typography (fontSize/fontWeight/lineHeight/fontFamily/letterSpacing), Auto Layout padding and gap, border-radius (per-corner), bounding-box overflow, absolute position drift (scroll-corrected x/y vs Figma bounds), border stroke (color+weight), box-shadow (offset+blur+spread+color), opacity, and text content. Selector fallback: tries [data-testid], [aria-label], #id, .class per node."
34
- }
35
- ]
36
- }
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "name": "argus",
4
+ "description": "AI-powered QA harness that audits web apps via Chrome DevTools Protocol. Catches JS errors, network failures, a11y violations, SEO issues, security headers, CSS regressions, and more — directly from Claude conversations. 7 MCP tools: argus_audit (fast 8-analyzer pass), argus_audit_full (Lighthouse + memory + responsive), argus_compare (dev vs staging diff), argus_last_report (retrieve last JSON report), argus_watch_snapshot (live tab snapshot without navigating), argus_get_context (LLM-optimized context + fix loop with snapshot_id diff), argus_design_audit (Figma design fidelity — 13 finding types). 130 test blocks, 581 hard assertions, 58 detection categories.",
5
+ "maintainers": ["ironclawdevs27"],
6
+ "tools": [
7
+ {
8
+ "name": "argus_audit",
9
+ "description": "Fast QA audit — JS errors, network failures (4xx/5xx), API frequency loops, CSS cascade issues, SEO violations, security headers, accessibility, and content. Returns { findings, summary }. Supports cache: true to skip re-crawl on repeat calls."
10
+ },
11
+ {
12
+ "name": "argus_audit_full",
13
+ "description": "Deep QA audit — extends argus_audit with Lighthouse performance/accessibility scoring, responsive layout checks at 4 viewports, memory leak detection via heap snapshot, and accessibility tree analysis."
14
+ },
15
+ {
16
+ "name": "argus_compare",
17
+ "description": "Diffs dev vs staging environments side-by-side. Captures screenshots, runs all analyzers on each, and surfaces regressions — findings present in staging but not dev, or with changed severity."
18
+ },
19
+ {
20
+ "name": "argus_last_report",
21
+ "description": "Returns the most recent Argus JSON report from the reports/ directory without re-running a scan."
22
+ },
23
+ {
24
+ "name": "argus_watch_snapshot",
25
+ "description": "Snapshots the currently open Chrome tab without navigating — captures console errors, network failures, CORS blocks, and auth failures in one poll. Accepts optional tabId to inspect a specific tab."
26
+ },
27
+ {
28
+ "name": "argus_get_context",
29
+ "description": "LLM-optimized diagnostic context for the open Chrome tab. Returns snapshot_id for fix-loop diffing: pass it back on the next call to get resolved/new_issues/persisting arrays. Accepts optional tabId for multi-tab workflows."
30
+ },
31
+ {
32
+ "name": "argus_design_audit",
33
+ "description": "Full Figma design-to-implementation fidelity audit. Fetches design spec from a Figma frame URL (requires FIGMA_API_TOKEN) and compares every extracted property against live DOM computed styles. Detects 13 mismatch finding types: CSS token values, component presence, fill/text color (RGB distance), typography (fontSize/fontWeight/lineHeight/fontFamily/letterSpacing), Auto Layout padding and gap, border-radius (per-corner), bounding-box overflow, absolute position drift (scroll-corrected x/y vs Figma bounds), border stroke (color+weight), box-shadow (offset+blur+spread+color), opacity, and text content. Selector fallback: tries [data-testid], [aria-label], #id, .class per node."
34
+ }
35
+ ]
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.5.3",
3
+ "version": "9.5.5",
4
4
  "mcpName": "io.github.ironclawdevs27/argus",
5
5
  "description": "Argus — AI-powered automated dev-testing platform using Chrome DevTools MCP and Claude Code",
6
6
  "keywords": [
@@ -64,6 +64,10 @@ export const thresholds = {
64
64
  seo: { critical: 50, warning: 90 },
65
65
  'best-practices': { critical: 50, warning: 90 },
66
66
  },
67
+ visual: {
68
+ warnPercent: parseFloat(process.env.VISUAL_WARN_PERCENT ?? '0.1'), // % pixels changed → warning
69
+ critPercent: parseFloat(process.env.VISUAL_CRIT_PERCENT ?? '5.0'), // % pixels changed → critical
70
+ },
67
71
  };
68
72
 
69
73
  /**
package/src/mcp-server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Argus MCP Server (v9.5.3)
3
+ * Argus MCP Server (v9.5.5)
4
4
  *
5
5
  * Exposes Argus as an MCP server so Claude (or any MCP client) can call
6
6
  * argus_audit, argus_audit_full, argus_compare, argus_last_report, and
@@ -336,7 +336,7 @@ async function handleLastReport() {
336
336
  // ── Server bootstrap ──────────────────────────────────────────────────────────
337
337
 
338
338
  const server = new Server(
339
- { name: 'argus', version: '9.5.3' },
339
+ { name: 'argus', version: '9.5.5' },
340
340
  { capabilities: { tools: {} } },
341
341
  );
342
342
 
@@ -43,6 +43,8 @@ import '../utils/snapshot-analyzer.js';
43
43
  import '../utils/keyboard-analyzer.js';
44
44
  import '../utils/theme-analyzer.js';
45
45
  import '../utils/design-fidelity-analyzer.js';
46
+ import '../utils/web-vitals-analyzer.js';
47
+ import '../utils/visual-diff-analyzer.js';
46
48
 
47
49
  import { getExpensive } from '../registry.js';
48
50
  import { deduplicateFindings as deduplicateErrors } from './report-processor.js';
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ARGUS Visual Regression Analyzer (Sprint 3 — A8)
3
+ *
4
+ * Per-route visual regression detection via screenshot baseline comparison.
5
+ * Takes a PNG screenshot, compares it pixel-by-pixel against a stored baseline,
6
+ * and emits a finding when the diff exceeds the configured threshold.
7
+ *
8
+ * Works in headless Chrome — uses the Performance API screenshot path, not Lighthouse.
9
+ *
10
+ * Findings emitted:
11
+ * visual_baseline_created — info, first run for a URL (baseline saved, no prior exists)
12
+ * visual_regression — warning ≥0.1%, critical ≥5% pixels changed
13
+ * visual_diff_summary — info, always emitted with full diff metrics
14
+ *
15
+ * Baseline storage: {config.outputDir}/baselines/screenshots/{slug}.png
16
+ * Override via opts.baselineDir for testing.
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import os from 'os';
22
+ import { PNG } from 'pngjs';
23
+ import pixelmatch from 'pixelmatch';
24
+ import { registerExpensive } from '../registry.js';
25
+ import { childLogger } from './logger.js';
26
+ import { slugify } from './slug.js';
27
+ import { config, thresholds } from '../config/targets.js';
28
+
29
+ const logger = childLogger('visual-diff');
30
+
31
+ // ── Thresholds ─────────────────────────────────────────────────────────────────
32
+ const WARN_PERCENT = thresholds.visual?.warnPercent ?? 0.1; // %
33
+ const CRIT_PERCENT = thresholds.visual?.critPercent ?? 5.0; // %
34
+
35
+ // ── PNG helpers ────────────────────────────────────────────────────────────────
36
+
37
+ function cropPng(img, width, height) {
38
+ if (img.width === width && img.height === height) return img;
39
+ const out = new PNG({ width, height });
40
+ for (let y = 0; y < height; y++) {
41
+ for (let x = 0; x < width; x++) {
42
+ const src = (y * img.width + x) * 4;
43
+ const dst = (y * width + x) * 4;
44
+ out.data[dst] = img.data[src];
45
+ out.data[dst + 1] = img.data[src + 1];
46
+ out.data[dst + 2] = img.data[src + 2];
47
+ out.data[dst + 3] = img.data[src + 3];
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Compare two PNG Buffers pixel-by-pixel using pixelmatch.
55
+ *
56
+ * @param {Buffer} bufA
57
+ * @param {Buffer} bufB
58
+ * @returns {{ diffPixels: number, totalPixels: number, diffPercent: number }}
59
+ */
60
+ function comparePngBuffers(bufA, bufB) {
61
+ const imgA = PNG.sync.read(bufA);
62
+ const imgB = PNG.sync.read(bufB);
63
+
64
+ const width = Math.min(imgA.width, imgB.width);
65
+ const height = Math.min(imgA.height, imgB.height);
66
+
67
+ if (width === 0 || height === 0) {
68
+ throw new Error(`visual-diff: zero-dimension PNG (${imgA.width}×${imgA.height} vs ${imgB.width}×${imgB.height})`);
69
+ }
70
+
71
+ const croppedA = cropPng(imgA, width, height);
72
+ const croppedB = cropPng(imgB, width, height);
73
+ const diff = new PNG({ width, height });
74
+
75
+ const diffPixels = pixelmatch(croppedA.data, croppedB.data, diff.data, width, height, { threshold: 0.1 });
76
+ const totalPixels = width * height;
77
+ const diffPercent = (diffPixels / totalPixels) * 100;
78
+
79
+ return { diffPixels, totalPixels, diffPercent };
80
+ }
81
+
82
+ // ── Public API ─────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Capture a screenshot of `url` and compare against the stored baseline.
86
+ *
87
+ * First run (no baseline): saves the screenshot as the new baseline and returns
88
+ * a `visual_baseline_created` info finding.
89
+ *
90
+ * Subsequent runs: compares pixel-by-pixel and emits `visual_regression` when
91
+ * the diff exceeds the threshold, plus always emits `visual_diff_summary`.
92
+ *
93
+ * @param {object} browser - CdpBrowserAdapter
94
+ * @param {string} url - Page URL (already loaded)
95
+ * @param {object} [opts]
96
+ * @param {string} [opts.baselineDir] - Override baseline storage directory
97
+ * @returns {Promise<object[]>}
98
+ */
99
+ export async function analyzeVisualRegression(browser, url, opts = {}) {
100
+ const findings = [];
101
+
102
+ // ── 1. Take screenshot ──────────────────────────────────────────────────────
103
+ // Use filePath so the MCP server writes the PNG to disk — take_screenshot
104
+ // returns an image content block, not { data: base64 }, so the filePath
105
+ // approach is the only reliable way to get raw PNG bytes in headless mode.
106
+ const tmpPath = path.join(os.tmpdir(), `argus-visual-${Date.now()}-${slugify(url)}.png`);
107
+ try {
108
+ await browser.screenshot({ format: 'png', filePath: tmpPath });
109
+ } catch (err) {
110
+ logger.warn(`[ARGUS] visual-diff: screenshot failed for ${url}: ${err.message}`);
111
+ return findings;
112
+ }
113
+
114
+ let currentBuf;
115
+ try {
116
+ currentBuf = fs.readFileSync(tmpPath);
117
+ } catch (err) {
118
+ logger.warn(`[ARGUS] visual-diff: could not read screenshot from ${tmpPath}: ${err.message}`);
119
+ return findings;
120
+ } finally {
121
+ try { fs.unlinkSync(tmpPath); } catch {}
122
+ }
123
+
124
+ if (!currentBuf || currentBuf.length === 0) {
125
+ logger.warn(`[ARGUS] visual-diff: empty screenshot for ${url}`);
126
+ return findings;
127
+ }
128
+
129
+ // ── 2. Resolve baseline path ────────────────────────────────────────────────
130
+ const baselineDir = opts.baselineDir ??
131
+ path.join(config.outputDir, 'baselines', 'screenshots');
132
+
133
+ try {
134
+ fs.mkdirSync(baselineDir, { recursive: true });
135
+ } catch (err) {
136
+ logger.warn(`[ARGUS] visual-diff: could not create baseline dir ${baselineDir}: ${err.message}`);
137
+ return findings;
138
+ }
139
+
140
+ const slug = slugify(url);
141
+ const baselinePath = path.join(baselineDir, `${slug}.png`);
142
+
143
+ // ── 3. First run: save baseline ─────────────────────────────────────────────
144
+ if (!fs.existsSync(baselinePath)) {
145
+ try {
146
+ fs.writeFileSync(baselinePath, currentBuf);
147
+ } catch (err) {
148
+ logger.warn(`[ARGUS] visual-diff: could not write baseline ${baselinePath}: ${err.message}`);
149
+ return findings;
150
+ }
151
+
152
+ findings.push({
153
+ type: 'visual_baseline_created',
154
+ message: `Visual baseline saved for ${url} — next run will compare against this snapshot`,
155
+ severity: 'info',
156
+ url,
157
+ baselinePath,
158
+ });
159
+ return findings;
160
+ }
161
+
162
+ // ── 4. Compare against existing baseline ────────────────────────────────────
163
+ let result;
164
+ try {
165
+ const baselineBuf = fs.readFileSync(baselinePath);
166
+ result = comparePngBuffers(baselineBuf, currentBuf);
167
+ } catch (err) {
168
+ logger.warn(`[ARGUS] visual-diff: comparison failed for ${url}: ${err.message}`);
169
+ return findings;
170
+ }
171
+
172
+ const { diffPixels, totalPixels, diffPercent } = result;
173
+
174
+ // ── 5. Emit regression finding if threshold exceeded ────────────────────────
175
+ if (diffPercent >= WARN_PERCENT) {
176
+ const sev = diffPercent >= CRIT_PERCENT ? 'critical' : 'warning';
177
+ findings.push({
178
+ type: 'visual_regression',
179
+ diffPercent: parseFloat(diffPercent.toFixed(3)),
180
+ diffPixels,
181
+ totalPixels,
182
+ threshold: WARN_PERCENT,
183
+ message: `Visual regression: ${diffPercent.toFixed(2)}% pixels changed — threshold ${WARN_PERCENT}% (warning) / ${CRIT_PERCENT}% (critical)`,
184
+ severity: sev,
185
+ url,
186
+ });
187
+ }
188
+
189
+ // ── 6. Summary — always emitted ─────────────────────────────────────────────
190
+ findings.push({
191
+ type: 'visual_diff_summary',
192
+ diffPercent: parseFloat(diffPercent.toFixed(3)),
193
+ diffPixels,
194
+ totalPixels,
195
+ message: `Visual diff: ${diffPercent.toFixed(3)}% (${diffPixels}/${totalPixels} pixels changed)`,
196
+ severity: 'info',
197
+ url,
198
+ });
199
+
200
+ return findings;
201
+ }
202
+
203
+ // ── Self-registration ──────────────────────────────────────────────────────────
204
+ registerExpensive({
205
+ name: 'visual',
206
+ analyze: (browser, url) => analyzeVisualRegression(browser, url),
207
+ });
@@ -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
+ });