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.
Files changed (51) hide show
  1. nextog/__init__.py +4 -0
  2. nextog/cli.py +545 -0
  3. nextog/config/__init__.py +1 -0
  4. nextog/config/settings.py +132 -0
  5. nextog/core/__init__.py +1 -0
  6. nextog/core/engine.py +193 -0
  7. nextog/core/permissions.py +129 -0
  8. nextog/core/privacy.py +130 -0
  9. nextog/core/reporter.py +204 -0
  10. nextog/core/runner.py +236 -0
  11. nextog/data/__init__.py +1 -0
  12. nextog/data/local_db.py +367 -0
  13. nextog/data/models.py +72 -0
  14. nextog/data/sync.py +65 -0
  15. nextog/engines/__init__.py +1 -0
  16. nextog/engines/api/__init__.py +1 -0
  17. nextog/engines/api/graphql.py +54 -0
  18. nextog/engines/api/rest.py +346 -0
  19. nextog/engines/api/websocket.py +59 -0
  20. nextog/engines/embedded/__init__.py +1 -0
  21. nextog/engines/embedded/firmware.py +53 -0
  22. nextog/engines/embedded/hardware.py +330 -0
  23. nextog/engines/mobile/__init__.py +1 -0
  24. nextog/engines/mobile/android.py +333 -0
  25. nextog/engines/mobile/cross.py +48 -0
  26. nextog/engines/mobile/ios.py +46 -0
  27. nextog/engines/system/__init__.py +1 -0
  28. nextog/engines/system/load.py +121 -0
  29. nextog/engines/system/performance.py +128 -0
  30. nextog/engines/system/security.py +170 -0
  31. nextog/engines/web/__init__.py +1 -0
  32. nextog/engines/web/accessibility.py +191 -0
  33. nextog/engines/web/browser.py +387 -0
  34. nextog/engines/web/elements.py +285 -0
  35. nextog/engines/web/responsive.py +79 -0
  36. nextog/live/__init__.py +1 -0
  37. nextog/live/dashboard.py +30 -0
  38. nextog/live/panel.py +325 -0
  39. nextog/reports/__init__.py +1359 -0
  40. nextog/training/__init__.py +1 -0
  41. nextog/training/learner.py +269 -0
  42. nextog/training/patterns.py +102 -0
  43. nextog/utils/__init__.py +1 -0
  44. nextog/utils/helpers.py +91 -0
  45. nextog/utils/logger.py +37 -0
  46. nextog/utils/validators.py +98 -0
  47. nextog_cli-1.0.0.dist-info/METADATA +344 -0
  48. nextog_cli-1.0.0.dist-info/RECORD +51 -0
  49. nextog_cli-1.0.0.dist-info/WHEEL +5 -0
  50. nextog_cli-1.0.0.dist-info/entry_points.txt +2 -0
  51. 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}