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,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Element Detector - Detect and explain all system elements for testing
|
|
3
|
+
Identifies every element on a page/system and explains their purpose
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Dict, List, Optional, Any
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Element type descriptions and testing priorities
|
|
16
|
+
ELEMENT_DESCRIPTIONS = {
|
|
17
|
+
"button": "Interactive element that triggers actions. Test: click behavior, disabled state, accessibility.",
|
|
18
|
+
"input_text": "Text input field. Test: validation, max length, special characters, required field.",
|
|
19
|
+
"input_email": "Email input. Test: format validation, domain check, required field.",
|
|
20
|
+
"input_password": "Password field. Test: masking, strength validation, visibility toggle.",
|
|
21
|
+
"input_number": "Number input. Test: min/max range, step values, negative numbers.",
|
|
22
|
+
"input_checkbox": "Checkbox. Test: checked/unchecked state, group selection, form submission.",
|
|
23
|
+
"input_radio": "Radio button. Test: single selection, group behavior, default value.",
|
|
24
|
+
"select": "Dropdown select. Test: options, default value, multi-select, keyboard navigation.",
|
|
25
|
+
"textarea": "Multi-line text input. Test: resize, max length, line breaks, copy/paste.",
|
|
26
|
+
"link": "Navigation link. Test: href validity, target behavior, broken links, anchor links.",
|
|
27
|
+
"image": "Image element. Test: alt text, loading, broken src, responsive sizing, lazy loading.",
|
|
28
|
+
"video": "Video player. Test: controls, autoplay policy, accessibility, responsive.",
|
|
29
|
+
"form": "Form container. Test: submission, validation, reset, CSRF protection.",
|
|
30
|
+
"nav": "Navigation menu. Test: links, responsive menu, keyboard nav, ARIA labels.",
|
|
31
|
+
"header": "Page header. Test: logo link, navigation, sticky behavior, responsive.",
|
|
32
|
+
"footer": "Page footer. Test: links, copyright, social links, responsive.",
|
|
33
|
+
"modal": "Modal/dialog. Test: open/close, overlay click, escape key, focus trap, ARIA.",
|
|
34
|
+
"table": "Data table. Test: sorting, pagination, responsive, header association.",
|
|
35
|
+
"list": "List element. Test: ordering, completeness, interactive items.",
|
|
36
|
+
"iframe": "Embedded content. Test: src validity, sandbox attributes, responsive, security.",
|
|
37
|
+
"canvas": "Canvas element. Test: rendering, interaction, accessibility fallback.",
|
|
38
|
+
"svg": "SVG graphic. Test: scaling, accessibility title, interactive elements.",
|
|
39
|
+
"notification": "Toast/notification. Test: display, dismiss, auto-dismiss timing, accessibility.",
|
|
40
|
+
"tabs": "Tab component. Test: switching, keyboard nav, ARIA roles, content update.",
|
|
41
|
+
"accordion": "Accordion component. Test: expand/collapse, multiple open, keyboard nav.",
|
|
42
|
+
"carousel": "Carousel/slider. Test: navigation, autoplay, pause, touch, accessibility.",
|
|
43
|
+
"search": "Search component. Test: autocomplete, results, empty state, clear button.",
|
|
44
|
+
"breadcrumb": "Breadcrumb navigation. Test: hierarchy, links, current page indication.",
|
|
45
|
+
"pagination": "Pagination. Test: page navigation, limits, current page, disabled states.",
|
|
46
|
+
"loading": "Loading indicator. Test: display during load, accessibility, timing.",
|
|
47
|
+
"error": "Error message. Test: display, dismissal, clarity, accessibility.",
|
|
48
|
+
"tooltip": "Tooltip. Test: hover/focus trigger, content, positioning, accessibility.",
|
|
49
|
+
"progress": "Progress bar. Test: value accuracy, accessibility, animation.",
|
|
50
|
+
"sidebar": "Sidebar panel. Test: toggle, responsive, keyboard nav, content.",
|
|
51
|
+
"cookie_banner": "Cookie consent. Test: accept/reject, persistence, compliance.",
|
|
52
|
+
"back_to_top": "Scroll to top. Test: visibility, smooth scroll, accessibility.",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Testing priority mapping
|
|
56
|
+
PRIORITY_MAP = {
|
|
57
|
+
"button": "critical",
|
|
58
|
+
"input_text": "critical",
|
|
59
|
+
"input_email": "critical",
|
|
60
|
+
"input_password": "critical",
|
|
61
|
+
"form": "critical",
|
|
62
|
+
"link": "high",
|
|
63
|
+
"nav": "high",
|
|
64
|
+
"modal": "high",
|
|
65
|
+
"search": "high",
|
|
66
|
+
"select": "high",
|
|
67
|
+
"input_checkbox": "high",
|
|
68
|
+
"input_radio": "high",
|
|
69
|
+
"table": "high",
|
|
70
|
+
"tabs": "medium",
|
|
71
|
+
"accordion": "medium",
|
|
72
|
+
"carousel": "medium",
|
|
73
|
+
"image": "medium",
|
|
74
|
+
"video": "medium",
|
|
75
|
+
"header": "medium",
|
|
76
|
+
"footer": "medium",
|
|
77
|
+
"notification": "medium",
|
|
78
|
+
"tooltip": "low",
|
|
79
|
+
"progress": "low",
|
|
80
|
+
"breadcrumb": "low",
|
|
81
|
+
"pagination": "low",
|
|
82
|
+
"loading": "low",
|
|
83
|
+
"error": "low",
|
|
84
|
+
"sidebar": "low",
|
|
85
|
+
"cookie_banner": "low",
|
|
86
|
+
"back_to_top": "low",
|
|
87
|
+
"textarea": "medium",
|
|
88
|
+
"input_number": "medium",
|
|
89
|
+
"iframe": "high",
|
|
90
|
+
"canvas": "medium",
|
|
91
|
+
"svg": "low",
|
|
92
|
+
"list": "medium",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ElementDetector:
|
|
97
|
+
"""Detect and explain system elements for comprehensive testing"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, settings, db):
|
|
100
|
+
self.settings = settings
|
|
101
|
+
self.db = db
|
|
102
|
+
|
|
103
|
+
def detect(self, target: str, explain: bool = True) -> List[Dict]:
|
|
104
|
+
"""Detect all elements on a target page/system"""
|
|
105
|
+
return asyncio.run(self._detect_async(target, explain))
|
|
106
|
+
|
|
107
|
+
async def _detect_async(self, target: str, explain: bool) -> List[Dict]:
|
|
108
|
+
"""Async element detection"""
|
|
109
|
+
elements = []
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
from playwright.async_api import async_playwright
|
|
113
|
+
|
|
114
|
+
async with async_playwright() as p:
|
|
115
|
+
browser = await p.chromium.launch(headless=True)
|
|
116
|
+
page = await browser.new_page()
|
|
117
|
+
await page.goto(target, wait_until="networkidle")
|
|
118
|
+
|
|
119
|
+
# Detect all element types
|
|
120
|
+
for elem_type, selector in self._get_selectors().items():
|
|
121
|
+
found = await page.query_selector_all(selector)
|
|
122
|
+
|
|
123
|
+
for i, elem in enumerate(found):
|
|
124
|
+
element_info = {
|
|
125
|
+
"name": f"{elem_type}_{i+1}" if len(found) > 1 else elem_type,
|
|
126
|
+
"type": elem_type,
|
|
127
|
+
"selector": await self._get_unique_selector(elem, elem_type, i),
|
|
128
|
+
"description": ELEMENT_DESCRIPTIONS.get(elem_type, f"Element of type {elem_type}") if explain else "",
|
|
129
|
+
"priority": PRIORITY_MAP.get(elem_type, "medium"),
|
|
130
|
+
"visible": await elem.is_visible(),
|
|
131
|
+
"attributes": await self._get_attributes(elem),
|
|
132
|
+
}
|
|
133
|
+
elements.append(element_info)
|
|
134
|
+
|
|
135
|
+
await browser.close()
|
|
136
|
+
|
|
137
|
+
except ImportError:
|
|
138
|
+
console.print("[yellow]⚠ Playwright not installed. Using basic detection.[/yellow]")
|
|
139
|
+
elements = self._basic_detection(target)
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
console.print(f"[red]Error detecting elements: {e}[/red]")
|
|
143
|
+
|
|
144
|
+
# Sort by priority
|
|
145
|
+
priority_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
146
|
+
elements.sort(key=lambda x: priority_order.get(x["priority"], 99))
|
|
147
|
+
|
|
148
|
+
# Save to local DB
|
|
149
|
+
self.db.save_elements(target, elements)
|
|
150
|
+
|
|
151
|
+
return elements
|
|
152
|
+
|
|
153
|
+
def _get_selectors(self) -> Dict[str, str]:
|
|
154
|
+
"""CSS selectors for each element type"""
|
|
155
|
+
return {
|
|
156
|
+
"button": "button, [role='button'], input[type='button'], input[type='submit']",
|
|
157
|
+
"input_text": "input[type='text']:not([role='search'])",
|
|
158
|
+
"input_email": "input[type='email']",
|
|
159
|
+
"input_password": "input[type='password']",
|
|
160
|
+
"input_number": "input[type='number']",
|
|
161
|
+
"input_checkbox": "input[type='checkbox']",
|
|
162
|
+
"input_radio": "input[type='radio']",
|
|
163
|
+
"select": "select",
|
|
164
|
+
"textarea": "textarea",
|
|
165
|
+
"link": "a[href]",
|
|
166
|
+
"image": "img",
|
|
167
|
+
"video": "video",
|
|
168
|
+
"form": "form",
|
|
169
|
+
"nav": "nav",
|
|
170
|
+
"header": "header",
|
|
171
|
+
"footer": "footer",
|
|
172
|
+
"table": "table",
|
|
173
|
+
"list": "ul, ol",
|
|
174
|
+
"iframe": "iframe",
|
|
175
|
+
"svg": "svg",
|
|
176
|
+
"tabs": "[role='tablist'], .tabs, .tab-container",
|
|
177
|
+
"accordion": "[role='tablist'][aria-multiselectable], .accordion",
|
|
178
|
+
"modal": "[role='dialog'], .modal, [aria-modal='true']",
|
|
179
|
+
"search": "[role='search'], input[type='search'], .search",
|
|
180
|
+
"notification": "[role='alert'], .notification, .toast",
|
|
181
|
+
"tooltip": "[role='tooltip'], .tooltip, [data-tooltip]",
|
|
182
|
+
"breadcrumb": "nav[aria-label='breadcrumb'], .breadcrumb",
|
|
183
|
+
"pagination": "[role='navigation'][aria-label*='page'], .pagination",
|
|
184
|
+
"progress": "[role='progressbar'], progress, .progress",
|
|
185
|
+
"carousel": "[role='tabpanel'], .carousel, .slider",
|
|
186
|
+
"cookie_banner": ".cookie-banner, [id*='cookie'], [class*='consent']",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async def _get_unique_selector(self, elem, elem_type: str, index: int) -> str:
|
|
190
|
+
"""Generate a unique CSS selector for an element"""
|
|
191
|
+
try:
|
|
192
|
+
# Try ID first
|
|
193
|
+
elem_id = await elem.get_attribute("id")
|
|
194
|
+
if elem_id:
|
|
195
|
+
return f"#{elem_id}"
|
|
196
|
+
|
|
197
|
+
# Try class
|
|
198
|
+
classes = await elem.get_attribute("class")
|
|
199
|
+
if classes:
|
|
200
|
+
first_class = classes.split()[0]
|
|
201
|
+
return f".{first_class}"
|
|
202
|
+
|
|
203
|
+
# Try name attribute
|
|
204
|
+
name = await elem.get_attribute("name")
|
|
205
|
+
if name:
|
|
206
|
+
return f"{elem_type}[name='{name}']"
|
|
207
|
+
|
|
208
|
+
# Fallback
|
|
209
|
+
return f"{elem_type}:nth-of-type({index + 1})"
|
|
210
|
+
except Exception:
|
|
211
|
+
return f"{elem_type}[data-index='{index}']"
|
|
212
|
+
|
|
213
|
+
async def _get_attributes(self, elem) -> Dict[str, str]:
|
|
214
|
+
"""Get relevant attributes of an element"""
|
|
215
|
+
attrs = {}
|
|
216
|
+
for attr in ["id", "class", "name", "type", "placeholder", "aria-label", "role", "href", "src", "alt"]:
|
|
217
|
+
try:
|
|
218
|
+
value = await elem.get_attribute(attr)
|
|
219
|
+
if value:
|
|
220
|
+
attrs[attr] = value
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
return attrs
|
|
224
|
+
|
|
225
|
+
def _basic_detection(self, target: str) -> List[Dict]:
|
|
226
|
+
"""Basic element detection without Playwright"""
|
|
227
|
+
import httpx
|
|
228
|
+
|
|
229
|
+
elements = []
|
|
230
|
+
try:
|
|
231
|
+
response = httpx.get(target, follow_redirects=True, timeout=10)
|
|
232
|
+
html = response.text
|
|
233
|
+
|
|
234
|
+
# Parse basic elements from HTML
|
|
235
|
+
import re
|
|
236
|
+
|
|
237
|
+
# Find buttons
|
|
238
|
+
buttons = re.findall(r'<button[^>]*>', html)
|
|
239
|
+
for i, btn in enumerate(buttons):
|
|
240
|
+
elements.append({
|
|
241
|
+
"name": f"button_{i+1}",
|
|
242
|
+
"type": "button",
|
|
243
|
+
"selector": f"button:nth-of-type({i+1})",
|
|
244
|
+
"description": ELEMENT_DESCRIPTIONS.get("button", ""),
|
|
245
|
+
"priority": "critical",
|
|
246
|
+
"visible": True,
|
|
247
|
+
"attributes": {},
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
# Find inputs
|
|
251
|
+
inputs = re.findall(r'<input[^>]*type=["\']([^"\']*)["\'][^>]*>', html)
|
|
252
|
+
for i, input_type in enumerate(inputs):
|
|
253
|
+
elem_type = f"input_{input_type}" if input_type in ["text", "email", "password", "number", "checkbox", "radio"] else "input_text"
|
|
254
|
+
elements.append({
|
|
255
|
+
"name": f"input_{input_type}_{i+1}",
|
|
256
|
+
"type": elem_type,
|
|
257
|
+
"selector": f"input[type='{input_type}']:nth-of-type({i+1})",
|
|
258
|
+
"description": ELEMENT_DESCRIPTIONS.get(elem_type, f"Input of type {input_type}"),
|
|
259
|
+
"priority": PRIORITY_MAP.get(elem_type, "medium"),
|
|
260
|
+
"visible": True,
|
|
261
|
+
"attributes": {"type": input_type},
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
# Find links
|
|
265
|
+
links = re.findall(r'<a[^>]*href=["\']([^"\']*)["\'][^>]*>', html)
|
|
266
|
+
for i, href in enumerate(links[:20]):
|
|
267
|
+
elements.append({
|
|
268
|
+
"name": f"link_{i+1}",
|
|
269
|
+
"type": "link",
|
|
270
|
+
"selector": f"a:nth-of-type({i+1})",
|
|
271
|
+
"description": ELEMENT_DESCRIPTIONS.get("link", ""),
|
|
272
|
+
"priority": "high",
|
|
273
|
+
"visible": True,
|
|
274
|
+
"attributes": {"href": href},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
console.print(f"[red]Basic detection error: {e}[/red]")
|
|
279
|
+
|
|
280
|
+
return elements
|
|
281
|
+
|
|
282
|
+
def save_elements(self, elements: List[Dict], output_path: str):
|
|
283
|
+
"""Save detected elements to file"""
|
|
284
|
+
import json
|
|
285
|
+
Path(output_path).write_text(json.dumps(elements, indent=2))
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Responsive Testing Module
|
|
3
|
+
Tests across multiple viewports and device types
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Viewport:
|
|
12
|
+
name: str
|
|
13
|
+
width: int
|
|
14
|
+
height: int
|
|
15
|
+
device_scale: float = 1.0
|
|
16
|
+
is_mobile: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Standard viewports for testing
|
|
20
|
+
VIEWPORTS = [
|
|
21
|
+
Viewport("iPhone SE", 375, 667, 2.0, True),
|
|
22
|
+
Viewport("iPhone 12", 390, 844, 3.0, True),
|
|
23
|
+
Viewport("iPhone 14 Pro Max", 430, 932, 3.0, True),
|
|
24
|
+
Viewport("iPad Mini", 768, 1024, 2.0, False),
|
|
25
|
+
Viewport("iPad Pro", 1024, 1366, 2.0, False),
|
|
26
|
+
Viewport("Desktop HD", 1366, 768, 1.0, False),
|
|
27
|
+
Viewport("Desktop FHD", 1920, 1080, 1.0, False),
|
|
28
|
+
Viewport("Desktop 4K", 3840, 2160, 1.5, False),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResponsiveTestEngine:
|
|
33
|
+
"""Test responsive design across viewports"""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.breakpoints = {
|
|
37
|
+
"mobile": (0, 480),
|
|
38
|
+
"tablet": (481, 1024),
|
|
39
|
+
"desktop": (1025, 1920),
|
|
40
|
+
"wide": (1921, 9999),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def get_test_plan(self, url: str) -> List[Dict]:
|
|
44
|
+
"""Generate responsive test plan"""
|
|
45
|
+
tests = []
|
|
46
|
+
|
|
47
|
+
for viewport in VIEWPORTS:
|
|
48
|
+
tests.append({
|
|
49
|
+
"name": f"{viewport.name} - Layout",
|
|
50
|
+
"viewport": viewport,
|
|
51
|
+
"type": "layout",
|
|
52
|
+
"checks": [
|
|
53
|
+
"no_horizontal_scroll",
|
|
54
|
+
"content_visible",
|
|
55
|
+
"no_overflow",
|
|
56
|
+
"images_responsive",
|
|
57
|
+
"text_readable",
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
tests.append({
|
|
62
|
+
"name": f"{viewport.name} - Touch Targets",
|
|
63
|
+
"viewport": viewport,
|
|
64
|
+
"type": "touch",
|
|
65
|
+
"checks": [
|
|
66
|
+
"buttons_min_44px" if viewport.is_mobile else "buttons_min_32px",
|
|
67
|
+
"links_tappable",
|
|
68
|
+
"form_inputs_usable",
|
|
69
|
+
],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return tests
|
|
73
|
+
|
|
74
|
+
def analyze_breakpoint(self, viewport: Viewport) -> str:
|
|
75
|
+
"""Determine which breakpoint a viewport falls into"""
|
|
76
|
+
for bp_name, (min_w, max_w) in self.breakpoints.items():
|
|
77
|
+
if min_w <= viewport.width <= max_w:
|
|
78
|
+
return bp_name
|
|
79
|
+
return "unknown"
|
nextog/live/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Live panel and dashboard modules"""
|
nextog/live/dashboard.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Dashboard utilities"""
|
|
2
|
+
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DashboardHelper:
|
|
7
|
+
"""Helper functions for dashboard"""
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def format_coverage(coverage: float) -> Dict:
|
|
11
|
+
"""Format coverage for display"""
|
|
12
|
+
color = "red" if coverage < 40 else "yellow" if coverage < 70 else "green"
|
|
13
|
+
status = "Low" if coverage < 40 else "Medium" if coverage < 70 else "Good" if coverage < 85 else "Excellent"
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
"value": round(coverage, 1),
|
|
17
|
+
"color": color,
|
|
18
|
+
"status": status,
|
|
19
|
+
"bar_width": min(coverage, 90), # Max 90%
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def format_duration(seconds: float) -> str:
|
|
24
|
+
"""Format duration for display"""
|
|
25
|
+
if seconds < 60:
|
|
26
|
+
return f"{seconds:.1f}s"
|
|
27
|
+
elif seconds < 3600:
|
|
28
|
+
return f"{seconds/60:.1f}m"
|
|
29
|
+
else:
|
|
30
|
+
return f"{seconds/3600:.1f}h"
|