argusqa-os 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.mcp.json +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +879 -0
  4. package/package.json +69 -0
  5. package/src/adapters/browser.js +82 -0
  6. package/src/argus.js +8 -0
  7. package/src/batch-runner.js +8 -0
  8. package/src/cli/init.js +314 -0
  9. package/src/config/schema.js +108 -0
  10. package/src/config/targets.js +309 -0
  11. package/src/domain/finding.js +25 -0
  12. package/src/mcp-server.js +156 -0
  13. package/src/orchestration/crawl-and-report.js +16 -0
  14. package/src/orchestration/dispatcher.js +263 -0
  15. package/src/orchestration/env-comparison.js +498 -0
  16. package/src/orchestration/orchestrator.js +1128 -0
  17. package/src/orchestration/report-processor.js +134 -0
  18. package/src/orchestration/slack-notifier.js +337 -0
  19. package/src/orchestration/watch-mode.js +316 -0
  20. package/src/registry.js +18 -0
  21. package/src/server/index.js +94 -0
  22. package/src/server/interaction-handler.js +126 -0
  23. package/src/server/slash-command-handler.js +185 -0
  24. package/src/utils/api-frequency.js +128 -0
  25. package/src/utils/baseline-manager.js +255 -0
  26. package/src/utils/codebase-analyzer.js +299 -0
  27. package/src/utils/content-analyzer.js +155 -0
  28. package/src/utils/contract-validator.js +178 -0
  29. package/src/utils/css-analyzer.js +407 -0
  30. package/src/utils/diff.js +189 -0
  31. package/src/utils/flakiness-detector.js +82 -0
  32. package/src/utils/flow-runner.js +572 -0
  33. package/src/utils/github-reporter.js +310 -0
  34. package/src/utils/hover-analyzer.js +214 -0
  35. package/src/utils/html-reporter.js +301 -0
  36. package/src/utils/issues-analyzer.js +171 -0
  37. package/src/utils/keyboard-analyzer.js +141 -0
  38. package/src/utils/lighthouse-checker.js +120 -0
  39. package/src/utils/logger.js +39 -0
  40. package/src/utils/login-orchestrator.js +99 -0
  41. package/src/utils/mcp-client.js +264 -0
  42. package/src/utils/mcp-parsers.js +57 -0
  43. package/src/utils/memory-analyzer.js +270 -0
  44. package/src/utils/network-timing-analyzer.js +76 -0
  45. package/src/utils/parallel-crawler.js +28 -0
  46. package/src/utils/responsive-analyzer.js +253 -0
  47. package/src/utils/retry.js +36 -0
  48. package/src/utils/route-discoverer.js +306 -0
  49. package/src/utils/security-analyzer.js +302 -0
  50. package/src/utils/seo-analyzer.js +164 -0
  51. package/src/utils/session-manager.js +12 -0
  52. package/src/utils/session-persistence.js +214 -0
  53. package/src/utils/severity-overrides.js +91 -0
  54. package/src/utils/slack-guard.js +18 -0
  55. package/src/utils/slug.js +8 -0
  56. package/src/utils/snapshot-analyzer.js +330 -0
  57. package/src/utils/telemetry.js +190 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * ARGUS Memory Analyzer (v3 Phase B1)
3
+ *
4
+ * Two detection surfaces:
5
+ * 1. Detached DOM nodes — via take_memory_snapshot (saves snapshot to disk, parsed here)
6
+ * Nodes removed from the live DOM but still referenced in JS are retained
7
+ * in the V8 heap as "Detached HTMLXxx" objects, causing memory pressure.
8
+ * 2. Heap size growth — via performance.memory across navigate-away + navigate-back
9
+ * Significant heap growth after a round-trip indicates a per-load leak.
10
+ *
11
+ * Called as a standalone function after crawlRoute, like analyzeResponsive.
12
+ * The function always leaves the browser navigated to the target URL.
13
+ *
14
+ * Note: take_memory_snapshot requires a filePath argument — it writes the V8
15
+ * heap snapshot JSON to disk. We read and parse it, then delete the temp file.
16
+ */
17
+
18
+ import fs from 'fs';
19
+ import os from 'os';
20
+ import path from 'path';
21
+ import { unwrapEval } from './mcp-client.js';
22
+ import { registerExpensive } from '../registry.js';
23
+ import { thresholds } from '../config/targets.js';
24
+ import { childLogger } from './logger.js';
25
+
26
+ const logger = childLogger('memory-analyzer');
27
+
28
+ // ── Scripts ────────────────────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Reads performance.memory from the page context.
32
+ * Chrome-only non-standard API, always present in Chrome/headless Chrome.
33
+ */
34
+ const HEAP_SIZE_SCRIPT = `() => {
35
+ var m = window.performance && window.performance.memory;
36
+ if (!m) return JSON.stringify({ usedJSHeapSize: null });
37
+ return JSON.stringify({
38
+ usedJSHeapSize: m.usedJSHeapSize,
39
+ totalJSHeapSize: m.totalJSHeapSize,
40
+ jsHeapSizeLimit: m.jsHeapSizeLimit,
41
+ });
42
+ }`;
43
+
44
+ // ── Snapshot Parsing ───────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Walk a flat V8 heap snapshot nodes array and count detached DOM nodes.
48
+ *
49
+ * The nodes array is a flat int array: each record has nodeFields.length entries.
50
+ * The "name" field indexes into the strings table; Chrome serializes detached
51
+ * DOM elements with "Detached " prepended to their class name (e.g.
52
+ * "Detached HTMLDivElement"). If the `detachedness` field is present (Chrome 90+),
53
+ * value 2 = detached — checked as a secondary signal.
54
+ *
55
+ * @param {object} snapshot - Parsed V8 heap snapshot ({ snapshot, nodes, strings })
56
+ * @returns {{ detachedNodeCount: number, totalNodeCount: number|null }}
57
+ */
58
+ function parseV8Snapshot(snapshot) {
59
+ const strings = Array.isArray(snapshot.strings) ? snapshot.strings : [];
60
+ const meta = snapshot.snapshot?.meta ?? {};
61
+ const nodeFields = Array.isArray(meta.node_fields) ? meta.node_fields : [];
62
+
63
+ let detachedCount = 0;
64
+ const nameIdx = nodeFields.indexOf('name');
65
+ const detachednessIdx = nodeFields.indexOf('detachedness');
66
+
67
+ if (Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0 &&
68
+ (nameIdx !== -1 || detachednessIdx !== -1)) {
69
+ const stride = nodeFields.length;
70
+ const nodes = snapshot.nodes;
71
+
72
+ for (let i = 0; i < nodes.length; i += stride) {
73
+ // Primary: "Detached " prefix in node name string (all Chrome versions)
74
+ if (nameIdx !== -1) {
75
+ const strIdx = nodes[i + nameIdx];
76
+ if (strIdx < strings.length && /^Detached /.test(strings[strIdx])) {
77
+ detachedCount++;
78
+ continue;
79
+ }
80
+ }
81
+ // Secondary: detachedness field value 2 = detached (Chrome 90+)
82
+ if (detachednessIdx !== -1 && nodes[i + detachednessIdx] === 2) {
83
+ detachedCount++;
84
+ }
85
+ }
86
+ } else if (strings.length > 0) {
87
+ // Structural fields not found — use string presence as a lower-bound indicator
88
+ detachedCount = strings.filter(s => /^Detached (HTML|SVG|Text|Document)/.test(s)).length;
89
+ }
90
+
91
+ return {
92
+ detachedNodeCount: detachedCount,
93
+ totalNodeCount: snapshot.snapshot?.node_count ?? null,
94
+ };
95
+ }
96
+
97
+ // ── Heap Size Helper ───────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Read usedJSHeapSize from the currently-loaded page via evaluate_script.
101
+ * Returns null if performance.memory is unavailable.
102
+ */
103
+ async function getHeapSize(browser) {
104
+ try {
105
+ const raw = await browser.evaluate(HEAP_SIZE_SCRIPT);
106
+ const val = unwrapEval(raw);
107
+ const parsed = typeof val === 'string' ? JSON.parse(val) : val;
108
+ return typeof parsed?.usedJSHeapSize === 'number' ? parsed.usedJSHeapSize : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ // ── Snapshot Capture ───────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Take a V8 heap snapshot, save it to a temp file, parse it, and delete the file.
118
+ * take_memory_snapshot writes the snapshot JSON to disk (filePath is required).
119
+ *
120
+ * @param {object} browser - CdpBrowserAdapter
121
+ * @returns {Promise<{ detachedNodeCount: number, totalNodeCount: number|null } | null>}
122
+ */
123
+ async function captureAndParseSnapshot(browser) {
124
+ // Include PID + random suffix — Date.now() alone can collide when two parallel
125
+ // shard workers both enter captureAndParseSnapshot within the same millisecond.
126
+ const filePath = path.join(os.tmpdir(), `argus-heap-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.heapsnapshot`);
127
+ try {
128
+ await browser.heapSnapshot({ filePath });
129
+
130
+ let raw;
131
+ try {
132
+ raw = await fs.promises.readFile(filePath, 'utf8');
133
+ } catch (readErr) {
134
+ if (readErr.code === 'ENOENT') {
135
+ logger.warn(`[ARGUS] Snapshot file not written at ${filePath}`);
136
+ return null;
137
+ }
138
+ throw readErr;
139
+ }
140
+ const snapshot = JSON.parse(raw);
141
+ return parseV8Snapshot(snapshot);
142
+ } catch (err) {
143
+ logger.warn(`[ARGUS] Snapshot capture/parse error: ${err.message}`);
144
+ return null;
145
+ } finally {
146
+ try { fs.unlinkSync(filePath); } catch { /* best-effort cleanup */ }
147
+ }
148
+ }
149
+
150
+ // ── Main Analysis ──────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Analyse a URL for memory leaks.
154
+ *
155
+ * Detection 1 — Detached DOM nodes (hard, deterministic):
156
+ * Navigate to url → wait → take_memory_snapshot to disk → parse for "Detached Xxx" nodes.
157
+ * Detached nodes are DOM elements removed from the live tree but still referenced
158
+ * in JS (e.g. stashed in a closure, array, or event handler), preventing GC.
159
+ *
160
+ * Detection 2 — Heap growth across navigate-away + back (soft, GC-dependent):
161
+ * Record baseline heap size → navigate to awayUrl → wait → navigate back → compare.
162
+ * A growing heap across identical page loads indicates a per-load leak.
163
+ * Tagged with soft:true so the harness can treat them as non-blocking.
164
+ *
165
+ * The function always ends with the browser on `url`.
166
+ *
167
+ * @param {object} browser - CdpBrowserAdapter
168
+ * @param {string} url - URL to analyse
169
+ * @param {string} [awayUrl='about:blank'] - Neutral URL for the navigate-away step
170
+ * @returns {Promise<object[]>} Memory findings (same shape as crawlRoute errors)
171
+ */
172
+ export async function analyzeMemory(browser, url, awayUrl = 'about:blank') {
173
+ const findings = [];
174
+
175
+ // ── 1. Navigate to the target page ──────────────────────────────────────────
176
+ try {
177
+ await browser.navigate(url);
178
+ await new Promise(r => setTimeout(r, 1500)); // let JS run, detached nodes accumulate
179
+ } catch (err) {
180
+ logger.warn(`[ARGUS] Memory analysis navigation failed for ${url}: ${err.message}`);
181
+ return findings;
182
+ }
183
+
184
+ // ── 2. Detached DOM node detection via heap snapshot ─────────────────────────
185
+ try {
186
+ const parsed = await captureAndParseSnapshot(browser);
187
+
188
+ if (parsed !== null) {
189
+ const { detachedNodeCount: count, totalNodeCount } = parsed;
190
+
191
+ if (count > thresholds.memory.detachedCritical) {
192
+ findings.push({
193
+ type: 'memory_detached_dom_nodes',
194
+ count,
195
+ totalNodes: totalNodeCount,
196
+ message: `${count} detached DOM node(s) in heap — severe leak (threshold: >${thresholds.memory.detachedCritical})`,
197
+ severity: 'critical',
198
+ url,
199
+ });
200
+ } else if (count > thresholds.memory.detachedWarning) {
201
+ findings.push({
202
+ type: 'memory_detached_dom_nodes',
203
+ count,
204
+ totalNodes: totalNodeCount,
205
+ message: `${count} detached DOM node(s) in heap — probable leak (threshold: >${thresholds.memory.detachedWarning})`,
206
+ severity: 'warning',
207
+ url,
208
+ });
209
+ }
210
+ }
211
+ } catch (err) {
212
+ logger.warn(`[ARGUS] Detached node detection skipped for ${url}: ${err.message}`);
213
+ }
214
+
215
+ // ── 3. Heap growth — navigate-away + navigate-back ────────────────────────────
216
+ // GC timing makes this non-deterministic; findings are tagged soft:true.
217
+ try {
218
+ const baseline = await getHeapSize(browser);
219
+
220
+ if (baseline !== null) {
221
+ await browser.navigate(awayUrl);
222
+ await new Promise(r => setTimeout(r, 2000)); // allow GC pass
223
+
224
+ await browser.navigate(url);
225
+ await new Promise(r => setTimeout(r, 1500));
226
+
227
+ const post = await getHeapSize(browser);
228
+
229
+ if (post !== null) {
230
+ const growth = post - baseline;
231
+
232
+ if (growth > thresholds.memory.heapGrowthCritical) {
233
+ findings.push({
234
+ type: 'memory_heap_growth',
235
+ baselineBytes: baseline,
236
+ postBytes: post,
237
+ growthBytes: growth,
238
+ message: `Heap grew ${Math.round(growth / 1024)} KB after navigate-away + back — probable leak (baseline ${Math.round(baseline / 1024)} KB → post ${Math.round(post / 1024)} KB)`,
239
+ severity: 'critical',
240
+ soft: true,
241
+ url,
242
+ });
243
+ } else if (growth > thresholds.memory.heapGrowthWarning) {
244
+ findings.push({
245
+ type: 'memory_heap_growth',
246
+ baselineBytes: baseline,
247
+ postBytes: post,
248
+ growthBytes: growth,
249
+ message: `Heap grew ${Math.round(growth / 1024)} KB after navigate-away + back (baseline ${Math.round(baseline / 1024)} KB → post ${Math.round(post / 1024)} KB)`,
250
+ severity: 'warning',
251
+ soft: true,
252
+ url,
253
+ });
254
+ }
255
+ }
256
+ }
257
+ } catch (err) {
258
+ logger.warn(`[ARGUS] Heap growth check skipped for ${url}: ${err.message}`);
259
+ }
260
+
261
+ return findings;
262
+ }
263
+
264
+ // ── Self-registration ─────────────────────────────────────────────────────────
265
+ registerExpensive({
266
+ name: 'memory',
267
+ async analyze(browser, url) {
268
+ return analyzeMemory(browser, url);
269
+ },
270
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ARGUS Network HAR Timing Analyzer
3
+ *
4
+ * Reads per-request TTFB (timing.wait) from list_network_requests HAR output.
5
+ * Detects third-party resources that block page load — cross-origin requests
6
+ * have 0ms in window.performance.getEntriesByType('resource') when the server
7
+ * omits Timing-Allow-Origin, so PerformanceResourceTiming (NETWORK_PERF_SCRIPT)
8
+ * is blind to them. Chrome DevTools HAR timing is always accurate.
9
+ *
10
+ * Same-origin slow requests are covered by the existing NETWORK_PERF_SCRIPT
11
+ * approach in crawlRouteExpensive — this module focuses exclusively on cross-
12
+ * origin (third-party) blocking resources.
13
+ *
14
+ * Detections:
15
+ * slow_third_party_blocking — cross-origin resource with timing.wait > 2000 ms
16
+ */
17
+
18
+ // Cross-origin slow-resource threshold (ms)
19
+ const THIRD_PARTY_WARNING_MS = 2000;
20
+
21
+ // Static asset extensions — focus analysis on scripts/XHR/fetch, not images/fonts
22
+ const STATIC_ASSET_EXT = /\.(png|jpg|jpeg|gif|webp|avif|svg|ico|woff2?|ttf|otf|eot)(\?.*)?$/i;
23
+
24
+ /**
25
+ * Extract wait time (ms) from a HAR timing object.
26
+ * HAR 1.2 uses req.timings.wait; chrome-devtools-mcp uses req.timing.wait.
27
+ */
28
+ function getWaitMs(req) {
29
+ if (req.timing && typeof req.timing.wait === 'number') return req.timing.wait;
30
+ if (req.timings && typeof req.timings.wait === 'number') return req.timings.wait;
31
+ // req.time/req.duration represent total request duration (not TTFB) — omitted to
32
+ // avoid flagging large-payload responses as slow when server response was fast.
33
+ return null;
34
+ }
35
+
36
+ function isStaticAsset(url) {
37
+ try { return STATIC_ASSET_EXT.test(new URL(url).pathname); } catch { return false; }
38
+ }
39
+
40
+ function isSameOrigin(reqUrl, pageUrl) {
41
+ try { return new URL(reqUrl).origin === new URL(pageUrl).origin; } catch { return true; }
42
+ }
43
+
44
+ /**
45
+ * Pure: analyse sliced list_network_requests results for slow third-party resources.
46
+ *
47
+ * @param {object[]} reqs - sliced array of network request HAR objects
48
+ * @param {string} pageUrl - URL of the page being analysed (for origin comparison)
49
+ * @returns {object[]} - array of slow_third_party_blocking finding objects
50
+ */
51
+ export function parseNetworkTiming(reqs, pageUrl) {
52
+ const findings = [];
53
+ for (const req of reqs) {
54
+ if (!req.url) continue;
55
+ if (isStaticAsset(req.url)) continue;
56
+ if (isSameOrigin(req.url, pageUrl)) continue; // covered by NETWORK_PERF_SCRIPT
57
+
58
+ // Skip failed/aborted requests — status errors are caught by classifyNetworkRequest
59
+ const status = req.status ?? 0;
60
+ if (status === 0) continue; // aborted or in-flight — timing fields are unreliable
61
+ if (status < 200 || status >= 400) continue;
62
+
63
+ const waitMs = getWaitMs(req);
64
+ if (waitMs == null || waitMs < THIRD_PARTY_WARNING_MS) continue;
65
+
66
+ findings.push({
67
+ type: 'slow_third_party_blocking',
68
+ requestUrl: req.url,
69
+ waitMs: Math.round(waitMs),
70
+ message: `Slow third-party resource: ${req.method ?? 'GET'} ${req.url} — ${Math.round(waitMs)}ms server wait may block page render (threshold: ${THIRD_PARTY_WARNING_MS}ms)`,
71
+ severity: 'warning',
72
+ url: pageUrl,
73
+ });
74
+ }
75
+ return findings;
76
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Argus D7.3 — Parallel route crawling: chunkArray utility.
3
+ * Exported separately so test-harness/validate.js can exercise it without
4
+ * importing crawl-and-report.js (which has heavyweight module-level side effects).
5
+ */
6
+
7
+ /**
8
+ * Split arr into at most n non-empty chunks of roughly equal size.
9
+ *
10
+ * Uses ceiling division so earlier chunks are at most 1 element larger than
11
+ * later ones. If arr.length < n only arr.length chunks are returned (no empty
12
+ * chunks). If arr is empty, returns [].
13
+ *
14
+ * @param {Array} arr - Source array (not mutated)
15
+ * @param {number} n - Target number of chunks (must be > 0)
16
+ * @returns {Array[]}
17
+ */
18
+ export function chunkArray(arr, n) {
19
+ // Validate inputs — arr.length throws on undefined; non-integer n produces
20
+ // fractional chunk sizes that silently skip elements or create unexpected extra chunks.
21
+ if (!Array.isArray(arr)) throw new TypeError('chunkArray: arr must be an array');
22
+ if (!Number.isInteger(n) || n <= 0) throw new RangeError('chunkArray: n must be a positive integer');
23
+ if (arr.length === 0) return [];
24
+ const size = Math.ceil(arr.length / Math.min(n, arr.length));
25
+ const chunks = [];
26
+ for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size));
27
+ return chunks;
28
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * ARGUS Responsive Analyzer (v3 Phase A6)
3
+ *
4
+ * Checks layout at four breakpoints: 375, 768, 1024, 1440 px
5
+ * - Horizontal overflow at ≤768 px → critical; at wider viewports → warning
6
+ * - Touch target size < 44×44 px at 375 px → warning
7
+ *
8
+ * Must be called as a standalone function — NOT inside crawlFixture/crawlRoute.
9
+ * Viewport changes would corrupt subsequent tests if called mid-pipeline.
10
+ * The function always restores the viewport to 1280×900 before returning.
11
+ */
12
+
13
+ import { registerExpensive } from '../registry.js';
14
+ import { childLogger } from './logger.js';
15
+
16
+ const logger = childLogger('responsive-analyzer');
17
+
18
+ const BREAKPOINTS = [
19
+ { width: 375, height: 812, label: 'mobile' },
20
+ { width: 768, height: 1024, label: 'tablet' },
21
+ { width: 1024, height: 768, label: 'laptop' },
22
+ { width: 1440, height: 900, label: 'desktop' },
23
+ ];
24
+
25
+ const RESTORE_VIEWPORT = { width: 1280, height: 900 };
26
+
27
+ /**
28
+ * Injected into the page to detect horizontal overflow.
29
+ *
30
+ * Uses clientWidth (visual viewport, excludes scrollbar) rather than window.innerWidth
31
+ * (layout viewport). With Chrome mobile emulation the layout viewport can be 952 px
32
+ * (legacy mobile default) even when the visual viewport is 375 px — clientWidth
33
+ * always reflects the correct visual width.
34
+ *
35
+ * Returns JSON string for safe serialisation through the MCP transport.
36
+ */
37
+ export const OVERFLOW_CHECK_SCRIPT = `() => JSON.stringify({
38
+ overflows: document.documentElement.scrollWidth > document.documentElement.clientWidth,
39
+ scrollWidth: document.documentElement.scrollWidth,
40
+ clientWidth: document.documentElement.clientWidth
41
+ })`;
42
+
43
+ /**
44
+ * Injected into the page at 375px to find interactive elements smaller than 44×44 px.
45
+ * Excludes hidden inputs and zero-size elements (e.g. display:none).
46
+ */
47
+ export const TOUCH_TARGET_SCRIPT = `() => {
48
+ var sel = 'button, a[href], input:not([type=hidden]), select, textarea, [role="button"], [onclick]';
49
+ var MIN = 44;
50
+ var small = [];
51
+ Array.prototype.forEach.call(document.querySelectorAll(sel), function(el) {
52
+ var r = el.getBoundingClientRect();
53
+ var w = Math.round(r.width);
54
+ var h = Math.round(r.height);
55
+ if (w > 0 && h > 0 && (w < MIN || h < MIN)) {
56
+ small.push({
57
+ tag: el.tagName.toLowerCase(),
58
+ id: el.id || null,
59
+ className: (el.className || '').slice(0, 60),
60
+ width: w,
61
+ height: h,
62
+ text: (el.innerText || el.value || '').slice(0, 40).trim()
63
+ });
64
+ }
65
+ });
66
+ return JSON.stringify(small);
67
+ }`;
68
+
69
+ /**
70
+ * Parse an evaluate_script result that should be a JSON object.
71
+ * Handles pre-parsed object, { result: ... } wrapper, or raw JSON string.
72
+ */
73
+ function parseEvalObject(raw) {
74
+ if (raw == null) return null;
75
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
76
+ const inner = raw.result !== undefined ? raw.result : raw;
77
+ if (typeof inner === 'object' && !Array.isArray(inner)) return inner;
78
+ if (typeof inner === 'string') {
79
+ // Log parse failures so a broken evaluate_script response is diagnosable.
80
+ try { return JSON.parse(inner); } catch (e) {
81
+ logger.warn('[ARGUS] parseEvalObject: JSON.parse failed —', e.message, '— raw type:', typeof raw);
82
+ return null;
83
+ }
84
+ }
85
+ }
86
+ if (typeof raw === 'string') {
87
+ try { return JSON.parse(raw); } catch (e) {
88
+ logger.warn('[ARGUS] parseEvalObject: JSON.parse failed —', e.message);
89
+ return null;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Parse an evaluate_script result that should be a JSON array.
97
+ */
98
+ function parseEvalArray(raw) {
99
+ if (raw == null) return [];
100
+ if (Array.isArray(raw)) return raw;
101
+ if (typeof raw === 'object') {
102
+ // Use raw as fallback (not raw.value) — MCP responses use .result consistently;
103
+ // .value is not part of the MCP wrapper schema.
104
+ const inner = raw.result !== undefined ? raw.result : raw;
105
+ if (Array.isArray(inner)) return inner;
106
+ if (typeof inner === 'string') {
107
+ // Log parse failures.
108
+ try { const p = JSON.parse(inner); return Array.isArray(p) ? p : []; } catch (e) {
109
+ logger.warn('[ARGUS] parseEvalArray: JSON.parse failed —', e.message, '— raw type:', typeof raw);
110
+ return [];
111
+ }
112
+ }
113
+ const vals = Object.values(raw);
114
+ if (vals.length === 1 && Array.isArray(vals[0])) return vals[0];
115
+ return [];
116
+ }
117
+ if (typeof raw === 'string') {
118
+ try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; } catch (e) {
119
+ logger.warn('[ARGUS] parseEvalArray: JSON.parse failed —', e.message);
120
+ return [];
121
+ }
122
+ }
123
+ return [];
124
+ }
125
+
126
+ /**
127
+ * Build the emulate viewport string for chrome-devtools-mcp's emulate tool.
128
+ * Format: '<width>x<height>x<dpr>[,mobile][,touch]'
129
+ * mobile+touch enables Emulation.setDeviceMetricsOverride with mobile=true.
130
+ * After emulation, window.innerWidth reflects the legacy layout viewport (~952px),
131
+ * NOT the device width. Use document.documentElement.clientWidth for the actual
132
+ * visual viewport width (see OVERFLOW_CHECK_SCRIPT and its comment above).
133
+ */
134
+ function viewportString(width, height) {
135
+ const mobile = width <= 768 ? ',mobile,touch' : '';
136
+ return `${width}x${height}x1${mobile}`;
137
+ }
138
+
139
+ /**
140
+ * Analyse a URL at four responsive breakpoints.
141
+ *
142
+ * Uses browser.emulate({ viewport }) rather than resize_page because resize_page only
143
+ * resizes the browser window — it does not update the CSS viewport that JS reads
144
+ * via window.innerWidth. emulate() calls Emulation.setDeviceMetricsOverride which
145
+ * properly sets both the layout viewport and window.innerWidth.
146
+ *
147
+ * @param {object} browser - CdpBrowserAdapter
148
+ * @param {string} url - Page URL to analyse
149
+ * @returns {Promise<{ findings: object[], screenshots: object }>}
150
+ * findings — array of responsive bug entries (same shape as crawlRoute errors)
151
+ * screenshots — map of "WIDTHxHEIGHT" → base64 PNG data
152
+ */
153
+ export async function analyzeResponsive(browser, url) {
154
+ const findings = [];
155
+ const screenshots = {};
156
+
157
+ try {
158
+ for (const bp of BREAKPOINTS) {
159
+ // Apply 4× CPU throttle for mobile/tablet breakpoints to expose JS-heavy
160
+ // layout issues that only manifest under realistic mobile device load.
161
+ // Reset to 1× for desktop breakpoints to avoid slowing subsequent analyses.
162
+ try {
163
+ await browser.emulateCpu(bp.width <= 768 ? 4 : 1);
164
+ } catch { /* emulateCpu is best-effort — proceed without throttle if unavailable */ }
165
+
166
+ try {
167
+ await browser.emulate(viewportString(bp.width, bp.height));
168
+ await browser.navigate(url);
169
+ await new Promise(r => setTimeout(r, 1000));
170
+
171
+ // ── Overflow check ──────────────────────────────────────────────────
172
+ try {
173
+ const raw = await browser.evaluate(OVERFLOW_CHECK_SCRIPT);
174
+ const overflowData = parseEvalObject(raw);
175
+
176
+ if (overflowData?.overflows) {
177
+ const isMobile = bp.width <= 768;
178
+ findings.push({
179
+ type: 'responsive_overflow',
180
+ viewport: bp.width,
181
+ label: bp.label,
182
+ scrollWidth: overflowData.scrollWidth,
183
+ clientWidth: overflowData.clientWidth,
184
+ message: `Horizontal overflow at ${bp.width}px (${bp.label}): scrollWidth ${overflowData.scrollWidth}px > viewport ${overflowData.clientWidth}px`,
185
+ severity: isMobile ? 'critical' : 'warning',
186
+ url,
187
+ });
188
+ }
189
+ } catch (err) {
190
+ logger.warn(`[ARGUS] Overflow check failed at ${bp.width}px: ${err.message}`);
191
+ }
192
+
193
+ // ── Touch target check — at 375 px (mobile) and 768 px (tablet) ──────
194
+ if (bp.width === 375 || bp.width === 768) {
195
+ try {
196
+ const raw = await browser.evaluate(TOUCH_TARGET_SCRIPT);
197
+ const smallTargets = parseEvalArray(raw);
198
+
199
+ if (smallTargets.length > 0) {
200
+ findings.push({
201
+ type: 'responsive_small_touch_target',
202
+ viewport: bp.width,
203
+ label: bp.label,
204
+ count: smallTargets.length,
205
+ targets: smallTargets.slice(0, 10),
206
+ message: `${smallTargets.length} interactive element(s) smaller than 44×44 px at ${bp.width}px (${bp.label}): ${
207
+ smallTargets.map(t => `<${t.tag}${t.id ? '#' + t.id : ''}> ${t.width}×${t.height}px`).join(', ')
208
+ }`,
209
+ severity: 'warning',
210
+ url,
211
+ });
212
+ }
213
+ } catch (err) {
214
+ logger.warn(`[ARGUS] Touch target check failed at ${bp.width}px: ${err.message}`);
215
+ }
216
+ }
217
+
218
+ // ── Screenshot ──────────────────────────────────────────────────────
219
+ try {
220
+ const shot = await browser.screenshot({ format: 'png' });
221
+ // Cap at 5 MB base64 — a 1440×900 PNG can exceed this on complex pages;
222
+ // storing unbounded data across 4 breakpoints × N routes risks OOM.
223
+ if (shot?.data && shot.data.length < 5_000_000) {
224
+ screenshots[`${bp.width}x${bp.height}`] = shot.data;
225
+ } else if (shot?.data) {
226
+ // Record omission metadata so operators know which breakpoints were
227
+ // skipped and can investigate rather than silently missing screenshot data.
228
+ screenshots[`${bp.width}x${bp.height}`] = { omitted: true, reason: 'size_cap', bytes: shot.data.length };
229
+ }
230
+ } catch { /* screenshots are optional */ }
231
+
232
+ } catch (err) {
233
+ logger.warn(`[ARGUS] Responsive analysis failed at ${bp.width}px: ${err.message}`);
234
+ }
235
+ }
236
+ } finally {
237
+ // ── Always restore viewport and CPU throttle ─────────────────────────
238
+ try { await browser.emulateCpu(1); } catch { /* best-effort */ }
239
+ try {
240
+ await browser.emulate(viewportString(RESTORE_VIEWPORT.width, RESTORE_VIEWPORT.height));
241
+ } catch { /* best-effort restore */ }
242
+ }
243
+
244
+ return { findings, screenshots };
245
+ }
246
+
247
+ // ── Self-registration ─────────────────────────────────────────────────────────
248
+ registerExpensive({
249
+ name: 'responsive',
250
+ async analyze(browser, url) {
251
+ return analyzeResponsive(browser, url);
252
+ },
253
+ });
@@ -0,0 +1,36 @@
1
+ import { childLogger } from './logger.js';
2
+
3
+ const logger = childLogger('retry');
4
+
5
+ /**
6
+ * withRetry — exponential-backoff retry wrapper for transient CDP failures.
7
+ *
8
+ * Applied in CdpBrowserAdapter to navigate() and fill() — idempotent operations
9
+ * most likely to fail transiently under load. click() is intentionally excluded
10
+ * (not idempotent — a retry could submit forms or trigger deletions twice).
11
+ *
12
+ * Set ARGUS_RETRY_ATTEMPTS=1 to disable retries (e.g. CI).
13
+ * Non-numeric values fall back to 3 silently.
14
+ *
15
+ * @param {() => Promise<*>} fn - Async function to call
16
+ * @param {object} opts
17
+ * @param {number} opts.attempts - Max total attempts (default: ARGUS_RETRY_ATTEMPTS ?? 3)
18
+ * @param {number} opts.delayMs - Base delay in ms; doubles each attempt (default: 400)
19
+ * @param {string} opts.label - Human-readable label for debug logging
20
+ * @returns {Promise<*>} Result of fn on success
21
+ * @throws Last error if all attempts fail
22
+ */
23
+ export async function withRetry(fn, { attempts, delayMs = 400, label = '' } = {}) {
24
+ const parsed = parseInt(process.env.ARGUS_RETRY_ATTEMPTS ?? '3', 10);
25
+ const maxAttempts = attempts ?? (Number.isFinite(parsed) && parsed >= 1 ? parsed : 3);
26
+ for (let i = 0; i < maxAttempts; i++) {
27
+ try {
28
+ return await fn();
29
+ } catch (err) {
30
+ if (i === maxAttempts - 1) throw err;
31
+ const wait = delayMs * Math.pow(2, i);
32
+ logger.debug(`[ARGUS] ${label ? label + ': ' : ''}retry ${i + 1}/${maxAttempts - 1} after ${wait}ms — ${err.message}`);
33
+ await new Promise(r => setTimeout(r, wait));
34
+ }
35
+ }
36
+ }