argusqa-os 9.5.1 → 9.5.3

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.
@@ -17,15 +17,8 @@ import { childLogger } from './logger.js';
17
17
 
18
18
  const logger = childLogger('mcp-client');
19
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.
20
+ // Validate MCP_BROWSER_URL new URL() rejects malformed/non-http(s) values.
21
+ // A legitimate Chrome remote-debug URL is always http(s)://host:port.
29
22
  const _rawBrowserUrl = process.env.MCP_BROWSER_URL ?? 'http://127.0.0.1:9222';
30
23
  let BROWSER_URL;
31
24
  try {
@@ -37,14 +30,6 @@ try {
37
30
  } catch (e) {
38
31
  throw new Error(`[ARGUS] Invalid MCP_BROWSER_URL "${_rawBrowserUrl}": ${e.message}`);
39
32
  }
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
33
 
49
34
  /**
50
35
  * Unwrap an evaluate_script result to its plain value.
@@ -29,7 +29,7 @@ export async function withRetry(fn, { attempts, delayMs = 400, label = '' } = {}
29
29
  } catch (err) {
30
30
  if (i === maxAttempts - 1) throw err;
31
31
  const wait = delayMs * Math.pow(2, i);
32
- logger.debug(`[ARGUS] ${label ? label + ': ' : ''}retry ${i + 1}/${maxAttempts - 1} after ${wait}ms — ${err.message}`);
32
+ logger.debug(`[ARGUS] ${label ? label + ': ' : ''}retry ${i + 1}/${maxAttempts - 1} after ${wait}ms — ${err.constructor?.name ?? 'Error'}: ${err.message}`);
33
33
  await new Promise(r => setTimeout(r, wait));
34
34
  }
35
35
  }
@@ -104,11 +104,19 @@ export async function saveSession(browser, sessionFile) {
104
104
  };
105
105
 
106
106
  const dir = path.dirname(sessionFile);
107
- if (dir) fs.mkdirSync(dir, { recursive: true });
107
+ try {
108
+ if (dir) fs.mkdirSync(dir, { recursive: true });
109
+ } catch (err) {
110
+ throw new Error(`[ARGUS] saveSession: failed to create directory "${dir}": ${err.message}`);
111
+ }
108
112
 
109
113
  const tmpFile = `${sessionFile}.tmp`;
110
- fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
111
- fs.renameSync(tmpFile, sessionFile);
114
+ try {
115
+ fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
116
+ fs.renameSync(tmpFile, sessionFile);
117
+ } catch (err) {
118
+ throw new Error(`[ARGUS] saveSession: failed to write session file "${sessionFile}": ${err.message}`);
119
+ }
112
120
 
113
121
  const lsCount = Object.keys(state.localStorage).length;
114
122
  const ssCount = Object.keys(state.sessionStorage).length;
@@ -162,7 +170,11 @@ export async function restoreSession(browser, baseUrl, sessionFile) {
162
170
  } catch { /* URL parse failure — proceed and let Chrome handle it */ }
163
171
  }
164
172
 
165
- await browser.navigate(baseUrl);
173
+ const NAV_TIMEOUT_MS = 10000;
174
+ await Promise.race([
175
+ browser.navigate(baseUrl),
176
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`restoreSession: navigate to "${baseUrl}" timed out after ${NAV_TIMEOUT_MS}ms`)), NAV_TIMEOUT_MS)),
177
+ ]);
166
178
  await new Promise(r => setTimeout(r, 400));
167
179
 
168
180
  const restoreScript = buildRestoreScript(state);
@@ -0,0 +1,173 @@
1
+ /**
2
+ * ARGUS Theme Analyzer (Sprint 1 — A7: Theme & Dark Mode)
3
+ *
4
+ * Detects dark mode support gaps and theme consistency issues by:
5
+ * 1. Scanning all stylesheets for @media (prefers-color-scheme: dark) rules
6
+ * 2. Collecting :root CSS custom properties in light mode
7
+ * 3. Emulating dark mode via CDP, re-collecting custom properties
8
+ * 4. Flagging properties whose value does not change between modes
9
+ *
10
+ * Detections:
11
+ * theme_no_dark_mode — info — no @media (prefers-color-scheme: dark) rule anywhere
12
+ * theme_static_var — warning — CSS custom property identical in light + dark mode
13
+ * theme_summary — info — summary: dark mode supported/not, var count, screenshot taken
14
+ */
15
+
16
+ import { registerExpensive } from '../registry.js';
17
+ import { unwrapEval } from './mcp-client.js';
18
+ import { childLogger } from './logger.js';
19
+
20
+ const logger = childLogger('theme-analyzer');
21
+
22
+ // ── Page script ────────────────────────────────────────────────────────────────
23
+ // Injected via evaluate_script. Scans stylesheets and :root custom properties.
24
+ // Returns JSON: { hasDarkModeQuery, rootVars }
25
+ const THEME_SCAN_SCRIPT = `() => {
26
+ var result = { hasDarkModeQuery: false, rootVars: {} };
27
+
28
+ // Scan all stylesheets for @media (prefers-color-scheme: dark) rules
29
+ var sheets = Array.from(document.styleSheets);
30
+ for (var s = 0; s < sheets.length; s++) {
31
+ try {
32
+ var rules = Array.from(sheets[s].cssRules || []);
33
+ for (var r = 0; r < rules.length; r++) {
34
+ var rule = rules[r];
35
+ if (rule.type === 4 /* MEDIA_RULE */) {
36
+ var cond = rule.conditionText || (rule.media && rule.media.mediaText) || '';
37
+ if (cond.indexOf('prefers-color-scheme') !== -1 && cond.indexOf('dark') !== -1) {
38
+ result.hasDarkModeQuery = true;
39
+ }
40
+ }
41
+ }
42
+ } catch (e) { /* cross-origin stylesheet — skip */ }
43
+ }
44
+
45
+ // Collect all CSS custom properties declared on :root
46
+ var rootStyle = getComputedStyle(document.documentElement);
47
+ for (var i = 0; i < rootStyle.length; i++) {
48
+ var prop = rootStyle.item(i);
49
+ if (prop.charAt(0) === '-' && prop.charAt(1) === '-') {
50
+ result.rootVars[prop] = rootStyle.getPropertyValue(prop).trim();
51
+ }
52
+ }
53
+
54
+ return JSON.stringify(result);
55
+ }`;
56
+
57
+ // Names suggesting a color/theme token — only these are flagged as static vars
58
+ const COLOR_VAR_RE = /color|bg|background|text|foreground|surface|fill|stroke|border|shadow|ring|accent|primary|secondary|muted|card|popover|input|destructive/i;
59
+
60
+ // ── JSON parse helper ──────────────────────────────────────────────────────────
61
+ function parseJson(raw) {
62
+ try {
63
+ const str = unwrapEval(raw);
64
+ if (typeof str === 'object' && str !== null) return str;
65
+ return JSON.parse(str);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // ── Public API ─────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Analyse theme and dark mode support for a single page.
75
+ *
76
+ * @param {object} browser - CdpBrowserAdapter
77
+ * @param {string} url - Fully-qualified URL to analyse
78
+ * @returns {Promise<object[]>} Array of theme finding objects
79
+ */
80
+ export async function analyzeTheme(browser, url) {
81
+ const findings = [];
82
+
83
+ // Navigate and settle
84
+ try {
85
+ await browser.navigate(url);
86
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
87
+ await new Promise(r => setTimeout(r, 400));
88
+ } catch {
89
+ return findings;
90
+ }
91
+
92
+ // ── Light mode scan ──────────────────────────────────────────────────────────
93
+ let lightData;
94
+ try {
95
+ const raw = await browser.evaluate(THEME_SCAN_SCRIPT);
96
+ lightData = parseJson(raw);
97
+ } catch (err) {
98
+ logger.warn(`[ARGUS] theme-analyzer: light scan failed for ${url}: ${err.message}`);
99
+ return findings;
100
+ }
101
+ if (!lightData) return findings;
102
+
103
+ const lightVars = lightData.rootVars ?? {};
104
+ const varCount = Object.keys(lightVars).length;
105
+
106
+ // ── Detection 1: no dark mode media query ────────────────────────────────────
107
+ if (!lightData.hasDarkModeQuery) {
108
+ findings.push({
109
+ type: 'theme_no_dark_mode',
110
+ message: 'No @media (prefers-color-scheme: dark) rule detected — page has no dark mode support',
111
+ severity: 'info',
112
+ url,
113
+ });
114
+ }
115
+
116
+ // ── Dark mode emulation + comparison ────────────────────────────────────────
117
+ let darkData = null;
118
+ try {
119
+ await browser.emulateColorScheme('dark');
120
+ await new Promise(r => setTimeout(r, 300));
121
+ const raw = await browser.evaluate(THEME_SCAN_SCRIPT);
122
+ darkData = parseJson(raw);
123
+ } catch (err) {
124
+ logger.debug(`[ARGUS] theme-analyzer: dark mode emulation skipped for ${url}: ${err.message}`);
125
+ } finally {
126
+ try { await browser.emulateColorScheme('light'); } catch { /* restore best-effort */ }
127
+ }
128
+
129
+ // ── Detection 2: CSS custom properties that don't adapt to dark mode ─────────
130
+ if (darkData && lightData.hasDarkModeQuery) {
131
+ const darkVars = darkData.rootVars ?? {};
132
+ const staticVars = [];
133
+
134
+ for (const [name, lightVal] of Object.entries(lightVars)) {
135
+ const darkVal = darkVars[name];
136
+ if (darkVal !== undefined && darkVal === lightVal && COLOR_VAR_RE.test(name)) {
137
+ staticVars.push(name);
138
+ }
139
+ }
140
+
141
+ if (staticVars.length > 0) {
142
+ const preview = staticVars.slice(0, 3).join(', ');
143
+ const extra = staticVars.length > 3 ? ` (+${staticVars.length - 3} more)` : '';
144
+ findings.push({
145
+ type: 'theme_static_var',
146
+ vars: staticVars.slice(0, 10),
147
+ count: staticVars.length,
148
+ message: `${staticVars.length} color custom propert${staticVars.length === 1 ? 'y does' : 'ies do'} not change between light and dark mode: ${preview}${extra}`,
149
+ severity: 'warning',
150
+ url,
151
+ });
152
+ }
153
+ }
154
+
155
+ // ── Summary finding ──────────────────────────────────────────────────────────
156
+ findings.push({
157
+ type: 'theme_summary',
158
+ hasDarkMode: lightData.hasDarkModeQuery,
159
+ rootVarCount: varCount,
160
+ darkEmulated: darkData !== null,
161
+ message: `Theme: ${lightData.hasDarkModeQuery ? 'dark mode supported' : 'no dark mode'}, ${varCount} CSS custom propert${varCount === 1 ? 'y' : 'ies'} on :root`,
162
+ severity: 'info',
163
+ url,
164
+ });
165
+
166
+ return findings;
167
+ }
168
+
169
+ // ── Self-registration ─────────────────────────────────────────────────────────
170
+ registerExpensive({
171
+ name: 'theme',
172
+ analyze: (browser, url) => analyzeTheme(browser, url),
173
+ });