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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Deep Accessibility Analyzer (Sprint 4 — A12)
|
|
3
|
+
*
|
|
4
|
+
* Extends Argus accessibility coverage via two mechanisms:
|
|
5
|
+
*
|
|
6
|
+
* 1. axe-core injection — runs the full axe-core ruleset (80+ rules) against
|
|
7
|
+
* the live page, covering WCAG 2.x Level A/AA violations not caught by the
|
|
8
|
+
* existing snapshot-analyzer or keyboard-analyzer.
|
|
9
|
+
*
|
|
10
|
+
* 2. Color blind simulation — transforms page element colors using protanopia
|
|
11
|
+
* and deuteranopia CVD matrices, then checks WCAG AA contrast ratios under
|
|
12
|
+
* each simulated palette. Flags elements that look fine to full-color vision
|
|
13
|
+
* but fail for users with red-green color deficiencies.
|
|
14
|
+
*
|
|
15
|
+
* Findings emitted:
|
|
16
|
+
* a11y_axe_violation — axe-core violation; severity mapped from impact
|
|
17
|
+
* (critical→critical, serious/moderate→warning, minor→info)
|
|
18
|
+
* a11y_colorblind_risk — element fails WCAG AA contrast (4.5:1) under
|
|
19
|
+
* protanopia or deuteranopia simulation
|
|
20
|
+
* a11y_deep_summary — info, always emitted with violation counts by impact
|
|
21
|
+
*
|
|
22
|
+
* Deduplication: findings already emitted by snapshot-analyzer
|
|
23
|
+
* (a11y_missing_name, a11y_missing_form_label, heading_level_skip) are
|
|
24
|
+
* suppressed to avoid double-reporting the same issue.
|
|
25
|
+
*
|
|
26
|
+
* Requires axe-core >= 4.12 (npm dependency).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import { createRequire } from 'module';
|
|
31
|
+
import { registerExpensive } from '../registry.js';
|
|
32
|
+
import { unwrapEval } from './mcp-client.js';
|
|
33
|
+
import { childLogger } from './logger.js';
|
|
34
|
+
import { thresholds } from '../config/targets.js';
|
|
35
|
+
|
|
36
|
+
const logger = childLogger('a11y-deep');
|
|
37
|
+
|
|
38
|
+
// ── axe-core source (injected into the page at runtime) ───────────────────────
|
|
39
|
+
const _require = createRequire(import.meta.url);
|
|
40
|
+
const AXE_MIN_PATH = _require.resolve('axe-core/axe.min.js');
|
|
41
|
+
const AXE_SOURCE = fs.readFileSync(AXE_MIN_PATH, 'utf8');
|
|
42
|
+
// Serialize once — used in every evaluate call
|
|
43
|
+
const AXE_SOURCE_JSON = JSON.stringify(AXE_SOURCE);
|
|
44
|
+
|
|
45
|
+
// ── Thresholds ─────────────────────────────────────────────────────────────────
|
|
46
|
+
const CONTRAST_AA = thresholds.a11y?.contrastAA ?? 4.5; // WCAG AA normal text
|
|
47
|
+
const MAX_AXE_VIOLATIONS = thresholds.a11y?.maxAxeViolations ?? 50; // cap per run
|
|
48
|
+
|
|
49
|
+
// ── Axe impact → Argus severity ───────────────────────────────────────────────
|
|
50
|
+
function axeSeverity(impact) {
|
|
51
|
+
if (impact === 'critical') return 'critical';
|
|
52
|
+
if (impact === 'serious' || impact === 'moderate') return 'warning';
|
|
53
|
+
return 'info';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Axe-core rule IDs already covered by snapshot/keyboard analyzers ─────────
|
|
57
|
+
// Suppress these to avoid double-reporting.
|
|
58
|
+
const ALREADY_COVERED = new Set([
|
|
59
|
+
'label', // → a11y_missing_form_label
|
|
60
|
+
'button-name', // partially → a11y_missing_name (SVG buttons)
|
|
61
|
+
'heading-order', // → heading_level_skip
|
|
62
|
+
'aria-required-children', // → aria_expanded_no_controls
|
|
63
|
+
'landmark-unique', // → a11y_duplicate_landmark
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// ── In-browser color-blind simulation script ───────────────────────────────────
|
|
67
|
+
// Returns an array of { selector, colorType, contrastRatio, fg, bg }
|
|
68
|
+
const COLORBLIND_SCRIPT = `() => {
|
|
69
|
+
// CVD transformation matrices (simplified Machado et al. 2009)
|
|
70
|
+
var CVD = {
|
|
71
|
+
protanopia: [[0.567,0.433,0],[0.558,0.442,0],[0,0.242,0.758]],
|
|
72
|
+
deuteranopia:[[0.625,0.375,0],[0.7,0.3,0],[0,0.3,0.7]],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function parseRgb(str) {
|
|
76
|
+
var m = (str || '').match(/rgb[a]?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/);
|
|
77
|
+
return m ? [+m[1], +m[2], +m[3]] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function simulateCvd(rgb, matrix) {
|
|
81
|
+
var r = rgb[0]/255, g = rgb[1]/255, b = rgb[2]/255;
|
|
82
|
+
return [
|
|
83
|
+
Math.round((matrix[0][0]*r + matrix[0][1]*g + matrix[0][2]*b) * 255),
|
|
84
|
+
Math.round((matrix[1][0]*r + matrix[1][1]*g + matrix[1][2]*b) * 255),
|
|
85
|
+
Math.round((matrix[2][0]*r + matrix[2][1]*g + matrix[2][2]*b) * 255),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function luminance(rgb) {
|
|
90
|
+
return rgb.map(function(c) {
|
|
91
|
+
var v = c / 255;
|
|
92
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
93
|
+
}).reduce(function(sum, v, i) { return sum + v * [0.2126,0.7152,0.0722][i]; }, 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function contrastRatio(a, b) {
|
|
97
|
+
var la = luminance(a), lb = luminance(b);
|
|
98
|
+
var lighter = Math.max(la, lb), darker = Math.min(la, lb);
|
|
99
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var issues = [];
|
|
103
|
+
var seen = new Set();
|
|
104
|
+
|
|
105
|
+
var elements = document.querySelectorAll('p,h1,h2,h3,h4,h5,h6,a,button,label,span,li,td,th');
|
|
106
|
+
for (var i = 0; i < Math.min(elements.length, 200); i++) {
|
|
107
|
+
var el = elements[i];
|
|
108
|
+
var style = window.getComputedStyle(el);
|
|
109
|
+
var fgRgb = parseRgb(style.color);
|
|
110
|
+
var bgRgb = parseRgb(style.backgroundColor);
|
|
111
|
+
if (!fgRgb || !bgRgb) continue;
|
|
112
|
+
// Skip transparent backgrounds
|
|
113
|
+
if (bgRgb[0] === 0 && bgRgb[1] === 0 && bgRgb[2] === 0 &&
|
|
114
|
+
style.backgroundColor.includes('rgba(0, 0, 0, 0')) continue;
|
|
115
|
+
|
|
116
|
+
var sel = el.tagName.toLowerCase() +
|
|
117
|
+
(el.id ? '#' + el.id : '') +
|
|
118
|
+
(el.className && typeof el.className === 'string' ?
|
|
119
|
+
'.' + el.className.trim().split(/\\s+/)[0] : '');
|
|
120
|
+
|
|
121
|
+
for (var type in CVD) {
|
|
122
|
+
var key = sel + '|' + type;
|
|
123
|
+
if (seen.has(key)) continue;
|
|
124
|
+
var simFg = simulateCvd(fgRgb, CVD[type]);
|
|
125
|
+
var simBg = simulateCvd(bgRgb, CVD[type]);
|
|
126
|
+
var cr = contrastRatio(simFg, simBg);
|
|
127
|
+
if (cr < 4.5) {
|
|
128
|
+
seen.add(key);
|
|
129
|
+
issues.push({
|
|
130
|
+
selector: sel,
|
|
131
|
+
colorType: type,
|
|
132
|
+
contrastRatio: Math.round(cr * 100) / 100,
|
|
133
|
+
fg: 'rgb(' + fgRgb.join(',') + ')',
|
|
134
|
+
bg: 'rgb(' + bgRgb.join(',') + ')',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return JSON.stringify(issues);
|
|
140
|
+
}`;
|
|
141
|
+
|
|
142
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run axe-core + color blind simulation against a loaded page.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} browser - CdpBrowserAdapter
|
|
148
|
+
* @param {string} url - Page URL (already loaded by caller or navigate here)
|
|
149
|
+
* @returns {Promise<object[]>}
|
|
150
|
+
*/
|
|
151
|
+
export async function analyzeA11yDeep(browser, url) {
|
|
152
|
+
const findings = [];
|
|
153
|
+
|
|
154
|
+
// Navigate fresh
|
|
155
|
+
try {
|
|
156
|
+
await browser.navigate(url);
|
|
157
|
+
await browser.waitFor({ state: 'networkidle' }).catch(() => {});
|
|
158
|
+
await new Promise(r => setTimeout(r, 800));
|
|
159
|
+
} catch {
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 1. Inject axe-core ────────────────────────────────────────────────────
|
|
164
|
+
try {
|
|
165
|
+
await browser.evaluate(`() => {
|
|
166
|
+
if (!window.axe) {
|
|
167
|
+
const s = document.createElement('script');
|
|
168
|
+
s.textContent = ${AXE_SOURCE_JSON};
|
|
169
|
+
document.head.appendChild(s);
|
|
170
|
+
}
|
|
171
|
+
return !!window.axe;
|
|
172
|
+
}`);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logger.warn(`[ARGUS] a11y-deep: axe-core injection failed for ${url}: ${err.message}`);
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── 2. Run axe-core analysis ──────────────────────────────────────────────
|
|
179
|
+
let violations = [];
|
|
180
|
+
try {
|
|
181
|
+
const raw = await browser.evaluate(`async () => {
|
|
182
|
+
if (!window.axe) return '{"violations":[]}';
|
|
183
|
+
try {
|
|
184
|
+
const results = await window.axe.run(document, {
|
|
185
|
+
reporter: 'v2',
|
|
186
|
+
runOnly: { type: 'tag', values: ['wcag2a','wcag2aa','wcag21a','wcag21aa','best-practice'] },
|
|
187
|
+
});
|
|
188
|
+
return JSON.stringify({
|
|
189
|
+
violations: results.violations.map(v => ({
|
|
190
|
+
id: v.id,
|
|
191
|
+
impact: v.impact,
|
|
192
|
+
description: v.description,
|
|
193
|
+
helpUrl: v.helpUrl,
|
|
194
|
+
nodes: v.nodes.slice(0, 3).map(n => ({
|
|
195
|
+
target: (n.target || []).join(', ').slice(0, 100),
|
|
196
|
+
html: (n.html || '').slice(0, 150),
|
|
197
|
+
impact: n.impact,
|
|
198
|
+
})),
|
|
199
|
+
})),
|
|
200
|
+
});
|
|
201
|
+
} catch (e) { return '{"violations":[]}'; }
|
|
202
|
+
}`);
|
|
203
|
+
|
|
204
|
+
const parsed = (() => {
|
|
205
|
+
try {
|
|
206
|
+
const s = unwrapEval(raw);
|
|
207
|
+
return typeof s === 'object' ? s : JSON.parse(s);
|
|
208
|
+
} catch { return null; }
|
|
209
|
+
})();
|
|
210
|
+
|
|
211
|
+
violations = parsed?.violations ?? [];
|
|
212
|
+
} catch (err) {
|
|
213
|
+
logger.warn(`[ARGUS] a11y-deep: axe.run() failed for ${url}: ${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── 3. Map violations → findings (with dedup suppression) ────────────────
|
|
217
|
+
let criticalCount = 0, seriousCount = 0, moderateCount = 0, minorCount = 0;
|
|
218
|
+
|
|
219
|
+
for (const v of violations.slice(0, MAX_AXE_VIOLATIONS)) {
|
|
220
|
+
if (ALREADY_COVERED.has(v.id)) continue;
|
|
221
|
+
|
|
222
|
+
const sev = axeSeverity(v.impact);
|
|
223
|
+
if (v.impact === 'critical') criticalCount++;
|
|
224
|
+
else if (v.impact === 'serious') seriousCount++;
|
|
225
|
+
else if (v.impact === 'moderate') moderateCount++;
|
|
226
|
+
else minorCount++;
|
|
227
|
+
|
|
228
|
+
for (const node of (v.nodes || [{ target: '', html: '' }]).slice(0, 2)) {
|
|
229
|
+
findings.push({
|
|
230
|
+
type: 'a11y_axe_violation',
|
|
231
|
+
axeId: v.id,
|
|
232
|
+
impact: v.impact,
|
|
233
|
+
selector: node.target || '',
|
|
234
|
+
html: node.html || '',
|
|
235
|
+
description: v.description,
|
|
236
|
+
helpUrl: v.helpUrl,
|
|
237
|
+
message: `[axe] ${v.impact}: ${v.description} — ${node.target || 'page'}`,
|
|
238
|
+
severity: sev,
|
|
239
|
+
url,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── 4. Color blind simulation ─────────────────────────────────────────────
|
|
245
|
+
let colorblindIssues = [];
|
|
246
|
+
try {
|
|
247
|
+
const raw = await browser.evaluate(COLORBLIND_SCRIPT);
|
|
248
|
+
const parsed = (() => {
|
|
249
|
+
try {
|
|
250
|
+
const s = unwrapEval(raw);
|
|
251
|
+
return typeof s === 'string' ? JSON.parse(s) : s;
|
|
252
|
+
} catch { return []; }
|
|
253
|
+
})();
|
|
254
|
+
colorblindIssues = Array.isArray(parsed) ? parsed : [];
|
|
255
|
+
} catch (err) {
|
|
256
|
+
logger.warn(`[ARGUS] a11y-deep: color blind check failed for ${url}: ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const issue of colorblindIssues.slice(0, 20)) {
|
|
260
|
+
findings.push({
|
|
261
|
+
type: 'a11y_colorblind_risk',
|
|
262
|
+
selector: issue.selector,
|
|
263
|
+
colorType: issue.colorType,
|
|
264
|
+
contrastRatio: issue.contrastRatio,
|
|
265
|
+
fg: issue.fg,
|
|
266
|
+
bg: issue.bg,
|
|
267
|
+
message: `Color blind risk (${issue.colorType}): contrast ${issue.contrastRatio}:1 < ${CONTRAST_AA}:1 on ${issue.selector}`,
|
|
268
|
+
severity: 'warning',
|
|
269
|
+
url,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── 5. Summary — always emitted ───────────────────────────────────────────
|
|
274
|
+
findings.push({
|
|
275
|
+
type: 'a11y_deep_summary',
|
|
276
|
+
axeViolations: violations.filter(v => !ALREADY_COVERED.has(v.id)).length,
|
|
277
|
+
criticalCount,
|
|
278
|
+
seriousCount,
|
|
279
|
+
moderateCount,
|
|
280
|
+
minorCount,
|
|
281
|
+
colorblindRisks: colorblindIssues.length,
|
|
282
|
+
message: `axe-core: ${criticalCount} critical, ${seriousCount} serious, ${moderateCount} moderate, ${minorCount} minor violations; ${colorblindIssues.length} color blind contrast risks`,
|
|
283
|
+
severity: 'info',
|
|
284
|
+
url,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return findings;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Self-registration ──────────────────────────────────────────────────────────
|
|
291
|
+
registerExpensive({
|
|
292
|
+
name: 'a11y-deep',
|
|
293
|
+
analyze: (browser, url) => analyzeA11yDeep(browser, url),
|
|
294
|
+
});
|
|
@@ -110,7 +110,7 @@ export function saveBaseline(baselineFile, report) {
|
|
|
110
110
|
flows[flowResult.flowName] = (flowResult.findings ?? []).map(findingKey);
|
|
111
111
|
}
|
|
112
112
|
const codebase = (report.codebase ?? []).map(findingKey);
|
|
113
|
-
const tmpBaseline = baselineFile
|
|
113
|
+
const tmpBaseline = `${baselineFile}.${process.pid}.${Date.now()}.tmp`;
|
|
114
114
|
fs.writeFileSync(
|
|
115
115
|
tmpBaseline,
|
|
116
116
|
JSON.stringify({ savedAt: new Date().toISOString(), routes, flows, codebase }, null, 2),
|
|
@@ -245,8 +245,8 @@ export function appendTrend(trendsFile, entry) {
|
|
|
245
245
|
}
|
|
246
246
|
trends.push(entry);
|
|
247
247
|
if (trends.length > 500) trends = trends.slice(-500);
|
|
248
|
-
const tmpTrends = trendsFile
|
|
249
|
-
fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2));
|
|
248
|
+
const tmpTrends = `${trendsFile}.${process.pid}.${Date.now()}.tmp`;
|
|
249
|
+
fs.writeFileSync(tmpTrends, JSON.stringify(trends, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl trend data to a local baseline file by design
|
|
250
250
|
fs.renameSync(tmpTrends, trendsFile);
|
|
251
251
|
} finally {
|
|
252
252
|
try { fs.closeSync(lockFd); } catch {}
|
|
@@ -40,9 +40,9 @@ function collectSourceFiles(sourceDir) {
|
|
|
40
40
|
if (e.isDirectory()) { walk(full); }
|
|
41
41
|
else if (SOURCE_EXTENSIONS.has(path.extname(e.name))) {
|
|
42
42
|
try {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
files.push({ filePath: full, content
|
|
43
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
44
|
+
if (Buffer.byteLength(content, 'utf8') > 1_000_000) continue; // skip files > 1MB
|
|
45
|
+
files.push({ filePath: full, content });
|
|
46
46
|
} catch {}
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -97,7 +97,7 @@ export function parseContentAnalysisResult(rawResult, url) {
|
|
|
97
97
|
// all field lookups (nullMatches, brokenImages, etc.) return undefined — zero findings.
|
|
98
98
|
// JSON.stringify on a circular object throws; catch logs and returns [].
|
|
99
99
|
let raw = rawResult;
|
|
100
|
-
if (typeof raw === 'object' && !Array.isArray(raw) && raw !== null && raw.result !== undefined) {
|
|
100
|
+
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
|
|
101
101
|
raw = raw.result;
|
|
102
102
|
}
|
|
103
103
|
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
package/src/utils/flow-runner.js
CHANGED
|
@@ -223,7 +223,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
223
223
|
const start = Date.now();
|
|
224
224
|
let present = false;
|
|
225
225
|
do {
|
|
226
|
-
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`);
|
|
226
|
+
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
227
227
|
present = !!unwrapEval(raw);
|
|
228
228
|
if (present) break;
|
|
229
229
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -244,7 +244,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
case 'element_not_visible': {
|
|
247
|
-
const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`);
|
|
247
|
+
const raw = await browser.evaluate(`() => !document.querySelector(${JSON.stringify(step.selector)})`); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
248
248
|
const absent = unwrapEval(raw);
|
|
249
249
|
if (!absent) {
|
|
250
250
|
findings.push({
|
|
@@ -261,7 +261,7 @@ async function runAssert(step, browser, flowName, baseUrl, baselines) {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
case 'url_contains': {
|
|
264
|
-
const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`);
|
|
264
|
+
const raw = await browser.evaluate(`() => window.location.href.includes(${JSON.stringify(step.value)})`); // lgtm[js/code-injection] — value is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
265
265
|
const matches = unwrapEval(raw);
|
|
266
266
|
if (!matches) {
|
|
267
267
|
findings.push({
|
|
@@ -545,7 +545,7 @@ export async function runFlow(flow, baseUrl, browser) {
|
|
|
545
545
|
export async function waitForSelector(browser, selector, timeoutMs = 10_000) {
|
|
546
546
|
const end = Date.now() + timeoutMs;
|
|
547
547
|
while (Date.now() < end) {
|
|
548
|
-
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null);
|
|
548
|
+
const raw = await browser.evaluate(`() => !!document.querySelector(${JSON.stringify(selector)})`).catch(() => null); // lgtm[js/code-injection] — selector is JSON.stringify-escaped; derived from developer-configured flow steps, not HTTP input
|
|
549
549
|
const found = unwrapEval(raw);
|
|
550
550
|
if (found === true || String(found) === 'true') return true;
|
|
551
551
|
if (Date.now() < end) await new Promise(r => setTimeout(r, 300));
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS Font Loading Analyzer (Sprint 5c — A10)
|
|
3
|
+
*
|
|
4
|
+
* Detects web font performance and reliability issues that cause invisible
|
|
5
|
+
* text (FOIT), layout shifts (FOUT/CLS), or deliver fonts in suboptimal formats.
|
|
6
|
+
*
|
|
7
|
+
* Findings emitted:
|
|
8
|
+
* font_foit_risk — @font-face with no font-display (defaults to auto = FOIT)
|
|
9
|
+
* font_fout_risk — font-display: swap or fallback (layout shift risk)
|
|
10
|
+
* font_no_fallback — font-family with web font but no system font fallback
|
|
11
|
+
* font_slow_load — web font resource took > threshold ms (PerformanceResourceTiming)
|
|
12
|
+
* font_suboptimal_format — font served as .ttf or .eot (not .woff2)
|
|
13
|
+
* font_summary — info, always emitted
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { registerExpensive } from '../registry.js';
|
|
17
|
+
import { unwrapEval } from './mcp-client.js';
|
|
18
|
+
import { childLogger } from './logger.js';
|
|
19
|
+
import { thresholds } from '../config/targets.js';
|
|
20
|
+
|
|
21
|
+
const logger = childLogger('font-analyzer');
|
|
22
|
+
const SLOW_FONT_MS = thresholds.font?.slowLoadMs ?? 1000;
|
|
23
|
+
|
|
24
|
+
// System font families that serve as valid fallbacks
|
|
25
|
+
const SYSTEM_FONTS = /serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-serif|ui-sans-serif|ui-monospace|arial|helvetica|verdana|georgia|times|courier|trebuchet|impact|comic sans/i;
|
|
26
|
+
|
|
27
|
+
const FONT_SCRIPT = `() => {
|
|
28
|
+
var result = {
|
|
29
|
+
fontFaceRules: [],
|
|
30
|
+
fontFamilyUsages: [],
|
|
31
|
+
slowFonts: [],
|
|
32
|
+
suboptimalFonts: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── Inspect @font-face rules ───────────────────────────────────────────
|
|
36
|
+
var sheets = Array.from(document.styleSheets);
|
|
37
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
38
|
+
var rules;
|
|
39
|
+
try { rules = Array.from(sheets[i].cssRules || []); } catch { continue; }
|
|
40
|
+
for (var j = 0; j < rules.length; j++) {
|
|
41
|
+
var rule = rules[j];
|
|
42
|
+
if (rule.type !== CSSRule.FONT_FACE_RULE) continue;
|
|
43
|
+
var style = rule.style;
|
|
44
|
+
var family = (style.getPropertyValue('font-family') || '').replace(/['"]/g, '').trim();
|
|
45
|
+
var display = style.getPropertyValue('font-display') || '';
|
|
46
|
+
var src = style.getPropertyValue('src') || '';
|
|
47
|
+
|
|
48
|
+
result.fontFaceRules.push({
|
|
49
|
+
family: family,
|
|
50
|
+
display: display || 'auto',
|
|
51
|
+
src: src.slice(0, 200),
|
|
52
|
+
hasDisplay: display !== '',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Suboptimal format: .ttf or .eot in src
|
|
56
|
+
if (/\\.ttf|format\\(('|")truetype('|")\\)|format\\(('|")embedded-opentype('|")\\)/.test(src)) {
|
|
57
|
+
result.suboptimalFonts.push({ family: family, src: src.slice(0, 100) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Inspect font-family declarations for fallback stacks ──────────────
|
|
63
|
+
for (var si = 0; si < sheets.length; si++) {
|
|
64
|
+
var rules2;
|
|
65
|
+
try { rules2 = Array.from(sheets[si].cssRules || []); } catch { continue; }
|
|
66
|
+
for (var sj = 0; sj < rules2.length; sj++) {
|
|
67
|
+
var r = rules2[sj];
|
|
68
|
+
if (r.type !== CSSRule.STYLE_RULE) continue;
|
|
69
|
+
var ff = r.style && r.style.fontFamily;
|
|
70
|
+
if (!ff) continue;
|
|
71
|
+
// Only flag declarations that reference a custom font (quoted name = web font)
|
|
72
|
+
if (!/'|"/.test(ff)) continue;
|
|
73
|
+
result.fontFamilyUsages.push({
|
|
74
|
+
selector: (r.selectorText || '').slice(0, 80),
|
|
75
|
+
fontFamily: ff.slice(0, 150),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── PerformanceResourceTiming: slow + suboptimal format fonts ─────────
|
|
81
|
+
var fontEntries = performance.getEntriesByType('resource').filter(function(e) {
|
|
82
|
+
return /\\.(woff2?|ttf|otf|eot)(\\?.*)?$/i.test(e.name);
|
|
83
|
+
});
|
|
84
|
+
for (var fi = 0; fi < fontEntries.length; fi++) {
|
|
85
|
+
var fe = fontEntries[fi];
|
|
86
|
+
var duration = Math.round(fe.duration);
|
|
87
|
+
result.slowFonts.push({
|
|
88
|
+
url: fe.name.slice(0, 150),
|
|
89
|
+
duration: duration,
|
|
90
|
+
format: /\\.ttf/.test(fe.name) ? 'ttf' : /\\.eot/.test(fe.name) ? 'eot' :
|
|
91
|
+
/\\.woff2/.test(fe.name) ? 'woff2' : 'woff',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return JSON.stringify(result);
|
|
96
|
+
}`;
|
|
97
|
+
|
|
98
|
+
export async function analyzeFont(browser, url) {
|
|
99
|
+
const findings = [];
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await browser.navigate(url);
|
|
103
|
+
await browser.waitFor({ state: 'networkidle' }).catch(() => {});
|
|
104
|
+
await new Promise(r => setTimeout(r, 500));
|
|
105
|
+
} catch {
|
|
106
|
+
return findings;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let data = null;
|
|
110
|
+
try {
|
|
111
|
+
const raw = await browser.evaluate(FONT_SCRIPT);
|
|
112
|
+
const s = unwrapEval(raw);
|
|
113
|
+
data = typeof s === 'object' ? s : JSON.parse(s);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
logger.warn(`[ARGUS] font-analyzer: failed for ${url}: ${err.message}`);
|
|
116
|
+
data = { fontFaceRules: [], fontFamilyUsages: [], slowFonts: [], suboptimalFonts: [] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fontFaceRules = data.fontFaceRules ?? [];
|
|
120
|
+
const fontFamilyUsages = data.fontFamilyUsages ?? [];
|
|
121
|
+
const slowFonts = data.slowFonts ?? [];
|
|
122
|
+
const suboptimalFonts = data.suboptimalFonts ?? [];
|
|
123
|
+
|
|
124
|
+
let foitCount = 0, foutCount = 0, noFallbackCount = 0, slowCount = 0, suboptCount = 0;
|
|
125
|
+
|
|
126
|
+
// FOIT risk: @font-face without font-display
|
|
127
|
+
for (const rule of fontFaceRules) {
|
|
128
|
+
if (!rule.hasDisplay) {
|
|
129
|
+
foitCount++;
|
|
130
|
+
findings.push({
|
|
131
|
+
type: 'font_foit_risk',
|
|
132
|
+
message: `@font-face for '${rule.family}' has no font-display — defaults to 'auto' (FOIT risk in Chrome)`,
|
|
133
|
+
fontFamily: rule.family,
|
|
134
|
+
severity: 'warning',
|
|
135
|
+
url,
|
|
136
|
+
});
|
|
137
|
+
} else if (rule.display === 'swap' || rule.display === 'fallback') {
|
|
138
|
+
// FOUT risk: swap/fallback causes layout shift on font load
|
|
139
|
+
foutCount++;
|
|
140
|
+
findings.push({
|
|
141
|
+
type: 'font_fout_risk',
|
|
142
|
+
message: `@font-face for '${rule.family}' uses font-display: ${rule.display} — layout shift (CLS) risk if fallback metrics differ`,
|
|
143
|
+
fontFamily: rule.family,
|
|
144
|
+
fontDisplay: rule.display,
|
|
145
|
+
severity: 'info',
|
|
146
|
+
url,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// No system fallback: quoted font-family with no generic fallback
|
|
152
|
+
for (const usage of fontFamilyUsages) {
|
|
153
|
+
const hasFallback = SYSTEM_FONTS.test(usage.fontFamily);
|
|
154
|
+
if (!hasFallback) {
|
|
155
|
+
noFallbackCount++;
|
|
156
|
+
findings.push({
|
|
157
|
+
type: 'font_no_fallback',
|
|
158
|
+
message: `font-family '${usage.fontFamily}' on '${usage.selector}' has no system font fallback — invisible text if web font fails to load`,
|
|
159
|
+
selector: usage.selector,
|
|
160
|
+
fontFamily: usage.fontFamily,
|
|
161
|
+
severity: 'warning',
|
|
162
|
+
url,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Slow font loads (from PerformanceResourceTiming)
|
|
168
|
+
for (const f of slowFonts) {
|
|
169
|
+
if (f.duration > SLOW_FONT_MS) {
|
|
170
|
+
slowCount++;
|
|
171
|
+
findings.push({
|
|
172
|
+
type: 'font_slow_load',
|
|
173
|
+
message: `Web font took ${f.duration}ms to load (threshold: ${SLOW_FONT_MS}ms): ${f.url}`,
|
|
174
|
+
fontUrl: f.url,
|
|
175
|
+
duration: f.duration,
|
|
176
|
+
severity: 'warning',
|
|
177
|
+
url,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Suboptimal font format (.ttf/.eot in @font-face src)
|
|
183
|
+
for (const f of suboptimalFonts) {
|
|
184
|
+
suboptCount++;
|
|
185
|
+
findings.push({
|
|
186
|
+
type: 'font_suboptimal_format',
|
|
187
|
+
message: `Font '${f.family}' served in suboptimal format (TTF/EOT) — use WOFF2 for production`,
|
|
188
|
+
fontFamily: f.family,
|
|
189
|
+
severity: 'info',
|
|
190
|
+
url,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Summary — always emitted
|
|
195
|
+
findings.push({
|
|
196
|
+
type: 'font_summary',
|
|
197
|
+
foitRisks: foitCount,
|
|
198
|
+
foutRisks: foutCount,
|
|
199
|
+
noFallbacks: noFallbackCount,
|
|
200
|
+
slowLoads: slowCount,
|
|
201
|
+
suboptimalFmts: suboptCount,
|
|
202
|
+
message: `Font: ${foitCount} FOIT, ${foutCount} FOUT, ${noFallbackCount} no-fallback, ${slowCount} slow, ${suboptCount} suboptimal-format`,
|
|
203
|
+
severity: 'info',
|
|
204
|
+
url,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return findings;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
registerExpensive({
|
|
211
|
+
name: 'font',
|
|
212
|
+
analyze: (browser, url) => analyzeFont(browser, url),
|
|
213
|
+
});
|