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,1128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus Orchestrator (v9.3.0)
|
|
3
|
+
*
|
|
4
|
+
* Per-route crawl loop: cheap×2 flakiness pass + expensive×1 pass.
|
|
5
|
+
* Extracted from crawl-and-report.js god object.
|
|
6
|
+
*
|
|
7
|
+
* Public exports: runCrawl, crawlRouteCheap, crawlRouteExpensive
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
|
|
15
|
+
import { routes, config, auth, flows, apiContracts, severityOverrides, codebase, autoDiscover, thresholds } from '../config/targets.js';
|
|
16
|
+
import { discoverRoutes } from '../utils/route-discoverer.js';
|
|
17
|
+
import { analyzeCodebase, detectDeadRoutes, INTERNAL_LINKS_SCRIPT } from '../utils/codebase-analyzer.js';
|
|
18
|
+
import { CSS_ANALYSIS_SCRIPT, parseCssAnalysisResult } from '../utils/css-analyzer.js';
|
|
19
|
+
import { SEO_ANALYSIS_SCRIPT, parseSeoAnalysisResult } from '../utils/seo-analyzer.js';
|
|
20
|
+
import { SECURITY_ANALYSIS_SCRIPT, parseSecurityAnalysisResult, analyzeSecurityConsole, analyzeSecurityNetwork } from '../utils/security-analyzer.js';
|
|
21
|
+
import { CONTENT_ANALYSIS_SCRIPT, parseContentAnalysisResult } from '../utils/content-analyzer.js';
|
|
22
|
+
import { runLoginFlow, saveSession, restoreSession, hasSession, refreshSession } from '../utils/session-manager.js';
|
|
23
|
+
import { mergeRunResults } from '../utils/flakiness-detector.js';
|
|
24
|
+
import { runAllFlows, normalizeArray, waitForSelector } from '../utils/flow-runner.js';
|
|
25
|
+
import { analyzeApiFrequency } from '../utils/api-frequency.js';
|
|
26
|
+
import { slugify } from '../utils/slug.js';
|
|
27
|
+
import { unwrapEval, createMcpClient } from '../utils/mcp-client.js';
|
|
28
|
+
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
29
|
+
import { chunkArray } from '../utils/parallel-crawler.js';
|
|
30
|
+
import { validateApiContracts } from '../utils/contract-validator.js';
|
|
31
|
+
import { checkLighthouse } from '../utils/lighthouse-checker.js';
|
|
32
|
+
import { parseIssues } from '../utils/issues-analyzer.js';
|
|
33
|
+
import { parseNetworkTiming } from '../utils/network-timing-analyzer.js';
|
|
34
|
+
|
|
35
|
+
// Side-effect imports: each module calls registerExpensive() at load time.
|
|
36
|
+
// lighthouse-checker.js also self-registers via its direct named import above (line 31).
|
|
37
|
+
// Order below controls iteration order in crawlAndAnalyzeRoute — must match original call order.
|
|
38
|
+
import '../utils/responsive-analyzer.js';
|
|
39
|
+
import '../utils/memory-analyzer.js';
|
|
40
|
+
import '../utils/hover-analyzer.js';
|
|
41
|
+
import '../utils/snapshot-analyzer.js';
|
|
42
|
+
import '../utils/keyboard-analyzer.js';
|
|
43
|
+
|
|
44
|
+
import { getExpensive } from '../registry.js';
|
|
45
|
+
import { deduplicateFindings as deduplicateErrors } from './report-processor.js';
|
|
46
|
+
import { processReport } from './report-processor.js';
|
|
47
|
+
import { dispatchAll } from './dispatcher.js';
|
|
48
|
+
import { validateConfig } from '../config/schema.js';
|
|
49
|
+
import { childLogger } from '../utils/logger.js';
|
|
50
|
+
import { startSpan, recordFinding, recordFlaky, recordNewFindings } from '../utils/telemetry.js';
|
|
51
|
+
|
|
52
|
+
const logger = childLogger('orchestrator');
|
|
53
|
+
|
|
54
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const BASE_URL = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
56
|
+
const OUTPUT_DIR = path.resolve(__dirname, '../../', config.outputDir);
|
|
57
|
+
|
|
58
|
+
// Thresholds for perf budgets and network analysis are centralized in targets.js.
|
|
59
|
+
|
|
60
|
+
// ── Injected Page Scripts ──────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const NETWORK_PERF_SCRIPT = `() => window.performance.getEntriesByType('resource').map(function(e){return{url:e.name,resourceType:e.initiatorType,duration:Math.round(e.duration||0),transferSize:e.transferSize||0,decodedBodySize:e.decodedBodySize||0}})`;
|
|
63
|
+
|
|
64
|
+
const CACHE_HEADER_SCRIPT = `async () => {
|
|
65
|
+
var ASSET_EXT = /\\.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)(\\?.*)?$/i;
|
|
66
|
+
var origin = window.location.origin;
|
|
67
|
+
var seen = {};
|
|
68
|
+
var candidates = window.performance.getEntriesByType('resource')
|
|
69
|
+
.map(function(e){ return e.name; })
|
|
70
|
+
.filter(function(u){
|
|
71
|
+
if (!u.startsWith(origin) || !ASSET_EXT.test(u)) return false;
|
|
72
|
+
if (seen[u]) return false;
|
|
73
|
+
seen[u] = true;
|
|
74
|
+
return true;
|
|
75
|
+
})
|
|
76
|
+
.slice(0, 25);
|
|
77
|
+
var missing = [];
|
|
78
|
+
await Promise.all(candidates.map(async function(assetUrl){
|
|
79
|
+
try {
|
|
80
|
+
var r = await fetch(assetUrl, { method: 'HEAD', cache: 'reload', credentials: 'same-origin' });
|
|
81
|
+
if (!r.headers.get('cache-control') && !r.headers.get('etag')) {
|
|
82
|
+
missing.push({ url: assetUrl });
|
|
83
|
+
}
|
|
84
|
+
} catch(e) {}
|
|
85
|
+
}));
|
|
86
|
+
return JSON.stringify(missing);
|
|
87
|
+
}`;
|
|
88
|
+
|
|
89
|
+
const INJECT_ERROR_LISTENER = `() => {
|
|
90
|
+
if (window.__argusErrorsPatched) return;
|
|
91
|
+
window.__argusErrorsPatched = true;
|
|
92
|
+
window.__argusErrors = [];
|
|
93
|
+
window.onerror = function(message, source, lineno, colno, error) {
|
|
94
|
+
window.__argusErrors.push({
|
|
95
|
+
type: 'uncaught_exception',
|
|
96
|
+
message: message,
|
|
97
|
+
source: source,
|
|
98
|
+
line: lineno,
|
|
99
|
+
col: colno,
|
|
100
|
+
stack: error ? error.stack : null,
|
|
101
|
+
ts: Date.now()
|
|
102
|
+
});
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
window.addEventListener('unhandledrejection', function(event) {
|
|
106
|
+
window.__argusErrors.push({
|
|
107
|
+
type: 'unhandled_rejection',
|
|
108
|
+
message: String(event.reason),
|
|
109
|
+
stack: event.reason && event.reason.stack ? event.reason.stack : null,
|
|
110
|
+
ts: Date.now()
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}`;
|
|
114
|
+
|
|
115
|
+
const EXTRACT_ERROR_LISTENER = `() => JSON.stringify(window.__argusErrors ?? [])`;
|
|
116
|
+
|
|
117
|
+
const DETECT_DOC_WRITE_STATIC = `async () => {
|
|
118
|
+
var found = [];
|
|
119
|
+
var seen = new Set();
|
|
120
|
+
function checkSrc(src, label) {
|
|
121
|
+
if (/\\bdocument\\.write\\s*\\(/.test(src) && !seen.has('write:'+label)) {
|
|
122
|
+
found.push({ method: 'write', content: label }); seen.add('write:'+label);
|
|
123
|
+
}
|
|
124
|
+
if (/\\bdocument\\.writeln\\s*\\(/.test(src) && !seen.has('writeln:'+label)) {
|
|
125
|
+
found.push({ method: 'writeln', content: label }); seen.add('writeln:'+label);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function isJsType(el) {
|
|
129
|
+
var t = (el.type || '').toLowerCase().trim();
|
|
130
|
+
return t === '' || t === 'text/javascript' || t === 'application/javascript' || t === 'module';
|
|
131
|
+
}
|
|
132
|
+
var inlines = document.querySelectorAll('script:not([src])');
|
|
133
|
+
for (var i = 0; i < inlines.length; i++) {
|
|
134
|
+
if (isJsType(inlines[i])) checkSrc(inlines[i].textContent||'','(inline)');
|
|
135
|
+
}
|
|
136
|
+
var externals = document.querySelectorAll('script[src]');
|
|
137
|
+
var fetches = [];
|
|
138
|
+
for (var i = 0; i < externals.length; i++) {
|
|
139
|
+
if (!isJsType(externals[i])) continue;
|
|
140
|
+
var u = externals[i].src;
|
|
141
|
+
if (!u || !u.startsWith(location.origin)) continue;
|
|
142
|
+
fetches.push(fetch(u).then(function(r){return r.text();}).then(function(t){checkSrc(t,u);}).catch(function(){}));
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(fetches);
|
|
145
|
+
return JSON.stringify(found);
|
|
146
|
+
}`;
|
|
147
|
+
|
|
148
|
+
const INJECT_SW_LISTENER = `() => {
|
|
149
|
+
if (!window.__argusSwErrors) window.__argusSwErrors = [];
|
|
150
|
+
if (window.__argusSwPatched) return;
|
|
151
|
+
window.__argusSwPatched = true;
|
|
152
|
+
if (!navigator.serviceWorker) return;
|
|
153
|
+
var _register = navigator.serviceWorker.register.bind(navigator.serviceWorker);
|
|
154
|
+
navigator.serviceWorker.register = function(scriptURL, options) {
|
|
155
|
+
var reg = _register(scriptURL, options);
|
|
156
|
+
reg.catch(function(err) {
|
|
157
|
+
window.__argusSwErrors.push({
|
|
158
|
+
scriptURL: String(scriptURL || ''),
|
|
159
|
+
message: err && err.message ? err.message : String(err),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
return reg;
|
|
163
|
+
};
|
|
164
|
+
}`;
|
|
165
|
+
|
|
166
|
+
const EXTRACT_SW_LISTENER = `() => JSON.stringify(window.__argusSwErrors ?? [])`;
|
|
167
|
+
|
|
168
|
+
const DEBUGGER_SCRIPT = `async () => {
|
|
169
|
+
var found = [];
|
|
170
|
+
function isJsType(el) {
|
|
171
|
+
var t = (el.type || '').toLowerCase().trim();
|
|
172
|
+
return t === '' || t === 'text/javascript' || t === 'application/javascript' || t === 'module';
|
|
173
|
+
}
|
|
174
|
+
var inline = document.querySelectorAll('script:not([src])');
|
|
175
|
+
for (var i = 0; i < inline.length; i++) {
|
|
176
|
+
if (!isJsType(inline[i])) continue;
|
|
177
|
+
var src = inline[i].textContent || '';
|
|
178
|
+
var lines = src.split('\\n');
|
|
179
|
+
for (var ln = 0; ln < lines.length; ln++) {
|
|
180
|
+
if (/\\bdebugger\\s*;/.test(lines[ln])) {
|
|
181
|
+
found.push({ scriptUrl: '(inline)', line: ln + 1, snippet: lines[ln].trim().slice(0, 120) });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
var origin = window.location.origin;
|
|
186
|
+
var seen = {};
|
|
187
|
+
var extEls = document.querySelectorAll('script[src]');
|
|
188
|
+
var extUrls = [];
|
|
189
|
+
for (var i = 0; i < extEls.length && extUrls.length < 20; i++) {
|
|
190
|
+
if (!isJsType(extEls[i])) continue;
|
|
191
|
+
var u = extEls[i].src;
|
|
192
|
+
if (!u || !u.startsWith(origin) || seen[u]) continue;
|
|
193
|
+
seen[u] = true;
|
|
194
|
+
extUrls.push(u);
|
|
195
|
+
}
|
|
196
|
+
await Promise.all(extUrls.map(async function(scriptUrl) {
|
|
197
|
+
try {
|
|
198
|
+
var r = await fetch(scriptUrl, { cache: 'force-cache', credentials: 'same-origin' });
|
|
199
|
+
var text = await r.text();
|
|
200
|
+
var lines = text.split('\\n');
|
|
201
|
+
for (var ln = 0; ln < lines.length; ln++) {
|
|
202
|
+
if (/\\bdebugger\\s*;/.test(lines[ln])) {
|
|
203
|
+
var filename = scriptUrl.replace(/^.*\\//, '').split('?')[0];
|
|
204
|
+
found.push({ scriptUrl: filename || scriptUrl, line: ln + 1, snippet: lines[ln].trim().slice(0, 120) });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch(e) {}
|
|
208
|
+
}));
|
|
209
|
+
return JSON.stringify(found);
|
|
210
|
+
}`;
|
|
211
|
+
|
|
212
|
+
const DUPLICATE_ID_SCRIPT = `() => {
|
|
213
|
+
var counts = {};
|
|
214
|
+
var els = document.querySelectorAll('[id]');
|
|
215
|
+
for (var i = 0; i < els.length; i++) {
|
|
216
|
+
var id = els[i].id;
|
|
217
|
+
if (!id) continue;
|
|
218
|
+
counts[id] = (counts[id] || 0) + 1;
|
|
219
|
+
}
|
|
220
|
+
var dupes = [];
|
|
221
|
+
for (var id in counts) {
|
|
222
|
+
if (counts[id] > 1) dupes.push({ id: id, count: counts[id] });
|
|
223
|
+
}
|
|
224
|
+
return JSON.stringify(dupes);
|
|
225
|
+
}`;
|
|
226
|
+
|
|
227
|
+
const INJECT_LONG_TASK_LISTENER = `() => {
|
|
228
|
+
if (!window.__argusLongTasks) window.__argusLongTasks = [];
|
|
229
|
+
if (window.__argusLongTaskPatched) return;
|
|
230
|
+
window.__argusLongTaskPatched = true;
|
|
231
|
+
try {
|
|
232
|
+
var obs = new PerformanceObserver(function(list) {
|
|
233
|
+
var entries = list.getEntries();
|
|
234
|
+
for (var i = 0; i < entries.length; i++) {
|
|
235
|
+
var e = entries[i];
|
|
236
|
+
var attr = e.attribution && e.attribution[0];
|
|
237
|
+
window.__argusLongTasks.push({
|
|
238
|
+
duration: Math.round(e.duration),
|
|
239
|
+
startTime: Math.round(e.startTime),
|
|
240
|
+
attribution: attr ? {
|
|
241
|
+
name: attr.name || null,
|
|
242
|
+
containerType: attr.containerType || null,
|
|
243
|
+
containerSrc: attr.containerSrc || null,
|
|
244
|
+
} : null,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
obs.observe({ entryTypes: ['longtask'] });
|
|
249
|
+
} catch (e) { /* longtask not supported */ }
|
|
250
|
+
}`;
|
|
251
|
+
|
|
252
|
+
const EXTRACT_LONG_TASK_LISTENER = `() => JSON.stringify(window.__argusLongTasks ?? [])`;
|
|
253
|
+
|
|
254
|
+
const INJECT_SYNC_XHR_LISTENER = `() => {
|
|
255
|
+
if (window.__argusSyncXhrPatched) return;
|
|
256
|
+
window.__argusSyncXhrPatched = true;
|
|
257
|
+
window.__argusSyncXhrs = [];
|
|
258
|
+
var _open = XMLHttpRequest.prototype.open;
|
|
259
|
+
XMLHttpRequest.prototype.open = function(method, url, async) {
|
|
260
|
+
if (async === false) {
|
|
261
|
+
window.__argusSyncXhrs.push({ method: String(method || 'GET'), url: String(url) });
|
|
262
|
+
}
|
|
263
|
+
return _open.apply(this, arguments);
|
|
264
|
+
};
|
|
265
|
+
}`;
|
|
266
|
+
|
|
267
|
+
const EXTRACT_SYNC_XHR_LISTENER = `() => JSON.stringify(window.__argusSyncXhrs ?? [])`;
|
|
268
|
+
|
|
269
|
+
const REDIRECT_COUNT_SCRIPT = `() => window.performance.getEntriesByType('navigation')[0]?.redirectCount ?? 0`;
|
|
270
|
+
|
|
271
|
+
// ── Severity Classification ────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
function classifyConsoleMessage(msg, routeIsCritical) {
|
|
274
|
+
const level = (msg.level ?? '').toLowerCase();
|
|
275
|
+
if (level === 'error') return routeIsCritical ? 'critical' : 'warning';
|
|
276
|
+
if (level === 'warning') return 'info';
|
|
277
|
+
return 'info';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function classifyNetworkRequest(req, routeIsCritical) {
|
|
281
|
+
const status = req.status ?? 0;
|
|
282
|
+
if (status >= 500) return 'critical';
|
|
283
|
+
if (status === 401 || status === 403) return 'critical';
|
|
284
|
+
if (status >= 400) return routeIsCritical ? 'warning' : 'info';
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function classifyOrigin(reqUrl, pageUrl) {
|
|
289
|
+
try {
|
|
290
|
+
return new URL(reqUrl).origin === new URL(pageUrl).origin ? 'first-party' : 'third-party';
|
|
291
|
+
} catch {
|
|
292
|
+
return 'first-party';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Network Performance Analysis ──────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
function analyzeNetworkPerformance(perfEntries, pageUrl) {
|
|
299
|
+
const bugs = [];
|
|
300
|
+
const staticExt = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif)(\?|$)/i;
|
|
301
|
+
|
|
302
|
+
for (const entry of perfEntries) {
|
|
303
|
+
const reqUrl = entry.url ?? '';
|
|
304
|
+
if (staticExt.test(reqUrl)) continue;
|
|
305
|
+
if (
|
|
306
|
+
!/\/(api|graphql|rest|v\d+)\//i.test(reqUrl) &&
|
|
307
|
+
!['xmlhttprequest', 'fetch', 'xhr'].includes((entry.resourceType ?? '').toLowerCase())
|
|
308
|
+
) continue;
|
|
309
|
+
|
|
310
|
+
const duration = entry.duration ?? 0;
|
|
311
|
+
const payloadBytes = entry.decodedBodySize || entry.transferSize || 0;
|
|
312
|
+
|
|
313
|
+
if (duration > thresholds.network.slowCritical) {
|
|
314
|
+
bugs.push({
|
|
315
|
+
type: 'slow_api',
|
|
316
|
+
requestUrl: reqUrl,
|
|
317
|
+
duration: Math.round(duration),
|
|
318
|
+
threshold: thresholds.network.slowCritical,
|
|
319
|
+
message: `Slow API response ${Math.round(duration)} ms — ${reqUrl} (critical threshold: ${thresholds.network.slowCritical} ms)`,
|
|
320
|
+
severity: 'critical',
|
|
321
|
+
url: pageUrl,
|
|
322
|
+
});
|
|
323
|
+
} else if (duration > thresholds.network.slowWarning) {
|
|
324
|
+
bugs.push({
|
|
325
|
+
type: 'slow_api',
|
|
326
|
+
requestUrl: reqUrl,
|
|
327
|
+
duration: Math.round(duration),
|
|
328
|
+
threshold: thresholds.network.slowWarning,
|
|
329
|
+
message: `Slow API response ${Math.round(duration)} ms — ${reqUrl} (warning threshold: ${thresholds.network.slowWarning} ms)`,
|
|
330
|
+
severity: 'warning',
|
|
331
|
+
url: pageUrl,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (payloadBytes > thresholds.network.sizeCritical) {
|
|
336
|
+
bugs.push({
|
|
337
|
+
type: 'large_payload',
|
|
338
|
+
requestUrl: reqUrl,
|
|
339
|
+
bytes: payloadBytes,
|
|
340
|
+
threshold: thresholds.network.sizeCritical,
|
|
341
|
+
message: `Oversized API payload ${Math.round(payloadBytes / 1024)} KB — ${reqUrl} (critical threshold: 2 MB)`,
|
|
342
|
+
severity: 'critical',
|
|
343
|
+
url: pageUrl,
|
|
344
|
+
});
|
|
345
|
+
} else if (payloadBytes > thresholds.network.sizeWarning) {
|
|
346
|
+
bugs.push({
|
|
347
|
+
type: 'large_payload',
|
|
348
|
+
requestUrl: reqUrl,
|
|
349
|
+
bytes: payloadBytes,
|
|
350
|
+
threshold: thresholds.network.sizeWarning,
|
|
351
|
+
message: `Oversized API payload ${Math.round(payloadBytes / 1024)} KB — ${reqUrl} (warning threshold: 500 KB)`,
|
|
352
|
+
severity: 'warning',
|
|
353
|
+
url: pageUrl,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return bugs;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Performance Budgets ────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
async function checkPerformanceBudgets(browser, url) {
|
|
364
|
+
const violations = [];
|
|
365
|
+
const LIGHTHOUSE_TIMEOUT_MS = parseInt(process.env.ARGUS_LIGHTHOUSE_TIMEOUT ?? '120000', 10);
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
await browser.startTrace();
|
|
369
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
370
|
+
const trace = await browser.stopTrace();
|
|
371
|
+
const insights = await browser.analyzeInsight({ insightSetId: trace?.insightSetId ?? trace?.id ?? trace });
|
|
372
|
+
|
|
373
|
+
const metrics = insights?.metrics ?? insights?.performanceMetrics ?? {};
|
|
374
|
+
|
|
375
|
+
const checks = [
|
|
376
|
+
{ key: 'LCP', value: metrics.largestContentfulPaint ?? metrics.LCP, budget: thresholds.perf.LCP, unit: 'ms' },
|
|
377
|
+
{ key: 'CLS', value: metrics.cumulativeLayoutShift ?? metrics.CLS, budget: thresholds.perf.CLS, unit: '' },
|
|
378
|
+
{ key: 'FID', value: metrics.totalBlockingTime ?? metrics.TBT ?? metrics.FID, budget: thresholds.perf.FID, unit: 'ms' },
|
|
379
|
+
{ key: 'TTFB', value: metrics.timeToFirstByte ?? metrics.TTFB, budget: thresholds.perf.TTFB, unit: 'ms' },
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
for (const { key, value, budget, unit } of checks) {
|
|
383
|
+
if (value == null) continue;
|
|
384
|
+
if (value > budget) {
|
|
385
|
+
violations.push({
|
|
386
|
+
type: 'performance_budget',
|
|
387
|
+
metric: key,
|
|
388
|
+
value: `${value}${unit}`,
|
|
389
|
+
budget: `${budget}${unit}`,
|
|
390
|
+
message: `Performance budget exceeded: ${key} = ${value}${unit} (budget: ${budget}${unit})`,
|
|
391
|
+
severity: 'warning',
|
|
392
|
+
url,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.warn(`[ARGUS] Performance trace skipped for ${url}: ${err.message}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
void LIGHTHOUSE_TIMEOUT_MS; // referenced only here to prevent unused-var lint
|
|
401
|
+
return violations;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Cheap Crawl (called ×2 for flakiness detection) ───────────────────────────
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Cheap detections for one route.
|
|
408
|
+
* Runs: console, network, JS errors, blank page, API frequency, contracts,
|
|
409
|
+
* SEO, security, content, CSS, debugger statements, duplicate ids, screenshot.
|
|
410
|
+
* Does NOT run: Lighthouse, perf budgets, network perf, redirect chain, broken links, cache headers.
|
|
411
|
+
*/
|
|
412
|
+
export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
413
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
414
|
+
const url = `${baseUrl}${route.path}`;
|
|
415
|
+
const result = {
|
|
416
|
+
route: route.name,
|
|
417
|
+
url,
|
|
418
|
+
crawledAt: new Date().toISOString(),
|
|
419
|
+
errors: [],
|
|
420
|
+
screenshot: null,
|
|
421
|
+
pageTitle: null,
|
|
422
|
+
isBlankPage: false,
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// 0. Snapshot session-wide baselines BEFORE this route starts (D5).
|
|
426
|
+
const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
|
|
427
|
+
const networkBaseline = (await browser.listNetwork().catch(() => [])).length;
|
|
428
|
+
// listConsoleRaw returns raw MCP response — normalizeArray required before .length
|
|
429
|
+
const issuesBaselineRaw = await browser.listConsoleRaw({ types: ['issue'] }).catch(() => null);
|
|
430
|
+
const issuesBaseline = normalizeArray(issuesBaselineRaw).length;
|
|
431
|
+
|
|
432
|
+
// 1. Navigate
|
|
433
|
+
await browser.navigate(url);
|
|
434
|
+
|
|
435
|
+
// 2. Inject listeners immediately after navigation (before settle)
|
|
436
|
+
await browser.evaluate(INJECT_ERROR_LISTENER).catch(() => {});
|
|
437
|
+
await browser.evaluate(INJECT_SYNC_XHR_LISTENER).catch(() => {});
|
|
438
|
+
await browser.evaluate(INJECT_LONG_TASK_LISTENER).catch(() => {});
|
|
439
|
+
await browser.evaluate(INJECT_SW_LISTENER).catch(() => {});
|
|
440
|
+
|
|
441
|
+
// 3. Wait for page settle
|
|
442
|
+
if (route.waitFor) {
|
|
443
|
+
const found = await waitForSelector(browser, route.waitFor, 10000);
|
|
444
|
+
if (!found) {
|
|
445
|
+
result.errors.push({
|
|
446
|
+
type: 'load_failure',
|
|
447
|
+
message: `Selector "${route.waitFor}" not found after 10s — page may not have loaded`,
|
|
448
|
+
severity: route.critical ? 'critical' : 'warning',
|
|
449
|
+
url,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
await new Promise(r => setTimeout(r, config.pageSettleMs));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 4. Blank/error page check
|
|
457
|
+
const titleResult = await browser.evaluate('() => document.title');
|
|
458
|
+
result.pageTitle = String(unwrapEval(titleResult) ?? '');
|
|
459
|
+
const bodyText = await browser.evaluate('() => document.body?.innerText?.trim() ?? ""');
|
|
460
|
+
const bodyTextVal = String(unwrapEval(bodyText) ?? '');
|
|
461
|
+
result.isBlankPage = !bodyTextVal || bodyTextVal.length < 50;
|
|
462
|
+
if (result.isBlankPage) {
|
|
463
|
+
result.errors.push({
|
|
464
|
+
type: 'blank_page',
|
|
465
|
+
message: `Page appears blank or nearly empty (body text length < 50 chars)`,
|
|
466
|
+
severity: 'critical',
|
|
467
|
+
url,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 5. Console messages — sliced from per-route baseline
|
|
472
|
+
const consoleMsgs = (await browser.listConsole().catch(() => [])).slice(consoleBaseline);
|
|
473
|
+
for (const msg of consoleMsgs) {
|
|
474
|
+
const text = (msg.text ?? msg.message ?? '');
|
|
475
|
+
if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
|
|
476
|
+
const severity = classifyConsoleMessage(msg, route.critical);
|
|
477
|
+
if (severity !== null && msg.level !== 'log') {
|
|
478
|
+
result.errors.push({
|
|
479
|
+
type: 'console',
|
|
480
|
+
level: msg.level,
|
|
481
|
+
message: text || String(msg),
|
|
482
|
+
source: msg.source ?? null,
|
|
483
|
+
line: msg.lineNumber ?? null,
|
|
484
|
+
severity,
|
|
485
|
+
url,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// 5b. CORS error detection
|
|
491
|
+
for (const msg of consoleMsgs) {
|
|
492
|
+
const text = (msg.text ?? msg.message ?? '');
|
|
493
|
+
if (text.toLowerCase().includes('has been blocked by cors policy')) {
|
|
494
|
+
result.errors.push({
|
|
495
|
+
type: 'cors_error',
|
|
496
|
+
message: text || 'CORS policy violation',
|
|
497
|
+
severity: 'critical',
|
|
498
|
+
url,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 6. Network requests — sliced from per-route baseline (cap AFTER slice, not before)
|
|
504
|
+
const networkReqs = (await browser.listNetwork())
|
|
505
|
+
.slice(networkBaseline).slice(0, 500);
|
|
506
|
+
for (const req of networkReqs) {
|
|
507
|
+
const severity = classifyNetworkRequest(req, route.critical);
|
|
508
|
+
if (severity !== null) {
|
|
509
|
+
result.errors.push({
|
|
510
|
+
type: 'network',
|
|
511
|
+
method: req.method ?? 'GET',
|
|
512
|
+
requestUrl: req.url,
|
|
513
|
+
status: req.status,
|
|
514
|
+
statusText: req.statusText ?? null,
|
|
515
|
+
origin: classifyOrigin(req.url, url),
|
|
516
|
+
message: `HTTP ${req.status}${req.statusText ? ` ${req.statusText}` : ''} — ${req.method ?? 'GET'} ${req.url}`,
|
|
517
|
+
severity,
|
|
518
|
+
url,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 6b. API frequency analysis
|
|
524
|
+
result.errors.push(...analyzeApiFrequency(networkReqs, url));
|
|
525
|
+
|
|
526
|
+
// 6d. Third-party blocking resource detection via HAR timing
|
|
527
|
+
try {
|
|
528
|
+
result.errors.push(...parseNetworkTiming(networkReqs, url));
|
|
529
|
+
} catch (err) {
|
|
530
|
+
logger.warn(`[ARGUS] Network timing analysis skipped for ${url}: ${err.message}`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 6c. API contract validation
|
|
534
|
+
if (apiContracts?.length > 0) {
|
|
535
|
+
try {
|
|
536
|
+
const contractFindings = await validateApiContracts(networkReqs, browser, apiContracts, url);
|
|
537
|
+
result.errors.push(...contractFindings);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
logger.warn(`[ARGUS] API contract validation skipped for ${url}: ${err.message}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// 7. Injected uncaught exceptions
|
|
544
|
+
const injectedErrors = await browser.evaluate(EXTRACT_ERROR_LISTENER);
|
|
545
|
+
try {
|
|
546
|
+
const rawInjected = unwrapEval(injectedErrors);
|
|
547
|
+
const parsed = Array.isArray(rawInjected) ? rawInjected
|
|
548
|
+
: JSON.parse(typeof rawInjected === 'string' ? rawInjected : '[]');
|
|
549
|
+
for (const err of parsed) {
|
|
550
|
+
result.errors.push({
|
|
551
|
+
type: err.type,
|
|
552
|
+
message: err.message,
|
|
553
|
+
stack: err.stack,
|
|
554
|
+
source: err.source ?? null,
|
|
555
|
+
line: err.line ?? null,
|
|
556
|
+
severity: route.critical ? 'critical' : 'warning',
|
|
557
|
+
url,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
} catch { /* parse failure */ }
|
|
561
|
+
|
|
562
|
+
// 7b. Sync XHR detection
|
|
563
|
+
try {
|
|
564
|
+
const syncXhrRaw = await browser.evaluate(EXTRACT_SYNC_XHR_LISTENER);
|
|
565
|
+
const rawSyncXhr = unwrapEval(syncXhrRaw);
|
|
566
|
+
const syncXhrs = Array.isArray(rawSyncXhr) ? rawSyncXhr
|
|
567
|
+
: JSON.parse(typeof rawSyncXhr === 'string' ? rawSyncXhr : '[]');
|
|
568
|
+
for (const entry of syncXhrs) {
|
|
569
|
+
result.errors.push({
|
|
570
|
+
type: 'sync_xhr',
|
|
571
|
+
method: entry.method,
|
|
572
|
+
requestUrl: entry.url,
|
|
573
|
+
message: `Synchronous XHR: ${entry.method} ${entry.url} — blocks the main thread`,
|
|
574
|
+
severity: 'warning',
|
|
575
|
+
url,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
} catch { /* parse failure */ }
|
|
579
|
+
|
|
580
|
+
// 7c. document.write detection
|
|
581
|
+
try {
|
|
582
|
+
const docWriteRaw = await browser.evaluate(DETECT_DOC_WRITE_STATIC);
|
|
583
|
+
const rawDocWrite = unwrapEval(docWriteRaw);
|
|
584
|
+
const docWrites = Array.isArray(rawDocWrite) ? rawDocWrite
|
|
585
|
+
: JSON.parse(typeof rawDocWrite === 'string' ? rawDocWrite : '[]');
|
|
586
|
+
for (const entry of docWrites) {
|
|
587
|
+
result.errors.push({
|
|
588
|
+
type: 'document_write',
|
|
589
|
+
method: entry.method,
|
|
590
|
+
content: entry.content,
|
|
591
|
+
message: `document.${entry.method}() is parser-blocking and degrades page performance`,
|
|
592
|
+
severity: 'warning',
|
|
593
|
+
url,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
} catch { /* parse failure or fetch error */ }
|
|
597
|
+
|
|
598
|
+
// 7d. Long task detection
|
|
599
|
+
try {
|
|
600
|
+
const longTaskRaw = await browser.evaluate(EXTRACT_LONG_TASK_LISTENER);
|
|
601
|
+
const rawLongTasks = unwrapEval(longTaskRaw);
|
|
602
|
+
const longTasks = Array.isArray(rawLongTasks) ? rawLongTasks
|
|
603
|
+
: JSON.parse(typeof rawLongTasks === 'string' ? rawLongTasks : '[]');
|
|
604
|
+
for (const entry of longTasks) {
|
|
605
|
+
result.errors.push({
|
|
606
|
+
type: 'long_task',
|
|
607
|
+
duration: entry.duration,
|
|
608
|
+
startTime: entry.startTime,
|
|
609
|
+
attribution: entry.attribution,
|
|
610
|
+
message: `Long task: ${entry.duration}ms — blocks the main thread (threshold: 50ms)`,
|
|
611
|
+
severity: 'warning',
|
|
612
|
+
url,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
} catch { /* PerformanceObserver not available */ }
|
|
616
|
+
|
|
617
|
+
// 7e. Service worker registration failures
|
|
618
|
+
try {
|
|
619
|
+
const swRaw = await browser.evaluate(EXTRACT_SW_LISTENER);
|
|
620
|
+
const rawSw = unwrapEval(swRaw);
|
|
621
|
+
const swErrs = Array.isArray(rawSw) ? rawSw
|
|
622
|
+
: JSON.parse(typeof rawSw === 'string' ? rawSw : '[]');
|
|
623
|
+
for (const entry of swErrs) {
|
|
624
|
+
result.errors.push({
|
|
625
|
+
type: 'sw_registration_error',
|
|
626
|
+
scriptURL: entry.scriptURL,
|
|
627
|
+
message: `Service worker registration failed for "${entry.scriptURL}": ${entry.message}`,
|
|
628
|
+
severity: 'warning',
|
|
629
|
+
url,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
} catch { /* service worker not supported */ }
|
|
633
|
+
|
|
634
|
+
// 7f. debugger; statement detection
|
|
635
|
+
try {
|
|
636
|
+
const dbgRaw = await browser.evaluate(DEBUGGER_SCRIPT);
|
|
637
|
+
const rawDbg = unwrapEval(dbgRaw);
|
|
638
|
+
const dbgHits = Array.isArray(rawDbg) ? rawDbg
|
|
639
|
+
: JSON.parse(typeof rawDbg === 'string' ? rawDbg : '[]');
|
|
640
|
+
for (const entry of dbgHits) {
|
|
641
|
+
result.errors.push({
|
|
642
|
+
type: 'debugger_statement',
|
|
643
|
+
scriptUrl: entry.scriptUrl,
|
|
644
|
+
line: entry.line,
|
|
645
|
+
snippet: entry.snippet,
|
|
646
|
+
message: `debugger; statement found in "${entry.scriptUrl}" (line ${entry.line}) — remove before shipping`,
|
|
647
|
+
severity: 'critical',
|
|
648
|
+
url,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
} catch { /* parse failure */ }
|
|
652
|
+
|
|
653
|
+
// 7g. Duplicate id="" detection
|
|
654
|
+
try {
|
|
655
|
+
const dupIdRaw = await browser.evaluate(DUPLICATE_ID_SCRIPT);
|
|
656
|
+
const rawDupIds = unwrapEval(dupIdRaw);
|
|
657
|
+
const dupIds = Array.isArray(rawDupIds) ? rawDupIds
|
|
658
|
+
: JSON.parse(typeof rawDupIds === 'string' ? rawDupIds : '[]');
|
|
659
|
+
for (const entry of dupIds) {
|
|
660
|
+
result.errors.push({
|
|
661
|
+
type: 'duplicate_id',
|
|
662
|
+
id: entry.id,
|
|
663
|
+
count: entry.count,
|
|
664
|
+
message: `Duplicate id="${entry.id}" found on ${entry.count} elements — id must be unique per document`,
|
|
665
|
+
severity: 'warning',
|
|
666
|
+
url,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
} catch { /* parse failure */ }
|
|
670
|
+
|
|
671
|
+
// 9b. SEO DOM checks
|
|
672
|
+
try {
|
|
673
|
+
const seoRaw = await browser.evaluate(SEO_ANALYSIS_SCRIPT);
|
|
674
|
+
result.errors.push(...parseSeoAnalysisResult(unwrapEval(seoRaw), url));
|
|
675
|
+
} catch (err) {
|
|
676
|
+
logger.warn(`[ARGUS] SEO analysis skipped for ${url}: ${err.message}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// 9c. Security checks
|
|
680
|
+
try {
|
|
681
|
+
const secRaw = await browser.evaluate(SECURITY_ANALYSIS_SCRIPT);
|
|
682
|
+
result.errors.push(...parseSecurityAnalysisResult(unwrapEval(secRaw), url));
|
|
683
|
+
} catch (err) {
|
|
684
|
+
logger.warn(`[ARGUS] Security DOM analysis skipped for ${url}: ${err.message}`);
|
|
685
|
+
}
|
|
686
|
+
result.errors.push(...analyzeSecurityConsole(consoleMsgs, url));
|
|
687
|
+
result.errors.push(...analyzeSecurityNetwork(networkReqs, url));
|
|
688
|
+
|
|
689
|
+
// 9d. Content quality checks
|
|
690
|
+
try {
|
|
691
|
+
const contentRaw = await browser.evaluate(CONTENT_ANALYSIS_SCRIPT);
|
|
692
|
+
result.errors.push(...parseContentAnalysisResult(unwrapEval(contentRaw), url));
|
|
693
|
+
} catch (err) {
|
|
694
|
+
logger.warn(`[ARGUS] Content analysis skipped for ${url}: ${err.message}`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// 9e. Chrome DevTools Issues panel
|
|
698
|
+
try {
|
|
699
|
+
const issueRaw = await browser.listConsoleRaw({ types: ['issue'] });
|
|
700
|
+
const issues = normalizeArray(issueRaw).slice(issuesBaseline);
|
|
701
|
+
result.errors.push(...parseIssues(issues, url, route.critical));
|
|
702
|
+
} catch (err) {
|
|
703
|
+
logger.warn(`[ARGUS] Issues analysis skipped for ${url}: ${err.message}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 9f. HTTPS enforcement check
|
|
707
|
+
try {
|
|
708
|
+
const parsed = new URL(url);
|
|
709
|
+
const isLocalhost = /^(localhost|127\.|::1)/.test(parsed.hostname);
|
|
710
|
+
if (parsed.protocol === 'http:' && !isLocalhost) {
|
|
711
|
+
result.errors.push({
|
|
712
|
+
type: 'security_no_https',
|
|
713
|
+
message: `Page served over HTTP — enforce HTTPS via server redirect or HSTS`,
|
|
714
|
+
severity: 'warning',
|
|
715
|
+
url,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
} catch { /* URL parse failure */ }
|
|
719
|
+
|
|
720
|
+
// 10. CSS analysis
|
|
721
|
+
try {
|
|
722
|
+
const cssRaw = await browser.evaluate(CSS_ANALYSIS_SCRIPT);
|
|
723
|
+
result.errors.push(...parseCssAnalysisResult(unwrapEval(cssRaw), url));
|
|
724
|
+
} catch (err) {
|
|
725
|
+
logger.warn(`[ARGUS] CSS analysis skipped for ${url}: ${err.message}`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 11. Deduplicate within this cheap run
|
|
729
|
+
result.errors = deduplicateErrors(result.errors);
|
|
730
|
+
|
|
731
|
+
// 12. Screenshot
|
|
732
|
+
const screenshotPath = path.join(OUTPUT_DIR, `screenshot-${slugify(route.name)}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.png`);
|
|
733
|
+
try {
|
|
734
|
+
const screenshotData = await browser.screenshot({ format: 'png' });
|
|
735
|
+
if (screenshotData?.data) {
|
|
736
|
+
fs.writeFileSync(screenshotPath, Buffer.from(screenshotData.data, 'base64'));
|
|
737
|
+
result.screenshot = screenshotPath;
|
|
738
|
+
}
|
|
739
|
+
} catch (err) {
|
|
740
|
+
logger.warn(`[ARGUS] Screenshot failed for ${url}: ${err.message}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── Expensive Crawl (called ×1) ────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Expensive/deterministic analyzers for one route — called ONCE per route.
|
|
750
|
+
* Runs: network perf, redirect chain, perf budgets, Lighthouse,
|
|
751
|
+
* broken internal links, cache headers.
|
|
752
|
+
*/
|
|
753
|
+
export async function crawlRouteExpensive(route, baseUrl, mcp) {
|
|
754
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
755
|
+
const url = `${baseUrl}${route.path}`;
|
|
756
|
+
const errors = [];
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
await browser.navigate(url);
|
|
760
|
+
if (route.waitFor) {
|
|
761
|
+
await waitForSelector(browser, route.waitFor, 10000);
|
|
762
|
+
} else {
|
|
763
|
+
await new Promise(r => setTimeout(r, config.pageSettleMs));
|
|
764
|
+
}
|
|
765
|
+
} catch (err) {
|
|
766
|
+
logger.warn(`[ARGUS] Expensive crawl: navigation failed for ${url}: ${err.message}`);
|
|
767
|
+
return errors;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Network performance — slow responses + oversized payloads
|
|
771
|
+
try {
|
|
772
|
+
const perfRaw = await browser.evaluate(NETWORK_PERF_SCRIPT);
|
|
773
|
+
const perfResult = unwrapEval(perfRaw);
|
|
774
|
+
let perfEntries = Array.isArray(perfResult) ? perfResult
|
|
775
|
+
: JSON.parse(typeof perfResult === 'string' ? perfResult : '[]');
|
|
776
|
+
errors.push(...analyzeNetworkPerformance(Array.isArray(perfEntries) ? perfEntries : [], url));
|
|
777
|
+
} catch (err) {
|
|
778
|
+
logger.warn(`[ARGUS] Network performance analysis skipped for ${url}: ${err.message}`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Redirect chain detection
|
|
782
|
+
try {
|
|
783
|
+
const rdRaw = await browser.evaluate(REDIRECT_COUNT_SCRIPT);
|
|
784
|
+
const rdCount = Number(unwrapEval(rdRaw) ?? 0);
|
|
785
|
+
if (rdCount > 2) {
|
|
786
|
+
errors.push({
|
|
787
|
+
type: 'redirect_chain',
|
|
788
|
+
count: rdCount,
|
|
789
|
+
message: `Redirect chain length ${rdCount} — navigated through ${rdCount} redirects (threshold: > 2)`,
|
|
790
|
+
severity: 'warning',
|
|
791
|
+
url,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
} catch (err) {
|
|
795
|
+
logger.warn(`[ARGUS] Redirect chain check skipped for ${url}: ${err.message}`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Performance budget check
|
|
799
|
+
errors.push(...(await checkPerformanceBudgets(browser, url)));
|
|
800
|
+
|
|
801
|
+
// Full Lighthouse audit
|
|
802
|
+
errors.push(...(await checkLighthouse(browser, url)));
|
|
803
|
+
|
|
804
|
+
// Broken internal link detection
|
|
805
|
+
try {
|
|
806
|
+
const linksRaw = await browser.evaluate(INTERNAL_LINKS_SCRIPT);
|
|
807
|
+
const rawLinks = unwrapEval(linksRaw);
|
|
808
|
+
const links = [...new Set(Array.isArray(rawLinks) ? rawLinks.filter(Boolean) : [])];
|
|
809
|
+
const headResults = await Promise.all(
|
|
810
|
+
links.map(async href => {
|
|
811
|
+
try {
|
|
812
|
+
const res = await fetch(href, { method: 'HEAD', signal: AbortSignal.timeout(5000) });
|
|
813
|
+
return { href, status: res.status };
|
|
814
|
+
} catch (err) {
|
|
815
|
+
return { href, status: 0, error: err.message };
|
|
816
|
+
}
|
|
817
|
+
})
|
|
818
|
+
);
|
|
819
|
+
for (const { href, status } of headResults) {
|
|
820
|
+
if (status === 404) {
|
|
821
|
+
errors.push({
|
|
822
|
+
type: 'broken_link',
|
|
823
|
+
requestUrl: href,
|
|
824
|
+
status: 404,
|
|
825
|
+
message: `Broken internal link: ${href} (HTTP 404)`,
|
|
826
|
+
severity: 'warning',
|
|
827
|
+
url,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
} catch (err) {
|
|
832
|
+
logger.warn(`[ARGUS] Broken link check skipped for ${url}: ${err.message}`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Cache header detection
|
|
836
|
+
try {
|
|
837
|
+
const cacheRaw = await browser.evaluate(CACHE_HEADER_SCRIPT);
|
|
838
|
+
const rawCache = unwrapEval(cacheRaw);
|
|
839
|
+
const cacheItems = Array.isArray(rawCache) ? rawCache
|
|
840
|
+
: JSON.parse(typeof rawCache === 'string' ? rawCache : '[]');
|
|
841
|
+
for (const entry of cacheItems) {
|
|
842
|
+
const filename = (entry.url ?? '').replace(/^.*\//, '').split('?')[0] || entry.url;
|
|
843
|
+
errors.push({
|
|
844
|
+
type: 'cache_headers_missing',
|
|
845
|
+
requestUrl: entry.url,
|
|
846
|
+
message: `No cache headers on "${filename}" — missing both Cache-Control and ETag`,
|
|
847
|
+
severity: 'info',
|
|
848
|
+
url,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
} catch (err) {
|
|
852
|
+
logger.warn(`[ARGUS] Cache header check skipped for ${url}: ${err.message}`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return errors;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── Per-Route Crawl Coordinator ────────────────────────────────────────────────
|
|
859
|
+
|
|
860
|
+
async function crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile) {
|
|
861
|
+
return startSpan('argus.crawl_route', { url: `${targetBaseUrl}${route.path}`, critical: String(!!route.critical) }, async () => {
|
|
862
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
863
|
+
const url = `${targetBaseUrl}${route.path}`;
|
|
864
|
+
|
|
865
|
+
if (auth?.steps?.length > 0) {
|
|
866
|
+
try {
|
|
867
|
+
await refreshSession(browser, auth, targetBaseUrl);
|
|
868
|
+
await restoreSession(browser, targetBaseUrl, sessionFile);
|
|
869
|
+
} catch (err) {
|
|
870
|
+
logger.warn(`[ARGUS] Auth: session restore skipped for ${route.name}: ${err.message}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Cheap pass × 2 → merge for flakiness
|
|
875
|
+
logger.info(`[ARGUS] ${route.name}: cheap run 1/2...`);
|
|
876
|
+
const cheapRun1 = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'cheap_1' }, () => crawlRouteCheap(route, targetBaseUrl, mcp));
|
|
877
|
+
logger.info(`[ARGUS] ${route.name}: cheap run 2/2 (flakiness check)...`);
|
|
878
|
+
const cheapRun2 = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'cheap_2' }, () => crawlRouteCheap(route, targetBaseUrl, mcp));
|
|
879
|
+
const result = mergeRunResults(cheapRun1, cheapRun2);
|
|
880
|
+
|
|
881
|
+
// Expensive pass × 1
|
|
882
|
+
logger.info(`[ARGUS] ${route.name}: expensive analyzers (once)...`);
|
|
883
|
+
const expensiveErrors = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'expensive' }, () => crawlRouteExpensive(route, targetBaseUrl, mcp));
|
|
884
|
+
result.errors.push(...expensiveErrors);
|
|
885
|
+
result.errors = deduplicateErrors(result.errors);
|
|
886
|
+
|
|
887
|
+
// Post-crawl expensive analyzers via registry (responsive, memory, hover, snapshot, keyboard)
|
|
888
|
+
for (const { name, analyze } of getExpensive()) {
|
|
889
|
+
if (name === 'lighthouse') continue; // runs inside crawlRouteExpensive
|
|
890
|
+
try {
|
|
891
|
+
const raw = await startSpan('argus.analyzer', { name, url }, () => analyze(browser, url, route));
|
|
892
|
+
const findings = Array.isArray(raw) ? raw : (raw?.findings ?? []);
|
|
893
|
+
result.errors.push(...findings);
|
|
894
|
+
// Handle responsive screenshot return shape: { findings, screenshots }
|
|
895
|
+
if (raw?.screenshots && Object.keys(raw.screenshots).length > 0) {
|
|
896
|
+
const screenshotPaths = {};
|
|
897
|
+
for (const [viewport, data] of Object.entries(raw.screenshots)) {
|
|
898
|
+
if (typeof data !== 'string') continue; // skip omitted entries ({ omitted: true, reason, bytes })
|
|
899
|
+
const shotPath = path.join(
|
|
900
|
+
OUTPUT_DIR,
|
|
901
|
+
`screenshot-${slugify(route.name)}-responsive-${viewport}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.png`
|
|
902
|
+
);
|
|
903
|
+
try {
|
|
904
|
+
fs.writeFileSync(shotPath, Buffer.from(data, 'base64'));
|
|
905
|
+
screenshotPaths[viewport] = shotPath;
|
|
906
|
+
} catch (err) {
|
|
907
|
+
logger.warn(`[ARGUS] Responsive screenshot write failed (${viewport}): ${err.message}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (Object.keys(screenshotPaths).length > 0) result.responsiveScreenshots = screenshotPaths;
|
|
911
|
+
}
|
|
912
|
+
} catch (err) {
|
|
913
|
+
logger.warn(`[ARGUS] ${name} skipped for ${route.name}: ${err.message}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Collect internal navigation links for dead route detection (C1.4)
|
|
918
|
+
try {
|
|
919
|
+
const linksRaw = await browser.evaluate(INTERNAL_LINKS_SCRIPT);
|
|
920
|
+
const parsed = unwrapEval(linksRaw);
|
|
921
|
+
result.discoveredLinks = Array.isArray(parsed) ? parsed
|
|
922
|
+
: (() => { try { const p = JSON.parse(String(parsed ?? '[]')); return Array.isArray(p) ? p : []; } catch { return []; } })();
|
|
923
|
+
} catch {
|
|
924
|
+
result.discoveredLinks = [];
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Record per-route finding metrics and flakiness
|
|
928
|
+
const flakyCount = result.errors.filter(e => e.flaky).length;
|
|
929
|
+
recordFlaky(flakyCount, route.name);
|
|
930
|
+
for (const f of result.errors) recordFinding(f.type, f.severity, route.name);
|
|
931
|
+
|
|
932
|
+
return result;
|
|
933
|
+
}); // end argus.crawl_route span
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── Parallel Shard Runner (D7.3) ──────────────────────────────────────────────
|
|
937
|
+
|
|
938
|
+
async function crawlShardWithClient(shard, targetBaseUrl, mcp, sessionFile) {
|
|
939
|
+
const results = [];
|
|
940
|
+
for (const route of shard) {
|
|
941
|
+
logger.info(`[ARGUS/parallel] Crawling: ${route.name} → ${targetBaseUrl}${route.path}`);
|
|
942
|
+
const result = await crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile);
|
|
943
|
+
const flakyCount = result.errors.filter(e => e.flaky).length;
|
|
944
|
+
if (flakyCount > 0) {
|
|
945
|
+
logger.info(`[ARGUS/parallel] ${route.name}: ${flakyCount} finding(s) downgraded to info (flaky)`);
|
|
946
|
+
}
|
|
947
|
+
results.push(result);
|
|
948
|
+
}
|
|
949
|
+
return results;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ── Main Entry Point ───────────────────────────────────────────────────────────
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Run all routes, collect results, process report, and dispatch.
|
|
956
|
+
* In production, `mcp` is provided by Claude Code's MCP integration.
|
|
957
|
+
*
|
|
958
|
+
* @param {object} mcp - Chrome DevTools MCP tool interface
|
|
959
|
+
* @param {Array} [routeOverrides] - Override the default routes from targets.js
|
|
960
|
+
* @param {string} [baseUrlOverride] - Override the default base URL
|
|
961
|
+
* @returns {object} Full report object
|
|
962
|
+
*/
|
|
963
|
+
export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = null) {
|
|
964
|
+
return startSpan('argus.run_crawl', { baseUrl: baseUrlOverride ?? BASE_URL }, async () => {
|
|
965
|
+
// Validate config at startup — catches targets.js misconfiguration before any crawl work begins.
|
|
966
|
+
// Named exports are already statically imported above; build the namespace object here.
|
|
967
|
+
validateConfig({ config, routes, thresholds, apiContracts, severityOverrides, auth, flows, codebase, autoDiscover });
|
|
968
|
+
|
|
969
|
+
const browser = new CdpBrowserAdapter(mcp);
|
|
970
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
971
|
+
|
|
972
|
+
const targetBaseUrl = baseUrlOverride ?? BASE_URL;
|
|
973
|
+
|
|
974
|
+
// C3: auto route discovery
|
|
975
|
+
const baseRoutes = routeOverrides ?? routes;
|
|
976
|
+
const targetRoutes = (autoDiscover && !routeOverrides)
|
|
977
|
+
? await discoverRoutes(targetBaseUrl, codebase?.sourceDir ?? null, autoDiscover, baseRoutes)
|
|
978
|
+
: baseRoutes;
|
|
979
|
+
|
|
980
|
+
// Validate route objects
|
|
981
|
+
for (const route of targetRoutes) {
|
|
982
|
+
if (!route || typeof route !== 'object') throw new Error(`[ARGUS] Invalid route entry: ${JSON.stringify(route)}`);
|
|
983
|
+
if (typeof route.path !== 'string' || !route.path.startsWith('/')) {
|
|
984
|
+
throw new Error(`[ARGUS] Invalid route.path "${route.path}" — must be a string starting with "/"`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const report = {
|
|
989
|
+
generatedAt: new Date().toISOString(),
|
|
990
|
+
baseUrl: targetBaseUrl,
|
|
991
|
+
summary: { total: 0, critical: 0, warning: 0, info: 0 },
|
|
992
|
+
routes: [],
|
|
993
|
+
flows: [],
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// Auth session persistence (B2)
|
|
997
|
+
const sessionFile = auth?.sessionFile ?? '.argus-session.json';
|
|
998
|
+
if (auth?.steps?.length > 0) {
|
|
999
|
+
if (!hasSession(sessionFile, auth.sessionMaxAgeMs)) {
|
|
1000
|
+
logger.info(`[ARGUS] Auth: running login flow (${auth.steps.length} steps)...`);
|
|
1001
|
+
try {
|
|
1002
|
+
await runLoginFlow(browser, targetBaseUrl, auth.steps);
|
|
1003
|
+
await saveSession(browser, sessionFile);
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
logger.warn(`[ARGUS] Auth: login flow failed — crawl will proceed unauthenticated: ${err.message}`);
|
|
1006
|
+
}
|
|
1007
|
+
} else {
|
|
1008
|
+
logger.info(`[ARGUS] Auth: reusing existing session from ${sessionFile}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// D7.3: parallel route crawling
|
|
1013
|
+
const _rawConcurrency = parseInt(process.env.ARGUS_CONCURRENCY ?? '1', 10);
|
|
1014
|
+
const concurrency = Math.min(10, Math.max(1, isNaN(_rawConcurrency) ? 1 : _rawConcurrency));
|
|
1015
|
+
|
|
1016
|
+
if (concurrency > 1) {
|
|
1017
|
+
logger.info(`[ARGUS] Parallel mode: concurrency=${concurrency}, sharding ${targetRoutes.length} route(s)`);
|
|
1018
|
+
const shards = chunkArray(targetRoutes, concurrency);
|
|
1019
|
+
const extraClients = [];
|
|
1020
|
+
try {
|
|
1021
|
+
for (let i = 1; i < shards.length; i++) {
|
|
1022
|
+
extraClients.push(await createMcpClient());
|
|
1023
|
+
}
|
|
1024
|
+
const shardPromises = shards.map((shard, idx) => {
|
|
1025
|
+
const shardMcp = idx === 0 ? mcp : extraClients[idx - 1];
|
|
1026
|
+
return crawlShardWithClient(shard, targetBaseUrl, shardMcp, sessionFile);
|
|
1027
|
+
});
|
|
1028
|
+
const shardResults = await Promise.all(shardPromises);
|
|
1029
|
+
for (const shardResult of shardResults) {
|
|
1030
|
+
for (const result of shardResult) {
|
|
1031
|
+
report.routes.push(result);
|
|
1032
|
+
for (const err of result.errors) {
|
|
1033
|
+
report.summary.total++;
|
|
1034
|
+
report.summary[err.severity] = (report.summary[err.severity] ?? 0) + 1;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
} finally {
|
|
1039
|
+
for (const client of extraClients) {
|
|
1040
|
+
try { await client?.close?.(); } catch {}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
} else {
|
|
1044
|
+
for (const route of targetRoutes) {
|
|
1045
|
+
logger.info(`[ARGUS] Crawling: ${route.name} → ${targetBaseUrl}${route.path}`);
|
|
1046
|
+
const result = await crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile);
|
|
1047
|
+
|
|
1048
|
+
const flakyCount = result.errors.filter(e => e.flaky).length;
|
|
1049
|
+
if (flakyCount > 0) {
|
|
1050
|
+
logger.info(`[ARGUS] ${route.name}: ${flakyCount} finding(s) downgraded to info (flaky — appeared in only one cheap run)`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
report.routes.push(result);
|
|
1054
|
+
for (const err of result.errors) {
|
|
1055
|
+
report.summary.total++;
|
|
1056
|
+
report.summary[err.severity] = (report.summary[err.severity] ?? 0) + 1;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// User flow testing (B5)
|
|
1062
|
+
if (flows?.length > 0) {
|
|
1063
|
+
logger.info(`[ARGUS] Running ${flows.length} user flow(s)...`);
|
|
1064
|
+
const { results: flowResults, findings: flowFindings } = await runAllFlows(flows, targetBaseUrl, browser);
|
|
1065
|
+
report.flows = flowResults;
|
|
1066
|
+
for (const finding of flowFindings) {
|
|
1067
|
+
report.summary.total++;
|
|
1068
|
+
report.summary[finding.severity] = (report.summary[finding.severity] ?? 0) + 1;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// C1: Codebase cross-reference
|
|
1073
|
+
report.codebase = [];
|
|
1074
|
+
const allConsoleFindings = report.routes.flatMap(r => r.errors.filter(e => e.type === 'console'));
|
|
1075
|
+
try {
|
|
1076
|
+
const cbFindings = await analyzeCodebase({
|
|
1077
|
+
sourceDir: codebase?.sourceDir ?? null,
|
|
1078
|
+
envFile: codebase?.envFile ?? null,
|
|
1079
|
+
consoleFindings: allConsoleFindings,
|
|
1080
|
+
});
|
|
1081
|
+
report.codebase.push(...cbFindings);
|
|
1082
|
+
if (cbFindings.length > 0) {
|
|
1083
|
+
logger.info(`[ARGUS] C1: ${cbFindings.length} codebase finding(s)`);
|
|
1084
|
+
}
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
logger.warn(`[ARGUS] C1: codebase analysis skipped: ${err.message}`);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// C1.4: Dead route detection
|
|
1090
|
+
try {
|
|
1091
|
+
const allLinks = [...new Set(report.routes.flatMap(r => r.discoveredLinks ?? []))];
|
|
1092
|
+
const testedPaths = targetRoutes.map(r => r.path);
|
|
1093
|
+
const deadFindings = await detectDeadRoutes(targetBaseUrl, allLinks, testedPaths);
|
|
1094
|
+
report.codebase.push(...deadFindings);
|
|
1095
|
+
if (deadFindings.length > 0) {
|
|
1096
|
+
logger.info(`[ARGUS] C1: ${deadFindings.length} dead route(s) detected`);
|
|
1097
|
+
}
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
logger.warn(`[ARGUS] C1: dead route detection skipped: ${err.message}`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Add codebase findings to running summary
|
|
1103
|
+
for (const finding of report.codebase) {
|
|
1104
|
+
report.summary.total++;
|
|
1105
|
+
report.summary[finding.severity] = (report.summary[finding.severity] ?? 0) + 1;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Post-crawl: overrides, baseline, write JSON, dispatch
|
|
1109
|
+
const { reportPath, diff } = await processReport(report, {
|
|
1110
|
+
outputDir: OUTPUT_DIR,
|
|
1111
|
+
severityOverrides,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
if (diff && !diff.isFirstRun) recordNewFindings(diff.newCount ?? 0);
|
|
1115
|
+
|
|
1116
|
+
await dispatchAll(report, diff, reportPath);
|
|
1117
|
+
|
|
1118
|
+
return report;
|
|
1119
|
+
}); // end argus.run_crawl span
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ── CLI Entry ──────────────────────────────────────────────────────────────────
|
|
1123
|
+
|
|
1124
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
1125
|
+
logger.info('[ARGUS] orchestrator.js loaded. Invoke runCrawl(mcp) from Claude Code with MCP tools connected.');
|
|
1126
|
+
logger.info('[ARGUS] Target base URL: ' + BASE_URL);
|
|
1127
|
+
logger.info('[ARGUS] Routes to crawl: ' + (routes ?? []).map(r => r?.path ?? '(no path)').join(', '));
|
|
1128
|
+
}
|