@capagents/forgeqe 0.0.0-stage → 1.0.1
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/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What went wrong in the browser during a run, for the report and for bugs: console errors, uncaught exceptions, failed
|
|
3
|
+
and slow requests (Chrome's own log and resource timing), accessibility checks on each page (Chrome's accessibility
|
|
4
|
+
tree, WCAG contrast), and where each action landed on the page the model saw.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from .network import NetworkCapture, hook
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
SLOW_MS = 3000
|
|
23
|
+
MAX_EVENTS = 500
|
|
24
|
+
MAX_PAGES = 25
|
|
25
|
+
MAX_EXAMPLES = 5
|
|
26
|
+
CHECK_TIMEOUT = 10.0
|
|
27
|
+
PROBLEMS_PER_STEP = 25
|
|
28
|
+
|
|
29
|
+
INTERACTIVE_ROLES = {
|
|
30
|
+
"button": ("button-name", "4.1.2", "critical", "Buttons need a name a screen reader can announce"),
|
|
31
|
+
"link": ("link-name", "2.4.4", "serious", "Links need text that says where they go"),
|
|
32
|
+
"textbox": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
33
|
+
"searchbox": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
34
|
+
"combobox": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
35
|
+
"listbox": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
36
|
+
"spinbutton": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
37
|
+
"slider": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
38
|
+
"checkbox": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
39
|
+
"radio": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
40
|
+
"switch": ("label", "1.3.1", "critical", "Form fields need a label"),
|
|
41
|
+
"menuitem": ("button-name", "4.1.2", "serious", "Menu items need a name a screen reader can announce"),
|
|
42
|
+
"tab": ("button-name", "4.1.2", "serious", "Tabs need a name a screen reader can announce"),
|
|
43
|
+
}
|
|
44
|
+
IMAGE_ROLES = {"image", "img"}
|
|
45
|
+
|
|
46
|
+
RESOURCES_JS = """(() => {
|
|
47
|
+
const o = performance.timeOrigin, out = [];
|
|
48
|
+
for (const e of [...performance.getEntriesByType('navigation'), ...performance.getEntriesByType('resource')]) {
|
|
49
|
+
const status = e.responseStatus || 0;
|
|
50
|
+
if (e.duration >= %d || status >= 400) out.push({url: e.name, type: e.initiatorType || e.entryType, ms: Math.round(e.duration), status, t: (o + e.startTime) / 1000});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
})()""" % SLOW_MS
|
|
54
|
+
|
|
55
|
+
# WCAG 1.4.3 contrast on visible text, the page language (3.1.1) and zoom (1.4.4). Text over a background image or
|
|
56
|
+
# gradient is skipped: its background colour is not known.
|
|
57
|
+
PAGE_CHECKS_JS = r"""(() => {
|
|
58
|
+
const parse = (c) => { const m = c.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number); return [p[0], p[1], p[2], p.length > 3 ? p[3] : 1]; };
|
|
59
|
+
const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
|
60
|
+
const blend = (top, under) => { const a = top[3]; return [top[0] * a + under[0] * (1 - a), top[1] * a + under[1] * (1 - a), top[2] * a + under[2] * (1 - a), 1]; };
|
|
61
|
+
const background = (el) => {
|
|
62
|
+
const layers = [];
|
|
63
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
64
|
+
const s = getComputedStyle(n);
|
|
65
|
+
if (s.backgroundImage && s.backgroundImage !== 'none') return null;
|
|
66
|
+
const c = parse(s.backgroundColor);
|
|
67
|
+
if (c && c[3] > 0) { layers.push(c); if (c[3] >= 1) break; }
|
|
68
|
+
}
|
|
69
|
+
let bg = [255, 255, 255, 1];
|
|
70
|
+
for (let i = layers.length - 1; i >= 0; i--) bg = blend(layers[i], bg);
|
|
71
|
+
return bg;
|
|
72
|
+
};
|
|
73
|
+
const low = [];
|
|
74
|
+
let checked = 0;
|
|
75
|
+
const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
while (walker.nextNode() && checked < 600) {
|
|
78
|
+
const t = walker.currentNode, el = t.parentElement;
|
|
79
|
+
if (!el || seen.has(el) || !t.textContent.trim()) continue;
|
|
80
|
+
seen.add(el);
|
|
81
|
+
const r = el.getBoundingClientRect();
|
|
82
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
83
|
+
const s = getComputedStyle(el);
|
|
84
|
+
if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) === 0) continue;
|
|
85
|
+
checked++;
|
|
86
|
+
const bg = background(el), fg0 = parse(s.color);
|
|
87
|
+
if (!bg || !fg0) continue;
|
|
88
|
+
const fg = blend(fg0, bg);
|
|
89
|
+
const a = lum(fg), b = lum(bg);
|
|
90
|
+
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
91
|
+
const size = parseFloat(s.fontSize), bold = Number(s.fontWeight) >= 700;
|
|
92
|
+
const need = size >= 24 || (bold && size >= 18.66) ? 3 : 4.5;
|
|
93
|
+
if (ratio < need) low.push({html: el.outerHTML.slice(0, 160), text: t.textContent.trim().slice(0, 80), ratio: Math.round(ratio * 100) / 100, need});
|
|
94
|
+
}
|
|
95
|
+
const vp = document.querySelector('meta[name="viewport"]');
|
|
96
|
+
const vpc = vp ? vp.getAttribute('content') || '' : '';
|
|
97
|
+
const maxScale = (vpc.match(/maximum-scale\s*=\s*([\d.]+)/) || [])[1];
|
|
98
|
+
return {lang: document.documentElement.getAttribute('lang') || '', contrast: low, checked,
|
|
99
|
+
noZoom: /user-scalable\s*=\s*(no|0)/i.test(vpc) || (maxScale !== undefined && Number(maxScale) < 2), viewport: vpc};
|
|
100
|
+
})()"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Core Web Vitals, observed from the start of each document (buffered entries cover a page that loaded before this ran).
|
|
104
|
+
# INP is the slowest interaction seen, close to the real p98 for the few interactions a run makes.
|
|
105
|
+
VITALS_INIT = r"""(() => {
|
|
106
|
+
if (window.__wpVitals) return;
|
|
107
|
+
const v = window.__wpVitals = {lcp: null, cls: 0, inp: null, fcp: null};
|
|
108
|
+
const watch = (type, cb, extra) => { try { new PerformanceObserver((l) => l.getEntries().forEach(cb)).observe({type, buffered: true, ...(extra || {})}); } catch (e) {} };
|
|
109
|
+
watch('largest-contentful-paint', (e) => { v.lcp = e.renderTime || e.loadTime || e.startTime; });
|
|
110
|
+
let session = 0, start = 0, last = 0;
|
|
111
|
+
watch('layout-shift', (e) => {
|
|
112
|
+
if (e.hadRecentInput) return;
|
|
113
|
+
if (e.startTime - last < 1000 && e.startTime - start < 5000) session += e.value; else { session = e.value; start = e.startTime; }
|
|
114
|
+
last = e.startTime; v.cls = Math.max(v.cls, session);
|
|
115
|
+
});
|
|
116
|
+
watch('paint', (e) => { if (e.name === 'first-contentful-paint') v.fcp = e.startTime; });
|
|
117
|
+
watch('event', (e) => { if (e.interactionId) v.inp = Math.max(v.inp || 0, e.duration); }, {durationThreshold: 16});
|
|
118
|
+
watch('first-input', (e) => { v.inp = Math.max(v.inp || 0, e.duration); });
|
|
119
|
+
})()"""
|
|
120
|
+
|
|
121
|
+
VITALS_READ = r"""(() => {
|
|
122
|
+
const v = window.__wpVitals;
|
|
123
|
+
const n = performance.getEntriesByType('navigation')[0] || {};
|
|
124
|
+
const r = performance.getEntriesByType('resource');
|
|
125
|
+
const fcp = (performance.getEntriesByName('first-contentful-paint')[0] || {}).startTime;
|
|
126
|
+
const ms = (x) => (typeof x === 'number' && x > 0 ? Math.round(x) : null);
|
|
127
|
+
return {url: location.href, observed: !!v, lcp: ms(v && v.lcp), cls: v ? Math.round(v.cls * 1000) / 1000 : null, inp: ms(v && v.inp),
|
|
128
|
+
fcp: ms((v && v.fcp) || fcp), ttfb: ms(n.responseStart), dcl: ms(n.domContentLoadedEventEnd), load: ms(n.loadEventEnd),
|
|
129
|
+
requests: r.length, kb: Math.round((r.reduce((a, x) => a + (x.transferSize || 0), 0) + (n.transferSize || 0)) / 1024)};
|
|
130
|
+
})()"""
|
|
131
|
+
|
|
132
|
+
# axe-core results, small: each violated rule with up to five elements, and the ids of the rules that passed.
|
|
133
|
+
AXE_RUN = r"""axe.run(document, {runOnly: {type: 'tag', values: %s}, resultTypes: ['violations']}).then((r) => ({
|
|
134
|
+
version: r.testEngine && r.testEngine.version,
|
|
135
|
+
passed: r.passes.map((p) => p.id),
|
|
136
|
+
incomplete: r.incomplete.map((p) => p.id),
|
|
137
|
+
violations: r.violations.map((v) => ({id: v.id, impact: v.impact, help: v.help, helpUrl: v.helpUrl, tags: v.tags, count: v.nodes.length,
|
|
138
|
+
examples: v.nodes.slice(0, 5).map((n) => ({html: (n.html || '').slice(0, 160), target: (n.target || []).join(' '), text: (n.failureSummary || '').slice(0, 300)}))}))
|
|
139
|
+
}))"""
|
|
140
|
+
|
|
141
|
+
WCAG_SC = re.compile(r"^wcag(\d)(\d)(\d{1,2})$")
|
|
142
|
+
WCAG_LEVEL = re.compile(r"^wcag2\d?(a{1,3})$")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def wcag_of(tags: List[str]) -> Tuple[str, str]:
|
|
146
|
+
"""The success criteria (1.4.3) and the level (A, AA, AAA) an axe rule's tags name."""
|
|
147
|
+
criteria = [".".join(m.groups()) for m in (WCAG_SC.match(t) for t in tags) if m]
|
|
148
|
+
levels = [m.group(1).upper() for m in (WCAG_LEVEL.match(t) for t in tags) if m]
|
|
149
|
+
return ", ".join(criteria), (min(levels, key=len) if levels else "")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Visual captures: animations, transitions and the text caret are frozen, and masked regions are hidden (their space
|
|
153
|
+
# kept), so a page compares with its baseline on what changed, not on what moves.
|
|
154
|
+
VISUAL_PREP = r"""((selectors) => {
|
|
155
|
+
const old = document.getElementById('__wp_visual'); if (old) old.remove();
|
|
156
|
+
const s = document.createElement('style'); s.id = '__wp_visual';
|
|
157
|
+
s.textContent = '*,*::before,*::after{animation-play-state:paused!important;animation-delay:-0.0001s!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}'
|
|
158
|
+
+ (selectors.length ? selectors.join(',') + '{visibility:hidden!important}' : '');
|
|
159
|
+
(document.head || document.documentElement).appendChild(s);
|
|
160
|
+
return true;
|
|
161
|
+
})(%s)"""
|
|
162
|
+
VISUAL_DONE = "(() => { const s = document.getElementById('__wp_visual'); if (s) s.remove(); })()"
|
|
163
|
+
MAX_FULL_HEIGHT = 8000
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def masks_for(url: str, visual: Dict[str, Any]) -> List[str]:
|
|
167
|
+
"""The selectors to mask on a page: the global ones, and those of every path prefix the page is under."""
|
|
168
|
+
path = urlsplit(url).path or "/"
|
|
169
|
+
out = [str(s) for s in (visual.get("mask") or [])]
|
|
170
|
+
for prefix, sels in (visual.get("pages") or {}).items():
|
|
171
|
+
if path == prefix or path.startswith(prefix.rstrip("/") + "/") or (prefix != "/" and path.startswith(prefix)):
|
|
172
|
+
out += [str(s) for s in (sels or [])]
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _page_key(url: str) -> str:
|
|
177
|
+
try:
|
|
178
|
+
u = urlsplit(url)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return url
|
|
181
|
+
return f"{u.scheme}://{u.netloc}{u.path or '/'}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _prop(node: Dict[str, Any], name: str) -> Any:
|
|
185
|
+
for p in node.get("properties") or []:
|
|
186
|
+
if p.get("name") == name:
|
|
187
|
+
return (p.get("value") or {}).get("value")
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _value(field: Any) -> str:
|
|
192
|
+
return str((field or {}).get("value") or "").strip() if isinstance(field, dict) else ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def box_for(browser_state: Any, index: Any) -> Optional[Dict[str, float]]:
|
|
196
|
+
"""Where the element at a selector-map index sat in the viewport screenshot, as fractions of its width and height."""
|
|
197
|
+
try:
|
|
198
|
+
node = browser_state.dom_state.selector_map.get(int(index))
|
|
199
|
+
pos = getattr(node, "absolute_position", None)
|
|
200
|
+
info = getattr(browser_state, "page_info", None)
|
|
201
|
+
if pos is None or info is None or not info.viewport_width or not info.viewport_height:
|
|
202
|
+
return None
|
|
203
|
+
vw, vh = float(info.viewport_width), float(info.viewport_height)
|
|
204
|
+
x, y = (pos.x - info.scroll_x) / vw, (pos.y - info.scroll_y) / vh
|
|
205
|
+
w, h = pos.width / vw, pos.height / vh
|
|
206
|
+
except Exception:
|
|
207
|
+
return None
|
|
208
|
+
if w <= 0 or h <= 0 or x >= 1 or y >= 1 or x + w <= 0 or y + h <= 0:
|
|
209
|
+
return None
|
|
210
|
+
x0, y0 = max(0.0, x), max(0.0, y)
|
|
211
|
+
return {"x": round(x0, 4), "y": round(y0, 4), "w": round(min(1.0, x + w) - x0, 4), "h": round(min(1.0, y + h) - y0, 4)}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Diagnostics:
|
|
215
|
+
def __init__(self, *, accessibility: bool = True, network: bool = True, axe: Optional[Dict[str, Any]] = None, vitals: bool = True, visual: Optional[Dict[str, Any]] = None) -> None:
|
|
216
|
+
self.accessibility = accessibility
|
|
217
|
+
# axe-core: {"script": path to axe.min.js, "tags": ["wcag2a", "wcag2aa"]}; without it, the built-in checks.
|
|
218
|
+
self.axe_tags: List[str] = [str(t) for t in ((axe or {}).get("tags") or ["wcag2a", "wcag2aa"])]
|
|
219
|
+
self._axe_source: Optional[str] = None
|
|
220
|
+
if axe and axe.get("script"):
|
|
221
|
+
try:
|
|
222
|
+
self._axe_source = Path(str(axe["script"])).read_text(encoding="utf-8")
|
|
223
|
+
except OSError:
|
|
224
|
+
logger.warning("axe-core not found at %s: using the built-in accessibility checks", axe.get("script"))
|
|
225
|
+
self.vitals = vitals
|
|
226
|
+
self.perf: Dict[str, Dict[str, Any]] = {}
|
|
227
|
+
# {"mask": [selectors], "pages": {"/path": [selectors]}, "full_page": bool}; None: no visual captures.
|
|
228
|
+
self.visual: Optional[Dict[str, Any]] = visual
|
|
229
|
+
self.shots: Dict[str, Dict[str, Any]] = {}
|
|
230
|
+
self.timeout = CHECK_TIMEOUT * (3 if self._axe_source else 1)
|
|
231
|
+
self.network: Optional[NetworkCapture] = NetworkCapture() if network else None
|
|
232
|
+
self._net_handlers: Dict[str, Any] = {}
|
|
233
|
+
self.events: List[Dict[str, Any]] = []
|
|
234
|
+
self.pages: Dict[str, Dict[str, Any]] = {}
|
|
235
|
+
self._client: Any = None
|
|
236
|
+
self._sessions: Set[str] = set()
|
|
237
|
+
self._resources: Set[Tuple[str, int]] = set()
|
|
238
|
+
self._lock = asyncio.Lock()
|
|
239
|
+
|
|
240
|
+
# -- Chrome events ------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _add(self, event: Dict[str, Any]) -> None:
|
|
243
|
+
if len(self.events) >= MAX_EVENTS:
|
|
244
|
+
return
|
|
245
|
+
for e in self.events[-60:]:
|
|
246
|
+
if all(e.get(k) == event.get(k) for k in ("kind", "text", "url", "status")) and abs(e["t"] - event["t"]) < 30:
|
|
247
|
+
e["count"] = e.get("count", 1) + 1
|
|
248
|
+
return
|
|
249
|
+
self.events.append(event)
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _when(ms: Any) -> float:
|
|
253
|
+
try:
|
|
254
|
+
v = float(ms)
|
|
255
|
+
return v / 1000 if v > 1e11 else v
|
|
256
|
+
except (TypeError, ValueError):
|
|
257
|
+
return time.time()
|
|
258
|
+
|
|
259
|
+
def _on_console(self, params: Any, session_id: Optional[str] = None) -> None:
|
|
260
|
+
try:
|
|
261
|
+
kind = str(params.get("type") or "")
|
|
262
|
+
if kind not in ("error", "assert", "warning"):
|
|
263
|
+
return
|
|
264
|
+
parts = []
|
|
265
|
+
for a in params.get("args") or []:
|
|
266
|
+
v = a.get("value")
|
|
267
|
+
parts.append(str(v) if v is not None else str(a.get("description") or a.get("unserializableValue") or a.get("type") or ""))
|
|
268
|
+
frames = ((params.get("stackTrace") or {}).get("callFrames") or [{}])
|
|
269
|
+
self._add({"t": self._when(params.get("timestamp")), "kind": "console", "level": "warning" if kind == "warning" else "error",
|
|
270
|
+
"text": " ".join(parts).strip()[:500], "url": str(frames[0].get("url") or "")})
|
|
271
|
+
except Exception:
|
|
272
|
+
logger.debug("console event not read", exc_info=True)
|
|
273
|
+
|
|
274
|
+
def _on_exception(self, params: Any, session_id: Optional[str] = None) -> None:
|
|
275
|
+
try:
|
|
276
|
+
d = params.get("exceptionDetails") or {}
|
|
277
|
+
text = str((d.get("exception") or {}).get("description") or d.get("text") or "Uncaught exception")
|
|
278
|
+
self._add({"t": self._when(params.get("timestamp")), "kind": "exception", "level": "error",
|
|
279
|
+
"text": text.strip()[:800], "url": str(d.get("url") or ""), **({"line": d["lineNumber"] + 1} if isinstance(d.get("lineNumber"), int) else {})})
|
|
280
|
+
except Exception:
|
|
281
|
+
logger.debug("exception event not read", exc_info=True)
|
|
282
|
+
|
|
283
|
+
def _on_log(self, params: Any, session_id: Optional[str] = None) -> None:
|
|
284
|
+
try:
|
|
285
|
+
e = params.get("entry") or {}
|
|
286
|
+
if e.get("level") != "error":
|
|
287
|
+
return
|
|
288
|
+
source = str(e.get("source") or "")
|
|
289
|
+
self._add({"t": self._when(e.get("timestamp")), "kind": "network" if source == "network" else "console", "level": "error",
|
|
290
|
+
"text": str(e.get("text") or "").strip()[:500], "url": str(e.get("url") or ""), "source": source})
|
|
291
|
+
except Exception:
|
|
292
|
+
logger.debug("log entry not read", exc_info=True)
|
|
293
|
+
|
|
294
|
+
async def _session(self, browser: Any) -> Any:
|
|
295
|
+
client = getattr(browser, "cdp_client", None)
|
|
296
|
+
if client is None:
|
|
297
|
+
return None
|
|
298
|
+
if client is not self._client:
|
|
299
|
+
reg = client.register
|
|
300
|
+
reg.Runtime.consoleAPICalled(self._on_console)
|
|
301
|
+
reg.Runtime.exceptionThrown(self._on_exception)
|
|
302
|
+
reg.Log.entryAdded(self._on_log)
|
|
303
|
+
self._client = client
|
|
304
|
+
self._sessions.clear()
|
|
305
|
+
if self.network is not None:
|
|
306
|
+
net = self.network
|
|
307
|
+
self._net_handlers = {
|
|
308
|
+
"Network.requestWillBeSent": net.on_request,
|
|
309
|
+
"Network.responseReceived": net.on_response,
|
|
310
|
+
"Network.loadingFinished": lambda p, s=None: net.on_finished(p, s, client),
|
|
311
|
+
"Network.loadingFailed": net.on_failed,
|
|
312
|
+
}
|
|
313
|
+
# Hooked again on each call: a watchdog that registers later would otherwise replace these handlers.
|
|
314
|
+
for method, fn in self._net_handlers.items():
|
|
315
|
+
hook(client, method, fn)
|
|
316
|
+
session = await browser.get_or_create_cdp_session()
|
|
317
|
+
if session.session_id not in self._sessions:
|
|
318
|
+
self._sessions.add(session.session_id)
|
|
319
|
+
await session.cdp_client.send.Runtime.enable(session_id=session.session_id)
|
|
320
|
+
await session.cdp_client.send.Log.enable(session_id=session.session_id)
|
|
321
|
+
if self.network is not None:
|
|
322
|
+
await session.cdp_client.send.Network.enable(session_id=session.session_id)
|
|
323
|
+
if self.vitals:
|
|
324
|
+
try:
|
|
325
|
+
await session.cdp_client.send.Page.addScriptToEvaluateOnNewDocument(params={"source": VITALS_INIT}, session_id=session.session_id)
|
|
326
|
+
except Exception:
|
|
327
|
+
logger.debug("vitals script not added", exc_info=True)
|
|
328
|
+
return session
|
|
329
|
+
|
|
330
|
+
async def start(self, browser: Any) -> None:
|
|
331
|
+
"""Listen from the first page on, so the calls the start page makes are recorded too."""
|
|
332
|
+
async with self._lock:
|
|
333
|
+
try:
|
|
334
|
+
await asyncio.wait_for(self._session(browser), timeout=CHECK_TIMEOUT)
|
|
335
|
+
except Exception:
|
|
336
|
+
logger.debug("diagnostics start failed", exc_info=True)
|
|
337
|
+
|
|
338
|
+
async def _eval(self, session: Any, expression: str) -> Any:
|
|
339
|
+
r = await session.cdp_client.send.Runtime.evaluate(
|
|
340
|
+
params={"expression": expression, "returnByValue": True}, session_id=session.session_id
|
|
341
|
+
)
|
|
342
|
+
return ((r or {}).get("result") or {}).get("value")
|
|
343
|
+
|
|
344
|
+
# -- per page -----------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
async def sample(self, browser: Any) -> None:
|
|
347
|
+
"""Called after each step: pick up failed and slow requests, and check a page not seen before."""
|
|
348
|
+
async with self._lock:
|
|
349
|
+
try:
|
|
350
|
+
await asyncio.wait_for(self._sample(browser), timeout=self.timeout)
|
|
351
|
+
except Exception:
|
|
352
|
+
logger.debug("diagnostics sample failed", exc_info=True)
|
|
353
|
+
|
|
354
|
+
async def _sample(self, browser: Any) -> None:
|
|
355
|
+
session = await self._session(browser)
|
|
356
|
+
if session is None:
|
|
357
|
+
return
|
|
358
|
+
if self.network is not None:
|
|
359
|
+
await self.network.settle(2.0)
|
|
360
|
+
for r in await self._eval(session, RESOURCES_JS) or []:
|
|
361
|
+
key = (str(r.get("url")), int(float(r.get("t") or 0) * 1000))
|
|
362
|
+
if key in self._resources:
|
|
363
|
+
continue
|
|
364
|
+
self._resources.add(key)
|
|
365
|
+
url, status, t = str(r.get("url") or ""), int(r.get("status") or 0), float(r.get("t") or time.time())
|
|
366
|
+
if status >= 400:
|
|
367
|
+
logged = next((e for e in self.events if e["kind"] == "network" and e.get("url") == url and not e.get("status")), None)
|
|
368
|
+
if logged:
|
|
369
|
+
logged["status"] = status
|
|
370
|
+
else:
|
|
371
|
+
self._add({"t": t, "kind": "network", "level": "error", "text": f"HTTP {status}", "url": url, "status": status})
|
|
372
|
+
if int(r.get("ms") or 0) >= SLOW_MS:
|
|
373
|
+
self._add({"t": t, "kind": "slow", "level": "warning", "text": f"{r.get('type') or 'request'} took {int(r['ms']) / 1000:.1f}s",
|
|
374
|
+
"url": url, "ms": int(r["ms"])})
|
|
375
|
+
if self.vitals:
|
|
376
|
+
await self._read_vitals(session)
|
|
377
|
+
if self.accessibility:
|
|
378
|
+
await self._check_page(browser, session)
|
|
379
|
+
if self.visual is not None:
|
|
380
|
+
await self._capture(session)
|
|
381
|
+
|
|
382
|
+
async def _capture(self, session: Any) -> None:
|
|
383
|
+
"""The page as it is now, for comparison with its baseline. Each page keeps its last capture: the state the flow left it in."""
|
|
384
|
+
try:
|
|
385
|
+
url = str(await self._eval(session, "location.href") or "")
|
|
386
|
+
except Exception:
|
|
387
|
+
return
|
|
388
|
+
key = _page_key(url)
|
|
389
|
+
if not url.startswith("http") or (key not in self.shots and len(self.shots) >= MAX_PAGES):
|
|
390
|
+
return
|
|
391
|
+
send = session.cdp_client.send
|
|
392
|
+
try:
|
|
393
|
+
await self._eval(session, VISUAL_PREP % json.dumps(masks_for(url, self.visual or {})))
|
|
394
|
+
await asyncio.sleep(0.15)
|
|
395
|
+
params: Dict[str, Any] = {"format": "png", "fromSurface": True}
|
|
396
|
+
if (self.visual or {}).get("full_page"):
|
|
397
|
+
metrics = await send.Page.getLayoutMetrics(session_id=session.session_id)
|
|
398
|
+
size = (metrics or {}).get("cssContentSize") or (metrics or {}).get("contentSize") or {}
|
|
399
|
+
width, height = int(size.get("width") or 0), min(int(size.get("height") or 0), MAX_FULL_HEIGHT)
|
|
400
|
+
if width and height:
|
|
401
|
+
params.update({"captureBeyondViewport": True, "clip": {"x": 0, "y": 0, "width": width, "height": height, "scale": 1}})
|
|
402
|
+
shot = await send.Page.captureScreenshot(params=params, session_id=session.session_id)
|
|
403
|
+
data = (shot or {}).get("data")
|
|
404
|
+
if data:
|
|
405
|
+
title = str(await self._eval(session, "document.title") or "")
|
|
406
|
+
self.shots[key] = {"url": url, "title": title, "png": base64.b64decode(data), "full_page": bool(params.get("clip"))}
|
|
407
|
+
except Exception:
|
|
408
|
+
logger.debug("visual capture failed", exc_info=True)
|
|
409
|
+
finally:
|
|
410
|
+
try:
|
|
411
|
+
await self._eval(session, VISUAL_DONE)
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
def write_visual(self, out_dir: Path) -> Optional[str]:
|
|
416
|
+
"""visual/<n>.png for each page, and visual.json listing them."""
|
|
417
|
+
if not self.shots:
|
|
418
|
+
return None
|
|
419
|
+
folder = out_dir / "visual"
|
|
420
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
index = []
|
|
422
|
+
for i, (key, s) in enumerate(self.shots.items(), 1):
|
|
423
|
+
name = f"{i:02d}-{re.sub(r'[^A-Za-z0-9]+', '-', urlsplit(key).path).strip('-')[:40] or 'home'}.png"
|
|
424
|
+
(folder / name).write_bytes(s["png"])
|
|
425
|
+
index.append({"page": key, "url": s["url"], "title": s["title"], "file": f"visual/{name}", "full_page": s["full_page"]})
|
|
426
|
+
file = out_dir / "visual.json"
|
|
427
|
+
file.write_text(json.dumps(index, indent=2), encoding="utf-8")
|
|
428
|
+
return str(file.resolve())
|
|
429
|
+
|
|
430
|
+
async def _read_vitals(self, session: Any) -> None:
|
|
431
|
+
"""This page's timings so far: the worst across visits for shifts and interactions, the first load's for the rest."""
|
|
432
|
+
try:
|
|
433
|
+
v = await self._eval(session, VITALS_READ) or {}
|
|
434
|
+
if not v.get("observed"):
|
|
435
|
+
# A page that loaded before the script was added: observe now; buffered entries arrive by the next step.
|
|
436
|
+
await self._eval(session, VITALS_INIT)
|
|
437
|
+
except Exception:
|
|
438
|
+
logger.debug("vitals not read", exc_info=True)
|
|
439
|
+
return
|
|
440
|
+
url = str(v.get("url") or "")
|
|
441
|
+
if not url.startswith("http"):
|
|
442
|
+
return
|
|
443
|
+
key = _page_key(url)
|
|
444
|
+
if key not in self.perf and len(self.perf) >= MAX_PAGES:
|
|
445
|
+
return
|
|
446
|
+
p = self.perf.setdefault(key, {"url": url, "samples": 0})
|
|
447
|
+
p["samples"] += 1
|
|
448
|
+
for k in ("ttfb", "fcp", "dcl", "load"):
|
|
449
|
+
if p.get(k) is None and v.get(k) is not None:
|
|
450
|
+
p[k] = v[k]
|
|
451
|
+
if v.get("lcp") is not None:
|
|
452
|
+
p["lcp"] = max(p.get("lcp") or 0, v["lcp"])
|
|
453
|
+
for k in ("cls", "inp", "requests", "kb"):
|
|
454
|
+
if v.get(k) is not None:
|
|
455
|
+
p[k] = max(p.get(k) or 0, v[k])
|
|
456
|
+
|
|
457
|
+
async def _axe(self, session: Any) -> Optional[Dict[str, Any]]:
|
|
458
|
+
"""axe-core's results for the page, or None when it could not run (the built-in checks run instead)."""
|
|
459
|
+
if not self._axe_source:
|
|
460
|
+
return None
|
|
461
|
+
try:
|
|
462
|
+
loaded = await self._eval(session, "typeof window.axe !== 'undefined'")
|
|
463
|
+
if not loaded:
|
|
464
|
+
await session.cdp_client.send.Runtime.evaluate(params={"expression": self._axe_source, "returnByValue": False}, session_id=session.session_id)
|
|
465
|
+
r = await session.cdp_client.send.Runtime.evaluate(
|
|
466
|
+
params={"expression": AXE_RUN % json.dumps(self.axe_tags), "returnByValue": True, "awaitPromise": True}, session_id=session.session_id
|
|
467
|
+
)
|
|
468
|
+
value = ((r or {}).get("result") or {}).get("value")
|
|
469
|
+
return value if isinstance(value, dict) and "violations" in value else None
|
|
470
|
+
except Exception:
|
|
471
|
+
logger.debug("axe-core did not run", exc_info=True)
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
async def _check_page(self, browser: Any, session: Any) -> None:
|
|
475
|
+
try:
|
|
476
|
+
url = str(await browser.get_current_page_url() or "")
|
|
477
|
+
except Exception:
|
|
478
|
+
return
|
|
479
|
+
key = _page_key(url)
|
|
480
|
+
if not url.startswith("http") or key in self.pages or len(self.pages) >= MAX_PAGES:
|
|
481
|
+
return
|
|
482
|
+
send = session.cdp_client.send
|
|
483
|
+
order = {"critical": 0, "serious": 1, "moderate": 2, "minor": 3}
|
|
484
|
+
axe = await self._axe(session)
|
|
485
|
+
if axe is not None:
|
|
486
|
+
issues_list = []
|
|
487
|
+
for v in axe["violations"]:
|
|
488
|
+
criteria, level = wcag_of(list(v.get("tags") or []))
|
|
489
|
+
issues_list.append({"rule": v.get("id"), "wcag": criteria, "level": level, "impact": v.get("impact") or "minor", "help": v.get("help") or "",
|
|
490
|
+
"helpUrl": v.get("helpUrl") or "", "count": int(v.get("count") or 0), "examples": v.get("examples") or []})
|
|
491
|
+
title = ""
|
|
492
|
+
try:
|
|
493
|
+
title = str(await self._eval(session, "document.title") or "")
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|
|
496
|
+
self.pages[key] = {
|
|
497
|
+
"url": url,
|
|
498
|
+
"title": title,
|
|
499
|
+
"engine": f"axe-core {axe.get('version') or ''}".strip(),
|
|
500
|
+
"tags": self.axe_tags,
|
|
501
|
+
"passed": list(axe.get("passed") or []),
|
|
502
|
+
"incomplete": list(axe.get("incomplete") or []),
|
|
503
|
+
"issues": sorted(issues_list, key=lambda i: (order.get(i["impact"], 9), -i["count"])),
|
|
504
|
+
}
|
|
505
|
+
return
|
|
506
|
+
tree = await send.Accessibility.getFullAXTree(params={}, session_id=session.session_id)
|
|
507
|
+
nodes = [n for n in (tree or {}).get("nodes") or [] if not n.get("ignored")]
|
|
508
|
+
issues: Dict[str, Dict[str, Any]] = {}
|
|
509
|
+
title = ""
|
|
510
|
+
|
|
511
|
+
async def add(rule: str, wcag: str, impact: str, help_text: str, example: Dict[str, Any], backend: Optional[int] = None) -> None:
|
|
512
|
+
issue = issues.setdefault(rule, {"rule": rule, "wcag": wcag, "impact": impact, "help": help_text, "count": 0, "examples": []})
|
|
513
|
+
issue["count"] += 1
|
|
514
|
+
if len(issue["examples"]) >= MAX_EXAMPLES:
|
|
515
|
+
return
|
|
516
|
+
if backend and "html" not in example:
|
|
517
|
+
try:
|
|
518
|
+
html = await send.DOM.getOuterHTML(params={"backendNodeId": backend}, session_id=session.session_id)
|
|
519
|
+
example["html"] = str((html or {}).get("outerHTML") or "")[:160]
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
issue["examples"].append(example)
|
|
523
|
+
|
|
524
|
+
last_level = 0
|
|
525
|
+
for n in nodes:
|
|
526
|
+
role, name = _value(n.get("role")), _value(n.get("name"))
|
|
527
|
+
backend = n.get("backendDOMNodeId")
|
|
528
|
+
if role == "RootWebArea":
|
|
529
|
+
title = title or name
|
|
530
|
+
if not name:
|
|
531
|
+
await add("document-title", "2.4.2", "serious", "Pages need a title", {"text": url})
|
|
532
|
+
elif role in INTERACTIVE_ROLES and not name:
|
|
533
|
+
rule, wcag, impact, help_text = INTERACTIVE_ROLES[role]
|
|
534
|
+
await add(rule, wcag, impact, help_text, {"role": role}, backend)
|
|
535
|
+
elif role in IMAGE_ROLES and not name:
|
|
536
|
+
await add("image-alt", "1.1.1", "critical", "Images need a text alternative (or alt=\"\" if decorative)", {"role": role}, backend)
|
|
537
|
+
elif role == "heading":
|
|
538
|
+
level = _prop(n, "level")
|
|
539
|
+
if isinstance(level, (int, float)):
|
|
540
|
+
if last_level and level > last_level + 1:
|
|
541
|
+
await add("heading-order", "1.3.1", "moderate", "Heading levels should go down one at a time",
|
|
542
|
+
{"text": f"h{last_level} then h{int(level)}: {name[:60]}"}, backend)
|
|
543
|
+
last_level = int(level)
|
|
544
|
+
|
|
545
|
+
page = await self._eval(session, PAGE_CHECKS_JS) or {}
|
|
546
|
+
if not page.get("lang"):
|
|
547
|
+
await add("html-lang", "3.1.1", "serious", "The page needs a lang attribute on <html>", {"html": "<html>"})
|
|
548
|
+
for c in page.get("contrast") or []:
|
|
549
|
+
await add("color-contrast", "1.4.3", "serious", "Text needs enough contrast with its background",
|
|
550
|
+
{"html": c.get("html"), "text": f"{c.get('text')} (contrast {c.get('ratio')}:1, needs {c.get('need')}:1)"})
|
|
551
|
+
if page.get("noZoom"):
|
|
552
|
+
await add("meta-viewport", "1.4.4", "critical", "Pages must let people zoom", {"html": f'<meta name="viewport" content="{page.get("viewport")}">'})
|
|
553
|
+
|
|
554
|
+
self.pages[key] = {
|
|
555
|
+
"url": url,
|
|
556
|
+
"title": title,
|
|
557
|
+
"engine": "forgeqe",
|
|
558
|
+
"checked": int(page.get("checked") or 0),
|
|
559
|
+
"issues": sorted(issues.values(), key=lambda i: (order.get(i["impact"], 9), -i["count"])),
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
# -- results ------------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
def summary(self) -> Dict[str, int]:
|
|
565
|
+
def total(kind: str, level: Optional[str] = None) -> int:
|
|
566
|
+
return sum(e.get("count", 1) for e in self.events if e["kind"] == kind and (level is None or e["level"] == level))
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
"console_errors": total("console", "error") + total("exception"),
|
|
570
|
+
"console_warnings": total("console", "warning"),
|
|
571
|
+
"failed_requests": total("network"),
|
|
572
|
+
"slow_requests": total("slow"),
|
|
573
|
+
"pages_checked": len(self.pages),
|
|
574
|
+
"accessibility_issues": sum(i["count"] for p in self.pages.values() for i in p["issues"]),
|
|
575
|
+
**({"api_calls": len(self.network.calls)} if self.network is not None and self.network.calls else {}),
|
|
576
|
+
**({"pages_timed": len(self.perf)} if self.perf else {}),
|
|
577
|
+
**({"pages_captured": len(self.shots)} if self.shots else {}),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
def write(self, out_dir: Path) -> Optional[str]:
|
|
581
|
+
if not self.pages:
|
|
582
|
+
return None
|
|
583
|
+
file = out_dir / "accessibility.json"
|
|
584
|
+
file.write_text(json.dumps(list(self.pages.values()), indent=2, ensure_ascii=False), encoding="utf-8")
|
|
585
|
+
return str(file.resolve())
|
|
586
|
+
|
|
587
|
+
def write_perf(self, out_dir: Path) -> Optional[str]:
|
|
588
|
+
if not self.perf:
|
|
589
|
+
return None
|
|
590
|
+
file = out_dir / "performance.json"
|
|
591
|
+
file.write_text(json.dumps(list(self.perf.values()), indent=2), encoding="utf-8")
|
|
592
|
+
return str(file.resolve())
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def assign_problems(entries: List[Dict[str, Any]], events: List[Dict[str, Any]]) -> None:
|
|
596
|
+
"""Attach each browser problem to the timeline step it happened in (by the steps' start times)."""
|
|
597
|
+
if not entries or not events:
|
|
598
|
+
return
|
|
599
|
+
starts = [(e.get("t0"), e) for e in entries if isinstance(e.get("t0"), (int, float))]
|
|
600
|
+
for ev in sorted(events, key=lambda e: e["t"]):
|
|
601
|
+
owner = entries[0]
|
|
602
|
+
for t0, entry in starts:
|
|
603
|
+
if t0 <= ev["t"]:
|
|
604
|
+
owner = entry
|
|
605
|
+
bucket = owner.setdefault("problems", [])
|
|
606
|
+
if len(bucket) < PROBLEMS_PER_STEP:
|
|
607
|
+
bucket.append({k: v for k, v in ev.items() if k != "t"})
|
|
608
|
+
else:
|
|
609
|
+
owner["more_problems"] = owner.get("more_problems", 0) + ev.get("count", 1)
|