argusqa-os 9.5.1 → 9.5.5

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
+ });
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ARGUS Visual Regression Analyzer (Sprint 3 — A8)
3
+ *
4
+ * Per-route visual regression detection via screenshot baseline comparison.
5
+ * Takes a PNG screenshot, compares it pixel-by-pixel against a stored baseline,
6
+ * and emits a finding when the diff exceeds the configured threshold.
7
+ *
8
+ * Works in headless Chrome — uses the Performance API screenshot path, not Lighthouse.
9
+ *
10
+ * Findings emitted:
11
+ * visual_baseline_created — info, first run for a URL (baseline saved, no prior exists)
12
+ * visual_regression — warning ≥0.1%, critical ≥5% pixels changed
13
+ * visual_diff_summary — info, always emitted with full diff metrics
14
+ *
15
+ * Baseline storage: {config.outputDir}/baselines/screenshots/{slug}.png
16
+ * Override via opts.baselineDir for testing.
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import os from 'os';
22
+ import { PNG } from 'pngjs';
23
+ import pixelmatch from 'pixelmatch';
24
+ import { registerExpensive } from '../registry.js';
25
+ import { childLogger } from './logger.js';
26
+ import { slugify } from './slug.js';
27
+ import { config, thresholds } from '../config/targets.js';
28
+
29
+ const logger = childLogger('visual-diff');
30
+
31
+ // ── Thresholds ─────────────────────────────────────────────────────────────────
32
+ const WARN_PERCENT = thresholds.visual?.warnPercent ?? 0.1; // %
33
+ const CRIT_PERCENT = thresholds.visual?.critPercent ?? 5.0; // %
34
+
35
+ // ── PNG helpers ────────────────────────────────────────────────────────────────
36
+
37
+ function cropPng(img, width, height) {
38
+ if (img.width === width && img.height === height) return img;
39
+ const out = new PNG({ width, height });
40
+ for (let y = 0; y < height; y++) {
41
+ for (let x = 0; x < width; x++) {
42
+ const src = (y * img.width + x) * 4;
43
+ const dst = (y * width + x) * 4;
44
+ out.data[dst] = img.data[src];
45
+ out.data[dst + 1] = img.data[src + 1];
46
+ out.data[dst + 2] = img.data[src + 2];
47
+ out.data[dst + 3] = img.data[src + 3];
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Compare two PNG Buffers pixel-by-pixel using pixelmatch.
55
+ *
56
+ * @param {Buffer} bufA
57
+ * @param {Buffer} bufB
58
+ * @returns {{ diffPixels: number, totalPixels: number, diffPercent: number }}
59
+ */
60
+ function comparePngBuffers(bufA, bufB) {
61
+ const imgA = PNG.sync.read(bufA);
62
+ const imgB = PNG.sync.read(bufB);
63
+
64
+ const width = Math.min(imgA.width, imgB.width);
65
+ const height = Math.min(imgA.height, imgB.height);
66
+
67
+ if (width === 0 || height === 0) {
68
+ throw new Error(`visual-diff: zero-dimension PNG (${imgA.width}×${imgA.height} vs ${imgB.width}×${imgB.height})`);
69
+ }
70
+
71
+ const croppedA = cropPng(imgA, width, height);
72
+ const croppedB = cropPng(imgB, width, height);
73
+ const diff = new PNG({ width, height });
74
+
75
+ const diffPixels = pixelmatch(croppedA.data, croppedB.data, diff.data, width, height, { threshold: 0.1 });
76
+ const totalPixels = width * height;
77
+ const diffPercent = (diffPixels / totalPixels) * 100;
78
+
79
+ return { diffPixels, totalPixels, diffPercent };
80
+ }
81
+
82
+ // ── Public API ─────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Capture a screenshot of `url` and compare against the stored baseline.
86
+ *
87
+ * First run (no baseline): saves the screenshot as the new baseline and returns
88
+ * a `visual_baseline_created` info finding.
89
+ *
90
+ * Subsequent runs: compares pixel-by-pixel and emits `visual_regression` when
91
+ * the diff exceeds the threshold, plus always emits `visual_diff_summary`.
92
+ *
93
+ * @param {object} browser - CdpBrowserAdapter
94
+ * @param {string} url - Page URL (already loaded)
95
+ * @param {object} [opts]
96
+ * @param {string} [opts.baselineDir] - Override baseline storage directory
97
+ * @returns {Promise<object[]>}
98
+ */
99
+ export async function analyzeVisualRegression(browser, url, opts = {}) {
100
+ const findings = [];
101
+
102
+ // ── 1. Take screenshot ──────────────────────────────────────────────────────
103
+ // Use filePath so the MCP server writes the PNG to disk — take_screenshot
104
+ // returns an image content block, not { data: base64 }, so the filePath
105
+ // approach is the only reliable way to get raw PNG bytes in headless mode.
106
+ const tmpPath = path.join(os.tmpdir(), `argus-visual-${Date.now()}-${slugify(url)}.png`);
107
+ try {
108
+ await browser.screenshot({ format: 'png', filePath: tmpPath });
109
+ } catch (err) {
110
+ logger.warn(`[ARGUS] visual-diff: screenshot failed for ${url}: ${err.message}`);
111
+ return findings;
112
+ }
113
+
114
+ let currentBuf;
115
+ try {
116
+ currentBuf = fs.readFileSync(tmpPath);
117
+ } catch (err) {
118
+ logger.warn(`[ARGUS] visual-diff: could not read screenshot from ${tmpPath}: ${err.message}`);
119
+ return findings;
120
+ } finally {
121
+ try { fs.unlinkSync(tmpPath); } catch {}
122
+ }
123
+
124
+ if (!currentBuf || currentBuf.length === 0) {
125
+ logger.warn(`[ARGUS] visual-diff: empty screenshot for ${url}`);
126
+ return findings;
127
+ }
128
+
129
+ // ── 2. Resolve baseline path ────────────────────────────────────────────────
130
+ const baselineDir = opts.baselineDir ??
131
+ path.join(config.outputDir, 'baselines', 'screenshots');
132
+
133
+ try {
134
+ fs.mkdirSync(baselineDir, { recursive: true });
135
+ } catch (err) {
136
+ logger.warn(`[ARGUS] visual-diff: could not create baseline dir ${baselineDir}: ${err.message}`);
137
+ return findings;
138
+ }
139
+
140
+ const slug = slugify(url);
141
+ const baselinePath = path.join(baselineDir, `${slug}.png`);
142
+
143
+ // ── 3. First run: save baseline ─────────────────────────────────────────────
144
+ if (!fs.existsSync(baselinePath)) {
145
+ try {
146
+ fs.writeFileSync(baselinePath, currentBuf);
147
+ } catch (err) {
148
+ logger.warn(`[ARGUS] visual-diff: could not write baseline ${baselinePath}: ${err.message}`);
149
+ return findings;
150
+ }
151
+
152
+ findings.push({
153
+ type: 'visual_baseline_created',
154
+ message: `Visual baseline saved for ${url} — next run will compare against this snapshot`,
155
+ severity: 'info',
156
+ url,
157
+ baselinePath,
158
+ });
159
+ return findings;
160
+ }
161
+
162
+ // ── 4. Compare against existing baseline ────────────────────────────────────
163
+ let result;
164
+ try {
165
+ const baselineBuf = fs.readFileSync(baselinePath);
166
+ result = comparePngBuffers(baselineBuf, currentBuf);
167
+ } catch (err) {
168
+ logger.warn(`[ARGUS] visual-diff: comparison failed for ${url}: ${err.message}`);
169
+ return findings;
170
+ }
171
+
172
+ const { diffPixels, totalPixels, diffPercent } = result;
173
+
174
+ // ── 5. Emit regression finding if threshold exceeded ────────────────────────
175
+ if (diffPercent >= WARN_PERCENT) {
176
+ const sev = diffPercent >= CRIT_PERCENT ? 'critical' : 'warning';
177
+ findings.push({
178
+ type: 'visual_regression',
179
+ diffPercent: parseFloat(diffPercent.toFixed(3)),
180
+ diffPixels,
181
+ totalPixels,
182
+ threshold: WARN_PERCENT,
183
+ message: `Visual regression: ${diffPercent.toFixed(2)}% pixels changed — threshold ${WARN_PERCENT}% (warning) / ${CRIT_PERCENT}% (critical)`,
184
+ severity: sev,
185
+ url,
186
+ });
187
+ }
188
+
189
+ // ── 6. Summary — always emitted ─────────────────────────────────────────────
190
+ findings.push({
191
+ type: 'visual_diff_summary',
192
+ diffPercent: parseFloat(diffPercent.toFixed(3)),
193
+ diffPixels,
194
+ totalPixels,
195
+ message: `Visual diff: ${diffPercent.toFixed(3)}% (${diffPixels}/${totalPixels} pixels changed)`,
196
+ severity: 'info',
197
+ url,
198
+ });
199
+
200
+ return findings;
201
+ }
202
+
203
+ // ── Self-registration ──────────────────────────────────────────────────────────
204
+ registerExpensive({
205
+ name: 'visual',
206
+ analyze: (browser, url) => analyzeVisualRegression(browser, url),
207
+ });