argusqa-os 9.7.4 → 9.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/argusqa-os?color=7C3AED)](https://www.npmjs.com/package/argusqa-os)
6
6
  [![MCP Server](https://glama.ai/mcp/servers/ironclawdevs27/Argus/badges/card.svg)](https://glama.ai/mcp/servers/ironclawdevs27/Argus)
7
- [![Harness](https://img.shields.io/badge/harness-688%2F688-4ADE80)](test-harness/)
7
+ [![Harness](https://img.shields.io/badge/harness-846%2F846-4ADE80)](test-harness/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
 
10
10
  **Argus catches the bugs your test suite misses — visual regressions, API loops, CSS drift, console noise, accessibility failures, and more — and delivers rich reports to Slack (or a local HTML dashboard).**
@@ -217,9 +217,10 @@ npm run report:html # Generate reports/report.html from last JSON audit
217
217
  npm run report:pdf # Export HTML report to A4 PDF (requires: npm install puppeteer)
218
218
  npm run server # Start Slack slash-command server (port 3001)
219
219
  npm run init # Interactive setup wizard
220
- npm run test:unit # 61 unit tests — no Chrome required
221
- npm run test:harness # 142-block correctness harness — requires Chrome
220
+ npm run test:unit # 94 unit tests — no Chrome required
221
+ npm run test:harness # 149-block correctness harness — requires Chrome
222
222
  npm run test:harness:log # same, but tees full output to harness-results.txt
223
+ npm run test:coverage # merged unit + harness coverage gate (requires Chrome)
223
224
  ```
224
225
 
225
226
  **Watch mode** — live monitoring as you develop:
@@ -342,7 +343,7 @@ Argus is a **complementary layer**, not a replacement for unit or E2E tests:
342
343
 
343
344
  ## Known Limitations
344
345
 
345
- All 688 harness assertions pass (`688/688`) — there are currently no known MCP- or Chrome-layer restrictions. Soft assertions (Lighthouse, performance traces) still require non-headless Chrome and are skipped in headless CI.
346
+ All 846 harness assertions pass (`846/846`) — there are currently no known MCP- or Chrome-layer restrictions. Lighthouse now runs in headless (after the `lighthouse_audit` argument fix); the remaining soft assertions (perf traces, GC-dependent heap-growth) are promoted to counted hard assertions only in the weekly strict-soft lane (`harness-strict.yml`) via `ARGUS_HARNESS_STRICT_SOFT`.
346
347
 
347
348
  ---
348
349
 
@@ -361,8 +362,8 @@ src/
361
362
  chrome-launcher.js — npm run chrome / argus-chrome — launches Chrome with correct flags
362
363
  doctor.js — npm run doctor / argus-doctor — pre-flight checks
363
364
  pr-validate.js — headless CI entry point for GitHub Actions
364
- test-harness/ — 142-block correctness harness, 688 hard assertions, 62 fixture pages
365
- test/unit/ — 61 Vitest unit tests (no Chrome required)
365
+ test-harness/ — 149-block correctness harness, 846 hard assertions, 63 fixture pages
366
+ test/unit/ — 94 Vitest unit tests (no Chrome required)
366
367
  landing/ — Product landing page (React 19 + Vite + Tailwind)
367
368
  ```
368
369
 
@@ -373,7 +374,7 @@ Full source map → [CLAUDE.md](CLAUDE.md) · MCP/DSL reference → [SKILL.md](S
373
374
  ## Contributing
374
375
 
375
376
  1. Fork the repo and create a branch
376
- 2. `npm run test:unit` — verify without Chrome (61 tests)
377
+ 2. `npm run test:unit` — verify without Chrome (94 tests)
377
378
  3. `npm run test:harness` — full integration coverage (requires Chrome on port 9222)
378
379
  4. Open a PR — Argus audits itself via the CI workflow
379
380
 
package/glama.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://glama.ai/mcp/schemas/server.json",
3
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. 9 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), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → targeted audit → blocked flag). Every finding is post-processed with intelligent baseline filtering (cross-run noise classifier) and root cause linking (recent git commits mapped to new findings). 142 test blocks, 688 hard assertions, 67 detection categories.",
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. 9 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), argus_visual_diff (screenshot baseline comparison, updateBaseline flag), argus_pr_validate (PR diff → affected routes → targeted audit → blocked flag). Every finding is post-processed with intelligent baseline filtering (cross-run noise classifier) and root cause linking (recent git commits mapped to new findings). 149 test blocks, 846 hard assertions, 67 detection categories.",
5
5
  "maintainers": ["ironclawdevs27"],
6
6
  "tools": [
7
7
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argusqa-os",
3
- "version": "9.7.4",
3
+ "version": "9.7.6",
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": [
@@ -53,6 +53,10 @@
53
53
  "test:harness:log": "node test-harness/run-with-log.mjs",
54
54
  "test:unit": "vitest run test/unit",
55
55
  "test": "npm run test:unit && npm run test:harness",
56
+ "coverage:unit": "vitest run test/unit --coverage",
57
+ "coverage:harness": "c8 npm run test:harness",
58
+ "coverage:gate": "node scripts/coverage-gate.mjs --min-lines 60 --allow-uncovered src/mcp-server.js,src/orchestration/env-comparison.js,src/server/index.js",
59
+ "test:coverage": "npm run coverage:harness && npm run coverage:unit && npm run coverage:gate",
56
60
  "report:html": "node src/utils/html-reporter.js",
57
61
  "report:pdf": "node src/utils/pdf-exporter.js",
58
62
  "mcp-server": "node src/mcp-server.js"
@@ -72,6 +76,10 @@
72
76
  "zod": "^4.4.3"
73
77
  },
74
78
  "devDependencies": {
79
+ "@vitest/coverage-v8": "^4.1.8",
80
+ "c8": "^10.1.3",
81
+ "fast-check": "^4.8.0",
82
+ "istanbul-lib-coverage": "^3.2.2",
75
83
  "vitest": "^4.1.8"
76
84
  }
77
85
  }
@@ -21,7 +21,24 @@ export class CdpBrowserAdapter {
21
21
  constructor(mcp) { this._mcp = mcp; }
22
22
 
23
23
  // ── Navigation ──────────────────────────────────────────────────────────────
24
- navigate(url) { return withRetry(() => this._mcp.navigate_page({ url }), { label: `navigate(${url})` }); }
24
+ // navigate_page reports failures as RESOLVED text ("Unable to navigate ...
25
+ // net::ERR_CONNECTION_REFUSED", "Could not connect to Chrome ..."), never as a
26
+ // thrown error. Unchecked, a dead target or dead browser produced a "clean"
27
+ // audit: analyzers ran against chrome-error://chromewebdata and emitted bogus
28
+ // findings (or none), and CI gates passed with Chrome down. Throw so failures
29
+ // propagate through the existing crawl error path.
30
+ navigate(url) {
31
+ return withRetry(async () => {
32
+ const resp = await this._mcp.navigate_page({ url });
33
+ if (typeof resp === 'string' &&
34
+ (resp.includes('Unable to navigate') ||
35
+ resp.includes('Could not connect to Chrome') ||
36
+ resp.includes('A dialog is open'))) {
37
+ throw new Error(`navigate(${url}) failed: ${resp.split('\n')[0].slice(0, 200)}`);
38
+ }
39
+ return resp;
40
+ }, { label: `navigate(${url})` });
41
+ }
25
42
 
26
43
  // ── Evaluation & snapshots ──────────────────────────────────────────────────
27
44
  evaluate(fn) { return this._mcp.evaluate_script({ function: fn }); }
@@ -41,14 +58,52 @@ export class CdpBrowserAdapter {
41
58
  hover(uid) { return this._mcp.hover({ uid }); }
42
59
  drag(src, tgt) { return this._mcp.drag({ from_uid: src, to_uid: tgt }); }
43
60
  uploadFile(uid, filePath) { return this._mcp.upload_file({ uid, filePath }); }
44
- handleDialog(accept, promptText = '') { return this._mcp.handle_dialog({ accept, promptText }); }
45
- waitFor(opts) { return this._mcp.wait_for(opts); }
61
+ // handle_dialog wire schema is { action: 'accept'|'dismiss', promptText? } — sending
62
+ // { accept: bool } is rejected by the tool's input validation (and the rejection comes
63
+ // back as a resolved error-text response, so the failure was silent in production).
64
+ handleDialog(accept, promptText = '') {
65
+ const args = { action: accept ? 'accept' : 'dismiss' };
66
+ if (promptText) args.promptText = promptText;
67
+ return this._mcp.handle_dialog(args);
68
+ }
69
+ // wait_for requires text as a non-empty string ARRAY. A bare string is rejected by
70
+ // input validation, and { state: 'networkidle' } is not part of the tool's schema at
71
+ // all — both shapes used to resolve to error text and silently wait for nothing.
72
+ waitFor(opts = {}) {
73
+ if (typeof opts.text === 'string') opts = { ...opts, text: [opts.text] };
74
+ if (opts.state === 'networkidle') return this.#waitForNetworkIdle();
75
+ return this._mcp.wait_for(opts);
76
+ }
77
+
78
+ // Bounded network-quiet poll: resolves once the page's resource-timing entry count
79
+ // is stable across two consecutive 250 ms polls, or after 3 s — whichever is first.
80
+ async #waitForNetworkIdle() {
81
+ let prev = -1;
82
+ for (let i = 0; i < 12; i++) {
83
+ const raw = await this.evaluate(`() => performance.getEntriesByType('resource').length`);
84
+ const count = Number(typeof raw === 'object' ? raw?.result ?? 0 : raw) || 0;
85
+ if (count === prev) return;
86
+ prev = count;
87
+ await new Promise(r => setTimeout(r, 250));
88
+ }
89
+ }
46
90
 
47
91
  // ── Viewport ────────────────────────────────────────────────────────────────
48
92
  emulate(viewport) { return this._mcp.emulate({ viewport }); }
49
93
  emulateCpu(rate) { return this._mcp.emulate({ cpuThrottlingRate: rate }); }
50
94
  emulateColorScheme(scheme) { return this._mcp.emulate({ colorScheme: scheme }); }
51
- emulateReducedMotion(pref) { return this._mcp.emulate({ reducedMotion: pref }); }
95
+ // chrome-devtools-mcp@1.1.1's emulate tool has no reduced-motion capability — the
96
+ // unsupported argument comes back as RESOLVED error text ("Unknown argument"), not a
97
+ // thrown error, so callers' graceful-skip catch paths (motion-analyzer) never ran and
98
+ // analysis proceeded unemulated. Surface it as a real error; if a future upstream
99
+ // version adds the argument, the call succeeds and emulation lights up automatically.
100
+ async emulateReducedMotion(pref) {
101
+ const resp = await this._mcp.emulate({ reducedMotion: pref });
102
+ if (typeof resp === 'string' && resp.includes('Unknown argument')) {
103
+ throw new Error(`emulate does not support reducedMotion in this chrome-devtools-mcp version: ${resp.slice(0, 120)}`);
104
+ }
105
+ return resp;
106
+ }
52
107
  resize(w, h) { return this._mcp.resize_page({ width: w, height: h }); }
53
108
 
54
109
  // ── Network & performance ───────────────────────────────────────────────────
@@ -56,7 +111,19 @@ export class CdpBrowserAdapter {
56
111
  // is rejected with an Unknown-argument error). Callers still pass the numeric
57
112
  // requestId parsed from list_network_requests.
58
113
  getNetworkRequest(reqId) { return this._mcp.get_network_request({ reqid: reqId }); }
59
- lighthouse(url, opts = {}) { return this._mcp.lighthouse_audit({ url, ...opts }); }
114
+ // lighthouse_audit audits the CURRENTLY-NAVIGATED page and accepts only
115
+ // mode/device/outputDirPath. Passing `url` (or `categories`) is REJECTED with an
116
+ // "Unknown argument" error that comes back as RESOLVED text — so every Argus Lighthouse
117
+ // run silently no-op'd (caught upstream as "Lighthouse skipped", scores perpetually N/A).
118
+ // Navigate to the target first, then audit; strip url/categories defensively so legacy
119
+ // callers cannot reintroduce the rejected args. mode 'navigation' (the tool default)
120
+ // reloads + audits. Performance is intentionally excluded by lighthouse_audit (covered by
121
+ // the web-vitals analyzer) — it returns accessibility/best-practices/seo/agentic-browsing.
122
+ async lighthouse(url, opts = {}) {
123
+ if (url) await this.navigate(url);
124
+ const { url: _ignoredUrl, categories: _ignoredCats, ...valid } = opts;
125
+ return this._mcp.lighthouse_audit(valid);
126
+ }
60
127
  startTrace() { return this._mcp.performance_start_trace({}); }
61
128
  stopTrace() { return this._mcp.performance_stop_trace({}); }
62
129
  analyzeInsight(opts) { return this._mcp.performance_analyze_insight(opts); }
@@ -11,6 +11,6 @@
11
11
  * continue to import from this file unchanged.
12
12
  */
13
13
 
14
- export { runCrawl, crawlRouteCheap, crawlRouteExpensive } from './orchestrator.js';
14
+ export { runCrawl, crawlRouteCheap, crawlRouteExpensive, checkHttpsRequired } from './orchestrator.js';
15
15
  export { processReport, deduplicateFindings, rebuildSummary } from './report-processor.js';
16
16
  export { dispatchAll } from './dispatcher.js';
@@ -370,45 +370,31 @@ function analyzeNetworkPerformance(perfEntries, pageUrl) {
370
370
  return bugs;
371
371
  }
372
372
 
373
- // ── Performance Budgets ────────────────────────────────────────────────────────
374
-
375
- async function checkPerformanceBudgets(browser, url) {
376
- const violations = [];
377
-
373
+ /**
374
+ * HTTPS-enforcement rule (single source of truth, exported so it can be verified
375
+ * directly — the harness can only crawl localhost, which is excluded, so the
376
+ * positive-trigger path has no live fixture).
377
+ *
378
+ * Returns a `security_no_https` finding for an http:// page on a non-loopback host,
379
+ * or null otherwise (https, or any localhost/127.x/::1 address).
380
+ *
381
+ * @param {string} url
382
+ * @returns {{type:string,message:string,severity:string,url:string}|null}
383
+ */
384
+ export function checkHttpsRequired(url) {
378
385
  try {
379
- await browser.startTrace();
380
- await new Promise(r => setTimeout(r, 3000));
381
- const trace = await browser.stopTrace();
382
- const insights = await browser.analyzeInsight({ insightSetId: trace?.insightSetId ?? trace?.id ?? trace });
383
-
384
- const metrics = insights?.metrics ?? insights?.performanceMetrics ?? {};
385
-
386
- const checks = [
387
- { key: 'LCP', value: metrics.largestContentfulPaint ?? metrics.LCP, budget: thresholds.perf.LCP, unit: 'ms' },
388
- { key: 'CLS', value: metrics.cumulativeLayoutShift ?? metrics.CLS, budget: thresholds.perf.CLS, unit: '' },
389
- { key: 'FID', value: metrics.totalBlockingTime ?? metrics.TBT ?? metrics.FID, budget: thresholds.perf.FID, unit: 'ms' },
390
- { key: 'TTFB', value: metrics.timeToFirstByte ?? metrics.TTFB, budget: thresholds.perf.TTFB, unit: 'ms' },
391
- ];
392
-
393
- for (const { key, value, budget, unit } of checks) {
394
- if (value == null) continue;
395
- if (value > budget) {
396
- violations.push({
397
- type: 'performance_budget',
398
- metric: key,
399
- value: `${value}${unit}`,
400
- budget: `${budget}${unit}`,
401
- message: `Performance budget exceeded: ${key} = ${value}${unit} (budget: ${budget}${unit})`,
402
- severity: 'warning',
403
- url,
404
- });
405
- }
386
+ const parsed = new URL(url);
387
+ const isLocalhost = /^(localhost|127\.|::1)/.test(parsed.hostname);
388
+ if (parsed.protocol === 'http:' && !isLocalhost) {
389
+ return {
390
+ type: 'security_no_https',
391
+ message: `Page served over HTTP enforce HTTPS via server redirect or HSTS`,
392
+ severity: 'warning',
393
+ url,
394
+ };
406
395
  }
407
- } catch (err) {
408
- logger.warn(`[ARGUS] Performance trace skipped for ${url}: ${err.message}`);
409
- }
410
-
411
- return violations;
396
+ } catch { /* URL parse failure */ }
397
+ return null;
412
398
  }
413
399
 
414
400
  // ── Cheap Crawl (called ×2 for flakiness detection) ───────────────────────────
@@ -417,7 +403,7 @@ async function checkPerformanceBudgets(browser, url) {
417
403
  * Cheap detections for one route.
418
404
  * Runs: console, network, JS errors, blank page, API frequency, contracts,
419
405
  * SEO, security, content, CSS, debugger statements, duplicate ids, screenshot.
420
- * Does NOT run: Lighthouse, perf budgets, network perf, redirect chain, broken links, cache headers.
406
+ * Does NOT run: Lighthouse, network perf, redirect chain, broken links, cache headers.
421
407
  */
422
408
  export async function crawlRouteCheap(route, baseUrl, mcp) {
423
409
  const browser = new CdpBrowserAdapter(mcp);
@@ -721,19 +707,9 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
721
707
  logger.warn(`[ARGUS] Issues analysis skipped for ${url}: ${err.message}`);
722
708
  }
723
709
 
724
- // 9f. HTTPS enforcement check
725
- try {
726
- const parsed = new URL(url);
727
- const isLocalhost = /^(localhost|127\.|::1)/.test(parsed.hostname);
728
- if (parsed.protocol === 'http:' && !isLocalhost) {
729
- result.errors.push({
730
- type: 'security_no_https',
731
- message: `Page served over HTTP — enforce HTTPS via server redirect or HSTS`,
732
- severity: 'warning',
733
- url,
734
- });
735
- }
736
- } catch { /* URL parse failure */ }
710
+ // 9f. HTTPS enforcement check (shared rule — see checkHttpsRequired above)
711
+ const httpsFinding = checkHttpsRequired(url);
712
+ if (httpsFinding) result.errors.push(httpsFinding);
737
713
 
738
714
  // 10. Deduplicate within this cheap run
739
715
  result.errors = deduplicateErrors(result.errors);
@@ -757,8 +733,11 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
757
733
 
758
734
  /**
759
735
  * Expensive/deterministic analyzers for one route — called ONCE per route.
760
- * Runs: network perf, redirect chain, perf budgets, Lighthouse,
736
+ * Runs: network perf, redirect chain, Lighthouse,
761
737
  * broken internal links, cache headers.
738
+ * (Core Web Vitals — LCP/CLS/TTFB — are emitted by the web-vitals-analyzer
739
+ * registerExpensive plugin, which is headless-compatible; the old trace-based
740
+ * perf-budget path was removed as dead + redundant.)
762
741
  */
763
742
  export async function crawlRouteExpensive(route, baseUrl, mcp) {
764
743
  const browser = new CdpBrowserAdapter(mcp);
@@ -805,9 +784,6 @@ export async function crawlRouteExpensive(route, baseUrl, mcp) {
805
784
  logger.warn(`[ARGUS] Redirect chain check skipped for ${url}: ${err.message}`);
806
785
  }
807
786
 
808
- // Performance budget check
809
- errors.push(...(await checkPerformanceBudgets(browser, url)));
810
-
811
787
  // Full Lighthouse audit (capped at LIGHTHOUSE_TIMEOUT_MS to prevent indefinite hang)
812
788
  errors.push(...(await Promise.race([
813
789
  checkLighthouse(browser, url),
@@ -31,7 +31,10 @@ const CLASSIFIERS = [
31
31
  {
32
32
  type: 'cors_violation',
33
33
  issueTypePattern: /cors/i,
34
- textPattern: /cors policy|cross.origin.*blocked|access.control.allow.origin/i,
34
+ // Live Chrome 149 surfaces CorsIssue in the panel as e.g.
35
+ // "Ensure CORS response header values are valid" — the older phrase-specific
36
+ // patterns missed it, so the \bcors\b word-match anchors any CORS issue title.
37
+ textPattern: /cors policy|cross.origin.*blocked|access.control.allow.origin|\bcors\b/i,
35
38
  severity: (isCritical) => isCritical ? 'critical' : 'warning',
36
39
  },
37
40
  {
@@ -49,7 +52,10 @@ const CLASSIFIERS = [
49
52
  {
50
53
  type: 'cookie_attribute_missing',
51
54
  issueTypePattern: /cookie/i,
52
- textPattern: /samesite|secure attribute|partitioned|cookie.*rejected|set-cookie.*blocked/i,
55
+ // Live Chrome 149 surfaces the SameSite=None-without-Secure cookie Issue as
56
+ // "Mark cross-site cookies as Secure to allow setting them in cross-site contexts"
57
+ // — neither "samesite" nor "secure attribute" appear, so match those phrasings too.
58
+ textPattern: /samesite|secure attribute|partitioned|cookie.*rejected|set-cookie.*blocked|cross-site cookies?|cookies? as secure/i,
53
59
  severity: () => 'warning',
54
60
  },
55
61
  {
@@ -5,12 +5,49 @@
5
5
  * checkLighthouse directly without pulling in the Slack-initialised orchestrator.
6
6
  */
7
7
 
8
+ import fs from 'node:fs';
8
9
  import { registerExpensive } from '../registry.js';
9
10
  import { thresholds } from '../config/targets.js';
10
11
  import { childLogger } from './logger.js';
11
12
 
12
13
  const logger = childLogger('lighthouse-checker');
13
14
 
15
+ /**
16
+ * Parse a chrome-devtools-mcp `lighthouse_audit` response into the Lighthouse
17
+ * result shape this module consumes: `{ categories, audits }` (category scores 0–1,
18
+ * `audits` keyed by id). The tool returns markdown with a "### Reports" section that
19
+ * points at a full `report.json`; we read that for complete category scores +
20
+ * per-audit detail (`auditRefs`, `title`, `description`). If the file is unavailable
21
+ * we fall back to the markdown "### Category Scores" block (scores only, no audits).
22
+ * Returns `{ categories: {}, audits: {} }` when nothing parses — never throws.
23
+ *
24
+ * @param {string} responseText - raw lighthouse_audit response (markdown text)
25
+ * @returns {{ categories: object, audits: object }}
26
+ */
27
+ export function parseLighthouseReport(responseText) {
28
+ const text = String(responseText ?? '');
29
+ // Prefer the authoritative report.json (categories + auditRefs + per-audit detail).
30
+ const pathMatch = text.match(/([A-Za-z]:\\[^\r\n]*?report\.json|\/[^\r\n]*?report\.json)/);
31
+ if (pathMatch) {
32
+ try {
33
+ const json = JSON.parse(fs.readFileSync(pathMatch[1].trim(), 'utf8'));
34
+ if (json && typeof json === 'object' && json.categories) {
35
+ return { categories: json.categories, audits: json.audits ?? {} };
36
+ }
37
+ } catch { /* fall through to the markdown scores */ }
38
+ }
39
+ // Fallback: synthesize categories from the "### Category Scores" markdown block,
40
+ // e.g. "- Accessibility: 96 (accessibility)". Scores normalised to 0–1 to match report.json.
41
+ const categories = {};
42
+ const block = text.match(/### Category Scores\s*\n([\s\S]*?)(?:\n###|\s*$)/);
43
+ if (block) {
44
+ for (const m of block[1].matchAll(/^\s*-\s+.+?:\s*([\d.]+)\s*\(([\w-]+)\)\s*$/gm)) {
45
+ categories[m[2]] = { id: m[2], score: Number(m[1]) / 100 };
46
+ }
47
+ }
48
+ return { categories, audits: {} };
49
+ }
50
+
14
51
  const LIGHTHOUSE_LABELS = {
15
52
  accessibility: 'Accessibility',
16
53
  performance: 'Performance',
@@ -39,13 +76,16 @@ export async function checkLighthouse(browser, url) {
39
76
  const LIGHTHOUSE_TIMEOUT_MS = parseInt(process.env.ARGUS_LIGHTHOUSE_TIMEOUT ?? '120000', 10);
40
77
 
41
78
  try {
42
- const auditPromise = browser.lighthouse(url, {
43
- categories: ['accessibility', 'performance', 'seo', 'best-practices'],
44
- });
79
+ // browser.lighthouse navigates to url + audits the current page. lighthouse_audit
80
+ // returns markdown referencing a full report.json — parseLighthouseReport reads that
81
+ // back into the { categories, audits } shape this function consumes. Performance is
82
+ // excluded by the tool (covered by web-vitals); thresholds.lighthouse.performance is
83
+ // simply skipped below when its category is absent.
84
+ const auditPromise = browser.lighthouse(url);
45
85
  const timeoutPromise = new Promise((_, reject) =>
46
86
  setTimeout(() => reject(new Error(`Lighthouse timed out after ${LIGHTHOUSE_TIMEOUT_MS / 1000}s`)), LIGHTHOUSE_TIMEOUT_MS)
47
87
  );
48
- const result = await Promise.race([auditPromise, timeoutPromise]);
88
+ const result = parseLighthouseReport(await Promise.race([auditPromise, timeoutPromise]));
49
89
 
50
90
  const categories = result?.categories ?? {};
51
91
  const audits = result?.audits ?? {};
@@ -181,11 +181,14 @@ export async function createMcpClient() {
181
181
  // MCP returns { content: [{ type, text|data }] } — extract the value
182
182
  const content = result?.content;
183
183
  if (Array.isArray(content) && content.length > 0) {
184
- const item = content[0];
185
- if (item.type === 'image') {
186
- // take_screenshot returns base64 image data return in a shape callers expect
187
- return { data: item.data, mimeType: item.mimeType ?? 'image/png' };
184
+ // take_screenshot returns [text caption, image] — the image is NOT content[0],
185
+ // so scan the whole array for it. Reading only content[0] returned the caption
186
+ // string and starved every screenshot consumer of image data.
187
+ const img = content.find(c => c.type === 'image');
188
+ if (img) {
189
+ return { data: img.data, mimeType: img.mimeType ?? 'image/png' };
188
190
  }
191
+ const item = content[0];
189
192
  if (item.type === 'text') {
190
193
  const text = item.text;
191
194
  // chrome-devtools-mcp wraps evaluate_script results in a markdown code block: