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.
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +879 -0
- package/package.json +69 -0
- package/src/adapters/browser.js +82 -0
- package/src/argus.js +8 -0
- package/src/batch-runner.js +8 -0
- package/src/cli/init.js +314 -0
- package/src/config/schema.js +108 -0
- package/src/config/targets.js +309 -0
- package/src/domain/finding.js +25 -0
- package/src/mcp-server.js +156 -0
- package/src/orchestration/crawl-and-report.js +16 -0
- package/src/orchestration/dispatcher.js +263 -0
- package/src/orchestration/env-comparison.js +498 -0
- package/src/orchestration/orchestrator.js +1128 -0
- package/src/orchestration/report-processor.js +134 -0
- package/src/orchestration/slack-notifier.js +337 -0
- package/src/orchestration/watch-mode.js +316 -0
- package/src/registry.js +18 -0
- package/src/server/index.js +94 -0
- package/src/server/interaction-handler.js +126 -0
- package/src/server/slash-command-handler.js +185 -0
- package/src/utils/api-frequency.js +128 -0
- package/src/utils/baseline-manager.js +255 -0
- package/src/utils/codebase-analyzer.js +299 -0
- package/src/utils/content-analyzer.js +155 -0
- package/src/utils/contract-validator.js +178 -0
- package/src/utils/css-analyzer.js +407 -0
- package/src/utils/diff.js +189 -0
- package/src/utils/flakiness-detector.js +82 -0
- package/src/utils/flow-runner.js +572 -0
- package/src/utils/github-reporter.js +310 -0
- package/src/utils/hover-analyzer.js +214 -0
- package/src/utils/html-reporter.js +301 -0
- package/src/utils/issues-analyzer.js +171 -0
- package/src/utils/keyboard-analyzer.js +141 -0
- package/src/utils/lighthouse-checker.js +120 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/login-orchestrator.js +99 -0
- package/src/utils/mcp-client.js +264 -0
- package/src/utils/mcp-parsers.js +57 -0
- package/src/utils/memory-analyzer.js +270 -0
- package/src/utils/network-timing-analyzer.js +76 -0
- package/src/utils/parallel-crawler.js +28 -0
- package/src/utils/responsive-analyzer.js +253 -0
- package/src/utils/retry.js +36 -0
- package/src/utils/route-discoverer.js +306 -0
- package/src/utils/security-analyzer.js +302 -0
- package/src/utils/seo-analyzer.js +164 -0
- package/src/utils/session-manager.js +12 -0
- package/src/utils/session-persistence.js +214 -0
- package/src/utils/severity-overrides.js +91 -0
- package/src/utils/slack-guard.js +18 -0
- package/src/utils/slug.js +8 -0
- package/src/utils/snapshot-analyzer.js +330 -0
- package/src/utils/telemetry.js +190 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus Lighthouse Checker (extracted D2.5)
|
|
3
|
+
*
|
|
4
|
+
* Extracted from crawl-and-report.js so test-harness/validate.js can import
|
|
5
|
+
* checkLighthouse directly without pulling in the Slack-initialised orchestrator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { registerExpensive } from '../registry.js';
|
|
9
|
+
import { thresholds } from '../config/targets.js';
|
|
10
|
+
import { childLogger } from './logger.js';
|
|
11
|
+
|
|
12
|
+
const logger = childLogger('lighthouse-checker');
|
|
13
|
+
|
|
14
|
+
const LIGHTHOUSE_LABELS = {
|
|
15
|
+
accessibility: 'Accessibility',
|
|
16
|
+
performance: 'Performance',
|
|
17
|
+
seo: 'SEO',
|
|
18
|
+
'best-practices': 'Best Practices',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run a full Lighthouse audit (accessibility, performance, SEO, best-practices).
|
|
23
|
+
*
|
|
24
|
+
* Each category is scored:
|
|
25
|
+
* score < threshold.critical → 'critical' violation
|
|
26
|
+
* score < threshold.warning → 'warning' violation
|
|
27
|
+
*
|
|
28
|
+
* Individual failing audit items (score === 0) are also surfaced.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
31
|
+
* @param {string} url - URL being tested
|
|
32
|
+
* @returns {Promise<object[]>} Lighthouse violation findings
|
|
33
|
+
*/
|
|
34
|
+
export async function checkLighthouse(browser, url) {
|
|
35
|
+
const violations = [];
|
|
36
|
+
|
|
37
|
+
// Lighthouse can hang indefinitely on heavy SPAs or when Chrome is under load.
|
|
38
|
+
// 120 s is generous — a real Lighthouse run completes in 15–30 s on most pages.
|
|
39
|
+
const LIGHTHOUSE_TIMEOUT_MS = parseInt(process.env.ARGUS_LIGHTHOUSE_TIMEOUT ?? '120000', 10);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const auditPromise = browser.lighthouse(url, {
|
|
43
|
+
categories: ['accessibility', 'performance', 'seo', 'best-practices'],
|
|
44
|
+
});
|
|
45
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
46
|
+
setTimeout(() => reject(new Error(`Lighthouse timed out after ${LIGHTHOUSE_TIMEOUT_MS / 1000}s`)), LIGHTHOUSE_TIMEOUT_MS)
|
|
47
|
+
);
|
|
48
|
+
const result = await Promise.race([auditPromise, timeoutPromise]);
|
|
49
|
+
|
|
50
|
+
const categories = result?.categories ?? {};
|
|
51
|
+
const audits = result?.audits ?? {};
|
|
52
|
+
|
|
53
|
+
for (const [catKey, catThresholds] of Object.entries(thresholds.lighthouse)) {
|
|
54
|
+
const catData = categories[catKey]
|
|
55
|
+
?? categories[catKey.replace('-', '_')]
|
|
56
|
+
?? categories[catKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
|
|
57
|
+
const score = catData?.score ?? result?.[catKey]?.score ?? null;
|
|
58
|
+
if (score == null) continue;
|
|
59
|
+
|
|
60
|
+
const pct = Math.round(score * 100);
|
|
61
|
+
const label = LIGHTHOUSE_LABELS[catKey];
|
|
62
|
+
|
|
63
|
+
if (pct < catThresholds.critical) {
|
|
64
|
+
violations.push({
|
|
65
|
+
type: 'lighthouse_score',
|
|
66
|
+
category: catKey,
|
|
67
|
+
score: pct,
|
|
68
|
+
threshold: catThresholds.critical,
|
|
69
|
+
message: `Lighthouse ${label} score ${pct}/100 — critical (threshold: ${catThresholds.critical})`,
|
|
70
|
+
severity: 'critical',
|
|
71
|
+
url,
|
|
72
|
+
});
|
|
73
|
+
} else if (pct < catThresholds.warning) {
|
|
74
|
+
violations.push({
|
|
75
|
+
type: 'lighthouse_score',
|
|
76
|
+
category: catKey,
|
|
77
|
+
score: pct,
|
|
78
|
+
threshold: catThresholds.warning,
|
|
79
|
+
message: `Lighthouse ${label} score ${pct}/100 — needs improvement (threshold: ${catThresholds.warning})`,
|
|
80
|
+
severity: 'warning',
|
|
81
|
+
url,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [auditId, audit] of Object.entries(audits)) {
|
|
87
|
+
if (audit.score == null || audit.score !== 0) continue;
|
|
88
|
+
if (audit.details?.type === 'manual') continue;
|
|
89
|
+
|
|
90
|
+
const auditCategory = Object.entries(categories).find(([, cat]) =>
|
|
91
|
+
cat?.auditRefs?.some?.(ref => ref.id === auditId)
|
|
92
|
+
)?.[0] ?? 'unknown';
|
|
93
|
+
|
|
94
|
+
const label = LIGHTHOUSE_LABELS[auditCategory] ?? auditCategory;
|
|
95
|
+
|
|
96
|
+
violations.push({
|
|
97
|
+
type: 'lighthouse_audit',
|
|
98
|
+
category: auditCategory,
|
|
99
|
+
auditId,
|
|
100
|
+
title: audit.title,
|
|
101
|
+
message: `[${label}] ${audit.title}${audit.description ? ' — ' + audit.description.slice(0, 120) : ''}`,
|
|
102
|
+
severity: 'warning',
|
|
103
|
+
url,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.warn(`[ARGUS] Lighthouse audit skipped for ${url}: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return violations;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Self-registration ─────────────────────────────────────────────────────────
|
|
115
|
+
registerExpensive({
|
|
116
|
+
name: 'lighthouse',
|
|
117
|
+
async analyze(browser, url) {
|
|
118
|
+
return checkLighthouse(browser, url);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pino structured logger for Argus.
|
|
3
|
+
*
|
|
4
|
+
* Usage in each module:
|
|
5
|
+
* import { childLogger } from '../utils/logger.js';
|
|
6
|
+
* const logger = childLogger('module-name');
|
|
7
|
+
*
|
|
8
|
+
* Environment variables:
|
|
9
|
+
* ARGUS_LOG_LEVEL — log level (default: 'info'). Set to 'debug' for MCP call details.
|
|
10
|
+
* ARGUS_LOG_PRETTY — '1' or any truthy value: force pino-pretty human-readable output.
|
|
11
|
+
* '0' or empty string: force JSON output (useful in CI).
|
|
12
|
+
* Unset: auto-detect — pino-pretty when stdout is a TTY, JSON otherwise.
|
|
13
|
+
*
|
|
14
|
+
* JSON output (default in CI) is compatible with Datadog / Grafana Loki / CloudWatch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import pino from 'pino';
|
|
18
|
+
|
|
19
|
+
function usePrettyOutput() {
|
|
20
|
+
const env = process.env.ARGUS_LOG_PRETTY;
|
|
21
|
+
if (env !== undefined) return env !== '0' && env !== '';
|
|
22
|
+
return process.stdout.isTTY ?? false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createLogger() {
|
|
26
|
+
const level = process.env.ARGUS_LOG_LEVEL ?? 'info';
|
|
27
|
+
if (usePrettyOutput()) {
|
|
28
|
+
try {
|
|
29
|
+
return pino({ level, transport: { target: 'pino-pretty', options: { colorize: true } } });
|
|
30
|
+
} catch {
|
|
31
|
+
// pino-pretty not installed or failed to load — fall back to JSON
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return pino({ level });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const logger = createLogger();
|
|
38
|
+
|
|
39
|
+
export const childLogger = (module) => logger.child({ module });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login Orchestrator — run login flows and manage mid-run session refresh.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from session-manager.js (v9.1.7). Handles:
|
|
5
|
+
* - runLoginFlow: execute a targets.js auth.steps flow
|
|
6
|
+
* - refreshSession: detect expiring sessions and re-login proactively
|
|
7
|
+
*
|
|
8
|
+
* Uses a lock file to prevent concurrent shards from running redundant
|
|
9
|
+
* login flows when ARGUS_CONCURRENCY > 1.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import { runFlow } from './flow-runner.js';
|
|
14
|
+
import { saveSession } from './session-persistence.js';
|
|
15
|
+
import { childLogger } from './logger.js';
|
|
16
|
+
|
|
17
|
+
const logger = childLogger('login-orchestrator');
|
|
18
|
+
|
|
19
|
+
// ── Login Flow Runner ───────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Execute a login flow defined as a steps array in targets.js.
|
|
23
|
+
*
|
|
24
|
+
* Delegates to flow-runner.js runFlow — same step DSL (navigate, fill, click,
|
|
25
|
+
* press_key, waitFor, sleep, handle_dialog, assert).
|
|
26
|
+
*
|
|
27
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
28
|
+
* @param {string} baseUrl - Base URL prepended to path-relative navigate steps
|
|
29
|
+
* @param {object[]} steps - Step definitions (same DSL as flows[] in targets.js)
|
|
30
|
+
*/
|
|
31
|
+
export async function runLoginFlow(browser, baseUrl, steps) {
|
|
32
|
+
await runFlow({ name: 'login', steps }, baseUrl, browser);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Session Refresh (D7.6) ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Refresh the session mid-run if it is approaching expiry.
|
|
39
|
+
*
|
|
40
|
+
* Called between routes (before restoreSession). When the saved session has
|
|
41
|
+
* less than auth.sessionRefreshWindowMs of validity remaining, the full login
|
|
42
|
+
* flow is re-run and a fresh session is saved.
|
|
43
|
+
*
|
|
44
|
+
* No-ops when:
|
|
45
|
+
* - auth is null or has no steps (public crawl)
|
|
46
|
+
* - no session file exists yet (initial login not done)
|
|
47
|
+
* - the session still has more than refreshWindowMs remaining
|
|
48
|
+
*
|
|
49
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
50
|
+
* @param {object|null} auth - Auth config from targets.js
|
|
51
|
+
* @param {string} baseUrl - Base URL used for the login navigate step
|
|
52
|
+
* @returns {Promise<{ refreshed: boolean }>}
|
|
53
|
+
*/
|
|
54
|
+
export async function refreshSession(browser, auth, baseUrl) {
|
|
55
|
+
if (!auth?.steps?.length) return { refreshed: false };
|
|
56
|
+
|
|
57
|
+
const sessionFile = auth.sessionFile ?? '.argus-session.json';
|
|
58
|
+
const maxAgeMs = auth.sessionMaxAgeMs ?? 60 * 60 * 1000;
|
|
59
|
+
const refreshWindowMs = auth.sessionRefreshWindowMs ?? 5 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(sessionFile)) return { refreshed: false };
|
|
62
|
+
|
|
63
|
+
let state;
|
|
64
|
+
try {
|
|
65
|
+
state = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
return { refreshed: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const age = Date.now() - new Date(state.savedAt).getTime();
|
|
71
|
+
if (isNaN(age)) return { refreshed: false };
|
|
72
|
+
const remainingMs = maxAgeMs - age;
|
|
73
|
+
|
|
74
|
+
if (remainingMs > refreshWindowMs) return { refreshed: false };
|
|
75
|
+
|
|
76
|
+
logger.info(
|
|
77
|
+
`[ARGUS] Auth: session expires in ${Math.round(remainingMs / 1000)}s — refreshing login...`
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const lockFile = sessionFile + '.lock';
|
|
81
|
+
let lockFd = null;
|
|
82
|
+
try {
|
|
83
|
+
lockFd = fs.openSync(lockFile, 'wx');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code === 'EEXIST') {
|
|
86
|
+
logger.info('[ARGUS] Auth: refresh lock held by another shard — skipping duplicate login');
|
|
87
|
+
return { refreshed: false };
|
|
88
|
+
}
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await runLoginFlow(browser, baseUrl, auth.steps);
|
|
93
|
+
await saveSession(browser, sessionFile);
|
|
94
|
+
return { refreshed: true };
|
|
95
|
+
} finally {
|
|
96
|
+
if (lockFd !== null) { try { fs.closeSync(lockFd); } catch {} }
|
|
97
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS MCP Client — Headless CI Mode
|
|
3
|
+
*
|
|
4
|
+
* In Claude Code (interactive), MCP tools are called natively by the agent.
|
|
5
|
+
* In CI (GitHub Actions, headless), this module spawns the chrome-devtools-mcp
|
|
6
|
+
* process and communicates via JSON-RPC over stdio, wrapping each tool as an
|
|
7
|
+
* async function with the same signature our orchestration scripts expect.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const mcp = await createMcpClient();
|
|
11
|
+
* await mcp.navigate_page({ url: 'http://localhost:3000' });
|
|
12
|
+
* const msgs = await mcp.list_console_messages();
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import { childLogger } from './logger.js';
|
|
17
|
+
|
|
18
|
+
const logger = childLogger('mcp-client');
|
|
19
|
+
|
|
20
|
+
// Validate MCP_BROWSER_URL before embedding it in a shell:true spawn argument.
|
|
21
|
+
// Two-step defense:
|
|
22
|
+
// 1. new URL() rejects malformed/non-http(s) values.
|
|
23
|
+
// 2. Shell-metacharacter check rejects valid URLs whose query strings contain
|
|
24
|
+
// &, |, ;, backtick, $() etc. — new URL().toString() preserves & in query
|
|
25
|
+
// strings (valid URL syntax), but & is a shell background-operator that
|
|
26
|
+
// would split the spawn command on both bash and cmd.exe.
|
|
27
|
+
// A legitimate Chrome remote-debug URL is always http(s)://host:port with
|
|
28
|
+
// no path or query string, so this check never fires in practice.
|
|
29
|
+
const _rawBrowserUrl = process.env.MCP_BROWSER_URL ?? 'http://127.0.0.1:9222';
|
|
30
|
+
let BROWSER_URL;
|
|
31
|
+
try {
|
|
32
|
+
const _parsed = new URL(_rawBrowserUrl);
|
|
33
|
+
if (_parsed.protocol !== 'http:' && _parsed.protocol !== 'https:') {
|
|
34
|
+
throw new Error('protocol must be http or https');
|
|
35
|
+
}
|
|
36
|
+
BROWSER_URL = _parsed.toString();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new Error(`[ARGUS] Invalid MCP_BROWSER_URL "${_rawBrowserUrl}": ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
// Shell-metacharacter guard — must run AFTER URL re-serialization.
|
|
41
|
+
const _SHELL_META = /[&|;<>`${}()\n\r!"]/;
|
|
42
|
+
if (_SHELL_META.test(BROWSER_URL)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[ARGUS] MCP_BROWSER_URL contains shell-unsafe characters — ` +
|
|
45
|
+
`use a plain http(s)://host:port URL (got: "${BROWSER_URL}")`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Unwrap an evaluate_script result to its plain value.
|
|
51
|
+
*
|
|
52
|
+
* MCP clients may return results in different shapes depending on whether they
|
|
53
|
+
* are running in interactive mode (Claude Code native) or headless CI mode:
|
|
54
|
+
* - { result: value } — some interactive-mode responses
|
|
55
|
+
* - value — headless client already extracts the value
|
|
56
|
+
* - null / undefined — script failed or Chrome not connected
|
|
57
|
+
*
|
|
58
|
+
* @param {any} raw - Raw return value from mcp.evaluate_script(...)
|
|
59
|
+
* @returns {any} The unwrapped value
|
|
60
|
+
*/
|
|
61
|
+
export function unwrapEval(raw) {
|
|
62
|
+
if (raw == null) return null;
|
|
63
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) return raw.result ?? raw;
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
const TOOL_TIMEOUT_MS = parseInt(process.env.MCP_TOOL_TIMEOUT_MS ?? '30000', 10);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create an MCP client that wraps chrome-devtools-mcp via JSON-RPC over stdio.
|
|
70
|
+
* @returns {Promise<object>} Object with all MCP tool methods
|
|
71
|
+
*/
|
|
72
|
+
export async function createMcpClient() {
|
|
73
|
+
// On Windows, npx is npx.cmd — shell:true resolves this cross-platform.
|
|
74
|
+
const proc = spawn('npx', [
|
|
75
|
+
'-y', 'chrome-devtools-mcp@latest',
|
|
76
|
+
`--browser-url=${BROWSER_URL}`,
|
|
77
|
+
'--headless=true',
|
|
78
|
+
'--viewport=1920x1080',
|
|
79
|
+
], {
|
|
80
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
81
|
+
shell: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
let messageId = 1;
|
|
85
|
+
const pending = new Map(); // id → { resolve, reject }
|
|
86
|
+
let buffer = '';
|
|
87
|
+
|
|
88
|
+
// Parse newline-delimited JSON-RPC responses from stdout
|
|
89
|
+
// Propagate stdin write errors — if the MCP process closes unexpectedly or the
|
|
90
|
+
// write buffer fills, stdin emits 'error'. Without this listener the error is an
|
|
91
|
+
// unhandled EventEmitter exception that crashes the process. Reject all pending calls
|
|
92
|
+
// so callers get a meaningful error instead of waiting for the 30 s timeout.
|
|
93
|
+
proc.stdin.on('error', err => {
|
|
94
|
+
logger.error('[ARGUS] MCP stdin error:', err.message);
|
|
95
|
+
for (const { reject } of pending.values()) {
|
|
96
|
+
reject(new Error(`MCP stdin error: ${err.message}`));
|
|
97
|
+
}
|
|
98
|
+
pending.clear();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
102
|
+
proc.stdout.on('data', (chunk) => {
|
|
103
|
+
if (buffer.length + chunk.length > MAX_BUFFER_BYTES) {
|
|
104
|
+
logger.error('[ARGUS] MCP stdout buffer overflow — discarding buffer');
|
|
105
|
+
buffer = '';
|
|
106
|
+
}
|
|
107
|
+
buffer += chunk.toString();
|
|
108
|
+
const lines = buffer.split(/\r?\n/);
|
|
109
|
+
buffer = lines.pop(); // keep incomplete line in buffer
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
try {
|
|
113
|
+
const msg = JSON.parse(line);
|
|
114
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
115
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
116
|
+
pending.delete(msg.id);
|
|
117
|
+
if (msg.error) {
|
|
118
|
+
reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
119
|
+
} else {
|
|
120
|
+
resolve(msg.result);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// non-JSON line from process — ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
proc.on('exit', (code, signal) => {
|
|
130
|
+
if (code !== 0 || signal) {
|
|
131
|
+
for (const { reject } of pending.values()) {
|
|
132
|
+
reject(new Error(`MCP process exited: code=${code}, signal=${signal}`));
|
|
133
|
+
}
|
|
134
|
+
pending.clear();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Send JSON-RPC initialize handshake
|
|
139
|
+
await call('initialize', {
|
|
140
|
+
protocolVersion: '2024-11-05',
|
|
141
|
+
capabilities: {},
|
|
142
|
+
clientInfo: { name: 'argus', version: '1.0.0' },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Call an MCP tool by name with params.
|
|
147
|
+
* @param {string} method - JSON-RPC method name
|
|
148
|
+
* @param {object} params
|
|
149
|
+
* @returns {Promise<any>}
|
|
150
|
+
*/
|
|
151
|
+
function call(method, params = {}) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const id = messageId++;
|
|
154
|
+
const request = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
155
|
+
pending.set(id, { resolve, reject });
|
|
156
|
+
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
if (pending.has(id)) {
|
|
159
|
+
pending.delete(id);
|
|
160
|
+
reject(new Error(`MCP tool timeout: ${method} (${TOOL_TIMEOUT_MS}ms)`));
|
|
161
|
+
}
|
|
162
|
+
}, TOOL_TIMEOUT_MS);
|
|
163
|
+
|
|
164
|
+
// Clear timer on resolution
|
|
165
|
+
const { resolve: origResolve, reject: origReject } = pending.get(id);
|
|
166
|
+
pending.set(id, {
|
|
167
|
+
resolve: (v) => { clearTimeout(timer); origResolve(v); },
|
|
168
|
+
reject: (e) => { clearTimeout(timer); origReject(e); },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proc.stdin.write(request, (err) => {
|
|
172
|
+
if (err && pending.has(id)) {
|
|
173
|
+
const { reject: rej } = pending.get(id);
|
|
174
|
+
pending.delete(id);
|
|
175
|
+
rej(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Call an MCP tool (tools/call JSON-RPC method).
|
|
183
|
+
*/
|
|
184
|
+
function tool(name, args = {}) {
|
|
185
|
+
return call('tools/call', { name, arguments: args })
|
|
186
|
+
.then(result => {
|
|
187
|
+
// MCP returns { content: [{ type, text|data }] } — extract the value
|
|
188
|
+
const content = result?.content;
|
|
189
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
190
|
+
const item = content[0];
|
|
191
|
+
if (item.type === 'image') {
|
|
192
|
+
// take_screenshot returns base64 image data — return in a shape callers expect
|
|
193
|
+
return { data: item.data, mimeType: item.mimeType ?? 'image/png' };
|
|
194
|
+
}
|
|
195
|
+
if (item.type === 'text') {
|
|
196
|
+
const text = item.text;
|
|
197
|
+
// chrome-devtools-mcp wraps evaluate_script results in a markdown code block:
|
|
198
|
+
// "Script ran on page and returned:\n```json\n<value>\n```"
|
|
199
|
+
// \n? before closing fence — responses without a trailing newline
|
|
200
|
+
// before the ``` would not match and fall through to raw JSON.parse, which
|
|
201
|
+
// then fails because the fence characters are still present in the text.
|
|
202
|
+
const mdMatch = text.match(/```(?:json)?\n([\s\S]*?)\n?```/);
|
|
203
|
+
if (mdMatch) {
|
|
204
|
+
try { return JSON.parse(mdMatch[1]); } catch { return mdMatch[1]; }
|
|
205
|
+
}
|
|
206
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Graceful shutdown — idempotent and rejects all in-flight calls immediately.
|
|
214
|
+
// Without this, pending promises wait until TOOL_TIMEOUT_MS (30 s) after
|
|
215
|
+
// shutdown, making CI teardown slow and leaving dangling rejections.
|
|
216
|
+
let closed = false;
|
|
217
|
+
function close() {
|
|
218
|
+
if (closed) return;
|
|
219
|
+
closed = true;
|
|
220
|
+
for (const { reject } of pending.values()) {
|
|
221
|
+
reject(new Error('MCP client closed'));
|
|
222
|
+
}
|
|
223
|
+
pending.clear();
|
|
224
|
+
proc.stdin.end();
|
|
225
|
+
proc.kill('SIGTERM');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build the mcp interface object matching what orchestration scripts expect
|
|
229
|
+
return {
|
|
230
|
+
navigate_page: (args) => tool('navigate_page', args),
|
|
231
|
+
list_pages: (args) => tool('list_pages', args),
|
|
232
|
+
new_page: (args) => tool('new_page', args),
|
|
233
|
+
select_page: (args) => tool('select_page', args),
|
|
234
|
+
close_page: (args) => tool('close_page', args),
|
|
235
|
+
take_screenshot: (args) => tool('take_screenshot', args),
|
|
236
|
+
take_snapshot: (args) => tool('take_snapshot', args),
|
|
237
|
+
list_console_messages: (args) => tool('list_console_messages', args),
|
|
238
|
+
get_console_message: (args) => tool('get_console_message', args),
|
|
239
|
+
list_network_requests: (args) => tool('list_network_requests', args),
|
|
240
|
+
get_network_request: (args) => tool('get_network_request', args),
|
|
241
|
+
evaluate_script: (args) => tool('evaluate_script', args),
|
|
242
|
+
wait_for: (args) => tool('wait_for', args),
|
|
243
|
+
click: (args) => tool('click', args),
|
|
244
|
+
fill: (args) => tool('fill', args),
|
|
245
|
+
fill_form: (args) => tool('fill_form', args),
|
|
246
|
+
hover: (args) => tool('hover', args),
|
|
247
|
+
type_text: (args) => tool('type_text', args),
|
|
248
|
+
press_key: (args) => tool('press_key', args),
|
|
249
|
+
resize_page: (args) => tool('resize_page', args),
|
|
250
|
+
emulate: (args) => tool('emulate', args),
|
|
251
|
+
performance_start_trace: (args) => tool('performance_start_trace', args),
|
|
252
|
+
performance_stop_trace: (args) => tool('performance_stop_trace', args),
|
|
253
|
+
performance_analyze_insight: (args) => tool('performance_analyze_insight', args),
|
|
254
|
+
take_memory_snapshot: (args) => tool('take_memory_snapshot', args),
|
|
255
|
+
lighthouse_audit: (args) => tool('lighthouse_audit', args),
|
|
256
|
+
handle_dialog: (args) => tool('handle_dialog', args),
|
|
257
|
+
drag: (args) => tool('drag', args),
|
|
258
|
+
upload_file: (args) => tool('upload_file', args),
|
|
259
|
+
select_option: (args) => tool('select_option', args),
|
|
260
|
+
emulate_cpu: (args) => tool('emulate_cpu', args),
|
|
261
|
+
emulate_network: (args) => tool('emulate_network', args),
|
|
262
|
+
close,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-format parsers for chrome-devtools-mcp responses.
|
|
3
|
+
*
|
|
4
|
+
* chrome-devtools-mcp@latest returns list_console_messages and
|
|
5
|
+
* list_network_requests as human-readable markdown text rather than JSON.
|
|
6
|
+
* These parsers extract structured objects so the rest of the pipeline
|
|
7
|
+
* can work with consistent data shapes regardless of MCP response format.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from watch-mode.js and promoted to a shared module so
|
|
10
|
+
* CdpBrowserAdapter can use them in listConsole() and listNetwork().
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { normalizeArray } from './flow-runner.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse the text response from list_console_messages.
|
|
17
|
+
* Format: "msgid=N [level] text (N args)\n..."
|
|
18
|
+
* @param {any} raw - Raw value returned by the MCP tool
|
|
19
|
+
* @returns {object[]}
|
|
20
|
+
*/
|
|
21
|
+
export function parseConsoleMsgResponse(raw) {
|
|
22
|
+
if (!raw) return [];
|
|
23
|
+
if (Array.isArray(raw)) return raw;
|
|
24
|
+
if (typeof raw === 'object') return normalizeArray(raw);
|
|
25
|
+
if (typeof raw !== 'string') return [];
|
|
26
|
+
const msgs = [];
|
|
27
|
+
const re = /msgid=(\d+)\s+\[(\w+)\]\s+(.*?)(?:\s+\(\d+\s+args?\))?$/gm;
|
|
28
|
+
let m;
|
|
29
|
+
while ((m = re.exec(raw)) !== null) {
|
|
30
|
+
const [, msgid, rawLevel, text] = m;
|
|
31
|
+
const level = rawLevel === 'warn' ? 'warning' : rawLevel.toLowerCase();
|
|
32
|
+
msgs.push({ _msgid: Number(msgid), level, text, message: text });
|
|
33
|
+
}
|
|
34
|
+
return msgs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse the text response from list_network_requests.
|
|
39
|
+
* Format: "reqid=N METHOD URL [STATUS]\n..."
|
|
40
|
+
* @param {any} raw - Raw value returned by the MCP tool
|
|
41
|
+
* @returns {object[]}
|
|
42
|
+
*/
|
|
43
|
+
export function parseNetworkReqResponse(raw) {
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
if (Array.isArray(raw)) return raw;
|
|
46
|
+
if (typeof raw === 'object') return normalizeArray(raw);
|
|
47
|
+
if (typeof raw !== 'string') return [];
|
|
48
|
+
const reqs = [];
|
|
49
|
+
const re = /reqid=(\d+)\s+(\w+)\s+(\S+)\s+\[(\d+)\]/gm;
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(raw)) !== null) {
|
|
52
|
+
const [, reqid, method, url, statusStr] = m;
|
|
53
|
+
const status = parseInt(statusStr, 10);
|
|
54
|
+
reqs.push({ _reqid: Number(reqid), requestId: Number(reqid), method, url, status, statusCode: status });
|
|
55
|
+
}
|
|
56
|
+
return reqs;
|
|
57
|
+
}
|