argusqa-os 9.5.0 → 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.
- package/README.md +16 -11
- package/glama.json +5 -1
- package/package.json +1 -1
- package/src/adapters/browser.js +5 -4
- package/src/adapters/figma.js +336 -0
- package/src/domain/finding.js +16 -1
- package/src/mcp-server.js +54 -3
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/orchestrator.js +47 -30
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +2 -1
- package/src/orchestration/watch-mode.js +1 -1
- package/src/registry.js +1 -1
- package/src/utils/css-analyzer.js +7 -0
- package/src/utils/design-fidelity-analyzer.js +685 -0
- package/src/utils/flow-runner.js +2 -0
- package/src/utils/html-reporter.js +1 -1
- package/src/utils/mcp-client.js +2 -17
- package/src/utils/mcp-parsers.js +1 -1
- package/src/utils/retry.js +1 -1
- package/src/utils/session-persistence.js +16 -4
- package/src/utils/theme-analyzer.js +173 -0
|
@@ -234,7 +234,7 @@ function buildHtml(report) {
|
|
|
234
234
|
|
|
235
235
|
${flowSection}
|
|
236
236
|
|
|
237
|
-
<p style="text-align:center;font-size:12px;color:#9ca3af;margin-top:32px">Generated by <strong>Argus</strong> · ${esc(
|
|
237
|
+
<p style="text-align:center;font-size:12px;color:#9ca3af;margin-top:32px">Generated by <strong>Argus</strong> · ${esc(runDate)}</p>
|
|
238
238
|
</div>
|
|
239
239
|
</body>
|
|
240
240
|
</html>`;
|
package/src/utils/mcp-client.js
CHANGED
|
@@ -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
|
|
21
|
-
//
|
|
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.
|
package/src/utils/mcp-parsers.js
CHANGED
|
@@ -46,7 +46,7 @@ export function parseNetworkReqResponse(raw) {
|
|
|
46
46
|
if (typeof raw === 'object') return normalizeArray(raw);
|
|
47
47
|
if (typeof raw !== 'string') return [];
|
|
48
48
|
const reqs = [];
|
|
49
|
-
const re = /reqid=(\d+)\s+(\w+)\s+(\S+)\s+\[(\d+)
|
|
49
|
+
const re = /reqid=(\d+)\s+(\w+)\s+(\S+)\s+\[(\d+)[^\]]*\]/gm;
|
|
50
50
|
let m;
|
|
51
51
|
while ((m = re.exec(raw)) !== null) {
|
|
52
52
|
const [, reqid, method, url, statusStr] = m;
|
package/src/utils/retry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
+
});
|