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,301 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ARGUS HTML Report Generator — D7.1
4
+ *
5
+ * Converts the latest (or a specified) JSON report into a single self-contained
6
+ * report.html with screenshots inlined as base64 data URIs. No external
7
+ * dependencies — the output file opens correctly offline.
8
+ *
9
+ * Usage:
10
+ * node src/utils/html-reporter.js # auto-picks latest report
11
+ * node src/utils/html-reporter.js path/to/report.json
12
+ * npm run report:html
13
+ *
14
+ * Output: <reports-dir>/report.html (overwrites on each run)
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import { fileURLToPath } from 'url';
20
+ import { childLogger } from './logger.js';
21
+
22
+ const logger = childLogger('html-reporter');
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const REPORTS_DIR = path.resolve(__dirname, '../../reports');
26
+
27
+ // ── Severity helpers ──────────────────────────────────────────────────────────
28
+
29
+ const SEV_COLOR = {
30
+ critical: { bg: '#fef2f2', border: '#fca5a5', text: '#991b1b', badge: '#dc2626', label: 'CRITICAL' },
31
+ warning: { bg: '#fffbeb', border: '#fcd34d', text: '#92400e', badge: '#d97706', label: 'WARNING' },
32
+ info: { bg: '#eff6ff', border: '#93c5fd', text: '#1e40af', badge: '#2563eb', label: 'INFO' },
33
+ };
34
+
35
+ function sevStyle(sev) {
36
+ const c = SEV_COLOR[sev] ?? SEV_COLOR.info;
37
+ return `background:${c.bg};border-left:4px solid ${c.border};color:${c.text}`;
38
+ }
39
+
40
+ function sevBadge(sev) {
41
+ const c = SEV_COLOR[sev] ?? SEV_COLOR.info;
42
+ return `<span style="background:${c.badge};color:#fff;border-radius:3px;font-size:11px;font-weight:700;padding:2px 7px;letter-spacing:.5px;white-space:nowrap">${c.label}</span>`;
43
+ }
44
+
45
+ function esc(str) {
46
+ return String(str ?? '')
47
+ .replace(/&/g, '&amp;')
48
+ .replace(/</g, '&lt;')
49
+ .replace(/>/g, '&gt;')
50
+ .replace(/"/g, '&quot;');
51
+ }
52
+
53
+ function safeHref(url) {
54
+ const s = String(url ?? '');
55
+ return /^https?:\/\//i.test(s) ? esc(s) : '#';
56
+ }
57
+
58
+ // ── Screenshot embedding ──────────────────────────────────────────────────────
59
+
60
+ function imgTag(filePath, alt = 'Screenshot', style = '') {
61
+ if (!filePath) return '';
62
+ try {
63
+ const buf = fs.readFileSync(filePath);
64
+ const ext = path.extname(filePath).slice(1).toLowerCase() || 'png';
65
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png';
66
+ return `<img src="data:${mime};base64,${buf.toString('base64')}" alt="${esc(alt)}" style="max-width:100%;border-radius:6px;border:1px solid #e5e7eb;${style}">`;
67
+ } catch {
68
+ return `<p style="color:#6b7280;font-size:13px">Screenshot not found: ${esc(path.basename(filePath))}</p>`;
69
+ }
70
+ }
71
+
72
+ // ── Finding renderer ──────────────────────────────────────────────────────────
73
+
74
+ function renderFinding(e) {
75
+ const sev = e.severity ?? 'info';
76
+ const type = esc(e.type ?? 'unknown');
77
+ const msg = esc(e.message ?? e.description ?? (e.requestUrl ? `HTTP ${e.status ?? '?'} ${e.requestUrl}` : ''));
78
+ const flaky = e.flaky ? ' <span style="color:#6b7280;font-size:11px">⚡ flaky</span>' : '';
79
+ const isNew = e.isNew ? ' <span style="color:#059669;font-size:11px">★ new</span>' : '';
80
+ return `
81
+ <div style="${sevStyle(sev)};padding:10px 14px;margin:6px 0;border-radius:0 4px 4px 0;font-size:13px;line-height:1.5">
82
+ <div style="display:flex;align-items:flex-start;gap:10px;flex-wrap:wrap">
83
+ ${sevBadge(sev)}
84
+ <code style="background:rgba(0,0,0,.06);border-radius:3px;padding:1px 6px;font-size:12px;white-space:nowrap">${type}</code>
85
+ ${flaky}${isNew}
86
+ </div>
87
+ <div style="margin-top:6px;word-break:break-word">${msg}</div>
88
+ ${e.requestUrl ? `<div style="margin-top:3px;font-size:11px;opacity:.8">URL: ${esc(e.requestUrl)}</div>` : ''}
89
+ </div>`;
90
+ }
91
+
92
+ // ── Route card ────────────────────────────────────────────────────────────────
93
+
94
+ function renderRoute(route) {
95
+ const errors = route.errors ?? [];
96
+ const criticals = errors.filter(e => e.severity === 'critical');
97
+ const warnings = errors.filter(e => e.severity === 'warning');
98
+ const infos = errors.filter(e => e.severity === 'info');
99
+
100
+ const headerColor = criticals.length > 0 ? '#dc2626'
101
+ : warnings.length > 0 ? '#d97706'
102
+ : '#16a34a';
103
+ const headerBg = criticals.length > 0 ? '#fef2f2'
104
+ : warnings.length > 0 ? '#fffbeb'
105
+ : '#f0fdf4';
106
+
107
+ // Summary pill row
108
+ const pills = [
109
+ criticals.length > 0 ? `<span style="background:#dc2626;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600">${criticals.length} critical</span>` : '',
110
+ warnings.length > 0 ? `<span style="background:#d97706;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600">${warnings.length} warning</span>` : '',
111
+ infos.length > 0 ? `<span style="background:#2563eb;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600">${infos.length} info</span>` : '',
112
+ errors.length === 0 ? `<span style="background:#16a34a;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600">✓ clean</span>` : '',
113
+ ].filter(Boolean).join(' ');
114
+
115
+ // Screenshot
116
+ const shot = imgTag(route.screenshot, `${route.route} screenshot`);
117
+
118
+ // Responsive screenshots grid
119
+ let responsiveGrid = '';
120
+ if (route.responsiveScreenshots && Object.keys(route.responsiveScreenshots).length > 0) {
121
+ const viewports = Object.entries(route.responsiveScreenshots)
122
+ .sort(([a], [b]) => Number(a) - Number(b))
123
+ .map(([vp, fp]) => `
124
+ <div style="text-align:center">
125
+ <div style="font-size:11px;color:#6b7280;margin-bottom:4px">${esc(String(vp))}px</div>
126
+ ${imgTag(fp, `${route.route} at ${vp}px`, 'width:100%')}
127
+ </div>`).join('');
128
+ responsiveGrid = `
129
+ <div style="margin-top:16px">
130
+ <h4 style="margin:0 0 8px;font-size:13px;color:#374151">Responsive snapshots</h4>
131
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px">${viewports}</div>
132
+ </div>`;
133
+ }
134
+
135
+ // Findings list
136
+ const findingRows = errors.length > 0
137
+ ? errors.map(renderFinding).join('')
138
+ : `<p style="color:#16a34a;margin:8px 0;font-size:13px">✓ No issues detected on this route.</p>`;
139
+
140
+ return `
141
+ <div style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:24px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
142
+ <!-- route header -->
143
+ <div style="background:${headerBg};border-bottom:1px solid #e5e7eb;padding:14px 20px;display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:8px">
144
+ <div>
145
+ <h3 style="margin:0;font-size:16px;color:${headerColor}">${esc(route.route)}</h3>
146
+ <a href="${safeHref(route.url)}" style="font-size:12px;color:#6b7280;word-break:break-all">${esc(route.url)}</a>
147
+ </div>
148
+ <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">${pills}</div>
149
+ </div>
150
+ <!-- route body -->
151
+ <div style="padding:16px 20px">
152
+ ${shot ? `<div style="margin-bottom:16px">${shot}</div>` : ''}
153
+ ${responsiveGrid}
154
+ <div style="margin-top:${shot || responsiveGrid ? '16px' : '0'}">
155
+ <h4 style="margin:0 0 8px;font-size:13px;color:#374151">Findings</h4>
156
+ ${findingRows}
157
+ </div>
158
+ </div>
159
+ </div>`;
160
+ }
161
+
162
+ // ── Flow card ─────────────────────────────────────────────────────────────────
163
+
164
+ function renderFlow(flow) {
165
+ const status = flow.status ?? 'unknown';
166
+ const findings = flow.findings ?? [];
167
+ const statusColor = status === 'pass' ? '#16a34a' : '#dc2626';
168
+ const findingRows = findings.length > 0
169
+ ? findings.map(renderFinding).join('')
170
+ : '<p style="color:#16a34a;margin:8px 0;font-size:13px">✓ All assertions passed.</p>';
171
+
172
+ return `
173
+ <div style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:16px">
174
+ <div style="background:#f9fafb;border-bottom:1px solid #e5e7eb;padding:12px 20px;display:flex;align-items:center;justify-content:space-between">
175
+ <span style="font-weight:600;font-size:15px">${esc(flow.flowName ?? flow.name ?? 'Flow')}</span>
176
+ <span style="font-size:13px;font-weight:600;color:${statusColor}">${status.toUpperCase()} (${flow.stepsCompleted ?? '?'}/${flow.totalSteps ?? '?'} steps)</span>
177
+ </div>
178
+ <div style="padding:14px 20px">${findingRows}</div>
179
+ </div>`;
180
+ }
181
+
182
+ // ── Full HTML document ────────────────────────────────────────────────────────
183
+
184
+ function buildHtml(report) {
185
+ const { generatedAt, baseUrl, summary, routes = [], flows = [] } = report;
186
+ const runDate = new Date(generatedAt).toLocaleString(undefined, { dateStyle: 'long', timeStyle: 'short' });
187
+
188
+ const totalBg = summary.critical > 0 ? '#dc2626' : summary.warning > 0 ? '#d97706' : '#16a34a';
189
+
190
+ const summaryCards = `
191
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:14px;margin-bottom:32px">
192
+ ${[
193
+ ['Total', summary.total ?? 0, '#374151', '#f3f4f6'],
194
+ ['Critical', summary.critical ?? 0, '#991b1b', '#fef2f2'],
195
+ ['Warning', summary.warning ?? 0, '#92400e', '#fffbeb'],
196
+ ['Info', summary.info ?? 0, '#1e40af', '#eff6ff'],
197
+ ].map(([label, count, color, bg]) => `
198
+ <div style="background:${bg};border:1px solid #e5e7eb;border-radius:8px;padding:18px;text-align:center">
199
+ <div style="font-size:32px;font-weight:700;color:${color}">${count}</div>
200
+ <div style="font-size:12px;color:#6b7280;margin-top:4px;text-transform:uppercase;letter-spacing:.5px">${label}</div>
201
+ </div>`).join('')}
202
+ </div>`;
203
+
204
+ const routeSections = routes.map(renderRoute).join('');
205
+
206
+ const flowSection = flows.length > 0 ? `
207
+ <h2 style="font-size:18px;color:#111827;border-bottom:2px solid #e5e7eb;padding-bottom:8px;margin:32px 0 16px">User Flows (${flows.length})</h2>
208
+ ${flows.map(renderFlow).join('')}` : '';
209
+
210
+ return `<!DOCTYPE html>
211
+ <html lang="en">
212
+ <head>
213
+ <meta charset="UTF-8">
214
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
215
+ <title>Argus Report — ${esc(runDate)}</title>
216
+ <style>
217
+ *, *::before, *::after { box-sizing: border-box; }
218
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f9fafb; color: #111827; }
219
+ a { color: inherit; }
220
+ </style>
221
+ </head>
222
+ <body>
223
+ <!-- top bar -->
224
+ <div style="background:${totalBg};padding:14px 32px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
225
+ <span style="color:#fff;font-weight:700;font-size:20px;letter-spacing:-.3px">🛡 Argus Report</span>
226
+ <span style="color:rgba(255,255,255,.85);font-size:13px">${esc(runDate)} · ${esc(baseUrl)}</span>
227
+ </div>
228
+
229
+ <div style="max-width:1100px;margin:0 auto;padding:32px 24px">
230
+ ${summaryCards}
231
+
232
+ <h2 style="font-size:18px;color:#111827;border-bottom:2px solid #e5e7eb;padding-bottom:8px;margin:0 0 16px">Routes (${routes.length})</h2>
233
+ ${routeSections}
234
+
235
+ ${flowSection}
236
+
237
+ <p style="text-align:center;font-size:12px;color:#9ca3af;margin-top:32px">Generated by <strong>Argus</strong> · ${esc(generatedAt)}</p>
238
+ </div>
239
+ </body>
240
+ </html>`;
241
+ }
242
+
243
+ // ── Public API ────────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Convert a JSON report file into a self-contained HTML file.
247
+ *
248
+ * Reads the JSON at `reportPath`, renders the full HTML dashboard (screenshots
249
+ * inlined as base64), and writes `report.html` alongside the source JSON.
250
+ * Called automatically by crawl-and-report.js when Slack is not configured (D7.7).
251
+ *
252
+ * @param {string} reportPath - Absolute or relative path to the JSON report file
253
+ * @returns {string} Absolute path to the written report.html
254
+ */
255
+ export function generateHtmlReport(reportPath) {
256
+ let report;
257
+ try {
258
+ report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
259
+ } catch (err) {
260
+ throw new Error(`[ARGUS] Failed to parse report JSON at ${reportPath}: ${err.message}`);
261
+ }
262
+ const html = buildHtml(report);
263
+ const outPath = path.join(path.dirname(path.resolve(reportPath)), 'report.html');
264
+
265
+ fs.writeFileSync(outPath, html, 'utf8');
266
+
267
+ const kb = Math.round(Buffer.byteLength(html, 'utf8') / 1024);
268
+ logger.info(`[ARGUS] HTML report written: ${outPath} (${kb} KB)`);
269
+ return outPath;
270
+ }
271
+
272
+ // ── CLI entry ─────────────────────────────────────────────────────────────────
273
+
274
+ function findLatestReport(dir) {
275
+ if (!fs.existsSync(dir)) {
276
+ throw new Error(`Reports directory not found: ${dir}\nRun "npm run crawl" first to generate a report.`);
277
+ }
278
+ const files = fs.readdirSync(dir)
279
+ .filter(f => f.startsWith('error-report-') && f.endsWith('.json'))
280
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
281
+ .sort((a, b) => b.mtime - a.mtime);
282
+
283
+ if (files.length === 0) {
284
+ throw new Error(`No error-report-*.json files found in ${dir}\nRun "npm run crawl" first.`);
285
+ }
286
+ return path.join(dir, files[0].name);
287
+ }
288
+
289
+ // Only runs when invoked directly (npm run report:html or node src/utils/html-reporter.js)
290
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
291
+ const arg = process.argv[2];
292
+ const reportPath = arg ? path.resolve(arg) : findLatestReport(REPORTS_DIR);
293
+
294
+ if (!fs.existsSync(reportPath)) {
295
+ logger.error(`Error: report not found — ${reportPath}`);
296
+ process.exit(1);
297
+ }
298
+
299
+ generateHtmlReport(reportPath);
300
+ logger.info(`[ARGUS] Source report: ${reportPath}`);
301
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * ARGUS Chrome DevTools Issues Analyzer
3
+ *
4
+ * Queries the Chrome DevTools Issues panel via
5
+ * list_console_messages({ types: ['issue'] }). The Issues panel is a
6
+ * completely separate namespace from the console — it surfaces CORS
7
+ * violations, CSP blocks, mixed content, cookie misconfiguration,
8
+ * deprecated API use, and native low-contrast findings. None of these
9
+ * appear in list_console_messages({ types: ['error'] }).
10
+ *
11
+ * Detections:
12
+ * cors_violation — Cross-origin request blocked by CORS policy
13
+ * csp_violation — Resource/script blocked by Content-Security-Policy
14
+ * mixed_content — HTTP resource loaded on HTTPS page
15
+ * cookie_attribute_missing — SameSite or Secure attribute missing/incorrect
16
+ * deprecated_api_use — Use of a deprecated browser API
17
+ * low_contrast_native — Text with insufficient color contrast (native check)
18
+ * permission_policy_violation — Feature blocked by Permissions-Policy header
19
+ *
20
+ * Two surfaces:
21
+ * parseIssues(issues, url, isCritical) — pure function for use in crawlRouteCheap
22
+ * after the D5 baseline-slice has already been applied.
23
+ * analyzeIssues(browser, url, isCritical) — standalone navigator for direct harness use.
24
+ */
25
+
26
+ import { normalizeArray } from './flow-runner.js';
27
+
28
+ // ── Issue classifiers ─────────────────────────────────────────────────────────
29
+
30
+ const CLASSIFIERS = [
31
+ {
32
+ type: 'cors_violation',
33
+ issueTypePattern: /cors/i,
34
+ textPattern: /cors policy|cross.origin.*blocked|access.control.allow.origin/i,
35
+ severity: (isCritical) => isCritical ? 'critical' : 'warning',
36
+ },
37
+ {
38
+ type: 'csp_violation',
39
+ issueTypePattern: /ContentSecurityPolicy|content.security|csp/i,
40
+ textPattern: /content.security.policy|refused to (execute|load|apply|connect|frame)|violates.*csp/i,
41
+ severity: () => 'critical',
42
+ },
43
+ {
44
+ type: 'mixed_content',
45
+ issueTypePattern: /mixed.content/i,
46
+ textPattern: /mixed content|http resource.*https|loaded over https.*http/i,
47
+ severity: () => 'warning',
48
+ },
49
+ {
50
+ type: 'cookie_attribute_missing',
51
+ issueTypePattern: /cookie/i,
52
+ textPattern: /samesite|secure attribute|partitioned|cookie.*rejected|set-cookie.*blocked/i,
53
+ severity: () => 'warning',
54
+ },
55
+ {
56
+ type: 'deprecated_api_use',
57
+ issueTypePattern: /deprecat/i,
58
+ textPattern: /deprecated|will be removed|no longer supported|mutation.event|document\.domain/i,
59
+ severity: () => 'info',
60
+ },
61
+ {
62
+ type: 'low_contrast_native',
63
+ issueTypePattern: /contrast/i,
64
+ textPattern: /contrast ratio|insufficient.*contrast|contrast.*insufficient/i,
65
+ severity: () => 'warning',
66
+ },
67
+ {
68
+ type: 'permission_policy_violation',
69
+ issueTypePattern: /permission.policy|feature.policy/i,
70
+ textPattern: /permission.policy|feature policy|not allowed in this document/i,
71
+ severity: () => 'info',
72
+ },
73
+ ];
74
+
75
+ function classifyIssue(issue, url, isCritical) {
76
+ const text = (issue.text ?? issue.message ?? issue.description ?? '').toString();
77
+ // chrome-devtools-mcp may expose a structured type identifier
78
+ const structuredType = (issue.issueType ?? issue.code ?? issue.kind ?? '').toString();
79
+ // Skip only when both text AND structured type are absent — structured-type-only
80
+ // issues (e.g. ContentSecurityPolicyIssue with no text body) must not be dropped.
81
+ if (!text && !structuredType) return null;
82
+
83
+ for (const c of CLASSIFIERS) {
84
+ const matchesType = structuredType && c.issueTypePattern.test(structuredType);
85
+ const matchesText = c.textPattern.test(text);
86
+ if (matchesType || matchesText) {
87
+ return {
88
+ type: c.type,
89
+ message: text.slice(0, 300),
90
+ severity: c.severity(isCritical),
91
+ url,
92
+ };
93
+ }
94
+ }
95
+
96
+ // Catch-all: emit unclassified issues so novel Chrome issue types are never silently dropped
97
+ if (text) {
98
+ return {
99
+ type: 'unclassified_devtools_issue',
100
+ message: `Unclassified DevTools issue: ${text.slice(0, 200)}`,
101
+ severity: 'info',
102
+ url,
103
+ };
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ // ── Public API ────────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Parse a pre-fetched, already-baseline-sliced issues array into findings.
113
+ * Pure function — used by crawlRouteCheap after the D5 baseline-slice.
114
+ *
115
+ * @param {object[]} issues - Issues from list_console_messages({ types: ['issue'] })
116
+ * @param {string} url - Page URL (used as finding context)
117
+ * @param {boolean} isCritical
118
+ * @returns {object[]}
119
+ */
120
+ export function parseIssues(issues, url, isCritical = false) {
121
+ const findings = [];
122
+ for (const issue of issues) {
123
+ const finding = classifyIssue(issue, url, isCritical);
124
+ if (finding) findings.push(finding);
125
+ }
126
+ return findings.slice(0, 20);
127
+ }
128
+
129
+ /**
130
+ * Standalone issues analyzer — navigates to a URL, baselines the current
131
+ * Issues count, queries the panel after load, and returns findings.
132
+ *
133
+ * Used by the test harness and any standalone caller. Baselines before
134
+ * navigation (D5 pattern) so pre-existing issues from prior pages are excluded.
135
+ *
136
+ * @param {object} browser
137
+ * @param {string} url
138
+ * @param {boolean} isCritical
139
+ * @returns {Promise<object[]>}
140
+ */
141
+ export async function analyzeIssues(browser, url, isCritical = false) {
142
+ const findings = [];
143
+
144
+ let baseline = 0;
145
+ try {
146
+ const priorRaw = await browser.listConsoleRaw({ types: ['issue'], includePreservedMessages: true });
147
+ baseline = normalizeArray(priorRaw).length;
148
+ } catch {
149
+ // Issues API may not be available — baseline stays 0
150
+ }
151
+
152
+ try {
153
+ await browser.navigate(url);
154
+ await new Promise(r => setTimeout(r, 1000));
155
+ } catch {
156
+ return findings;
157
+ }
158
+
159
+ try {
160
+ const raw = await browser.listConsoleRaw({
161
+ types: ['issue'],
162
+ includePreservedMessages: true,
163
+ });
164
+ const issues = normalizeArray(raw).slice(baseline);
165
+ findings.push(...parseIssues(issues, url, isCritical));
166
+ } catch {
167
+ // Issues API not available in this chrome-devtools-mcp build — silent skip
168
+ }
169
+
170
+ return findings;
171
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ARGUS Keyboard Navigation Analyzer
3
+ *
4
+ * Tab-walks the page using press_key({ key: 'Tab' }) and evaluates
5
+ * document.activeElement after each step to detect focus management issues.
6
+ * take_snapshot() is called to satisfy the D8 tool-coverage requirement.
7
+ *
8
+ * Detections:
9
+ * focus_visible_missing — interactive element receives Tab focus but has no
10
+ * visible focus indicator (outline:0 with no box-shadow)
11
+ * focus_lost — Tab lands on document.body instead of an interactive
12
+ * element (focus escapes the page's expected tab order)
13
+ *
14
+ * Tab-walk is capped at 20 steps to bound runtime. Duplicate elements (cycle
15
+ * complete) short-circuit the walk early.
16
+ */
17
+
18
+ import { registerExpensive } from '../registry.js';
19
+
20
+ const MAX_TAB_STEPS = 20;
21
+
22
+ // Evaluate the currently focused element's visibility and position in tab order.
23
+ const FOCUS_INFO_SCRIPT = `() => {
24
+ var el = document.activeElement;
25
+ if (!el || el === document.body || el === document.documentElement) {
26
+ return JSON.stringify({ lost: true });
27
+ }
28
+ var style = window.getComputedStyle(el);
29
+ var outlineWidth = parseFloat(style.outlineWidth) || 0;
30
+ var outlineStyle = style.outlineStyle || 'none';
31
+ var boxShadow = style.boxShadow || 'none';
32
+ var noOutline = (outlineWidth === 0 || outlineStyle === 'none') && boxShadow === 'none';
33
+ return JSON.stringify({
34
+ tag: el.tagName.toLowerCase(),
35
+ id: el.id || null,
36
+ role: el.getAttribute('role') || null,
37
+ tabIndex: el.tabIndex,
38
+ snippet: el.outerHTML.slice(0, 100),
39
+ noOutline: noOutline,
40
+ lost: false,
41
+ });
42
+ }`;
43
+
44
+ function parseJson(raw) {
45
+ if (raw == null) return null;
46
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
47
+ const inner = raw.result !== undefined ? raw.result : raw;
48
+ if (typeof inner === 'string') { try { return JSON.parse(inner); } catch { return null; } }
49
+ return typeof inner === 'object' ? inner : null;
50
+ }
51
+ if (typeof raw === 'string') { try { return JSON.parse(raw); } catch { return null; } }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Walk focus via Tab key and detect visibility and order issues.
57
+ *
58
+ * @param {object} browser - CdpBrowserAdapter
59
+ * @param {string} url - Fully-qualified URL to analyse
60
+ * @returns {Promise<object[]>} Array of finding objects
61
+ */
62
+ export async function analyzeKeyboard(browser, url) {
63
+ const findings = [];
64
+
65
+ try {
66
+ await browser.navigate(url);
67
+ await new Promise(r => setTimeout(r, 800));
68
+ } catch {
69
+ return findings;
70
+ }
71
+
72
+ // Satisfy D8 tool-coverage requirement.
73
+ try { await browser.snapshot(); } catch {}
74
+
75
+ // Reset focus to body for consistent Tab-walk start state across all pages
76
+ await browser.evaluate('() => { document.body.click(); document.body.focus(); }').catch(() => {});
77
+
78
+ const seen = new Set();
79
+ const focusLostAt = [];
80
+ const noOutlineEls = [];
81
+
82
+ for (let step = 0; step < MAX_TAB_STEPS; step++) {
83
+ try {
84
+ await browser.pressKey('Tab');
85
+ await new Promise(r => setTimeout(r, 80));
86
+
87
+ const raw = await browser.evaluate(FOCUS_INFO_SCRIPT);
88
+ const info = parseJson(raw);
89
+ if (!info) continue;
90
+
91
+ if (info.lost) {
92
+ focusLostAt.push(step + 1);
93
+ continue;
94
+ }
95
+
96
+ // Dedup by stable element identity — use tag/id/role rather than outerHTML
97
+ // which can include dynamic aria-expanded/counter attributes that change on focus
98
+ const key = `${info.tag}|${info.id ?? ''}|${info.role ?? ''}|${info.tabIndex}`;
99
+ if (seen.has(key)) break;
100
+ seen.add(key);
101
+
102
+ if (info.noOutline) {
103
+ noOutlineEls.push({ tag: info.tag, id: info.id, snippet: info.snippet });
104
+ }
105
+ } catch {
106
+ break;
107
+ }
108
+ }
109
+
110
+ for (const step of focusLostAt) {
111
+ findings.push({
112
+ type: 'focus_lost',
113
+ step,
114
+ message: `Tab focus escapes to document.body at step ${step} — check tabindex assignments and focus traps`,
115
+ severity: 'warning',
116
+ url,
117
+ });
118
+ }
119
+
120
+ for (const item of noOutlineEls) {
121
+ findings.push({
122
+ type: 'focus_visible_missing',
123
+ tag: item.tag,
124
+ id: item.id,
125
+ snippet: item.snippet,
126
+ message: `<${item.tag}${item.id ? '#' + item.id : ''}> receives Tab focus but has no visible focus indicator (outline:0 and no box-shadow) — add :focus-visible styles`,
127
+ severity: 'warning',
128
+ url,
129
+ });
130
+ }
131
+
132
+ return findings;
133
+ }
134
+
135
+ // ── Self-registration ─────────────────────────────────────────────────────────
136
+ registerExpensive({
137
+ name: 'keyboard',
138
+ async analyze(browser, url) {
139
+ return analyzeKeyboard(browser, url);
140
+ },
141
+ });