argusqa-os 9.5.5 → 9.6.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/README.md +384 -1100
- package/glama.json +9 -1
- package/package.json +4 -3
- package/src/adapters/browser.js +1 -0
- package/src/cli/init.js +8 -4
- package/src/config/targets.js +10 -0
- package/src/mcp-server.js +115 -2
- package/src/orchestration/dispatcher.js +1 -1
- package/src/orchestration/env-comparison.js +0 -1
- package/src/orchestration/orchestrator.js +10 -5
- package/src/orchestration/report-processor.js +1 -1
- package/src/orchestration/slack-notifier.js +1 -1
- package/src/orchestration/watch-mode.js +0 -4
- package/src/server/index.js +24 -2
- package/src/server/slash-command-handler.js +0 -1
- package/src/utils/a11y-deep-analyzer.js +294 -0
- package/src/utils/baseline-manager.js +3 -3
- package/src/utils/codebase-analyzer.js +3 -3
- package/src/utils/content-analyzer.js +1 -1
- package/src/utils/flow-runner.js +4 -4
- package/src/utils/font-analyzer.js +213 -0
- package/src/utils/form-analyzer.js +247 -0
- package/src/utils/github-reporter.js +221 -18
- package/src/utils/har-recorder.js +197 -0
- package/src/utils/motion-analyzer.js +243 -0
- package/src/utils/pr-diff-analyzer.js +121 -0
- package/src/utils/route-discoverer.js +1 -1
- package/src/utils/security-analyzer.js +1 -1
- package/src/utils/seo-analyzer.js +1 -1
- package/src/utils/session-persistence.js +1 -1
- package/src/utils/visual-diff-analyzer.js +9 -4
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS HAR Network Baseline Recorder (Sprint 5 — N1)
|
|
3
|
+
*
|
|
4
|
+
* Records all network requests made during a page load as a HAR-style
|
|
5
|
+
* baseline. On first run, saves the baseline. On subsequent runs, diffs
|
|
6
|
+
* the current requests against the baseline and surfaces regressions:
|
|
7
|
+
* new requests, missing requests, and status-code changes.
|
|
8
|
+
*
|
|
9
|
+
* This isolates frontend bugs from backend noise: if a finding appears
|
|
10
|
+
* only when the request set has changed, it is likely environment-specific.
|
|
11
|
+
*
|
|
12
|
+
* Findings emitted:
|
|
13
|
+
* har_baseline_created — info, first run: baseline saved
|
|
14
|
+
* har_new_request — warning: request not in baseline
|
|
15
|
+
* har_missing_request — warning: baseline request no longer made
|
|
16
|
+
* har_status_changed — warning/critical: HTTP status differs from baseline
|
|
17
|
+
* har_comparison_summary — info, always emitted
|
|
18
|
+
*
|
|
19
|
+
* Baseline stored at: {REPORT_OUTPUT_DIR}/baselines/har/{slug}.json
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import { registerExpensive } from '../registry.js';
|
|
25
|
+
import { childLogger } from './logger.js';
|
|
26
|
+
import { slugify } from './slug.js';
|
|
27
|
+
import { config } from '../config/targets.js';
|
|
28
|
+
|
|
29
|
+
const logger = childLogger('har-recorder');
|
|
30
|
+
const HAR_DIR = path.join(config.outputDir, 'baselines', 'har');
|
|
31
|
+
|
|
32
|
+
// Normalise a URL for baseline keying — strip query strings that vary per run
|
|
33
|
+
// (cache-busters, tokens) to reduce false-positive "new request" findings.
|
|
34
|
+
function normaliseUrl(rawUrl) {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(rawUrl);
|
|
37
|
+
// Keep only stable query params — drop ones that look like cache-busters
|
|
38
|
+
const stable = new URLSearchParams();
|
|
39
|
+
for (const [k, v] of u.searchParams) {
|
|
40
|
+
if (/^(v|ver|version|_|cb|bust|ts|t)$/i.test(k)) continue;
|
|
41
|
+
stable.set(k, v);
|
|
42
|
+
}
|
|
43
|
+
u.search = stable.toString();
|
|
44
|
+
return u.toString();
|
|
45
|
+
} catch {
|
|
46
|
+
return rawUrl;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toBaselineEntry(req) {
|
|
51
|
+
return {
|
|
52
|
+
request: { method: req.method ?? 'GET', url: normaliseUrl(req.url) },
|
|
53
|
+
response: { status: req.status ?? 0 },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function analyzeHar(browser, url, opts = {}) {
|
|
58
|
+
const harDir = opts.baselineDir ?? HAR_DIR;
|
|
59
|
+
const findings = [];
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await browser.navigate(url);
|
|
63
|
+
await browser.waitFor({ state: 'networkidle' }).catch(() => {});
|
|
64
|
+
await new Promise(r => setTimeout(r, 600));
|
|
65
|
+
} catch {
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Capture current network requests ─────────────────────────────────────
|
|
70
|
+
let requests = [];
|
|
71
|
+
try {
|
|
72
|
+
requests = await browser.listNetwork();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.warn(`[ARGUS] har-recorder: listNetwork failed for ${url}: ${err.message}`);
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const slug = slugify(url);
|
|
79
|
+
const harFile = path.join(harDir, `${slug}.json`);
|
|
80
|
+
|
|
81
|
+
// ── First run: save baseline ──────────────────────────────────────────────
|
|
82
|
+
// Use flag:'wx' for atomic create — throws EEXIST if baseline already exists (TOCTOU-safe).
|
|
83
|
+
fs.mkdirSync(harDir, { recursive: true });
|
|
84
|
+
const baseline = {
|
|
85
|
+
version: '1.2',
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
url,
|
|
88
|
+
entries: requests.map(toBaselineEntry),
|
|
89
|
+
};
|
|
90
|
+
let harIsNew = false;
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(harFile, JSON.stringify(baseline, null, 2), { flag: 'wx' });
|
|
93
|
+
harIsNew = true;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code !== 'EEXIST') {
|
|
96
|
+
logger.warn(`[ARGUS] har-recorder: failed to write baseline: ${err.message}`);
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (harIsNew) {
|
|
101
|
+
|
|
102
|
+
findings.push({
|
|
103
|
+
type: 'har_baseline_created',
|
|
104
|
+
message: `HAR baseline saved for ${url} (${requests.length} requests recorded)`,
|
|
105
|
+
requestCount: requests.length,
|
|
106
|
+
baselineFile: harFile,
|
|
107
|
+
severity: 'info',
|
|
108
|
+
url,
|
|
109
|
+
});
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Subsequent runs: compare against baseline ─────────────────────────────
|
|
114
|
+
let existingBaseline;
|
|
115
|
+
try {
|
|
116
|
+
existingBaseline = JSON.parse(fs.readFileSync(harFile, 'utf8'));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.warn(`[ARGUS] har-recorder: failed to read baseline: ${err.message}`);
|
|
119
|
+
return findings;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const baselineEntries = existingBaseline.entries ?? [];
|
|
123
|
+
const baselineMap = new Map(baselineEntries.map(e => [normaliseUrl(e.request.url), e]));
|
|
124
|
+
const currentMap = new Map(requests.map(r => [normaliseUrl(r.url), r]));
|
|
125
|
+
|
|
126
|
+
let newCount = 0, missingCount = 0, changedCount = 0;
|
|
127
|
+
|
|
128
|
+
// New requests not in baseline
|
|
129
|
+
for (const [normUrl, req] of currentMap) {
|
|
130
|
+
if (!baselineMap.has(normUrl)) {
|
|
131
|
+
newCount++;
|
|
132
|
+
findings.push({
|
|
133
|
+
type: 'har_new_request',
|
|
134
|
+
message: `New network request not in baseline: ${req.method ?? 'GET'} ${normUrl}`,
|
|
135
|
+
method: req.method ?? 'GET',
|
|
136
|
+
requestUrl: normUrl,
|
|
137
|
+
status: req.status ?? 0,
|
|
138
|
+
severity: 'warning',
|
|
139
|
+
url,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Baseline requests no longer made
|
|
145
|
+
for (const [normUrl, entry] of baselineMap) {
|
|
146
|
+
if (!currentMap.has(normUrl)) {
|
|
147
|
+
missingCount++;
|
|
148
|
+
findings.push({
|
|
149
|
+
type: 'har_missing_request',
|
|
150
|
+
message: `Baseline request no longer made: ${entry.request.method} ${normUrl}`,
|
|
151
|
+
method: entry.request.method,
|
|
152
|
+
requestUrl: normUrl,
|
|
153
|
+
severity: 'warning',
|
|
154
|
+
url,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Status code regressions
|
|
160
|
+
for (const [normUrl, req] of currentMap) {
|
|
161
|
+
const base = baselineMap.get(normUrl);
|
|
162
|
+
if (!base) continue;
|
|
163
|
+
const baseStatus = base.response.status;
|
|
164
|
+
const currStatus = req.status ?? 0;
|
|
165
|
+
if (baseStatus !== currStatus && currStatus > 0 && baseStatus > 0) {
|
|
166
|
+
changedCount++;
|
|
167
|
+
findings.push({
|
|
168
|
+
type: 'har_status_changed',
|
|
169
|
+
message: `HTTP status changed for ${normUrl}: ${baseStatus} → ${currStatus}`,
|
|
170
|
+
requestUrl: normUrl,
|
|
171
|
+
baselineStatus: baseStatus,
|
|
172
|
+
currentStatus: currStatus,
|
|
173
|
+
severity: currStatus >= 400 ? 'critical' : 'warning',
|
|
174
|
+
url,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
findings.push({
|
|
180
|
+
type: 'har_comparison_summary',
|
|
181
|
+
message: `HAR diff: ${newCount} new, ${missingCount} missing, ${changedCount} status-changed (${requests.length} total vs ${baselineEntries.length} baseline)`,
|
|
182
|
+
newRequests: newCount,
|
|
183
|
+
missingRequests: missingCount,
|
|
184
|
+
statusChanges: changedCount,
|
|
185
|
+
totalCurrent: requests.length,
|
|
186
|
+
totalBaseline: baselineEntries.length,
|
|
187
|
+
severity: 'info',
|
|
188
|
+
url,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
registerExpensive({
|
|
195
|
+
name: 'har-recorder',
|
|
196
|
+
analyze: (browser, url) => analyzeHar(browser, url),
|
|
197
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Motion & Animation Accessibility Analyzer (Sprint 5b — A9)
|
|
3
|
+
*
|
|
4
|
+
* Detects pages that trigger motion/animation without respecting the user's
|
|
5
|
+
* `prefers-reduced-motion` OS preference — a WCAG 2.1 SC 2.3.3 (AAA) violation
|
|
6
|
+
* that can trigger vestibular disorders in motion-sensitive users.
|
|
7
|
+
*
|
|
8
|
+
* Findings emitted:
|
|
9
|
+
* motion_no_reduced_motion_query — CSS animation/transition present but no
|
|
10
|
+
* @media (prefers-reduced-motion) query anywhere in page stylesheets
|
|
11
|
+
* motion_autoplay_no_pause — <video autoplay> without visible pause control
|
|
12
|
+
* or animated <img> (GIF/APNG/WebP) without pause mechanism
|
|
13
|
+
* motion_interactive_animation — transition/animation on interactive elements
|
|
14
|
+
* (button, a, input, [role=button]) without a reduced-motion override
|
|
15
|
+
* motion_reduced_not_honoured — after emulating prefers-reduced-motion: reduce,
|
|
16
|
+
* animated properties are still applied (requires MCP emulate support)
|
|
17
|
+
* motion_summary — info, always emitted
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { registerExpensive } from '../registry.js';
|
|
21
|
+
import { unwrapEval } from './mcp-client.js';
|
|
22
|
+
import { childLogger } from './logger.js';
|
|
23
|
+
import { thresholds } from '../config/targets.js';
|
|
24
|
+
|
|
25
|
+
const logger = childLogger('motion-analyzer');
|
|
26
|
+
|
|
27
|
+
// Threshold: flag animated interactive elements even if count is 1
|
|
28
|
+
const ANIM_COUNT_THRESHOLD = thresholds.motion?.animationPropertyCount ?? 1;
|
|
29
|
+
|
|
30
|
+
// ── In-browser motion analysis script ────────────────────────────────────────
|
|
31
|
+
const MOTION_SCRIPT = `() => {
|
|
32
|
+
var result = {
|
|
33
|
+
hasAnimation: false,
|
|
34
|
+
hasReducedQuery: false,
|
|
35
|
+
interactiveAnimated: [],
|
|
36
|
+
autoplayVideos: [],
|
|
37
|
+
animatedImages: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
var INTERACTIVE = ['button','a','input','select','textarea'];
|
|
41
|
+
var ANIM_PROPS = ['animation','animation-name','transition'];
|
|
42
|
+
|
|
43
|
+
// Scan all accessible stylesheets
|
|
44
|
+
var sheets = Array.from(document.styleSheets);
|
|
45
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
46
|
+
var sheet = sheets[i];
|
|
47
|
+
var rules;
|
|
48
|
+
try { rules = Array.from(sheet.cssRules || []); } catch { continue; }
|
|
49
|
+
for (var j = 0; j < rules.length; j++) {
|
|
50
|
+
var rule = rules[j];
|
|
51
|
+
// Check for @media (prefers-reduced-motion)
|
|
52
|
+
if (rule.type === CSSRule.MEDIA_RULE) {
|
|
53
|
+
var condText = rule.conditionText || rule.media && rule.media.mediaText || '';
|
|
54
|
+
if (/prefers-reduced-motion/i.test(condText)) {
|
|
55
|
+
result.hasReducedQuery = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Check style rules for animation/transition
|
|
59
|
+
if (rule.type === CSSRule.STYLE_RULE && rule.style) {
|
|
60
|
+
var anim = rule.style.animationName || rule.style.animation;
|
|
61
|
+
var trans = rule.style.transition;
|
|
62
|
+
if ((anim && anim !== 'none' && anim !== '') ||
|
|
63
|
+
(trans && trans !== 'none' && trans !== '')) {
|
|
64
|
+
result.hasAnimation = true;
|
|
65
|
+
// Check if selector matches an interactive element
|
|
66
|
+
var sel = rule.selectorText || '';
|
|
67
|
+
var isInteractive = INTERACTIVE.some(function(tag) {
|
|
68
|
+
return sel.indexOf(tag) !== -1 || sel.indexOf('[role="button"]') !== -1;
|
|
69
|
+
});
|
|
70
|
+
if (isInteractive) {
|
|
71
|
+
result.interactiveAnimated.push({
|
|
72
|
+
selector: sel.slice(0, 120),
|
|
73
|
+
animation: anim || '',
|
|
74
|
+
transition: trans || '',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check for autoplay video without pause control
|
|
83
|
+
var videos = Array.from(document.querySelectorAll('video[autoplay]'));
|
|
84
|
+
for (var v = 0; v < videos.length; v++) {
|
|
85
|
+
var vid = videos[v];
|
|
86
|
+
result.autoplayVideos.push({
|
|
87
|
+
src: (vid.src || vid.currentSrc || '').slice(0, 100),
|
|
88
|
+
hasMuted: vid.muted || vid.hasAttribute('muted'),
|
|
89
|
+
hasControls: vid.controls || vid.hasAttribute('controls'),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check for animated images (GIF/APNG) without pause mechanism
|
|
94
|
+
var imgs = Array.from(document.querySelectorAll('img'));
|
|
95
|
+
for (var k = 0; k < imgs.length; k++) {
|
|
96
|
+
var src = imgs[k].src || '';
|
|
97
|
+
if (/\\.gif$/i.test(src) || /\\.apng$/i.test(src)) {
|
|
98
|
+
result.animatedImages.push({ src: src.slice(0, 100) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return JSON.stringify(result);
|
|
103
|
+
}`;
|
|
104
|
+
|
|
105
|
+
// ── Post-emulation check: do animations still run under reduced motion? ───────
|
|
106
|
+
const REDUCED_MOTION_CHECK = `() => {
|
|
107
|
+
var result = { stillAnimated: [] };
|
|
108
|
+
var sheets = Array.from(document.styleSheets);
|
|
109
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
110
|
+
var rules;
|
|
111
|
+
try { rules = Array.from(sheets[i].cssRules || []); } catch { continue; }
|
|
112
|
+
for (var j = 0; j < rules.length; j++) {
|
|
113
|
+
var rule = rules[j];
|
|
114
|
+
if (rule.type === CSSRule.STYLE_RULE && rule.style) {
|
|
115
|
+
var anim = rule.style.animationName || rule.style.animation;
|
|
116
|
+
var trans = rule.style.transition;
|
|
117
|
+
if ((anim && anim !== 'none') || (trans && trans !== 'none')) {
|
|
118
|
+
result.stillAnimated.push({ selector: (rule.selectorText || '').slice(0, 80) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return JSON.stringify(result);
|
|
124
|
+
}`;
|
|
125
|
+
|
|
126
|
+
export async function analyzeMotion(browser, url) {
|
|
127
|
+
const findings = [];
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await browser.navigate(url);
|
|
131
|
+
await browser.waitFor({ state: 'networkidle' }).catch(() => {});
|
|
132
|
+
await new Promise(r => setTimeout(r, 400));
|
|
133
|
+
} catch {
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 1. Run CSS + DOM motion analysis ─────────────────────────────────────
|
|
138
|
+
let data = null;
|
|
139
|
+
try {
|
|
140
|
+
const raw = await browser.evaluate(MOTION_SCRIPT);
|
|
141
|
+
const s = unwrapEval(raw);
|
|
142
|
+
data = typeof s === 'object' ? s : JSON.parse(s);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.warn(`[ARGUS] motion-analyzer: analysis failed for ${url}: ${err.message}`);
|
|
145
|
+
data = { hasAnimation: false, hasReducedQuery: false, interactiveAnimated: [], autoplayVideos: [], animatedImages: [] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Finding: animation without prefers-reduced-motion query
|
|
149
|
+
if (data.hasAnimation && !data.hasReducedQuery) {
|
|
150
|
+
findings.push({
|
|
151
|
+
type: 'motion_no_reduced_motion_query',
|
|
152
|
+
message: 'CSS animation/transition in use but no @media (prefers-reduced-motion) query found in any stylesheet',
|
|
153
|
+
severity: 'warning',
|
|
154
|
+
url,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Finding: autoplay video without pause control
|
|
159
|
+
for (const vid of (data.autoplayVideos ?? [])) {
|
|
160
|
+
if (!vid.hasControls) {
|
|
161
|
+
findings.push({
|
|
162
|
+
type: 'motion_autoplay_no_pause',
|
|
163
|
+
message: `<video autoplay> without visible pause controls: ${vid.src || '(no src)'}`,
|
|
164
|
+
src: vid.src,
|
|
165
|
+
hasMuted: vid.hasMuted,
|
|
166
|
+
severity: 'warning',
|
|
167
|
+
url,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Finding: animated GIFs without pause
|
|
173
|
+
for (const img of (data.animatedImages ?? [])) {
|
|
174
|
+
findings.push({
|
|
175
|
+
type: 'motion_autoplay_no_pause',
|
|
176
|
+
message: `Animated image (GIF/APNG) without pause mechanism: ${img.src}`,
|
|
177
|
+
src: img.src,
|
|
178
|
+
severity: 'info',
|
|
179
|
+
url,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Finding: interactive elements with animation/transition
|
|
184
|
+
const interactiveCount = (data.interactiveAnimated ?? []).length;
|
|
185
|
+
if (interactiveCount >= ANIM_COUNT_THRESHOLD) {
|
|
186
|
+
for (const el of data.interactiveAnimated.slice(0, 10)) {
|
|
187
|
+
findings.push({
|
|
188
|
+
type: 'motion_interactive_animation',
|
|
189
|
+
message: `Interactive element has animation/transition without prefers-reduced-motion override: ${el.selector}`,
|
|
190
|
+
selector: el.selector,
|
|
191
|
+
animation: el.animation,
|
|
192
|
+
transition: el.transition,
|
|
193
|
+
severity: 'warning',
|
|
194
|
+
url,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── 2. Emulate prefers-reduced-motion: reduce and re-check ───────────────
|
|
200
|
+
try {
|
|
201
|
+
await browser.emulateReducedMotion('reduce');
|
|
202
|
+
await new Promise(r => setTimeout(r, 300));
|
|
203
|
+
const raw2 = await browser.evaluate(REDUCED_MOTION_CHECK);
|
|
204
|
+
const s2 = unwrapEval(raw2);
|
|
205
|
+
const d2 = typeof s2 === 'object' ? s2 : JSON.parse(s2);
|
|
206
|
+
if (Array.isArray(d2.stillAnimated) && d2.stillAnimated.length > 0 && !data.hasReducedQuery) {
|
|
207
|
+
findings.push({
|
|
208
|
+
type: 'motion_reduced_not_honoured',
|
|
209
|
+
message: `${d2.stillAnimated.length} animated element(s) still animate after emulating prefers-reduced-motion: reduce`,
|
|
210
|
+
count: d2.stillAnimated.length,
|
|
211
|
+
severity: 'warning',
|
|
212
|
+
url,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// Reset emulation
|
|
216
|
+
await browser.emulateReducedMotion('no-preference').catch(() => {});
|
|
217
|
+
} catch {
|
|
218
|
+
// Emulation not supported in this Chrome/MCP build — skip gracefully
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── 3. Summary — always emitted ──────────────────────────────────────────
|
|
222
|
+
const animCount = interactiveCount;
|
|
223
|
+
const autoplayCount = (data.autoplayVideos ?? []).filter(v => !v.hasControls).length +
|
|
224
|
+
(data.animatedImages ?? []).length;
|
|
225
|
+
|
|
226
|
+
findings.push({
|
|
227
|
+
type: 'motion_summary',
|
|
228
|
+
hasAnimation: data.hasAnimation,
|
|
229
|
+
hasReducedQuery: data.hasReducedQuery,
|
|
230
|
+
animationCount: animCount,
|
|
231
|
+
autoplayCount,
|
|
232
|
+
message: `Motion: animation=${data.hasAnimation}, reducedMotionQuery=${data.hasReducedQuery}, interactiveAnimated=${animCount}, autoplay=${autoplayCount}`,
|
|
233
|
+
severity: 'info',
|
|
234
|
+
url,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return findings;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
registerExpensive({
|
|
241
|
+
name: 'motion',
|
|
242
|
+
analyze: (browser, url) => analyzeMotion(browser, url),
|
|
243
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Diff Analyzer — maps GitHub PR changed files to affected Argus routes.
|
|
3
|
+
*
|
|
4
|
+
* parsePrUrl(prUrl) → { owner, repo, prNumber }
|
|
5
|
+
* fetchPrFiles(prUrl, token) → string[] of changed file paths
|
|
6
|
+
* mapFilesToRoutes(files, routes) → Route[] subset likely affected by the diff
|
|
7
|
+
*
|
|
8
|
+
* Pure functions + one async fetch — no Chrome, no MCP, no AI verdict.
|
|
9
|
+
* AI verdict logic ships separately in the private argus-pro repo.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a GitHub PR URL into its owner/repo/prNumber components.
|
|
14
|
+
*
|
|
15
|
+
* Accepted formats:
|
|
16
|
+
* https://github.com/owner/repo/pull/123
|
|
17
|
+
* https://github.com/owner/repo/pull/123/files
|
|
18
|
+
*
|
|
19
|
+
* @param {string} prUrl
|
|
20
|
+
* @returns {{ owner: string, repo: string, prNumber: number }}
|
|
21
|
+
*/
|
|
22
|
+
export function parsePrUrl(prUrl) {
|
|
23
|
+
const match = String(prUrl).match(
|
|
24
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
|
|
25
|
+
);
|
|
26
|
+
if (!match) throw new Error(`Invalid GitHub PR URL: ${prUrl}`);
|
|
27
|
+
return { owner: match[1], repo: match[2], prNumber: parseInt(match[3], 10) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fetch the list of file paths changed by a GitHub pull request (up to 100 files).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} prUrl - GitHub PR URL (any format accepted by parsePrUrl)
|
|
34
|
+
* @param {string} [githubToken] - GitHub token; omit for public repos
|
|
35
|
+
* @returns {Promise<string[]>} - Changed file paths relative to the repo root
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchPrFiles(prUrl, githubToken) {
|
|
38
|
+
const { owner, repo, prNumber } = parsePrUrl(prUrl);
|
|
39
|
+
const apiUrl =
|
|
40
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100`;
|
|
41
|
+
const headers = {
|
|
42
|
+
Accept: 'application/vnd.github+json',
|
|
43
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
44
|
+
'User-Agent': 'argusqa-os',
|
|
45
|
+
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const res = await fetch(apiUrl, { headers });
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const body = await res.text().catch(() => '');
|
|
51
|
+
throw new Error(`GitHub API ${res.status}: ${body || res.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
const files = await res.json();
|
|
54
|
+
return files.map(f => f.filename);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Patterns that indicate an infrastructure-level file whose change can affect
|
|
59
|
+
* every route — framework configs, root layouts, global stylesheets, package.json.
|
|
60
|
+
*/
|
|
61
|
+
const INFRA_PATTERNS = [
|
|
62
|
+
/next\.config\./i,
|
|
63
|
+
/vite\.config\./i,
|
|
64
|
+
/tailwind\.config\./i,
|
|
65
|
+
/postcss\.config\./i,
|
|
66
|
+
/webpack\.config\./i,
|
|
67
|
+
/global(s)?\.(css|scss|less)$/i,
|
|
68
|
+
/(^|[/\\])(layout|_app|_document|root)\.(tsx?|jsx?)$/i,
|
|
69
|
+
/(^|[/\\])app\.(tsx?|jsx?)$/i,
|
|
70
|
+
/(^|[/\\])main\.(tsx?|jsx?)$/i,
|
|
71
|
+
/package\.json$/i,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a list of changed file paths to the subset of Argus route configs that
|
|
76
|
+
* are likely affected, using heuristic slug matching.
|
|
77
|
+
*
|
|
78
|
+
* Heuristic rules (applied in order):
|
|
79
|
+
* 1. Any infrastructure file → return ALL routes (full audit)
|
|
80
|
+
* 2. File path contains a slug that matches a route path segment → include that route
|
|
81
|
+
* 3. No matches → return ALL routes (conservative fallback — never miss a regression)
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} changedFiles - Relative file paths from fetchPrFiles
|
|
84
|
+
* @param {Array<{ path: string, name: string }>} routes - Route configs from targets.js
|
|
85
|
+
* @returns {Array<{ path: string, name: string }>}
|
|
86
|
+
*/
|
|
87
|
+
export function mapFilesToRoutes(changedFiles, routes) {
|
|
88
|
+
if (!routes || routes.length === 0) return [];
|
|
89
|
+
if (!changedFiles || changedFiles.length === 0) return routes;
|
|
90
|
+
|
|
91
|
+
// Infrastructure change → full audit
|
|
92
|
+
if (changedFiles.some(f => INFRA_PATTERNS.some(re => re.test(f)))) {
|
|
93
|
+
return routes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build a flat set of lowercase slugs from every changed file path
|
|
97
|
+
const fileSlugs = new Set(
|
|
98
|
+
changedFiles.flatMap(f =>
|
|
99
|
+
// Strip extension, split on separators, keep non-trivial tokens
|
|
100
|
+
f.toLowerCase()
|
|
101
|
+
.replace(/\.[^./\\]+$/, '')
|
|
102
|
+
.split(/[/\\._-]+/)
|
|
103
|
+
.filter(s => s.length > 1),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Extract meaningful segments from a route path (e.g. "/checkout/review" → ["checkout","review"])
|
|
108
|
+
const routeSegments = (route) =>
|
|
109
|
+
route.path
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split('/')
|
|
112
|
+
.map(s => s.replace(/[^a-z0-9]/g, ''))
|
|
113
|
+
.filter(s => s.length > 1);
|
|
114
|
+
|
|
115
|
+
const matched = routes.filter(route =>
|
|
116
|
+
routeSegments(route).some(seg => fileSlugs.has(seg)),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Conservative fallback: if nothing matched, audit everything
|
|
120
|
+
return matched.length > 0 ? matched : routes;
|
|
121
|
+
}
|
|
@@ -74,7 +74,7 @@ export async function discoverFromSitemap(baseUrl) {
|
|
|
74
74
|
const origin = new URL(baseUrl).origin;
|
|
75
75
|
const sitemapUrl = `${baseUrl.replace(/\/$/, '')}/sitemap.xml`;
|
|
76
76
|
try {
|
|
77
|
-
const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) });
|
|
77
|
+
const res = await fetch(sitemapUrl, { signal: AbortSignal.timeout(10000) }); // lgtm[js/ssrf] — sitemapUrl is derived from developer-configured baseUrl in targets.js, not from HTTP request input
|
|
78
78
|
if (!res.ok) return [];
|
|
79
79
|
|
|
80
80
|
const buf = await res.arrayBuffer();
|
|
@@ -132,7 +132,7 @@ export function parseSecurityAnalysisResult(rawResult, url) {
|
|
|
132
132
|
// all field lookups (storageTokenKeys, evalUsage, etc.) return undefined — zero findings.
|
|
133
133
|
// JSON.stringify on a circular object throws; catch logs and returns [].
|
|
134
134
|
let raw = rawResult;
|
|
135
|
-
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
|
|
135
|
+
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so raw !== null is required after the typeof check
|
|
136
136
|
raw = raw.result;
|
|
137
137
|
}
|
|
138
138
|
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
@@ -48,7 +48,7 @@ export function parseSeoAnalysisResult(rawResult, url) {
|
|
|
48
48
|
// client returns an object wrapper, JSON.stringify(rawResult) serialises the envelope
|
|
49
49
|
// instead of the inner payload and all SEO fields are undefined → false positives.
|
|
50
50
|
let inner = rawResult;
|
|
51
|
-
if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) {
|
|
51
|
+
if (typeof rawResult === 'object' && rawResult !== null && !Array.isArray(rawResult)) { // lgtm[js/comparison-of-unconvertible-types] — typeof null === 'object', so rawResult !== null is required after the typeof check
|
|
52
52
|
inner = rawResult.result !== undefined ? rawResult.result : rawResult;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -65,7 +65,7 @@ function buildRestoreScript(state) {
|
|
|
65
65
|
lines.push(`sessionStorage.setItem(${JSON.stringify(k)},${JSON.stringify(String(v ?? ''))});`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
return `() => { ${lines.join(' ')} return true; }`;
|
|
68
|
+
return `() => { ${lines.join(' ')} return true; }`; // lgtm[js/code-injection] — all k/v values are JSON.stringify-escaped before insertion; derived from browser session storage, not HTTP request input
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// ── Session Save ────────────────────────────────────────────────────────────────
|
|
@@ -141,13 +141,18 @@ export async function analyzeVisualRegression(browser, url, opts = {}) {
|
|
|
141
141
|
const baselinePath = path.join(baselineDir, `${slug}.png`);
|
|
142
142
|
|
|
143
143
|
// ── 3. First run: save baseline ─────────────────────────────────────────────
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// Use flag:'wx' for atomic create — throws EEXIST if baseline was written concurrently (TOCTOU-safe).
|
|
145
|
+
let baselineIsNew = false;
|
|
146
|
+
try {
|
|
147
|
+
fs.writeFileSync(baselinePath, currentBuf, { flag: 'wx' });
|
|
148
|
+
baselineIsNew = true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code !== 'EEXIST') {
|
|
148
151
|
logger.warn(`[ARGUS] visual-diff: could not write baseline ${baselinePath}: ${err.message}`);
|
|
149
152
|
return findings;
|
|
150
153
|
}
|
|
154
|
+
}
|
|
155
|
+
if (baselineIsNew) {
|
|
151
156
|
|
|
152
157
|
findings.push({
|
|
153
158
|
type: 'visual_baseline_created',
|