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.
@@ -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 + '.tmp';
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 + '.tmp';
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 stat = fs.statSync(full);
44
- if (stat.size > 1_000_000) continue; // skip files > 1MB (minified bundles, etc.)
45
- files.push({ filePath: full, content: fs.readFileSync(full, 'utf8') });
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);
@@ -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
+ });