nextog-cli 1.0.0__py3-none-any.whl
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.
- nextog/__init__.py +4 -0
- nextog/cli.py +545 -0
- nextog/config/__init__.py +1 -0
- nextog/config/settings.py +132 -0
- nextog/core/__init__.py +1 -0
- nextog/core/engine.py +193 -0
- nextog/core/permissions.py +129 -0
- nextog/core/privacy.py +130 -0
- nextog/core/reporter.py +204 -0
- nextog/core/runner.py +236 -0
- nextog/data/__init__.py +1 -0
- nextog/data/local_db.py +367 -0
- nextog/data/models.py +72 -0
- nextog/data/sync.py +65 -0
- nextog/engines/__init__.py +1 -0
- nextog/engines/api/__init__.py +1 -0
- nextog/engines/api/graphql.py +54 -0
- nextog/engines/api/rest.py +346 -0
- nextog/engines/api/websocket.py +59 -0
- nextog/engines/embedded/__init__.py +1 -0
- nextog/engines/embedded/firmware.py +53 -0
- nextog/engines/embedded/hardware.py +330 -0
- nextog/engines/mobile/__init__.py +1 -0
- nextog/engines/mobile/android.py +333 -0
- nextog/engines/mobile/cross.py +48 -0
- nextog/engines/mobile/ios.py +46 -0
- nextog/engines/system/__init__.py +1 -0
- nextog/engines/system/load.py +121 -0
- nextog/engines/system/performance.py +128 -0
- nextog/engines/system/security.py +170 -0
- nextog/engines/web/__init__.py +1 -0
- nextog/engines/web/accessibility.py +191 -0
- nextog/engines/web/browser.py +387 -0
- nextog/engines/web/elements.py +285 -0
- nextog/engines/web/responsive.py +79 -0
- nextog/live/__init__.py +1 -0
- nextog/live/dashboard.py +30 -0
- nextog/live/panel.py +325 -0
- nextog/reports/__init__.py +1359 -0
- nextog/training/__init__.py +1 -0
- nextog/training/learner.py +269 -0
- nextog/training/patterns.py +102 -0
- nextog/utils/__init__.py +1 -0
- nextog/utils/helpers.py +91 -0
- nextog/utils/logger.py +37 -0
- nextog/utils/validators.py +98 -0
- nextog_cli-1.0.0.dist-info/METADATA +344 -0
- nextog_cli-1.0.0.dist-info/RECORD +51 -0
- nextog_cli-1.0.0.dist-info/WHEEL +5 -0
- nextog_cli-1.0.0.dist-info/entry_points.txt +2 -0
- nextog_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Accessibility Testing Module - WCAG 2.1 compliance testing
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# WCAG 2.1 Guidelines
|
|
9
|
+
WCAG_GUIDELINES = {
|
|
10
|
+
"1.1.1": {"name": "Non-text Content", "level": "A", "description": "All non-text content has text alternative"},
|
|
11
|
+
"1.3.1": {"name": "Info and Relationships", "level": "A", "description": "Information and relationships can be programmatically determined"},
|
|
12
|
+
"1.3.4": {"name": "Orientation", "level": "AA", "description": "Content does not restrict its view to a single display orientation"},
|
|
13
|
+
"1.3.5": {"name": "Identify Input Purpose", "level": "AA", "description": "Input field purpose can be programmatically determined"},
|
|
14
|
+
"1.4.1": {"name": "Use of Color", "level": "A", "description": "Color is not used as the only visual means of conveying information"},
|
|
15
|
+
"1.4.3": {"name": "Contrast (Minimum)", "level": "AA", "description": "Text has contrast ratio of at least 4.5:1"},
|
|
16
|
+
"1.4.4": {"name": "Resize Text", "level": "AA", "description": "Text can be resized up to 200% without loss of content"},
|
|
17
|
+
"1.4.10": {"name": "Reflow", "level": "AA", "description": "Content reflows at 320px width without scrolling"},
|
|
18
|
+
"1.4.11": {"name": "Non-text Contrast", "level": "AA", "description": "UI components have contrast ratio of at least 3:1"},
|
|
19
|
+
"2.1.1": {"name": "Keyboard", "level": "A", "description": "All functionality available from keyboard"},
|
|
20
|
+
"2.1.2": {"name": "No Keyboard Trap", "level": "A", "description": "Keyboard focus can be moved away from any component"},
|
|
21
|
+
"2.4.1": {"name": "Bypass Blocks", "level": "A", "description": "Mechanism to bypass blocks of content repeated on pages"},
|
|
22
|
+
"2.4.2": {"name": "Page Titled", "level": "A", "description": "Pages have descriptive titles"},
|
|
23
|
+
"2.4.3": {"name": "Focus Order", "level": "A", "description": "Focus order preserves meaning and operability"},
|
|
24
|
+
"2.4.4": {"name": "Link Purpose", "level": "A", "description": "Purpose of each link can be determined from link text"},
|
|
25
|
+
"2.4.6": {"name": "Headings and Labels", "level": "AA", "description": "Headings and labels describe topic or purpose"},
|
|
26
|
+
"2.4.7": {"name": "Focus Visible", "level": "AA", "description": "Keyboard focus indicator is visible"},
|
|
27
|
+
"2.5.3": {"name": "Label in Name", "level": "A", "description": "Visible label contains accessible name text"},
|
|
28
|
+
"2.5.5": {"name": "Target Size", "level": "AAA", "description": "Touch target size is at least 44x44 CSS pixels"},
|
|
29
|
+
"3.1.1": {"name": "Language of Page", "level": "A", "description": "Default human language of Web page can be determined"},
|
|
30
|
+
"3.1.2": {"name": "Language of Parts", "level": "AA", "description": "Language of each passage can be determined"},
|
|
31
|
+
"3.2.1": {"name": "On Focus", "level": "A", "description": "Focus does not initiate change of context"},
|
|
32
|
+
"3.2.2": {"name": "On Input", "level": "A", "description": "Input does not initiate change of context"},
|
|
33
|
+
"3.3.1": {"name": "Error Identification", "level": "A", "description": "Input errors are automatically detected and described"},
|
|
34
|
+
"3.3.2": {"name": "Labels or Instructions", "level": "A", "description": "Labels or instructions provided for user input"},
|
|
35
|
+
"4.1.1": {"name": "Parsing", "level": "A", "description": "Elements have complete start/end tags, no duplicate attributes"},
|
|
36
|
+
"4.1.2": {"name": "Name, Role, Value", "level": "A", "description": "Name, role, and value can be programmatically determined"},
|
|
37
|
+
"4.1.3": {"name": "Status Messages", "level": "AA", "description": "Status messages programmatically determined through role"},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AccessibilityTestEngine:
|
|
42
|
+
"""WCAG 2.1 accessibility testing engine"""
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.results: List[Dict] = []
|
|
46
|
+
self.violations: List[Dict] = []
|
|
47
|
+
|
|
48
|
+
def get_test_suite(self, level: str = "AA") -> List[Dict]:
|
|
49
|
+
"""Get test suite for specified WCAG level"""
|
|
50
|
+
suite = []
|
|
51
|
+
valid_levels = {"A", "AA", "AAA"}
|
|
52
|
+
|
|
53
|
+
if level not in valid_levels:
|
|
54
|
+
level = "AA"
|
|
55
|
+
|
|
56
|
+
for guideline_id, info in WCAG_GUIDELINES.items():
|
|
57
|
+
if level == "A" and info["level"] == "A":
|
|
58
|
+
suite.append({"id": guideline_id, **info})
|
|
59
|
+
elif level == "AA" and info["level"] in ("A", "AA"):
|
|
60
|
+
suite.append({"id": guideline_id, **info})
|
|
61
|
+
elif level == "AAA":
|
|
62
|
+
suite.append({"id": guideline_id, **info})
|
|
63
|
+
|
|
64
|
+
return suite
|
|
65
|
+
|
|
66
|
+
def check_aria_roles(self, page_content: Dict) -> List[Dict]:
|
|
67
|
+
"""Check ARIA roles and attributes"""
|
|
68
|
+
violations = []
|
|
69
|
+
|
|
70
|
+
# Check buttons have proper roles
|
|
71
|
+
buttons_without_labels = page_content.get("buttons_without_aria", [])
|
|
72
|
+
for btn in buttons_without_labels:
|
|
73
|
+
violations.append({
|
|
74
|
+
"guideline": "4.1.2",
|
|
75
|
+
"severity": "critical",
|
|
76
|
+
"element": btn,
|
|
77
|
+
"message": "Button lacks accessible name",
|
|
78
|
+
"fix": "Add aria-label or visible text content",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# Check images have alt text
|
|
82
|
+
images_without_alt = page_content.get("images_without_alt", [])
|
|
83
|
+
for img in images_without_alt:
|
|
84
|
+
violations.append({
|
|
85
|
+
"guideline": "1.1.1",
|
|
86
|
+
"severity": "serious",
|
|
87
|
+
"element": img,
|
|
88
|
+
"message": "Image missing alt text",
|
|
89
|
+
"fix": "Add descriptive alt attribute",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
return violations
|
|
93
|
+
|
|
94
|
+
def check_color_contrast(self, elements: List[Dict]) -> List[Dict]:
|
|
95
|
+
"""Check color contrast ratios"""
|
|
96
|
+
violations = []
|
|
97
|
+
|
|
98
|
+
for elem in elements:
|
|
99
|
+
fg = elem.get("color", "")
|
|
100
|
+
bg = elem.get("background", "")
|
|
101
|
+
ratio = self._calculate_contrast_ratio(fg, bg)
|
|
102
|
+
|
|
103
|
+
if ratio < 4.5: # AA minimum for normal text
|
|
104
|
+
violations.append({
|
|
105
|
+
"guideline": "1.4.3",
|
|
106
|
+
"severity": "serious",
|
|
107
|
+
"element": elem.get("selector", "unknown"),
|
|
108
|
+
"message": f"Contrast ratio {ratio:.1f}:1 is below 4.5:1 minimum",
|
|
109
|
+
"fix": "Increase contrast between text and background",
|
|
110
|
+
"ratio": ratio,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return violations
|
|
114
|
+
|
|
115
|
+
def check_keyboard_navigation(self, page_content: Dict) -> List[Dict]:
|
|
116
|
+
"""Check keyboard accessibility"""
|
|
117
|
+
violations = []
|
|
118
|
+
|
|
119
|
+
# Elements that should be keyboard accessible
|
|
120
|
+
interactive_selectors = [
|
|
121
|
+
"a[href]", "button", "input", "select", "textarea",
|
|
122
|
+
"[tabindex]", "[role='button']", "[role='link']",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
non_focusable = page_content.get("non_focusable_interactive", [])
|
|
126
|
+
for elem in non_focusable:
|
|
127
|
+
violations.append({
|
|
128
|
+
"guideline": "2.1.1",
|
|
129
|
+
"severity": "critical",
|
|
130
|
+
"element": elem,
|
|
131
|
+
"message": "Interactive element not keyboard accessible",
|
|
132
|
+
"fix": "Add tabindex='0' and keyboard event handlers",
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return violations
|
|
136
|
+
|
|
137
|
+
def check_heading_hierarchy(self, headings: List[Dict]) -> List[Dict]:
|
|
138
|
+
"""Check heading structure"""
|
|
139
|
+
violations = []
|
|
140
|
+
prev_level = 0
|
|
141
|
+
|
|
142
|
+
for heading in headings:
|
|
143
|
+
level = heading.get("level", 0)
|
|
144
|
+
|
|
145
|
+
if level > prev_level + 1 and prev_level > 0:
|
|
146
|
+
violations.append({
|
|
147
|
+
"guideline": "1.3.1",
|
|
148
|
+
"severity": "moderate",
|
|
149
|
+
"element": heading.get("selector", ""),
|
|
150
|
+
"message": f"Heading level jumps from h{prev_level} to h{level}",
|
|
151
|
+
"fix": f"Use h{prev_level + 1} instead of h{level}",
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
prev_level = level
|
|
155
|
+
|
|
156
|
+
return violations
|
|
157
|
+
|
|
158
|
+
def _calculate_contrast_ratio(self, fg: str, bg: str) -> float:
|
|
159
|
+
"""Calculate WCAG contrast ratio between two colors"""
|
|
160
|
+
try:
|
|
161
|
+
fg_lum = self._relative_luminance(fg)
|
|
162
|
+
bg_lum = self._relative_luminance(bg)
|
|
163
|
+
|
|
164
|
+
lighter = max(fg_lum, bg_lum)
|
|
165
|
+
darker = min(fg_lum, bg_lum)
|
|
166
|
+
|
|
167
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
168
|
+
except Exception:
|
|
169
|
+
return 0.0
|
|
170
|
+
|
|
171
|
+
def _relative_luminance(self, color: str) -> float:
|
|
172
|
+
"""Calculate relative luminance of a color"""
|
|
173
|
+
try:
|
|
174
|
+
if color.startswith("#"):
|
|
175
|
+
r = int(color[1:3], 16) / 255
|
|
176
|
+
g = int(color[3:5], 16) / 255
|
|
177
|
+
b = int(color[5:7], 16) / 255
|
|
178
|
+
elif color.startswith("rgb"):
|
|
179
|
+
parts = color.replace("rgb(", "").replace(")", "").split(",")
|
|
180
|
+
r = int(parts[0].strip()) / 255
|
|
181
|
+
g = int(parts[1].strip()) / 255
|
|
182
|
+
b = int(parts[2].strip()) / 255
|
|
183
|
+
else:
|
|
184
|
+
return 0.0
|
|
185
|
+
|
|
186
|
+
def linearize(c):
|
|
187
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
188
|
+
|
|
189
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
190
|
+
except Exception:
|
|
191
|
+
return 0.0
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web Browser Testing Engine - Playwright-based web testing
|
|
3
|
+
Handles: smoke tests, functional tests, responsive, accessibility, cross-browser
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Dict, List, Optional, Any
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebTestEngine:
|
|
15
|
+
"""Web testing engine using Playwright"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, settings, db, privacy):
|
|
18
|
+
self.settings = settings
|
|
19
|
+
self.db = db
|
|
20
|
+
self.privacy = privacy
|
|
21
|
+
self.browser = None
|
|
22
|
+
self.page = None
|
|
23
|
+
self.results: Dict[str, Any] = {}
|
|
24
|
+
|
|
25
|
+
async def _setup_browser(self, browser_name: str = "chromium", headless: bool = True):
|
|
26
|
+
"""Initialize browser instance"""
|
|
27
|
+
try:
|
|
28
|
+
from playwright.async_api import async_playwright
|
|
29
|
+
self._playwright = await async_playwright().start()
|
|
30
|
+
|
|
31
|
+
browser_types = {
|
|
32
|
+
"chromium": self._playwright.chromium,
|
|
33
|
+
"firefox": self._playwright.firefox,
|
|
34
|
+
"webkit": self._playwright.webkit,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
browser_type = browser_types.get(browser_name, self._playwright.chromium)
|
|
38
|
+
self.browser = await browser_type.launch(headless=headless)
|
|
39
|
+
self.page = await self.browser.new_page()
|
|
40
|
+
|
|
41
|
+
except ImportError:
|
|
42
|
+
console.print("[yellow]⚠ Playwright not installed. Run: playwright install[/yellow]")
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
async def _teardown(self):
|
|
46
|
+
"""Clean up browser resources"""
|
|
47
|
+
if self.browser:
|
|
48
|
+
await self.browser.close()
|
|
49
|
+
if hasattr(self, '_playwright') and self._playwright:
|
|
50
|
+
await self._playwright.stop()
|
|
51
|
+
|
|
52
|
+
def run_tests(self, url: str, browser: str = "chromium", responsive: bool = True,
|
|
53
|
+
accessibility: bool = True, performance: bool = False,
|
|
54
|
+
coverage_target: int = 90, headless: bool = True) -> Dict:
|
|
55
|
+
"""Run all web tests"""
|
|
56
|
+
return asyncio.run(self._run_async(
|
|
57
|
+
url, browser, responsive, accessibility, performance, coverage_target, headless
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
async def _run_async(self, url, browser_name, responsive, accessibility,
|
|
61
|
+
performance, coverage_target, headless) -> Dict:
|
|
62
|
+
"""Async test execution"""
|
|
63
|
+
results = {
|
|
64
|
+
"total": 0,
|
|
65
|
+
"passed": 0,
|
|
66
|
+
"failed": 0,
|
|
67
|
+
"skipped": 0,
|
|
68
|
+
"coverage": 0,
|
|
69
|
+
"details": [],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await self._setup_browser(browser_name, headless)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
# Phase 1: Smoke Tests
|
|
76
|
+
smoke_results = await self._run_smoke_tests(url)
|
|
77
|
+
results["total"] += smoke_results["total"]
|
|
78
|
+
results["passed"] += smoke_results["passed"]
|
|
79
|
+
results["failed"] += smoke_results["failed"]
|
|
80
|
+
|
|
81
|
+
# Phase 2: Functional Tests
|
|
82
|
+
func_results = await self._run_functional_tests(url)
|
|
83
|
+
results["total"] += func_results["total"]
|
|
84
|
+
results["passed"] += func_results["passed"]
|
|
85
|
+
results["failed"] += func_results["failed"]
|
|
86
|
+
|
|
87
|
+
# Phase 3: Responsive Tests
|
|
88
|
+
if responsive:
|
|
89
|
+
resp_results = await self._run_responsive_tests(url)
|
|
90
|
+
results["total"] += resp_results["total"]
|
|
91
|
+
results["passed"] += resp_results["passed"]
|
|
92
|
+
results["failed"] += resp_results["failed"]
|
|
93
|
+
|
|
94
|
+
# Phase 4: Accessibility Tests
|
|
95
|
+
if accessibility:
|
|
96
|
+
a11y_results = await self._run_accessibility_tests(url)
|
|
97
|
+
results["total"] += a11y_results["total"]
|
|
98
|
+
results["passed"] += a11y_results["passed"]
|
|
99
|
+
results["failed"] += a11y_results["failed"]
|
|
100
|
+
|
|
101
|
+
# Phase 5: Performance Tests
|
|
102
|
+
if performance:
|
|
103
|
+
perf_results = await self._run_performance_tests(url)
|
|
104
|
+
results["total"] += perf_results["total"]
|
|
105
|
+
results["passed"] += perf_results["passed"]
|
|
106
|
+
results["failed"] += perf_results["failed"]
|
|
107
|
+
|
|
108
|
+
# Calculate coverage
|
|
109
|
+
if results["total"] > 0:
|
|
110
|
+
results["coverage"] = round((results["passed"] / results["total"]) * 100, 2)
|
|
111
|
+
results["coverage"] = min(results["coverage"], coverage_target)
|
|
112
|
+
|
|
113
|
+
finally:
|
|
114
|
+
await self._teardown()
|
|
115
|
+
|
|
116
|
+
# Save to local DB (privacy-first)
|
|
117
|
+
self.db.save_test_results({"engine": "web", "results": results, "url": url})
|
|
118
|
+
self.privacy.record_activity("web_test", results)
|
|
119
|
+
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
async def _run_smoke_tests(self, url: str) -> Dict:
|
|
123
|
+
"""Smoke tests: page loads, basic elements exist"""
|
|
124
|
+
results = {"total": 0, "passed": 0, "failed": 0}
|
|
125
|
+
|
|
126
|
+
# Test: Page loads
|
|
127
|
+
results["total"] += 1
|
|
128
|
+
try:
|
|
129
|
+
response = await self.page.goto(url, wait_until="networkidle")
|
|
130
|
+
if response and response.ok:
|
|
131
|
+
results["passed"] += 1
|
|
132
|
+
else:
|
|
133
|
+
results["failed"] += 1
|
|
134
|
+
except Exception:
|
|
135
|
+
results["failed"] += 1
|
|
136
|
+
|
|
137
|
+
# Test: Title exists
|
|
138
|
+
results["total"] += 1
|
|
139
|
+
try:
|
|
140
|
+
title = await self.page.title()
|
|
141
|
+
if title:
|
|
142
|
+
results["passed"] += 1
|
|
143
|
+
else:
|
|
144
|
+
results["failed"] += 1
|
|
145
|
+
except Exception:
|
|
146
|
+
results["failed"] += 1
|
|
147
|
+
|
|
148
|
+
# Test: Body exists
|
|
149
|
+
results["total"] += 1
|
|
150
|
+
try:
|
|
151
|
+
body = await self.page.query_selector("body")
|
|
152
|
+
if body:
|
|
153
|
+
results["passed"] += 1
|
|
154
|
+
else:
|
|
155
|
+
results["failed"] += 1
|
|
156
|
+
except Exception:
|
|
157
|
+
results["failed"] += 1
|
|
158
|
+
|
|
159
|
+
# Test: No console errors
|
|
160
|
+
results["total"] += 1
|
|
161
|
+
errors = []
|
|
162
|
+
self.page.on("console", lambda msg: errors.append(msg) if msg.type == "error" else None)
|
|
163
|
+
await self.page.reload()
|
|
164
|
+
if len(errors) == 0:
|
|
165
|
+
results["passed"] += 1
|
|
166
|
+
else:
|
|
167
|
+
results["failed"] += 1
|
|
168
|
+
|
|
169
|
+
return results
|
|
170
|
+
|
|
171
|
+
async def _run_functional_tests(self, url: str) -> Dict:
|
|
172
|
+
"""Functional tests: forms, links, navigation"""
|
|
173
|
+
results = {"total": 0, "passed": 0, "failed": 0}
|
|
174
|
+
|
|
175
|
+
# Test: All links are valid
|
|
176
|
+
results["total"] += 1
|
|
177
|
+
try:
|
|
178
|
+
links = await self.page.query_selector_all("a[href]")
|
|
179
|
+
broken_links = 0
|
|
180
|
+
for link in links[:20]: # Check first 20 links
|
|
181
|
+
href = await link.get_attribute("href")
|
|
182
|
+
if href and not href.startswith("#"):
|
|
183
|
+
try:
|
|
184
|
+
resp = await self.page.request.head(href if href.startswith("http") else f"{url}{href}")
|
|
185
|
+
if not resp.ok:
|
|
186
|
+
broken_links += 1
|
|
187
|
+
except Exception:
|
|
188
|
+
broken_links += 1
|
|
189
|
+
|
|
190
|
+
if broken_links == 0:
|
|
191
|
+
results["passed"] += 1
|
|
192
|
+
else:
|
|
193
|
+
results["failed"] += 1
|
|
194
|
+
except Exception:
|
|
195
|
+
results["failed"] += 1
|
|
196
|
+
|
|
197
|
+
# Test: Images have alt text
|
|
198
|
+
results["total"] += 1
|
|
199
|
+
try:
|
|
200
|
+
images = await self.page.query_selector_all("img")
|
|
201
|
+
missing_alt = 0
|
|
202
|
+
for img in images:
|
|
203
|
+
alt = await img.get_attribute("alt")
|
|
204
|
+
if not alt:
|
|
205
|
+
missing_alt += 1
|
|
206
|
+
|
|
207
|
+
if missing_alt == 0:
|
|
208
|
+
results["passed"] += 1
|
|
209
|
+
else:
|
|
210
|
+
results["failed"] += 1
|
|
211
|
+
except Exception:
|
|
212
|
+
results["failed"] += 1
|
|
213
|
+
|
|
214
|
+
# Test: Forms are functional
|
|
215
|
+
results["total"] += 1
|
|
216
|
+
try:
|
|
217
|
+
forms = await self.page.query_selector_all("form")
|
|
218
|
+
all_valid = True
|
|
219
|
+
for form in forms:
|
|
220
|
+
action = await form.get_attribute("action")
|
|
221
|
+
method = await form.get_attribute("method")
|
|
222
|
+
if not action:
|
|
223
|
+
all_valid = False
|
|
224
|
+
|
|
225
|
+
if all_valid:
|
|
226
|
+
results["passed"] += 1
|
|
227
|
+
else:
|
|
228
|
+
results["failed"] += 1
|
|
229
|
+
except Exception:
|
|
230
|
+
results["failed"] += 1
|
|
231
|
+
|
|
232
|
+
# Test: Navigation works
|
|
233
|
+
results["total"] += 1
|
|
234
|
+
try:
|
|
235
|
+
nav_links = await self.page.query_selector_all("nav a, header a")
|
|
236
|
+
if len(nav_links) > 0:
|
|
237
|
+
results["passed"] += 1
|
|
238
|
+
else:
|
|
239
|
+
results["failed"] += 1
|
|
240
|
+
except Exception:
|
|
241
|
+
results["failed"] += 1
|
|
242
|
+
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
async def _run_responsive_tests(self, url: str) -> Dict:
|
|
246
|
+
"""Responsive design tests across viewports"""
|
|
247
|
+
results = {"total": 0, "passed": 0, "failed": 0}
|
|
248
|
+
|
|
249
|
+
viewports = [
|
|
250
|
+
("Mobile", 375, 667),
|
|
251
|
+
("Tablet", 768, 1024),
|
|
252
|
+
("Desktop", 1920, 1080),
|
|
253
|
+
("Wide", 2560, 1440),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
for name, width, height in viewports:
|
|
257
|
+
results["total"] += 1
|
|
258
|
+
try:
|
|
259
|
+
await self.page.set_viewport_size({"width": width, "height": height})
|
|
260
|
+
await self.page.goto(url, wait_until="networkidle")
|
|
261
|
+
|
|
262
|
+
# Check no horizontal scroll
|
|
263
|
+
has_hscroll = await self.page.evaluate("""
|
|
264
|
+
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
265
|
+
""")
|
|
266
|
+
|
|
267
|
+
if not has_hscroll:
|
|
268
|
+
results["passed"] += 1
|
|
269
|
+
else:
|
|
270
|
+
results["failed"] += 1
|
|
271
|
+
except Exception:
|
|
272
|
+
results["failed"] += 1
|
|
273
|
+
|
|
274
|
+
return results
|
|
275
|
+
|
|
276
|
+
async def _run_accessibility_tests(self, url: str) -> Dict:
|
|
277
|
+
"""Accessibility (WCAG) tests"""
|
|
278
|
+
results = {"total": 0, "passed": 0, "failed": 0}
|
|
279
|
+
|
|
280
|
+
await self.page.goto(url, wait_until="networkidle")
|
|
281
|
+
|
|
282
|
+
# Test: ARIA labels
|
|
283
|
+
results["total"] += 1
|
|
284
|
+
try:
|
|
285
|
+
aria_issues = await self.page.evaluate("""
|
|
286
|
+
() => {
|
|
287
|
+
const buttons = document.querySelectorAll('button:not([aria-label]):not([aria-labelledby])');
|
|
288
|
+
const inputs = document.querySelectorAll('input:not([aria-label]):not([labelledby])');
|
|
289
|
+
return buttons.length + inputs.length;
|
|
290
|
+
}
|
|
291
|
+
""")
|
|
292
|
+
if aria_issues == 0:
|
|
293
|
+
results["passed"] += 1
|
|
294
|
+
else:
|
|
295
|
+
results["failed"] += 1
|
|
296
|
+
except Exception:
|
|
297
|
+
results["failed"] += 1
|
|
298
|
+
|
|
299
|
+
# Test: Color contrast (basic check)
|
|
300
|
+
results["total"] += 1
|
|
301
|
+
try:
|
|
302
|
+
contrast_ok = await self.page.evaluate("""
|
|
303
|
+
() => {
|
|
304
|
+
const elements = document.querySelectorAll('p, h1, h2, h3, span, a');
|
|
305
|
+
let issues = 0;
|
|
306
|
+
elements.forEach(el => {
|
|
307
|
+
const style = getComputedStyle(el);
|
|
308
|
+
const color = style.color;
|
|
309
|
+
const bg = style.backgroundColor;
|
|
310
|
+
if (color === bg) issues++;
|
|
311
|
+
});
|
|
312
|
+
return issues;
|
|
313
|
+
}
|
|
314
|
+
""")
|
|
315
|
+
if contrast_ok == 0:
|
|
316
|
+
results["passed"] += 1
|
|
317
|
+
else:
|
|
318
|
+
results["failed"] += 1
|
|
319
|
+
except Exception:
|
|
320
|
+
results["failed"] += 1
|
|
321
|
+
|
|
322
|
+
# Test: Heading hierarchy
|
|
323
|
+
results["total"] += 1
|
|
324
|
+
try:
|
|
325
|
+
heading_ok = await self.page.evaluate("""
|
|
326
|
+
() => {
|
|
327
|
+
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
328
|
+
let prev = 0;
|
|
329
|
+
for (const h of headings) {
|
|
330
|
+
const level = parseInt(h.tagName[1]);
|
|
331
|
+
if (level > prev + 1 && prev > 0) return false;
|
|
332
|
+
prev = level;
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
""")
|
|
337
|
+
if heading_ok:
|
|
338
|
+
results["passed"] += 1
|
|
339
|
+
else:
|
|
340
|
+
results["failed"] += 1
|
|
341
|
+
except Exception:
|
|
342
|
+
results["failed"] += 1
|
|
343
|
+
|
|
344
|
+
return results
|
|
345
|
+
|
|
346
|
+
async def _run_performance_tests(self, url: str) -> Dict:
|
|
347
|
+
"""Performance and load time tests"""
|
|
348
|
+
results = {"total": 0, "passed": 0, "failed": 0}
|
|
349
|
+
|
|
350
|
+
# Test: Page load time
|
|
351
|
+
results["total"] += 1
|
|
352
|
+
try:
|
|
353
|
+
import time
|
|
354
|
+
start = time.time()
|
|
355
|
+
await self.page.goto(url, wait_until="networkidle")
|
|
356
|
+
load_time = time.time() - start
|
|
357
|
+
|
|
358
|
+
if load_time < 5: # Under 5 seconds
|
|
359
|
+
results["passed"] += 1
|
|
360
|
+
else:
|
|
361
|
+
results["failed"] += 1
|
|
362
|
+
except Exception:
|
|
363
|
+
results["failed"] += 1
|
|
364
|
+
|
|
365
|
+
# Test: Core Web Vitals
|
|
366
|
+
results["total"] += 1
|
|
367
|
+
try:
|
|
368
|
+
metrics = await self.page.evaluate("""
|
|
369
|
+
() => {
|
|
370
|
+
const timing = performance.timing;
|
|
371
|
+
const domReady = timing.domContentLoadedEventEnd - timing.navigationStart;
|
|
372
|
+
const loadComplete = timing.loadEventEnd - timing.navigationStart;
|
|
373
|
+
return { domReady, loadComplete };
|
|
374
|
+
}
|
|
375
|
+
""")
|
|
376
|
+
if metrics.get("domReady", 99999) < 3000:
|
|
377
|
+
results["passed"] += 1
|
|
378
|
+
else:
|
|
379
|
+
results["failed"] += 1
|
|
380
|
+
except Exception:
|
|
381
|
+
results["failed"] += 1
|
|
382
|
+
|
|
383
|
+
return results
|
|
384
|
+
|
|
385
|
+
def execute_phase(self, phase: str) -> Dict:
|
|
386
|
+
"""Execute a specific phase (called by TestEngine)"""
|
|
387
|
+
return {"total": 0, "passed": 0, "failed": 0, "skipped": 0}
|