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,310 @@
1
+ /**
2
+ * Argus Phase C2: GitHub PR comment + commit status integration.
3
+ *
4
+ * C2.1 formatPrComment(report, diff) — build Markdown PR comment body (pure)
5
+ * C2.2 buildStatusPayload(report, diff) — build GitHub commit status payload (pure)
6
+ * C2.3 postPrComment(report, diff) — create/update PR comment via GitHub API
7
+ * C2.4 setCommitStatus(report, diff) — set commit status (blocks merge on new criticals)
8
+ * C2.5 isGitHubConfigured() — guard: true when GITHUB_TOKEN + GITHUB_REPOSITORY set
9
+ * C2.6 reportToGitHub(report, diff) — orchestrates C2.3 + C2.4
10
+ *
11
+ * Required env vars:
12
+ * GITHUB_TOKEN — personal access token or Actions GITHUB_TOKEN (required)
13
+ * GITHUB_REPOSITORY — "owner/repo" (set automatically in GitHub Actions)
14
+ * GITHUB_SHA — commit SHA for status checks (set automatically in GitHub Actions)
15
+ * GITHUB_PR_NUMBER — PR number; set in workflow via:
16
+ * env:
17
+ * GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
18
+ *
19
+ * Optional env vars:
20
+ * ARGUS_REPORT_URL — URL to the full HTML report; linked in the commit status check
21
+ */
22
+
23
+ import { childLogger } from './logger.js';
24
+
25
+ const logger = childLogger('github-reporter');
26
+
27
+ const COMMENT_MARKER = '<!-- argus-qa-report -->';
28
+ const GITHUB_API = 'https://api.github.com';
29
+ const MAX_TABLE_ROWS = 15; // cap table rows to stay within GitHub's 65536-char limit
30
+
31
+ // ── Helpers ───────────────────────────────────────────────────────────────────
32
+
33
+ const SEV_ICON = { critical: '🔴', warning: '🟡', info: '🔵' };
34
+ function sevIcon(sev) { return SEV_ICON[sev] ?? '⚪'; }
35
+
36
+ /** Escape pipe characters so they don't break Markdown tables. */
37
+ function mdCell(text, maxLen = 100) {
38
+ return String(text ?? '').slice(0, maxLen).replace(/\|/g, '\\|').replace(/\n/g, ' ');
39
+ }
40
+
41
+ // ── C2.1: PR comment formatter (pure — no I/O) ───────────────────────────────
42
+
43
+ /**
44
+ * Build the full Markdown body for a PR comment.
45
+ * Embed COMMENT_MARKER so future runs can find and update the same comment.
46
+ *
47
+ * @param {object} report - runCrawl() report object
48
+ * @param {object|null} diff - applyBaseline() diff result (null = first run)
49
+ * @returns {string} Markdown comment body
50
+ */
51
+ export function formatPrComment(report, diff) {
52
+ const { baseUrl, summary, routes = [], codebase = [], flows = [] } = report;
53
+ const runDate = new Date(report.generatedAt).toUTCString();
54
+ const isFirst = !diff || diff.isFirstRun;
55
+
56
+ // Collect new findings from all sources, tagging each with a display source label
57
+ const allNewFindings = [
58
+ ...routes.flatMap(r =>
59
+ r.errors
60
+ .filter(e => e.isNew !== false)
61
+ .map(e => ({ ...e, _source: r.route }))
62
+ ),
63
+ ...(codebase)
64
+ .filter(f => f.isNew !== false)
65
+ .map(f => ({ ...f, _source: 'Codebase (C1)' })),
66
+ ...flows.flatMap(f =>
67
+ (f.findings ?? [])
68
+ .filter(e => e.isNew !== false)
69
+ .map(e => ({ ...e, _source: `Flow: ${f.flowName}` }))
70
+ ),
71
+ ];
72
+
73
+ const newCriticals = allNewFindings.filter(f => f.severity === 'critical').length;
74
+ const newWarnings = allNewFindings.filter(f => f.severity === 'warning').length;
75
+ const newInfos = allNewFindings.filter(f => f.severity === 'info').length;
76
+ // Sum route + flow resolved findings for the display count
77
+ const resolvedCount = (diff?.resolvedCount ?? 0) + (diff?.flowResolvedCount ?? 0);
78
+
79
+ const lines = [
80
+ COMMENT_MARKER,
81
+ '## 🔍 Argus QA Report',
82
+ '',
83
+ `**Base URL**: ${baseUrl} `,
84
+ `**Run time**: ${runDate} `,
85
+ '',
86
+ '| | 🔴 Critical | 🟡 Warning | 🔵 Info | Total |',
87
+ '|---|---|---|---|---|',
88
+ `| **Total** | ${summary.critical} | ${summary.warning} | ${summary.info} | ${summary.total} |`,
89
+ ];
90
+
91
+ if (isFirst) {
92
+ lines.push('| **New** | _first run_ | _first run_ | _first run_ | _baseline established_ |');
93
+ } else {
94
+ lines.push(`| **New** | ${newCriticals} | ${newWarnings} | ${newInfos} | ${allNewFindings.length} |`);
95
+ lines.push(`| **Resolved** | — | — | — | ${resolvedCount} |`);
96
+ }
97
+
98
+ // ── New findings table — skipped on first run (all findings would be "new") ──
99
+ if (allNewFindings.length > 0 && !isFirst) {
100
+ lines.push('', `### 🆕 New Findings (${allNewFindings.length})`);
101
+ lines.push('| Severity | Source | Type | Details |');
102
+ lines.push('|---|---|---|---|');
103
+ for (const f of allNewFindings.slice(0, MAX_TABLE_ROWS)) {
104
+ lines.push(`| ${sevIcon(f.severity)} ${f.severity} | ${f._source} | \`${f.type}\` | ${mdCell(f.message)} |`);
105
+ }
106
+ if (allNewFindings.length > MAX_TABLE_ROWS) {
107
+ lines.push(`| … | … | … | _${allNewFindings.length - MAX_TABLE_ROWS} more — see full report_ |`);
108
+ }
109
+ }
110
+
111
+ // ── Resolved note ──
112
+ if (!isFirst && resolvedCount > 0) {
113
+ lines.push('', `### ✅ Resolved (${resolvedCount})`);
114
+ lines.push(`${resolvedCount} finding(s) resolved since last baseline.`);
115
+ }
116
+
117
+ // ── C1 codebase findings (all, flagged new where applicable) ──
118
+ if (codebase.length > 0) {
119
+ lines.push('', `### 📦 Codebase Analysis — ${codebase.length} finding(s)`);
120
+ lines.push('| Severity | Type | Details |');
121
+ lines.push('|---|---|---|');
122
+ for (const f of codebase.slice(0, MAX_TABLE_ROWS)) {
123
+ const newTag = f.isNew !== false ? ' _(new)_' : '';
124
+ lines.push(`| ${sevIcon(f.severity)} | \`${f.type}\` | ${mdCell(f.message)}${newTag} |`);
125
+ }
126
+ if (codebase.length > MAX_TABLE_ROWS) {
127
+ lines.push(`| … | … | _${codebase.length - MAX_TABLE_ROWS} more_ |`);
128
+ }
129
+ }
130
+
131
+ // ── Screenshot note ──
132
+ const screenshotCount = routes.filter(r => r.screenshot).length;
133
+ if (screenshotCount > 0) {
134
+ lines.push('', `> 📸 ${screenshotCount} route screenshot(s) available in CI artifacts.`);
135
+ }
136
+
137
+ lines.push('', '---');
138
+ lines.push(`_Generated by [Argus](https://github.com/ironclawdevs/GodMode---AI-Dev-Testing-Tool) · ${new Date(report.generatedAt).toISOString()}_`);
139
+
140
+ return lines.join('\n');
141
+ }
142
+
143
+ // ── C2.2: Commit status payload builder (pure — no I/O) ──────────────────────
144
+
145
+ /**
146
+ * Build the payload for the GitHub commit status API.
147
+ * State is 'failure' when any new critical findings exist (blocks PR merge).
148
+ * Pure function — reads no env vars; callers attach target_url if desired.
149
+ *
150
+ * @param {object} report
151
+ * @param {object|null} diff
152
+ * @returns {{ state: string, description: string, context: string }}
153
+ */
154
+ export function buildStatusPayload(report, diff) {
155
+ const newCriticals = [
156
+ ...(report.routes ?? []).flatMap(r =>
157
+ (r.errors ?? []).filter(e => e.severity === 'critical' && e.isNew !== false)
158
+ ),
159
+ ...(report.codebase ?? []).filter(f => f.severity === 'critical' && f.isNew !== false),
160
+ ...(report.flows ?? []).flatMap(f =>
161
+ (f.findings ?? []).filter(e => e.severity === 'critical' && e.isNew !== false)
162
+ ),
163
+ ].length;
164
+
165
+ const passing = newCriticals === 0;
166
+ return {
167
+ state: passing ? 'success' : 'failure',
168
+ description: passing
169
+ ? `Argus: All checks passed (${report.summary.total} total finding(s))`
170
+ : `Argus: ${newCriticals} new critical issue(s) — merge blocked`,
171
+ context: 'argus-qa',
172
+ };
173
+ }
174
+
175
+ // ── GitHub API helper ─────────────────────────────────────────────────────────
176
+
177
+ async function ghFetch(urlPath, method, body, attempt = 1) {
178
+ if (!process.env.GITHUB_TOKEN) {
179
+ throw new Error('GITHUB_TOKEN environment variable is not set — GitHub reporting is disabled');
180
+ }
181
+ const headers = {
182
+ 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
183
+ 'Accept': 'application/vnd.github+json',
184
+ 'X-GitHub-Api-Version': '2022-11-28',
185
+ };
186
+ if (body) headers['Content-Type'] = 'application/json';
187
+
188
+ let res;
189
+ try {
190
+ res = await fetch(`${GITHUB_API}${urlPath}`, {
191
+ method,
192
+ headers,
193
+ body: body ? JSON.stringify(body) : undefined,
194
+ signal: AbortSignal.timeout(15000),
195
+ });
196
+ } catch (err) {
197
+ // Network error or timeout — retry up to 3 times with exponential backoff
198
+ if (attempt < 3) {
199
+ await new Promise(r => setTimeout(r, attempt * 1000));
200
+ return ghFetch(urlPath, method, body, attempt + 1);
201
+ }
202
+ throw err;
203
+ }
204
+
205
+ // Retry on transient server errors (5xx) and rate-limit (429) with exponential backoff
206
+ if ((res.status >= 500 || res.status === 429) && attempt < 3) {
207
+ await new Promise(r => setTimeout(r, attempt * 1000));
208
+ return ghFetch(urlPath, method, body, attempt + 1);
209
+ }
210
+
211
+ if (!res.ok) {
212
+ const text = await res.text().catch(() => '');
213
+ throw new Error(`GitHub API ${method} ${urlPath} → ${res.status}: ${text.slice(0, 200)}`);
214
+ }
215
+ return res.json();
216
+ }
217
+
218
+ // ── C2.3: Post or update PR comment ──────────────────────────────────────────
219
+
220
+ /**
221
+ * Create a PR comment, or update the existing Argus comment if one is already present.
222
+ * Idempotent: re-running on the same PR updates in-place rather than spamming new comments.
223
+ */
224
+ export async function postPrComment(report, diff) {
225
+ const repo = process.env.GITHUB_REPOSITORY;
226
+ const prNum = process.env.GITHUB_PR_NUMBER;
227
+ if (!repo || !prNum) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY or GITHUB_PR_NUMBER not set');
228
+
229
+ let body = formatPrComment(report, diff);
230
+
231
+ // GitHub hard limit is 65536 chars; truncate gracefully if exceeded.
232
+ const GITHUB_COMMENT_LIMIT = 65000;
233
+ if (body.length > GITHUB_COMMENT_LIMIT) {
234
+ const truncMsg = '\n\n_⚠️ Report truncated — full details in the saved JSON report._';
235
+ body = body.slice(0, GITHUB_COMMENT_LIMIT - truncMsg.length) + truncMsg;
236
+ }
237
+
238
+ // Find existing Argus comment to update
239
+ const existing = await ghFetch(`/repos/${repo}/issues/${prNum}/comments?per_page=100`, 'GET');
240
+ const prev = Array.isArray(existing)
241
+ ? existing.find(c => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER))
242
+ : null;
243
+
244
+ if (prev) {
245
+ await ghFetch(`/repos/${repo}/issues/comments/${prev.id}`, 'PATCH', { body });
246
+ logger.info(`[ARGUS] C2: Updated PR #${prNum} comment (id: ${prev.id})`);
247
+ } else {
248
+ await ghFetch(`/repos/${repo}/issues/${prNum}/comments`, 'POST', { body });
249
+ logger.info(`[ARGUS] C2: Posted new comment on PR #${prNum}`);
250
+ }
251
+ }
252
+
253
+ // ── C2.4: Set commit status ───────────────────────────────────────────────────
254
+
255
+ /**
256
+ * Set a GitHub commit status on GITHUB_SHA.
257
+ * 'failure' state prevents merge when required status checks are enforced.
258
+ */
259
+ export async function setCommitStatus(report, diff) {
260
+ const repo = process.env.GITHUB_REPOSITORY;
261
+ const sha = process.env.GITHUB_SHA;
262
+ if (!repo || !sha) throw new Error('[ARGUS] C2: GITHUB_REPOSITORY or GITHUB_SHA not set');
263
+
264
+ const payload = buildStatusPayload(report, diff);
265
+ // ARGUS_REPORT_URL is I/O-dependent — attached here, not in the pure builder
266
+ if (process.env.ARGUS_REPORT_URL) {
267
+ payload.target_url = process.env.ARGUS_REPORT_URL;
268
+ }
269
+ await ghFetch(`/repos/${repo}/statuses/${sha}`, 'POST', payload);
270
+ logger.info(`[ARGUS] C2: Commit status → ${payload.state} (${payload.description})`);
271
+ }
272
+
273
+ // ── C2.5: Configuration guard ─────────────────────────────────────────────────
274
+
275
+ export function isGitHubConfigured() {
276
+ return !!(process.env.GITHUB_TOKEN && process.env.GITHUB_REPOSITORY);
277
+ }
278
+
279
+ // ── C2.6: Orchestrator ────────────────────────────────────────────────────────
280
+
281
+ /**
282
+ * Run both PR comment and commit status updates in parallel.
283
+ * Each operation is independent — a failure in one doesn't block the other.
284
+ */
285
+ export async function reportToGitHub(report, diff) {
286
+ const tasks = [];
287
+
288
+ if (process.env.GITHUB_PR_NUMBER) {
289
+ tasks.push(
290
+ postPrComment(report, diff).catch(err =>
291
+ logger.warn(`[ARGUS] C2: PR comment failed — ${err.message}`)
292
+ )
293
+ );
294
+ }
295
+
296
+ if (process.env.GITHUB_SHA) {
297
+ tasks.push(
298
+ setCommitStatus(report, diff).catch(err =>
299
+ logger.warn(`[ARGUS] C2: Commit status failed — ${err.message}`)
300
+ )
301
+ );
302
+ }
303
+
304
+ if (tasks.length === 0) {
305
+ logger.info('[ARGUS] C2: No GITHUB_PR_NUMBER or GITHUB_SHA — skipping GitHub reporting');
306
+ return;
307
+ }
308
+
309
+ await Promise.all(tasks);
310
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * ARGUS Hover-State Analyzer (v3 Phase D8.1)
3
+ *
4
+ * Uses browser.hover() to trigger hover state on interactive elements that declare
5
+ * dropdown or tooltip behaviour via ARIA attributes, then verifies the expected
6
+ * DOM change occurred after the hover.
7
+ *
8
+ * Detections:
9
+ * hover_dropdown_broken — [aria-haspopup] element whose controlled popup does
10
+ * not become visible (aria-expanded stays false, popup
11
+ * remains display:none / visibility:hidden / opacity:0)
12
+ * hover_tooltip_missing — [data-tooltip] element whose tooltip is not visible
13
+ * in the DOM after hover (not found or opacity:0 /
14
+ * display:none)
15
+ *
16
+ * Candidates are capped (8 dropdowns, 5 tooltips) to keep crawl time bounded.
17
+ * All errors are silently swallowed per-element so a single broken selector
18
+ * cannot abort the entire analysis.
19
+ */
20
+
21
+ import { resolveUidForSelector } from './flow-runner.js';
22
+ import { registerExpensive } from '../registry.js';
23
+ import { thresholds } from '../config/targets.js';
24
+
25
+ // ── Discovery script ──────────────────────────────────────────────────────────
26
+ // Runs in the live page to find hover-testable candidates.
27
+ // Returns JSON array of { kind, selector, controls, tooltipId, label }.
28
+ const HOVER_CANDIDATE_SCRIPT = `() => {
29
+ var results = [];
30
+ function buildSelector(el) {
31
+ // Use CSS.escape() — raw id/class names containing :, ., [, / or spaces
32
+ // produce invalid selectors that querySelector silently fails to match.
33
+ if (el.id) return '#' + CSS.escape(el.id);
34
+ var classes = Array.from(el.classList)
35
+ .filter(function(c) { return /^[a-zA-Z_-]/.test(c); })
36
+ .slice(0, 2)
37
+ .map(function(c) { return CSS.escape(c); });
38
+ var tag = el.tagName.toLowerCase();
39
+ if (!classes.length) return tag;
40
+ var selector = tag + '.' + classes.join('.');
41
+ if (document.querySelectorAll(selector).length === 1) return selector;
42
+ // nth-of-type counts within parent, not the full document — may misfire
43
+ // when same-tag elements exist across multiple parents. Acceptable heuristic.
44
+ var idx = Array.from(document.querySelectorAll(tag)).indexOf(el) + 1;
45
+ return tag + ':nth-of-type(' + idx + ')';
46
+ }
47
+ var popupEls = document.querySelectorAll('[aria-haspopup]');
48
+ for (var i = 0; i < Math.min(popupEls.length, ${thresholds.hover.maxDropdowns}); i++) {
49
+ var el = popupEls[i];
50
+ var r = el.getBoundingClientRect();
51
+ if (r.width === 0 && r.height === 0) continue;
52
+ results.push({
53
+ kind: 'haspopup',
54
+ selector: buildSelector(el),
55
+ controls: el.getAttribute('aria-controls') || null,
56
+ label: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 40),
57
+ });
58
+ }
59
+ var tipEls = document.querySelectorAll('[data-tooltip]');
60
+ for (var j = 0; j < Math.min(tipEls.length, ${thresholds.hover.maxTooltips}); j++) {
61
+ var tel = tipEls[j];
62
+ var tr = tel.getBoundingClientRect();
63
+ if (tr.width === 0 && tr.height === 0) continue;
64
+ results.push({
65
+ kind: 'tooltip',
66
+ selector: buildSelector(tel),
67
+ tooltipId: tel.getAttribute('aria-describedby') || null,
68
+ label: tel.getAttribute('data-tooltip') || '',
69
+ });
70
+ }
71
+ return JSON.stringify(results);
72
+ }`;
73
+
74
+ // ── Post-hover check — dropdown ───────────────────────────────────────────────
75
+ // After hovering an [aria-haspopup] element, checks whether:
76
+ // (a) aria-expanded="true" is now present on any haspopup element, OR
77
+ // (b) the controlled popup element is visually visible.
78
+ // Returns JSON { expanded: bool, popupVisible: bool }.
79
+ function popupCheckScript(controls) {
80
+ const ctrlExpr = controls
81
+ ? `document.getElementById(${JSON.stringify(controls)})`
82
+ : `(document.querySelector('[role="menu"],[role="listbox"],[role="dialog"]'))`;
83
+ return `() => {
84
+ var expanded = document.querySelector('[aria-haspopup][aria-expanded="true"]') !== null;
85
+ var ctrlEl = ${ctrlExpr};
86
+ var popupVisible = false;
87
+ if (ctrlEl) {
88
+ var s = window.getComputedStyle(ctrlEl);
89
+ popupVisible = s.display !== 'none'
90
+ && s.visibility !== 'hidden'
91
+ && parseFloat(s.opacity) > 0.05
92
+ && ctrlEl.offsetHeight > 0;
93
+ }
94
+ return JSON.stringify({ expanded: expanded, popupVisible: popupVisible });
95
+ }`;
96
+ }
97
+
98
+ // ── Post-hover check — tooltip ────────────────────────────────────────────────
99
+ // After hovering a [data-tooltip] element, checks whether the associated
100
+ // tooltip element is visible (non-zero size, opacity > 0, not display:none).
101
+ // Returns JSON { found: bool, visible: bool }.
102
+ function tooltipCheckScript(tooltipId) {
103
+ const tipExpr = tooltipId
104
+ ? `document.getElementById(${JSON.stringify(tooltipId)})`
105
+ : `document.querySelector('[role="tooltip"]')`;
106
+ return `() => {
107
+ var tip = ${tipExpr};
108
+ if (!tip) tip = document.querySelector('[role="tooltip"]');
109
+ if (!tip) return JSON.stringify({ found: false, visible: false });
110
+ var s = window.getComputedStyle(tip);
111
+ var visible = s.display !== 'none'
112
+ && s.visibility !== 'hidden'
113
+ && parseFloat(s.opacity) > 0.05
114
+ && tip.offsetHeight > 0;
115
+ return JSON.stringify({ found: true, visible: visible });
116
+ }`;
117
+ }
118
+
119
+ // ── JSON parse helper ─────────────────────────────────────────────────────────
120
+ function parseJson(raw) {
121
+ if (raw == null) return null;
122
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
123
+ const inner = raw.result !== undefined ? raw.result : raw;
124
+ if (typeof inner === 'string') { try { return JSON.parse(inner); } catch { return null; } }
125
+ return typeof inner === 'object' ? inner : null;
126
+ }
127
+ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return null; } }
128
+ return null;
129
+ }
130
+
131
+ // ── Public API ────────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Analyse hover-state behaviour for interactive elements on a page.
135
+ *
136
+ * Navigates to the URL, discovers hover-testable elements, hovers each one,
137
+ * then verifies that the expected DOM change occurred. Silently skips elements
138
+ * whose selector cannot be resolved or whose hover call throws.
139
+ *
140
+ * @param {object} mcp - MCP tool interface (navigate_page, evaluate_script, hover)
141
+ * @param {string} url - Fully-qualified URL to analyse
142
+ * @param {boolean} isCritical - Whether the route is marked critical in targets.js
143
+ * @returns {Promise<object[]>} Array of hover-bug finding objects
144
+ */
145
+ export async function analyzeHover(browser, url, isCritical = false) {
146
+ const findings = [];
147
+
148
+ try {
149
+ await browser.navigate(url);
150
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
151
+ await new Promise(r => setTimeout(r, 1000));
152
+ } catch {
153
+ return findings;
154
+ }
155
+
156
+ let candidates = [];
157
+ try {
158
+ const raw = await browser.evaluate(HOVER_CANDIDATE_SCRIPT);
159
+ const parsed = parseJson(raw);
160
+ candidates = Array.isArray(parsed) ? parsed : [];
161
+ } catch {
162
+ return findings;
163
+ }
164
+
165
+ for (const candidate of candidates) {
166
+ try {
167
+ // Browser hover requires uid (not CSS selector) — resolve via snapshot
168
+ const hoverUid = await resolveUidForSelector(browser, candidate.selector);
169
+ if (!hoverUid) continue; // element not found in snapshot — skip
170
+ await browser.hover(hoverUid);
171
+ await new Promise(r => setTimeout(r, thresholds.hover.waitMs));
172
+
173
+ if (candidate.kind === 'haspopup') {
174
+ const raw = await browser.evaluate(popupCheckScript(candidate.controls));
175
+ const state = parseJson(raw);
176
+ if (state && !state.expanded && !state.popupVisible) {
177
+ findings.push({
178
+ type: 'hover_dropdown_broken',
179
+ selector: candidate.selector,
180
+ label: candidate.label,
181
+ message: `Hover on "${candidate.label || candidate.selector}" (aria-haspopup) did not open its dropdown — aria-expanded stayed false and controlled popup remained hidden`,
182
+ severity: isCritical ? 'critical' : 'warning',
183
+ url,
184
+ });
185
+ }
186
+ } else if (candidate.kind === 'tooltip') {
187
+ const raw = await browser.evaluate(tooltipCheckScript(candidate.tooltipId));
188
+ const state = parseJson(raw);
189
+ if (state && !state.visible) {
190
+ findings.push({
191
+ type: 'hover_tooltip_missing',
192
+ selector: candidate.selector,
193
+ label: candidate.label,
194
+ message: `Hover on "${candidate.label || candidate.selector}" (data-tooltip) did not reveal a tooltip — tooltip element ${state.found ? 'exists but is hidden (opacity / display / visibility override)' : 'was not found in DOM'}`,
195
+ severity: 'warning',
196
+ url,
197
+ });
198
+ }
199
+ }
200
+ } catch {
201
+ // Individual hover check failed — skip this element silently
202
+ }
203
+ }
204
+
205
+ return findings;
206
+ }
207
+
208
+ // ── Self-registration ─────────────────────────────────────────────────────────
209
+ registerExpensive({
210
+ name: 'hover',
211
+ async analyze(browser, url, route) {
212
+ return analyzeHover(browser, url, route?.critical ?? false);
213
+ },
214
+ });