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,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Phase 3: Environment Comparison Engine
|
|
3
|
+
*
|
|
4
|
+
* Compares dev vs staging (or any two environments) for the same routes.
|
|
5
|
+
* Captures screenshots, DOM snapshots, console messages, and network requests
|
|
6
|
+
* from both environments, then diffs them across all four dimensions.
|
|
7
|
+
*
|
|
8
|
+
* Run: node src/orchestration/env-comparison.js
|
|
9
|
+
* Or invoke: runComparison(mcp) from Claude Code with MCP tools connected.
|
|
10
|
+
*
|
|
11
|
+
* MCP Tools Used:
|
|
12
|
+
* navigate_page, take_screenshot, take_snapshot, list_console_messages,
|
|
13
|
+
* list_network_requests, wait_for, evaluate_script
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import 'dotenv/config';
|
|
20
|
+
import { unwrapEval } from '../utils/mcp-client.js';
|
|
21
|
+
import { childLogger } from '../utils/logger.js';
|
|
22
|
+
|
|
23
|
+
const logger = childLogger('env-comparison');
|
|
24
|
+
import { normalizeArray } from '../utils/flow-runner.js';
|
|
25
|
+
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
26
|
+
|
|
27
|
+
import { comparisonRoutes, config } from '../config/targets.js';
|
|
28
|
+
import { compareScreenshots, diffDomSnapshots, diffNetworkRequests, diffConsoleMessages } from '../utils/diff.js';
|
|
29
|
+
import { postBugReport } from './slack-notifier.js';
|
|
30
|
+
import { CSS_ANALYSIS_SCRIPT, parseCssAnalysisResult } from '../utils/css-analyzer.js';
|
|
31
|
+
import { analyzeApiFrequency } from '../utils/api-frequency.js';
|
|
32
|
+
import { slugify } from '../utils/slug.js';
|
|
33
|
+
|
|
34
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const DEV_URL = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
36
|
+
const RAW_STAGING_URL = process.env.TARGET_STAGING_URL ?? '';
|
|
37
|
+
// Validate as a parseable URL with a non-localhost hostname — checking only against
|
|
38
|
+
// one hardcoded placeholder string misses 'TODO', 'your-url-here', http://localhost, etc.
|
|
39
|
+
const STAGING_URL_SET = (() => {
|
|
40
|
+
if (!RAW_STAGING_URL || RAW_STAGING_URL === 'https://staging.yourapp.com') return false;
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(RAW_STAGING_URL);
|
|
43
|
+
return u.hostname !== 'localhost' && u.hostname !== '127.0.0.1' && u.hostname !== '';
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
const STAGING_URL = RAW_STAGING_URL;
|
|
49
|
+
const OUTPUT_DIR = path.resolve(__dirname, '../../', config.outputDir);
|
|
50
|
+
const SCREENSHOT_THRESHOLD = config.screenshotDiffThreshold; // %
|
|
51
|
+
|
|
52
|
+
// ── Per-Environment Capture ────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Capture the full state of a page for comparison.
|
|
56
|
+
* Returns screenshot path, DOM snapshot, console messages, and network requests.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} url - Full URL to capture
|
|
59
|
+
* @param {string} label - Label for file naming (e.g., 'dev', 'staging')
|
|
60
|
+
* @param {string} routeName - Human-readable route name
|
|
61
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
62
|
+
* @returns {object} Captured page state
|
|
63
|
+
*/
|
|
64
|
+
async function capturePage(url, label, routeName, browser) {
|
|
65
|
+
logger.info(`[ARGUS] Capturing ${label}: ${url}`);
|
|
66
|
+
|
|
67
|
+
// Snapshot buffer counts BEFORE navigation so staging capture does not
|
|
68
|
+
// include dev's accumulated console messages and network requests from the prior capture.
|
|
69
|
+
const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
|
|
70
|
+
const networkBaseline = (await browser.listNetwork().catch(() => [])).length;
|
|
71
|
+
|
|
72
|
+
await browser.navigate(url);
|
|
73
|
+
await new Promise(r => setTimeout(r, config.pageSettleMs));
|
|
74
|
+
|
|
75
|
+
// Screenshot
|
|
76
|
+
const screenshotPath = path.join(
|
|
77
|
+
OUTPUT_DIR,
|
|
78
|
+
`compare-${slugify(routeName)}-${label}-${Date.now()}.png`
|
|
79
|
+
);
|
|
80
|
+
const screenshotData = await browser.screenshot({ format: 'png' });
|
|
81
|
+
if (screenshotData?.data) {
|
|
82
|
+
fs.writeFileSync(screenshotPath, Buffer.from(screenshotData.data, 'base64'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// DOM snapshot
|
|
86
|
+
const domSnapshot = await browser.snapshot();
|
|
87
|
+
const domString = JSON.stringify(domSnapshot?.document ?? domSnapshot ?? '');
|
|
88
|
+
|
|
89
|
+
// Console messages — sliced from per-capture baseline to exclude prior environment's messages
|
|
90
|
+
const consoleMsgs = (await browser.listConsole().catch(() => [])).slice(consoleBaseline);
|
|
91
|
+
|
|
92
|
+
// Network requests — sliced from per-capture baseline to exclude prior environment's requests
|
|
93
|
+
const networkReqs = (await browser.listNetwork().catch(() => [])).slice(networkBaseline);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
url,
|
|
97
|
+
label,
|
|
98
|
+
screenshotPath: screenshotData?.data ? screenshotPath : null,
|
|
99
|
+
domString,
|
|
100
|
+
consoleMsgs,
|
|
101
|
+
networkReqs,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Route Comparison ───────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Compare dev and staging for a single route.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} route - Route definition from targets.js
|
|
111
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
112
|
+
* @returns {object} Comparison result with all diffs
|
|
113
|
+
*/
|
|
114
|
+
async function compareRoute(route, browser) {
|
|
115
|
+
const devUrl = `${DEV_URL}${route.path}`;
|
|
116
|
+
const stagingUrl = `${STAGING_URL}${route.path}`;
|
|
117
|
+
|
|
118
|
+
let devData, stagingData;
|
|
119
|
+
try {
|
|
120
|
+
devData = await capturePage(devUrl, 'dev', route.name, browser);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { route: route.name, devUrl, stagingUrl, error: `dev capture failed: ${err.message}`, diffs: [] };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
stagingData = await capturePage(stagingUrl, 'staging', route.name, browser);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return { route: route.name, devUrl, stagingUrl, error: `staging capture failed: ${err.message}`, diffs: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const diffs = [];
|
|
131
|
+
|
|
132
|
+
// ── 1. Screenshot diff ─────────────────────────────────────────────────────
|
|
133
|
+
if (devData.screenshotPath && stagingData.screenshotPath) {
|
|
134
|
+
const diffImagePath = path.join(
|
|
135
|
+
OUTPUT_DIR,
|
|
136
|
+
`diff-${slugify(route.name)}-${Date.now()}.png`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
// Pass a fixed pixelmatch color-sensitivity (0.1 = 10% per-channel tolerance);
|
|
141
|
+
// SCREENSHOT_THRESHOLD is a separate %‑of‑pixels threshold used only for alerting.
|
|
142
|
+
const { diffPercent } = await compareScreenshots(
|
|
143
|
+
devData.screenshotPath,
|
|
144
|
+
stagingData.screenshotPath,
|
|
145
|
+
diffImagePath,
|
|
146
|
+
0.1
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (diffPercent > SCREENSHOT_THRESHOLD) {
|
|
150
|
+
diffs.push({
|
|
151
|
+
type: 'screenshot',
|
|
152
|
+
diffPercent: parseFloat(diffPercent.toFixed(2)),
|
|
153
|
+
threshold: SCREENSHOT_THRESHOLD,
|
|
154
|
+
diffImagePath,
|
|
155
|
+
devScreenshot: devData.screenshotPath,
|
|
156
|
+
stagingScreenshot: stagingData.screenshotPath,
|
|
157
|
+
severity: diffPercent > SCREENSHOT_THRESHOLD * 10 ? 'warning' : 'info',
|
|
158
|
+
description: `Visual diff ${diffPercent.toFixed(2)}% (threshold: ${SCREENSHOT_THRESHOLD}%)`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
diffs.push({
|
|
163
|
+
type: 'screenshot_error',
|
|
164
|
+
severity: 'info',
|
|
165
|
+
description: `Screenshot comparison failed: ${err.message}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 2. DOM structural diff ─────────────────────────────────────────────────
|
|
171
|
+
const domDiffs = diffDomSnapshots(devData.domString, stagingData.domString);
|
|
172
|
+
for (const d of domDiffs) {
|
|
173
|
+
diffs.push({
|
|
174
|
+
type: 'dom',
|
|
175
|
+
severity: Math.abs(d.delta) > 5 ? 'warning' : 'info',
|
|
176
|
+
...d,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 3. Network request diff ────────────────────────────────────────────────
|
|
181
|
+
const { added, removed, changed } = diffNetworkRequests(devData.networkReqs, stagingData.networkReqs);
|
|
182
|
+
|
|
183
|
+
for (const req of added) {
|
|
184
|
+
diffs.push({
|
|
185
|
+
type: 'network_added',
|
|
186
|
+
severity: 'warning',
|
|
187
|
+
description: `New request in staging: ${req.method ?? 'GET'} ${req.url} (${req.status})`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
for (const req of removed) {
|
|
191
|
+
diffs.push({
|
|
192
|
+
type: 'network_removed',
|
|
193
|
+
severity: 'warning',
|
|
194
|
+
description: `Request present in dev but missing in staging: ${req.method ?? 'GET'} ${req.url}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const req of changed) {
|
|
198
|
+
const isRegression = req.statusB >= 400 && req.statusA < 400;
|
|
199
|
+
diffs.push({
|
|
200
|
+
type: 'network_status_changed',
|
|
201
|
+
severity: isRegression ? 'critical' : 'warning',
|
|
202
|
+
description: `${req.url}: status ${req.statusA} (dev) → ${req.statusB} (staging)`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 4. Console error diff ──────────────────────────────────────────────────
|
|
207
|
+
const newConsoleErrors = diffConsoleMessages(devData.consoleMsgs, stagingData.consoleMsgs);
|
|
208
|
+
for (const msg of newConsoleErrors) {
|
|
209
|
+
diffs.push({
|
|
210
|
+
type: 'console_regression',
|
|
211
|
+
severity: 'warning',
|
|
212
|
+
description: `New console error in staging (not in dev): ${msg.text ?? msg.message}`,
|
|
213
|
+
source: msg.source ?? null,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
route: route.name,
|
|
219
|
+
devUrl,
|
|
220
|
+
stagingUrl,
|
|
221
|
+
capturedAt: new Date().toISOString(),
|
|
222
|
+
devScreenshot: devData.screenshotPath,
|
|
223
|
+
stagingScreenshot: stagingData.screenshotPath,
|
|
224
|
+
diffs,
|
|
225
|
+
summary: {
|
|
226
|
+
total: diffs.length,
|
|
227
|
+
critical: diffs.filter(d => d.severity === 'critical').length,
|
|
228
|
+
warning: diffs.filter(d => d.severity === 'warning').length,
|
|
229
|
+
info: diffs.filter(d => d.severity === 'info').length,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Main Orchestration ─────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Run comparison across all configured routes.
|
|
238
|
+
*
|
|
239
|
+
* If TARGET_STAGING_URL is not set (or is still the placeholder), automatically
|
|
240
|
+
* falls back to CSS-only analysis mode — inspects the dev environment for:
|
|
241
|
+
* - CSS overrides and !important conflicts
|
|
242
|
+
* - Styles leaking from unexpected components
|
|
243
|
+
* - Unused CSS rules
|
|
244
|
+
* - API endpoints called multiple times per page load
|
|
245
|
+
*
|
|
246
|
+
* @param {object} mcp - Chrome DevTools MCP tool interface (provided by Claude Code)
|
|
247
|
+
* @returns {object} Full comparison or CSS-analysis report
|
|
248
|
+
*/
|
|
249
|
+
export async function runComparison(mcp) {
|
|
250
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
251
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
252
|
+
|
|
253
|
+
if (!STAGING_URL_SET) {
|
|
254
|
+
logger.info('[ARGUS] No staging URL configured — running CSS & API analysis mode on dev environment');
|
|
255
|
+
return runCssAnalysisMode(browser);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const report = {
|
|
259
|
+
mode: 'env-comparison',
|
|
260
|
+
generatedAt: new Date().toISOString(),
|
|
261
|
+
devUrl: DEV_URL,
|
|
262
|
+
stagingUrl: STAGING_URL,
|
|
263
|
+
summary: { total: 0, critical: 0, warning: 0, info: 0 },
|
|
264
|
+
routes: [],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
for (const route of comparisonRoutes) {
|
|
268
|
+
logger.info(`[ARGUS] Comparing route: ${route.name} (${route.path})`);
|
|
269
|
+
const result = await compareRoute(route, browser);
|
|
270
|
+
report.routes.push(result);
|
|
271
|
+
|
|
272
|
+
report.summary.total += result.summary?.total ?? 0;
|
|
273
|
+
report.summary.critical += result.summary?.critical ?? 0;
|
|
274
|
+
report.summary.warning += result.summary?.warning ?? 0;
|
|
275
|
+
report.summary.info += result.summary?.info ?? 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
279
|
+
const reportPath = path.join(OUTPUT_DIR, `comparison-report-${timestamp}.json`);
|
|
280
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
281
|
+
logger.info(`[ARGUS] Comparison report: ${reportPath}`);
|
|
282
|
+
|
|
283
|
+
// Slack dispatch is best-effort — a network error or Slack API failure should not
|
|
284
|
+
// crash the entire comparison run and discard the already-written JSON report.
|
|
285
|
+
await dispatchComparisonToSlack(report).catch(err =>
|
|
286
|
+
logger.error('[ARGUS] Slack dispatch failed:', err.message)
|
|
287
|
+
);
|
|
288
|
+
return report;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── CSS-Only Analysis Mode (no staging URL) ────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Fallback mode when no staging URL is configured.
|
|
295
|
+
* Visits each dev route and runs deep CSS + API frequency analysis.
|
|
296
|
+
*
|
|
297
|
+
* Reports:
|
|
298
|
+
* - CSS property overrides (cascade conflicts, !important abuse)
|
|
299
|
+
* - Component style leaks (BEM selectors in wrong stylesheet)
|
|
300
|
+
* - Unused CSS rules (declared but no element matches)
|
|
301
|
+
* - API endpoints called more than once per page load
|
|
302
|
+
*
|
|
303
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
304
|
+
* @returns {object} CSS analysis report
|
|
305
|
+
*/
|
|
306
|
+
async function runCssAnalysisMode(browser) {
|
|
307
|
+
const report = {
|
|
308
|
+
mode: 'css-analysis',
|
|
309
|
+
generatedAt: new Date().toISOString(),
|
|
310
|
+
baseUrl: DEV_URL,
|
|
311
|
+
note: 'Staging URL not configured — running CSS & API analysis on dev environment only',
|
|
312
|
+
summary: { total: 0, critical: 0, warning: 0, info: 0 },
|
|
313
|
+
routes: [],
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
for (const route of comparisonRoutes) {
|
|
317
|
+
const url = `${DEV_URL}${route.path}`;
|
|
318
|
+
logger.info(`[ARGUS] CSS analysis: ${route.name} (${url})`);
|
|
319
|
+
|
|
320
|
+
const routeResult = {
|
|
321
|
+
route: route.name,
|
|
322
|
+
url,
|
|
323
|
+
analyzedAt: new Date().toISOString(),
|
|
324
|
+
findings: [],
|
|
325
|
+
screenshot: null,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
// Snapshot network count BEFORE navigation so API frequency analysis for
|
|
330
|
+
// this route does not include requests accumulated from previous CSS-analysis routes.
|
|
331
|
+
const networkBaseline = (await browser.listNetwork().catch(() => [])).length;
|
|
332
|
+
|
|
333
|
+
// Navigate and settle
|
|
334
|
+
await browser.navigate(url);
|
|
335
|
+
await new Promise(r => setTimeout(r, config.pageSettleMs));
|
|
336
|
+
|
|
337
|
+
// CSS analysis
|
|
338
|
+
const cssRaw = await browser.evaluate(CSS_ANALYSIS_SCRIPT);
|
|
339
|
+
const cssResult = unwrapEval(cssRaw);
|
|
340
|
+
// Type-check before parse — unwrapEval may return null/string on MCP error;
|
|
341
|
+
// parseCssAnalysisResult iterating a non-object would throw and drop all findings.
|
|
342
|
+
if (cssResult && typeof cssResult === 'object') {
|
|
343
|
+
const cssBugs = parseCssAnalysisResult(cssResult, url);
|
|
344
|
+
routeResult.findings.push(...cssBugs);
|
|
345
|
+
} else if (cssResult !== null) {
|
|
346
|
+
logger.warn(`[ARGUS] CSS analysis: unexpected response type (${typeof cssResult}), skipping ${url}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// API frequency analysis — sliced from per-route baseline
|
|
350
|
+
const networkReqs = (await browser.listNetwork().catch(() => [])).slice(networkBaseline);
|
|
351
|
+
const apiFindings = analyzeApiFrequency(networkReqs, url);
|
|
352
|
+
routeResult.findings.push(...apiFindings);
|
|
353
|
+
|
|
354
|
+
// Screenshot
|
|
355
|
+
const screenshotPath = path.join(OUTPUT_DIR, `css-analysis-${slugify(route.name)}-${Date.now()}.png`);
|
|
356
|
+
const screenshotData = await browser.screenshot({ format: 'png' });
|
|
357
|
+
if (screenshotData?.data) {
|
|
358
|
+
fs.writeFileSync(screenshotPath, Buffer.from(screenshotData.data, 'base64'));
|
|
359
|
+
routeResult.screenshot = screenshotPath;
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
routeResult.findings.push({
|
|
363
|
+
type: 'analysis_error',
|
|
364
|
+
message: `CSS analysis failed for ${url}: ${err.message}`,
|
|
365
|
+
severity: 'warning',
|
|
366
|
+
url,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Tally summary
|
|
371
|
+
for (const f of routeResult.findings) {
|
|
372
|
+
report.summary.total++;
|
|
373
|
+
report.summary[f.severity] = (report.summary[f.severity] ?? 0) + 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
report.routes.push(routeResult);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Write report
|
|
380
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
381
|
+
const reportPath = path.join(OUTPUT_DIR, `css-analysis-report-${timestamp}.json`);
|
|
382
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
383
|
+
logger.info(`[ARGUS] CSS analysis report: ${reportPath}`);
|
|
384
|
+
|
|
385
|
+
// Dispatch to Slack
|
|
386
|
+
await dispatchCssAnalysisToSlack(report).catch(err =>
|
|
387
|
+
logger.error('[ARGUS] CSS Slack dispatch failed:', err.message)
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
return report;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Dispatch CSS analysis findings to Slack.
|
|
395
|
+
*/
|
|
396
|
+
async function dispatchCssAnalysisToSlack(report) {
|
|
397
|
+
for (const routeResult of report.routes) {
|
|
398
|
+
const overrides = routeResult.findings.filter(f => f.type === 'css_override' && f.severity === 'warning');
|
|
399
|
+
const leaks = routeResult.findings.filter(f => f.type === 'css_component_leak');
|
|
400
|
+
const duplicateApis = routeResult.findings.filter(f => f.type === 'api_duplicate_call' && f.severity !== 'info');
|
|
401
|
+
const criticalApis = routeResult.findings.filter(f => f.type === 'api_duplicate_call' && f.severity === 'critical');
|
|
402
|
+
const summary = routeResult.findings.find(f => f.type === 'css_summary');
|
|
403
|
+
|
|
404
|
+
// Critical API calls (5+ duplicates) → #bugs-critical
|
|
405
|
+
for (const api of criticalApis) {
|
|
406
|
+
await postBugReport({
|
|
407
|
+
severity: 'critical',
|
|
408
|
+
title: `Runaway API call on ${routeResult.route}`,
|
|
409
|
+
description: api.message,
|
|
410
|
+
url: routeResult.url,
|
|
411
|
+
screenshotPath: routeResult.screenshot,
|
|
412
|
+
details: api,
|
|
413
|
+
}).catch(err => logger.error('[ARGUS] Slack dispatch failed (css critical):', err.message));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// CSS overrides + leaks + duplicate APIs bundled as one warning per route
|
|
417
|
+
const warningItems = [...overrides, ...leaks, ...duplicateApis.filter(a => a.severity === 'warning')];
|
|
418
|
+
if (warningItems.length > 0) {
|
|
419
|
+
const desc = warningItems.map(f => `• ${f.message}`).join('\n');
|
|
420
|
+
await postBugReport({
|
|
421
|
+
severity: 'warning',
|
|
422
|
+
title: `CSS/API issues on ${routeResult.route} (${warningItems.length} found)`,
|
|
423
|
+
description: desc,
|
|
424
|
+
url: routeResult.url,
|
|
425
|
+
screenshotPath: routeResult.screenshot,
|
|
426
|
+
details: { route: routeResult.route, findings: warningItems },
|
|
427
|
+
}).catch(err => logger.error('[ARGUS] Slack dispatch failed (css warning):', err.message));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Summary as info digest
|
|
431
|
+
if (summary) {
|
|
432
|
+
await postBugReport({
|
|
433
|
+
severity: 'info',
|
|
434
|
+
title: `CSS analysis complete: ${routeResult.route}`,
|
|
435
|
+
description: summary.message +
|
|
436
|
+
(summary.stylesheetSources?.length ? `\nStylesheets: ${summary.stylesheetSources.map(s => s.source.split('/').pop()).join(', ')}` : ''),
|
|
437
|
+
url: routeResult.url,
|
|
438
|
+
screenshotPath: null,
|
|
439
|
+
details: summary,
|
|
440
|
+
}).catch(err => logger.error('[ARGUS] Slack dispatch failed (css info):', err.message));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Dispatch comparison diffs to Slack.
|
|
447
|
+
* Critical diffs (e.g., a 200→500 status regression) get immediate notification.
|
|
448
|
+
* Screenshot diffs get posted with both images and the diff overlay.
|
|
449
|
+
*/
|
|
450
|
+
async function dispatchComparisonToSlack(report) {
|
|
451
|
+
for (const routeResult of report.routes) {
|
|
452
|
+
if (!routeResult.diffs?.length) continue;
|
|
453
|
+
const criticals = routeResult.diffs.filter(d => d.severity === 'critical');
|
|
454
|
+
const warnings = routeResult.diffs.filter(d => d.severity === 'warning');
|
|
455
|
+
|
|
456
|
+
for (const diff of criticals) {
|
|
457
|
+
await postBugReport({
|
|
458
|
+
severity: 'critical',
|
|
459
|
+
title: `Regression on ${routeResult.route}: ${diff.type}`,
|
|
460
|
+
description: diff.description,
|
|
461
|
+
url: routeResult.stagingUrl,
|
|
462
|
+
screenshotPath: diff.stagingScreenshot ?? routeResult.stagingScreenshot,
|
|
463
|
+
details: {
|
|
464
|
+
diff,
|
|
465
|
+
devUrl: routeResult.devUrl,
|
|
466
|
+
stagingUrl: routeResult.stagingUrl,
|
|
467
|
+
},
|
|
468
|
+
}).catch(err => logger.error('[ARGUS] Slack dispatch failed (comparison critical):', err.message));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (warnings.length > 0) {
|
|
472
|
+
// For screenshot diffs, attach the diff overlay image
|
|
473
|
+
const screenshotDiff = warnings.find(d => d.type === 'screenshot');
|
|
474
|
+
await postBugReport({
|
|
475
|
+
severity: 'warning',
|
|
476
|
+
title: `${warnings.length} diff(s) on ${routeResult.route}`,
|
|
477
|
+
description: warnings.map(d => `• ${d.description}`).join('\n'),
|
|
478
|
+
url: routeResult.stagingUrl,
|
|
479
|
+
screenshotPath: screenshotDiff?.diffImagePath ?? routeResult.stagingScreenshot,
|
|
480
|
+
details: {
|
|
481
|
+
route: routeResult.route,
|
|
482
|
+
devUrl: routeResult.devUrl,
|
|
483
|
+
stagingUrl: routeResult.stagingUrl,
|
|
484
|
+
diffs: warnings,
|
|
485
|
+
},
|
|
486
|
+
}).catch(err => logger.error('[ARGUS] Slack dispatch failed (comparison warning):', err.message));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── CLI Entry ──────────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
494
|
+
logger.info('[ARGUS] env-comparison.js loaded. Invoke runComparison(mcp) from Claude Code with MCP tools connected.');
|
|
495
|
+
logger.info('[ARGUS] Dev URL:', DEV_URL);
|
|
496
|
+
logger.info('[ARGUS] Staging URL:', STAGING_URL);
|
|
497
|
+
logger.info('[ARGUS] Routes to compare:', comparisonRoutes.map(r => r.path).join(', '));
|
|
498
|
+
}
|