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 @@
1
+ """Training and auto-learning modules"""
@@ -0,0 +1,269 @@
1
+ """
2
+ Auto-Training Engine - Learns from user patterns automatically
3
+ All training happens locally - no external data transfer
4
+ """
5
+
6
+ import json
7
+ import time
8
+ from typing import Dict, List, Any, Optional
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from collections import Counter
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+
17
+ class TrainingEngine:
18
+ """Auto-trains from user interaction patterns to optimize testing"""
19
+
20
+ def __init__(self, db, settings):
21
+ self.db = db
22
+ self.settings = settings
23
+ self.patterns: List[Dict] = []
24
+ self.test_history: List[Dict] = []
25
+
26
+ def get_status(self) -> Dict:
27
+ """Get training status"""
28
+ pattern_count = self.db.count_patterns()
29
+ test_count = self.db.count_test_results()
30
+ last_trained = self.db.get_metadata("last_trained") or "Never"
31
+ accuracy = self.db.get_metadata("model_accuracy") or 0
32
+
33
+ return {
34
+ "patterns": pattern_count,
35
+ "optimized": self.db.count_optimized_tests(),
36
+ "last_trained": last_trained,
37
+ "accuracy": accuracy,
38
+ }
39
+
40
+ def train(self, iterations: int = 100):
41
+ """Run training cycle on user data"""
42
+ console.print("[bold]Loading user data (local only)...[/bold]")
43
+
44
+ # Load all local test history
45
+ self.test_history = self.db.get_all_test_results()
46
+
47
+ # Phase 1: Extract patterns
48
+ console.print(" → Extracting patterns...")
49
+ self._extract_patterns()
50
+
51
+ # Phase 2: Build frequency model
52
+ console.print(" → Building frequency model...")
53
+ self._build_frequency_model()
54
+
55
+ # Phase 3: Identify common failures
56
+ console.print(" → Identifying failure patterns...")
57
+ self._identify_failure_patterns()
58
+
59
+ # Phase 4: Optimize test order
60
+ console.print(" → Optimizing test order...")
61
+ self._optimize_test_order()
62
+
63
+ # Phase 5: Generate suggestions
64
+ console.print(" → Generating test suggestions...")
65
+ self._generate_suggestions()
66
+
67
+ # Save training results
68
+ self._save_training_results(iterations)
69
+
70
+ def _extract_patterns(self):
71
+ """Extract patterns from test history"""
72
+ pattern_types = [
73
+ "element_click", "form_submit", "navigation",
74
+ "api_call", "error_occurred", "timeout",
75
+ "element_not_found", "assertion_failed",
76
+ ]
77
+
78
+ for test in self.test_history:
79
+ results = test.get("results", {})
80
+
81
+ for pattern_type in pattern_types:
82
+ occurrences = self._count_pattern(test, pattern_type)
83
+
84
+ if occurrences > 0:
85
+ pattern = {
86
+ "type": pattern_type,
87
+ "frequency": occurrences,
88
+ "context": test.get("engine", "unknown"),
89
+ "timestamp": test.get("timestamp", ""),
90
+ "related_elements": self._extract_elements(test),
91
+ }
92
+ self.patterns.append(pattern)
93
+ self.db.save_pattern(pattern)
94
+
95
+ def _build_frequency_model(self):
96
+ """Build frequency model of user actions"""
97
+ action_counts = Counter()
98
+
99
+ for test in self.test_history:
100
+ engine = test.get("engine", "unknown")
101
+ action_counts[engine] += 1
102
+
103
+ # Count test types
104
+ categories = test.get("results", {}).get("categories", {})
105
+ for cat_name, cat_data in categories.items():
106
+ action_counts[f"{engine}:{cat_name}"] += cat_data.get("total", 0)
107
+
108
+ # Save frequency model
109
+ model = {
110
+ "action_frequencies": dict(action_counts),
111
+ "total_tests": len(self.test_history),
112
+ "most_used_engine": action_counts.most_common(1)[0][0] if action_counts else "none",
113
+ }
114
+
115
+ self.db.save_metadata("frequency_model", model)
116
+
117
+ def _identify_failure_patterns(self):
118
+ """Identify common failure patterns"""
119
+ failure_patterns = []
120
+
121
+ for test in self.test_history:
122
+ results = test.get("results", {})
123
+ details = results.get("details", [])
124
+
125
+ for detail in details:
126
+ if isinstance(detail, dict) and detail.get("status") == "failed":
127
+ pattern = {
128
+ "engine": test.get("engine", "unknown"),
129
+ "test_name": detail.get("name", "unknown"),
130
+ "error": detail.get("error", ""),
131
+ "element": detail.get("element", ""),
132
+ "timestamp": test.get("timestamp", ""),
133
+ }
134
+ failure_patterns.append(pattern)
135
+
136
+ # Group similar failures
137
+ error_groups = {}
138
+ for fp in failure_patterns:
139
+ key = fp["error"][:50] # Group by error prefix
140
+ if key not in error_groups:
141
+ error_groups[key] = []
142
+ error_groups[key].append(fp)
143
+
144
+ # Save failure patterns
145
+ self.db.save_metadata("failure_patterns", {
146
+ "groups": {k: len(v) for k, v in error_groups.items()},
147
+ "top_failures": sorted(error_groups.items(), key=lambda x: len(x[1]), reverse=True)[:10],
148
+ })
149
+
150
+ def _optimize_test_order(self):
151
+ """Optimize test execution order based on failure history"""
152
+ # Tests that fail more often should run earlier (fail fast)
153
+ failure_count = Counter()
154
+
155
+ for test in self.test_history:
156
+ results = test.get("results", {})
157
+ details = results.get("details", [])
158
+
159
+ for detail in details:
160
+ if isinstance(detail, dict) and detail.get("status") == "failed":
161
+ failure_count[detail.get("name", "unknown")] += 1
162
+
163
+ # Save optimized order
164
+ optimized_order = [name for name, _ in failure_count.most_common()]
165
+ self.db.save_metadata("optimized_test_order", optimized_order)
166
+
167
+ def _generate_suggestions(self):
168
+ """Generate test suggestions based on patterns"""
169
+ suggestions = []
170
+
171
+ # If many form failures, suggest more form tests
172
+ form_failures = sum(1 for p in self.patterns if "form" in p.get("type", ""))
173
+ if form_failures > 5:
174
+ suggestions.append({
175
+ "type": "add_tests",
176
+ "area": "forms",
177
+ "reason": f"High form failure rate ({form_failures} patterns)",
178
+ "priority": "high",
179
+ })
180
+
181
+ # If many timeouts, suggest performance tests
182
+ timeout_patterns = sum(1 for p in self.patterns if p.get("type") == "timeout")
183
+ if timeout_patterns > 3:
184
+ suggestions.append({
185
+ "type": "add_tests",
186
+ "area": "performance",
187
+ "reason": f"Frequent timeouts ({timeout_patterns} occurrences)",
188
+ "priority": "high",
189
+ })
190
+
191
+ # If many element_not_found, suggest element coverage review
192
+ not_found = sum(1 for p in self.patterns if p.get("type") == "element_not_found")
193
+ if not_found > 5:
194
+ suggestions.append({
195
+ "type": "review",
196
+ "area": "elements",
197
+ "reason": f"Elements frequently not found ({not_found} times)",
198
+ "priority": "medium",
199
+ })
200
+
201
+ self.db.save_metadata("suggestions", suggestions)
202
+
203
+ def _count_pattern(self, test: Dict, pattern_type: str) -> int:
204
+ """Count occurrences of a pattern in a test result"""
205
+ results = test.get("results", {})
206
+ details = results.get("details", [])
207
+ count = 0
208
+
209
+ for detail in details:
210
+ if isinstance(detail, dict):
211
+ if pattern_type in str(detail).lower():
212
+ count += 1
213
+
214
+ return count
215
+
216
+ def _extract_elements(self, test: Dict) -> List[str]:
217
+ """Extract element names from test results"""
218
+ elements = []
219
+ details = test.get("results", {}).get("details", [])
220
+
221
+ for detail in details:
222
+ if isinstance(detail, dict) and "element" in detail:
223
+ elements.append(detail["element"])
224
+
225
+ return elements
226
+
227
+ def _save_training_results(self, iterations: int):
228
+ """Save training results"""
229
+ accuracy = self._calculate_accuracy()
230
+
231
+ self.db.save_metadata("last_trained", datetime.now().isoformat())
232
+ self.db.save_metadata("model_accuracy", accuracy)
233
+ self.db.save_metadata("training_iterations", iterations)
234
+
235
+ def _calculate_accuracy(self) -> float:
236
+ """Calculate model accuracy from pattern matching"""
237
+ if not self.test_history:
238
+ return 0.0
239
+
240
+ correct_predictions = 0
241
+ total_predictions = 0
242
+
243
+ for i, test in enumerate(self.test_history[1:], 1):
244
+ # Simple prediction: based on previous test patterns
245
+ prev_test = self.test_history[i - 1]
246
+ prev_engine = prev_test.get("engine", "")
247
+ curr_engine = test.get("engine", "")
248
+
249
+ total_predictions += 1
250
+ if prev_engine == curr_engine:
251
+ correct_predictions += 1
252
+
253
+ if total_predictions == 0:
254
+ return 0.0
255
+
256
+ return round((correct_predictions / total_predictions) * 100, 2)
257
+
258
+ def optimize_tests(self):
259
+ """Apply learned optimizations to test suite"""
260
+ console.print("[bold]Applying optimizations...[/bold]")
261
+
262
+ optimized_order = self.db.get_metadata("optimized_test_order") or []
263
+ suggestions = self.db.get_metadata("suggestions") or []
264
+
265
+ console.print(f" → Reordered {len(optimized_order)} tests for fail-fast execution")
266
+ console.print(f" → Generated {len(suggestions)} test suggestions")
267
+
268
+ for suggestion in suggestions:
269
+ console.print(f" → [{suggestion['priority']}] {suggestion['reason']}")
@@ -0,0 +1,102 @@
1
+ """Pattern Recognition for Test Optimization"""
2
+
3
+ from typing import Dict, List, Any
4
+ from collections import Counter, defaultdict
5
+
6
+
7
+ class PatternRecognizer:
8
+ """Recognizes patterns in test execution for optimization"""
9
+
10
+ def __init__(self):
11
+ self.patterns: List[Dict] = []
12
+ self.sequence_patterns: List[Dict] = []
13
+ self.failure_correlations: Dict[str, List[str]] = defaultdict(list)
14
+
15
+ def analyze_sequences(self, test_results: List[Dict]) -> List[Dict]:
16
+ """Analyze sequences of test executions to find patterns"""
17
+ sequences = []
18
+
19
+ for i in range(len(test_results) - 2):
20
+ seq = [
21
+ test_results[i].get("engine", ""),
22
+ test_results[i+1].get("engine", ""),
23
+ test_results[i+2].get("engine", ""),
24
+ ]
25
+ sequences.append(" → ".join(seq))
26
+
27
+ # Find most common sequences
28
+ seq_counts = Counter(sequences)
29
+
30
+ return [
31
+ {"pattern": pattern, "count": count, "confidence": count / len(sequences) * 100}
32
+ for pattern, count in seq_counts.most_common(10)
33
+ ]
34
+
35
+ def find_failure_correlations(self, test_results: List[Dict]) -> Dict[str, List[str]]:
36
+ """Find correlations between test failures"""
37
+ failures_by_engine = defaultdict(list)
38
+
39
+ for test in test_results:
40
+ engine = test.get("engine", "unknown")
41
+ results = test.get("results", {})
42
+
43
+ if results.get("failed", 0) > 0:
44
+ failures_by_engine[engine].append(test.get("timestamp", ""))
45
+
46
+ # Find correlations
47
+ correlations = {}
48
+ engines = list(failures_by_engine.keys())
49
+
50
+ for i, eng1 in enumerate(engines):
51
+ for eng2 in engines[i+1:]:
52
+ # Check if failures happen at similar times
53
+ times1 = set(failures_by_engine[eng1])
54
+ times2 = set(failures_by_engine[eng2])
55
+
56
+ overlap = times1 & times2
57
+ if len(overlap) > 0:
58
+ correlations[f"{eng1}↔{eng2}"] = list(overlap)
59
+
60
+ return correlations
61
+
62
+ def predict_failures(self, test_results: List[Dict], next_test: Dict) -> float:
63
+ """Predict likelihood of failure for next test based on patterns"""
64
+ if not test_results:
65
+ return 0.0
66
+
67
+ # Simple prediction based on recent failure rate
68
+ recent = test_results[-10:] # Last 10 tests
69
+ failures = sum(1 for t in recent if t.get("results", {}).get("failed", 0) > 0)
70
+
71
+ # Same engine failure rate
72
+ same_engine = [t for t in recent if t.get("engine") == next_test.get("engine")]
73
+ if same_engine:
74
+ engine_failures = sum(1 for t in same_engine if t.get("results", {}).get("failed", 0) > 0)
75
+ return round(engine_failures / len(same_engine) * 100, 2)
76
+
77
+ return round(failures / len(recent) * 100, 2)
78
+
79
+ def suggest_test_coverage(self, test_results: List[Dict]) -> List[Dict]:
80
+ """Suggest areas that need more test coverage"""
81
+ coverage_by_area = defaultdict(lambda: {"tested": 0, "total": 0})
82
+
83
+ for test in test_results:
84
+ engine = test.get("engine", "unknown")
85
+ results = test.get("results", {})
86
+
87
+ coverage_by_area[engine]["tested"] += results.get("passed", 0)
88
+ coverage_by_area[engine]["total"] += results.get("total", 0)
89
+
90
+ suggestions = []
91
+ for area, data in coverage_by_area.items():
92
+ coverage = (data["tested"] / data["total"] * 100) if data["total"] > 0 else 0
93
+
94
+ if coverage < 60:
95
+ suggestions.append({
96
+ "area": area,
97
+ "current_coverage": round(coverage, 2),
98
+ "recommended": "Add more test cases",
99
+ "priority": "high" if coverage < 40 else "medium",
100
+ })
101
+
102
+ return suggestions
@@ -0,0 +1 @@
1
+ """Utility modules"""
@@ -0,0 +1,91 @@
1
+ """Helper utilities for nextOG CLI"""
2
+
3
+ import re
4
+ import hashlib
5
+ from typing import Any, Dict, List
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+
9
+
10
+ def validate_url(url: str) -> bool:
11
+ """Validate URL format"""
12
+ pattern = re.compile(
13
+ r'^https?://'
14
+ r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
15
+ r'localhost|'
16
+ r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
17
+ r'(?::\d+)?'
18
+ r'(?:/?|[/?]\S+)$', re.IGNORECASE
19
+ )
20
+ return bool(pattern.match(url))
21
+
22
+
23
+ def validate_email(email: str) -> bool:
24
+ """Validate email format"""
25
+ pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
26
+ return bool(pattern.match(email))
27
+
28
+
29
+ def format_timestamp(dt: datetime = None) -> str:
30
+ """Format timestamp for display"""
31
+ if dt is None:
32
+ dt = datetime.now()
33
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
34
+
35
+
36
+ def generate_id(prefix: str = "test") -> str:
37
+ """Generate unique test ID"""
38
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
39
+ hash_suffix = hashlib.md5(str(datetime.now().microsecond).encode()).hexdigest()[:6]
40
+ return f"{prefix}_{timestamp}_{hash_suffix}"
41
+
42
+
43
+ def format_bytes(size: int) -> str:
44
+ """Format bytes to human readable"""
45
+ for unit in ["B", "KB", "MB", "GB"]:
46
+ if size < 1024:
47
+ return f"{size:.1f} {unit}"
48
+ size /= 1024
49
+ return f"{size:.1f} TB"
50
+
51
+
52
+ def truncate(text: str, max_length: int = 100) -> str:
53
+ """Truncate text with ellipsis"""
54
+ if len(text) <= max_length:
55
+ return text
56
+ return text[:max_length - 3] + "..."
57
+
58
+
59
+ def ensure_dir(path: Path) -> Path:
60
+ """Ensure directory exists"""
61
+ path.mkdir(parents=True, exist_ok=True)
62
+ return path
63
+
64
+
65
+ def safe_filename(name: str) -> str:
66
+ """Convert string to safe filename"""
67
+ return re.sub(r'[^\w\-_.]', '_', name)
68
+
69
+
70
+ def merge_dicts(base: Dict, override: Dict) -> Dict:
71
+ """Deep merge two dictionaries"""
72
+ result = dict(base)
73
+ for key, value in override.items():
74
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
75
+ result[key] = merge_dicts(result[key], value)
76
+ else:
77
+ result[key] = value
78
+ return result
79
+
80
+
81
+ def parse_duration(duration_str: str) -> float:
82
+ """Parse duration string to seconds"""
83
+ units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
84
+
85
+ match = re.match(r'^(\d+(?:\.\d+)?)\s*([smhd]?)$', duration_str.lower())
86
+ if match:
87
+ value = float(match.group(1))
88
+ unit = match.group(2) or "s"
89
+ return value * units.get(unit, 1)
90
+
91
+ return 0.0
nextog/utils/logger.py ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ Logging module for nextOG CLI
3
+ """
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from rich.logging import RichHandler
8
+
9
+
10
+ def get_logger(name: str = "nextog", verbose: bool = False) -> logging.Logger:
11
+ """Get configured logger"""
12
+ logger = logging.getLogger(name)
13
+
14
+ if not logger.handlers:
15
+ # Console handler with rich formatting
16
+ console_handler = RichHandler(
17
+ rich_tracebacks=True,
18
+ markup=True,
19
+ show_path=verbose,
20
+ )
21
+ console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
22
+
23
+ # File handler
24
+ log_dir = Path.home() / ".nextog" / "logs"
25
+ log_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ file_handler = logging.FileHandler(log_dir / "nextog.log")
28
+ file_handler.setLevel(logging.DEBUG)
29
+ file_handler.setFormatter(
30
+ logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s")
31
+ )
32
+
33
+ logger.addHandler(console_handler)
34
+ logger.addHandler(file_handler)
35
+ logger.setLevel(logging.DEBUG)
36
+
37
+ return logger
@@ -0,0 +1,98 @@
1
+ """Input validators for nextOG CLI"""
2
+
3
+ import re
4
+ from typing import Optional
5
+ from pathlib import Path
6
+
7
+
8
+ class ValidationError(Exception):
9
+ """Custom validation error"""
10
+ pass
11
+
12
+
13
+ def validate_project_name(name: str) -> str:
14
+ """Validate project name"""
15
+ if not name:
16
+ raise ValidationError("Project name cannot be empty")
17
+ if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
18
+ raise ValidationError("Project name must start with letter and contain only alphanumeric, -, _")
19
+ if len(name) > 64:
20
+ raise ValidationError("Project name must be 64 characters or less")
21
+ return name
22
+
23
+
24
+ def validate_url(url: str) -> str:
25
+ """Validate URL"""
26
+ if not url:
27
+ raise ValidationError("URL cannot be empty")
28
+ if not re.match(r'^https?://', url):
29
+ raise ValidationError("URL must start with http:// or https://")
30
+ return url
31
+
32
+
33
+ def validate_port(port: int) -> int:
34
+ """Validate port number"""
35
+ if not isinstance(port, int):
36
+ raise ValidationError("Port must be an integer")
37
+ if port < 1 or port > 65535:
38
+ raise ValidationError("Port must be between 1 and 65535")
39
+ return port
40
+
41
+
42
+ def validate_coverage_target(target: int) -> int:
43
+ """Validate coverage target"""
44
+ if target < 0 or target > 100:
45
+ raise ValidationError("Coverage target must be between 0 and 100")
46
+ return target
47
+
48
+
49
+ def validate_role(role: str) -> str:
50
+ """Validate user role"""
51
+ valid_roles = ["admin", "tester", "viewer", "ci"]
52
+ if role not in valid_roles:
53
+ raise ValidationError(f"Role must be one of: {valid_roles}")
54
+ return role
55
+
56
+
57
+ def validate_file_exists(path: str) -> Path:
58
+ """Validate file exists"""
59
+ p = Path(path)
60
+ if not p.exists():
61
+ raise ValidationError(f"File not found: {path}")
62
+ if not p.is_file():
63
+ raise ValidationError(f"Not a file: {path}")
64
+ return p
65
+
66
+
67
+ def validate_dir_exists(path: str) -> Path:
68
+ """Validate directory exists"""
69
+ p = Path(path)
70
+ if not p.exists():
71
+ raise ValidationError(f"Directory not found: {path}")
72
+ if not p.is_dir():
73
+ raise ValidationError(f"Not a directory: {path}")
74
+ return p
75
+
76
+
77
+ def validate_browser(browser: str) -> str:
78
+ """Validate browser name"""
79
+ valid_browsers = ["chromium", "firefox", "webkit"]
80
+ if browser not in valid_browsers:
81
+ raise ValidationError(f"Browser must be one of: {valid_browsers}")
82
+ return browser
83
+
84
+
85
+ def validate_platform(platform: str) -> str:
86
+ """Validate platform name"""
87
+ valid_platforms = ["android", "ios"]
88
+ if platform not in valid_platforms:
89
+ raise ValidationError(f"Platform must be one of: {valid_platforms}")
90
+ return platform
91
+
92
+
93
+ def validate_protocol(protocol: str) -> str:
94
+ """Validate protocol"""
95
+ valid_protocols = ["mqtt", "coap", "http", "serial"]
96
+ if protocol not in valid_protocols:
97
+ raise ValidationError(f"Protocol must be one of: {valid_protocols}")
98
+ return protocol