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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Argus Orchestrator
|
|
2
|
+
* Argus Orchestrator
|
|
3
3
|
*
|
|
4
4
|
* Per-route crawl loop: cheap×2 flakiness pass + expensive×1 pass.
|
|
5
5
|
* Extracted from crawl-and-report.js god object.
|
|
@@ -15,7 +15,6 @@ import 'dotenv/config';
|
|
|
15
15
|
import { routes, config, auth, flows, apiContracts, severityOverrides, codebase, autoDiscover, thresholds } from '../config/targets.js';
|
|
16
16
|
import { discoverRoutes } from '../utils/route-discoverer.js';
|
|
17
17
|
import { analyzeCodebase, detectDeadRoutes, INTERNAL_LINKS_SCRIPT } from '../utils/codebase-analyzer.js';
|
|
18
|
-
import { CSS_ANALYSIS_SCRIPT, parseCssAnalysisResult } from '../utils/css-analyzer.js';
|
|
19
18
|
import { SEO_ANALYSIS_SCRIPT, parseSeoAnalysisResult } from '../utils/seo-analyzer.js';
|
|
20
19
|
import { SECURITY_ANALYSIS_SCRIPT, parseSecurityAnalysisResult, analyzeSecurityConsole, analyzeSecurityNetwork } from '../utils/security-analyzer.js';
|
|
21
20
|
import { CONTENT_ANALYSIS_SCRIPT, parseContentAnalysisResult } from '../utils/content-analyzer.js';
|
|
@@ -26,6 +25,7 @@ import { analyzeApiFrequency } from '.
|
|
|
26
25
|
import { slugify } from '../utils/slug.js';
|
|
27
26
|
import { unwrapEval, createMcpClient } from '../utils/mcp-client.js';
|
|
28
27
|
import { CdpBrowserAdapter } from '../adapters/browser.js';
|
|
28
|
+
import { getFigmaFrame } from '../adapters/figma.js';
|
|
29
29
|
import { chunkArray } from '../utils/parallel-crawler.js';
|
|
30
30
|
import { validateApiContracts } from '../utils/contract-validator.js';
|
|
31
31
|
import { checkLighthouse } from '../utils/lighthouse-checker.js';
|
|
@@ -33,13 +33,16 @@ import { parseIssues } from '.
|
|
|
33
33
|
import { parseNetworkTiming } from '../utils/network-timing-analyzer.js';
|
|
34
34
|
|
|
35
35
|
// Side-effect imports: each module calls registerExpensive() at load time.
|
|
36
|
-
// lighthouse-checker.js also self-registers via its direct named import above
|
|
36
|
+
// lighthouse-checker.js also self-registers via its direct named import above.
|
|
37
37
|
// Order below controls iteration order in crawlAndAnalyzeRoute — must match original call order.
|
|
38
|
+
import '../utils/css-analyzer.js';
|
|
38
39
|
import '../utils/responsive-analyzer.js';
|
|
39
40
|
import '../utils/memory-analyzer.js';
|
|
40
41
|
import '../utils/hover-analyzer.js';
|
|
41
42
|
import '../utils/snapshot-analyzer.js';
|
|
42
43
|
import '../utils/keyboard-analyzer.js';
|
|
44
|
+
import '../utils/theme-analyzer.js';
|
|
45
|
+
import '../utils/design-fidelity-analyzer.js';
|
|
43
46
|
|
|
44
47
|
import { getExpensive } from '../registry.js';
|
|
45
48
|
import { deduplicateFindings as deduplicateErrors } from './report-processor.js';
|
|
@@ -54,6 +57,7 @@ const logger = childLogger('orchestrator');
|
|
|
54
57
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
55
58
|
const BASE_URL = process.env.TARGET_DEV_URL ?? 'http://localhost:3000';
|
|
56
59
|
const OUTPUT_DIR = path.resolve(__dirname, '../../', config.outputDir);
|
|
60
|
+
const LIGHTHOUSE_TIMEOUT_MS = parseInt(process.env.ARGUS_LIGHTHOUSE_TIMEOUT ?? '120000', 10);
|
|
57
61
|
|
|
58
62
|
// Thresholds for perf budgets and network analysis are centralized in targets.js.
|
|
59
63
|
|
|
@@ -362,7 +366,6 @@ function analyzeNetworkPerformance(perfEntries, pageUrl) {
|
|
|
362
366
|
|
|
363
367
|
async function checkPerformanceBudgets(browser, url) {
|
|
364
368
|
const violations = [];
|
|
365
|
-
const LIGHTHOUSE_TIMEOUT_MS = parseInt(process.env.ARGUS_LIGHTHOUSE_TIMEOUT ?? '120000', 10);
|
|
366
369
|
|
|
367
370
|
try {
|
|
368
371
|
await browser.startTrace();
|
|
@@ -397,7 +400,6 @@ async function checkPerformanceBudgets(browser, url) {
|
|
|
397
400
|
logger.warn(`[ARGUS] Performance trace skipped for ${url}: ${err.message}`);
|
|
398
401
|
}
|
|
399
402
|
|
|
400
|
-
void LIGHTHOUSE_TIMEOUT_MS; // referenced only here to prevent unused-var lint
|
|
401
403
|
return violations;
|
|
402
404
|
}
|
|
403
405
|
|
|
@@ -424,7 +426,8 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
424
426
|
|
|
425
427
|
// 0. Snapshot session-wide baselines BEFORE this route starts (D5).
|
|
426
428
|
const consoleBaseline = (await browser.listConsole().catch(() => [])).length;
|
|
427
|
-
const
|
|
429
|
+
const baselineNetList = await browser.listNetwork().catch(() => []);
|
|
430
|
+
const networkMaxReqId = baselineNetList.reduce((max, r) => Math.max(max, r._reqid ?? 0), 0);
|
|
428
431
|
// listConsoleRaw returns raw MCP response — normalizeArray required before .length
|
|
429
432
|
const issuesBaselineRaw = await browser.listConsoleRaw({ types: ['issue'] }).catch(() => null);
|
|
430
433
|
const issuesBaseline = normalizeArray(issuesBaselineRaw).length;
|
|
@@ -468,8 +471,14 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
468
471
|
});
|
|
469
472
|
}
|
|
470
473
|
|
|
471
|
-
// 5. Console messages — sliced from per-route baseline
|
|
472
|
-
|
|
474
|
+
// 5. Console messages — sliced from per-route baseline.
|
|
475
|
+
// Guard: chrome-devtools-mcp list_console_messages resets per navigation. If the
|
|
476
|
+
// new page has fewer total messages than the pre-navigation baseline, the baseline
|
|
477
|
+
// refers to the previous page's context and slicing by it would give an empty array.
|
|
478
|
+
// Fall back to 0 (take all messages from the new page) in that case.
|
|
479
|
+
const allConsoleMsgs = await browser.listConsole().catch(() => []);
|
|
480
|
+
const consoleSliceAt = allConsoleMsgs.length > consoleBaseline ? consoleBaseline : 0;
|
|
481
|
+
const consoleMsgs = allConsoleMsgs.slice(consoleSliceAt);
|
|
473
482
|
for (const msg of consoleMsgs) {
|
|
474
483
|
const text = (msg.text ?? msg.message ?? '');
|
|
475
484
|
if (text.toLowerCase().includes('has been blocked by cors policy')) continue;
|
|
@@ -500,9 +509,9 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
500
509
|
}
|
|
501
510
|
}
|
|
502
511
|
|
|
503
|
-
// 6. Network requests —
|
|
504
|
-
const networkReqs = (await browser.listNetwork())
|
|
505
|
-
.
|
|
512
|
+
// 6. Network requests — filtered from per-route baseline by _reqid (cap AFTER filter, not before)
|
|
513
|
+
const networkReqs = (await browser.listNetwork().catch(() => []))
|
|
514
|
+
.filter(r => (r._reqid ?? 0) > networkMaxReqId).slice(0, 500);
|
|
506
515
|
for (const req of networkReqs) {
|
|
507
516
|
const severity = classifyNetworkRequest(req, route.critical);
|
|
508
517
|
if (severity !== null) {
|
|
@@ -717,15 +726,7 @@ export async function crawlRouteCheap(route, baseUrl, mcp) {
|
|
|
717
726
|
}
|
|
718
727
|
} catch { /* URL parse failure */ }
|
|
719
728
|
|
|
720
|
-
// 10.
|
|
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
|
+
// 10. Deduplicate within this cheap run
|
|
729
730
|
result.errors = deduplicateErrors(result.errors);
|
|
730
731
|
|
|
731
732
|
// 12. Screenshot
|
|
@@ -798,8 +799,14 @@ export async function crawlRouteExpensive(route, baseUrl, mcp) {
|
|
|
798
799
|
// Performance budget check
|
|
799
800
|
errors.push(...(await checkPerformanceBudgets(browser, url)));
|
|
800
801
|
|
|
801
|
-
// Full Lighthouse audit
|
|
802
|
-
errors.push(...(await
|
|
802
|
+
// Full Lighthouse audit (capped at LIGHTHOUSE_TIMEOUT_MS to prevent indefinite hang)
|
|
803
|
+
errors.push(...(await Promise.race([
|
|
804
|
+
checkLighthouse(browser, url),
|
|
805
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Lighthouse timed out after ${LIGHTHOUSE_TIMEOUT_MS}ms`)), LIGHTHOUSE_TIMEOUT_MS)),
|
|
806
|
+
]).catch(err => {
|
|
807
|
+
logger.warn(`[ARGUS] Lighthouse skipped for ${url}: ${err.message}`);
|
|
808
|
+
return [];
|
|
809
|
+
})));
|
|
803
810
|
|
|
804
811
|
// Broken internal link detection
|
|
805
812
|
try {
|
|
@@ -873,24 +880,34 @@ async function crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile) {
|
|
|
873
880
|
await refreshSession(browser, auth, targetBaseUrl);
|
|
874
881
|
await restoreSession(browser, targetBaseUrl, sessionFile);
|
|
875
882
|
} catch (err) {
|
|
876
|
-
logger.warn(`[ARGUS] Auth: session restore skipped for ${route.name}: ${err.message}`);
|
|
883
|
+
logger.warn(`[ARGUS] Auth: session restore skipped for ${route.name} (${route.path}): ${err.message}`);
|
|
877
884
|
}
|
|
878
885
|
}
|
|
879
886
|
|
|
880
887
|
// Cheap pass × 2 → merge for flakiness
|
|
881
|
-
logger.info(`[ARGUS] ${route.name}: cheap run 1/2...`);
|
|
888
|
+
logger.info(`[ARGUS] ${route.name} (${route.path}): cheap run 1/2...`);
|
|
882
889
|
const cheapRun1 = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'cheap_1' }, () => crawlRouteCheap(route, targetBaseUrl, mcp));
|
|
883
|
-
logger.info(`[ARGUS] ${route.name}: cheap run 2/2 (flakiness check)...`);
|
|
890
|
+
logger.info(`[ARGUS] ${route.name} (${route.path}): cheap run 2/2 (flakiness check)...`);
|
|
884
891
|
const cheapRun2 = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'cheap_2' }, () => crawlRouteCheap(route, targetBaseUrl, mcp));
|
|
885
892
|
const result = mergeRunResults(cheapRun1, cheapRun2);
|
|
886
893
|
|
|
887
894
|
// Expensive pass × 1
|
|
888
|
-
logger.info(`[ARGUS] ${route.name}: expensive analyzers (once)...`);
|
|
895
|
+
logger.info(`[ARGUS] ${route.name} (${route.path}): expensive analyzers (once)...`);
|
|
889
896
|
const expensiveErrors = await startSpan('argus.crawl_route', { url, critical: String(!!route.critical), pass: 'expensive' }, () => crawlRouteExpensive(route, targetBaseUrl, mcp));
|
|
890
897
|
result.errors.push(...expensiveErrors);
|
|
891
898
|
result.errors = deduplicateErrors(result.errors);
|
|
892
899
|
|
|
893
|
-
//
|
|
900
|
+
// D9: Pre-fetch Figma design tokens when route specifies a figmaFrameUrl.
|
|
901
|
+
// Attaches figmaData to the route object so design-fidelity-analyzer can consume it.
|
|
902
|
+
if (route.figmaFrameUrl && !route.figmaData) {
|
|
903
|
+
try {
|
|
904
|
+
route.figmaData = await getFigmaFrame(route.figmaFrameUrl);
|
|
905
|
+
} catch (err) {
|
|
906
|
+
logger.warn(`[ARGUS] D9: Figma fetch failed for ${route.name} (${route.path}): ${err.message}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Post-crawl expensive analyzers via registry (css, responsive, memory, hover, snapshot, keyboard, theme, design-fidelity)
|
|
894
911
|
for (const { name, analyze } of getExpensive()) {
|
|
895
912
|
if (name === 'lighthouse') continue; // runs inside crawlRouteExpensive
|
|
896
913
|
try {
|
|
@@ -916,7 +933,7 @@ async function crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile) {
|
|
|
916
933
|
if (Object.keys(screenshotPaths).length > 0) result.responsiveScreenshots = screenshotPaths;
|
|
917
934
|
}
|
|
918
935
|
} catch (err) {
|
|
919
|
-
logger.warn(`[ARGUS] ${name} skipped for ${route.name}: ${err.message}`);
|
|
936
|
+
logger.warn(`[ARGUS] ${name} skipped for ${route.name} (${route.path}): ${err.message}`);
|
|
920
937
|
}
|
|
921
938
|
}
|
|
922
939
|
|
|
@@ -948,7 +965,7 @@ async function crawlShardWithClient(shard, targetBaseUrl, mcp, sessionFile) {
|
|
|
948
965
|
const result = await crawlAndAnalyzeRoute(route, targetBaseUrl, mcp, sessionFile);
|
|
949
966
|
const flakyCount = result.errors.filter(e => e.flaky).length;
|
|
950
967
|
if (flakyCount > 0) {
|
|
951
|
-
logger.info(`[ARGUS/parallel] ${route.name}: ${flakyCount} finding(s) downgraded to info (flaky)`);
|
|
968
|
+
logger.info(`[ARGUS/parallel] ${route.name} (${route.path}): ${flakyCount} finding(s) downgraded to info (flaky)`);
|
|
952
969
|
}
|
|
953
970
|
results.push(result);
|
|
954
971
|
}
|
|
@@ -1053,7 +1070,7 @@ export async function runCrawl(mcp, routeOverrides = null, baseUrlOverride = nul
|
|
|
1053
1070
|
|
|
1054
1071
|
const flakyCount = result.errors.filter(e => e.flaky).length;
|
|
1055
1072
|
if (flakyCount > 0) {
|
|
1056
|
-
logger.info(`[ARGUS] ${route.name}: ${flakyCount} finding(s) downgraded to info (flaky — appeared in only one cheap run)`);
|
|
1073
|
+
logger.info(`[ARGUS] ${route.name} (${route.path}): ${flakyCount} finding(s) downgraded to info (flaky — appeared in only one cheap run)`);
|
|
1057
1074
|
}
|
|
1058
1075
|
|
|
1059
1076
|
report.routes.push(result);
|
|
@@ -54,7 +54,8 @@ async function slackPostWithBackoff(args) {
|
|
|
54
54
|
const isRateLimit = err.code === 'slack_webapi_rate_limited'
|
|
55
55
|
|| err.message?.toLowerCase().includes('ratelimited');
|
|
56
56
|
if (!isRateLimit || attempt === SLACK_RATE_LIMIT_RETRIES - 1) throw err;
|
|
57
|
-
const
|
|
57
|
+
const jitter = Math.floor(Math.random() * 1000);
|
|
58
|
+
const retryAfterMs = (err.retryAfter ?? 1) * 1000 + jitter;
|
|
58
59
|
logger.warn(`[ARGUS] Slack rate limited — retrying in ${retryAfterMs}ms (attempt ${attempt + 1})`);
|
|
59
60
|
await new Promise(r => setTimeout(r, retryAfterMs));
|
|
60
61
|
}
|
|
@@ -172,7 +172,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
172
172
|
* @param {number} port — TCP port to listen on (default 3002)
|
|
173
173
|
* @returns {http.Server}
|
|
174
174
|
*/
|
|
175
|
-
function startDashboard(getFindings, target, port) {
|
|
175
|
+
export function startDashboard(getFindings, target, port) {
|
|
176
176
|
const server = http.createServer((req, res) => {
|
|
177
177
|
if (req.url === '/data' || req.url?.startsWith('/data?')) {
|
|
178
178
|
const payload = JSON.stringify({
|
package/src/registry.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { childLogger } from './logger.js';
|
|
29
|
+
import { registerExpensive } from '../registry.js';
|
|
30
|
+
import { unwrapEval } from './mcp-client.js';
|
|
29
31
|
|
|
30
32
|
const logger = childLogger('css-analyzer');
|
|
31
33
|
|
|
@@ -405,3 +407,8 @@ export function parseCssAnalysisResult(rawResult, url) {
|
|
|
405
407
|
|
|
406
408
|
return bugs;
|
|
407
409
|
}
|
|
410
|
+
|
|
411
|
+
registerExpensive({ name: 'css', analyze: async (browser, url) => {
|
|
412
|
+
const cssRaw = await browser.evaluate(CSS_ANALYSIS_SCRIPT);
|
|
413
|
+
return parseCssAnalysisResult(unwrapEval(cssRaw), url);
|
|
414
|
+
} });
|