portacode 0.3.4.dev0__py3-none-any.whl ā 1.4.11.dev0__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info ā portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info ā portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.4.dev0.dist-info ā portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Hierarchical test runner with dependency management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import deque, defaultdict
|
|
5
|
+
from typing import List, Dict, Set, Optional, Any
|
|
6
|
+
import logging
|
|
7
|
+
import traceback
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .base_test import BaseTest, TestResult
|
|
12
|
+
from .runner import TestRunner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HierarchicalTestRunner(TestRunner):
|
|
16
|
+
"""Test runner that handles hierarchical dependencies between tests."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_path: str = ".", output_dir: str = "test_results", clear_results: bool = False):
|
|
19
|
+
super().__init__(base_path, output_dir, clear_results)
|
|
20
|
+
self.dependency_graph: Dict[str, List[str]] = {}
|
|
21
|
+
self.test_states: Dict[str, TestResult] = {}
|
|
22
|
+
self.pending_teardowns: Dict[str, BaseTest] = {} # Tests waiting for teardown
|
|
23
|
+
self.dependents_map: Dict[str, Set[str]] = {} # Map test_name -> set of dependent test names
|
|
24
|
+
|
|
25
|
+
def build_dependency_graph(self, tests: List[BaseTest]) -> Dict[str, List[str]]:
|
|
26
|
+
"""Build dependency graph from tests."""
|
|
27
|
+
graph = defaultdict(list)
|
|
28
|
+
dependents_map = defaultdict(set)
|
|
29
|
+
|
|
30
|
+
for test in tests:
|
|
31
|
+
for dependency in test.depends_on:
|
|
32
|
+
graph[dependency].append(test.name)
|
|
33
|
+
dependents_map[dependency].add(test.name)
|
|
34
|
+
|
|
35
|
+
self.dependents_map = dict(dependents_map)
|
|
36
|
+
return dict(graph)
|
|
37
|
+
|
|
38
|
+
def topological_sort(self, tests: List[BaseTest]) -> List[BaseTest]:
|
|
39
|
+
"""Sort tests using depth-first traversal that prioritizes children after parents."""
|
|
40
|
+
test_map = {test.name: test for test in tests}
|
|
41
|
+
graph = self.build_dependency_graph(tests)
|
|
42
|
+
|
|
43
|
+
visited = set()
|
|
44
|
+
temp_visited = set()
|
|
45
|
+
result = []
|
|
46
|
+
|
|
47
|
+
def visit_depth_first(test_name: str):
|
|
48
|
+
if test_name in temp_visited:
|
|
49
|
+
raise ValueError(f"Circular dependency detected involving test: {test_name}")
|
|
50
|
+
if test_name in visited or test_name not in test_map:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
temp_visited.add(test_name)
|
|
54
|
+
|
|
55
|
+
# Visit all dependencies first
|
|
56
|
+
for dep_name in test_map[test_name].depends_on:
|
|
57
|
+
visit_depth_first(dep_name)
|
|
58
|
+
|
|
59
|
+
temp_visited.remove(test_name)
|
|
60
|
+
visited.add(test_name)
|
|
61
|
+
result.append(test_map[test_name])
|
|
62
|
+
|
|
63
|
+
# Custom ordering: prioritize depth-first by visiting tests that have the deepest dependency chains first
|
|
64
|
+
def get_dependency_depth(test: BaseTest) -> int:
|
|
65
|
+
"""Calculate the maximum depth of dependencies for a test."""
|
|
66
|
+
if not test.depends_on:
|
|
67
|
+
return 0
|
|
68
|
+
max_depth = 0
|
|
69
|
+
for dep_name in test.depends_on:
|
|
70
|
+
if dep_name in test_map:
|
|
71
|
+
max_depth = max(max_depth, get_dependency_depth(test_map[dep_name]) + 1)
|
|
72
|
+
return max_depth
|
|
73
|
+
|
|
74
|
+
# Sort all tests by dependency depth (deepest first) then by name for stability
|
|
75
|
+
sorted_tests = sorted(tests, key=lambda t: (-get_dependency_depth(t), t.name))
|
|
76
|
+
|
|
77
|
+
# Visit tests in the calculated order
|
|
78
|
+
for test in sorted_tests:
|
|
79
|
+
visit_depth_first(test.name)
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
def resolve_dependencies(self, requested_tests: List[BaseTest]) -> List[BaseTest]:
|
|
84
|
+
"""Resolve and include all dependencies for requested tests."""
|
|
85
|
+
all_tests = self.discovery.discover_tests(str(self.base_path))
|
|
86
|
+
test_map = {test.name: test for test in all_tests.values()}
|
|
87
|
+
|
|
88
|
+
needed_tests = set()
|
|
89
|
+
to_process = [test.name for test in requested_tests]
|
|
90
|
+
|
|
91
|
+
while to_process:
|
|
92
|
+
current_name = to_process.pop(0)
|
|
93
|
+
if current_name in needed_tests:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
needed_tests.add(current_name)
|
|
97
|
+
|
|
98
|
+
# Add dependencies if they exist
|
|
99
|
+
if current_name in test_map:
|
|
100
|
+
current_test = test_map[current_name]
|
|
101
|
+
for dep_name in current_test.depends_on:
|
|
102
|
+
if dep_name not in needed_tests:
|
|
103
|
+
to_process.append(dep_name)
|
|
104
|
+
|
|
105
|
+
# Return tests in dependency order
|
|
106
|
+
return [test_map[name] for name in needed_tests if name in test_map]
|
|
107
|
+
|
|
108
|
+
def get_all_dependents(self, test_name: str) -> Set[str]:
|
|
109
|
+
"""Get all direct and indirect dependents of a test."""
|
|
110
|
+
all_dependents = set()
|
|
111
|
+
to_check = [test_name]
|
|
112
|
+
|
|
113
|
+
while to_check:
|
|
114
|
+
current = to_check.pop(0)
|
|
115
|
+
if current in self.dependents_map:
|
|
116
|
+
for dependent in self.dependents_map[current]:
|
|
117
|
+
if dependent not in all_dependents:
|
|
118
|
+
all_dependents.add(dependent)
|
|
119
|
+
to_check.append(dependent)
|
|
120
|
+
|
|
121
|
+
return all_dependents
|
|
122
|
+
|
|
123
|
+
def all_dependents_completed(self, test_name: str, completed_tests: Set[str], failed_tests: Set[str]) -> bool:
|
|
124
|
+
"""Check if all dependents of a test have completed (passed or failed)."""
|
|
125
|
+
all_dependents = self.get_all_dependents(test_name)
|
|
126
|
+
if not all_dependents:
|
|
127
|
+
return True # No dependents, can teardown immediately
|
|
128
|
+
|
|
129
|
+
# Check if all dependents have completed
|
|
130
|
+
for dependent in all_dependents:
|
|
131
|
+
if dependent not in completed_tests and dependent not in failed_tests:
|
|
132
|
+
return False
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
async def run_pending_teardowns(self, completed_tests: Set[str], failed_tests: Set[str]):
|
|
136
|
+
"""Execute teardowns for tests whose dependents have all completed."""
|
|
137
|
+
tests_to_teardown = []
|
|
138
|
+
|
|
139
|
+
# Find tests ready for teardown
|
|
140
|
+
for test_name, test_instance in list(self.pending_teardowns.items()):
|
|
141
|
+
if self.all_dependents_completed(test_name, completed_tests, failed_tests):
|
|
142
|
+
tests_to_teardown.append((test_name, test_instance))
|
|
143
|
+
|
|
144
|
+
# Execute teardowns
|
|
145
|
+
for test_name, test_instance in tests_to_teardown:
|
|
146
|
+
try:
|
|
147
|
+
self.logger.info(f"Running delayed teardown for {test_name}")
|
|
148
|
+
await test_instance.teardown()
|
|
149
|
+
del self.pending_teardowns[test_name]
|
|
150
|
+
except Exception as e:
|
|
151
|
+
self.logger.error(f"Error during delayed teardown for {test_name}: {e}")
|
|
152
|
+
|
|
153
|
+
async def run_tests_by_names(self, test_names: List[str], progress_callback=None) -> Dict[str, Any]:
|
|
154
|
+
"""Run specific tests by name, automatically including dependencies."""
|
|
155
|
+
all_tests = self.discovery.discover_tests(str(self.base_path))
|
|
156
|
+
requested_tests = [all_tests[name] for name in test_names if name in all_tests]
|
|
157
|
+
|
|
158
|
+
if len(requested_tests) != len(test_names):
|
|
159
|
+
found_names = {test.name for test in requested_tests}
|
|
160
|
+
missing = set(test_names) - found_names
|
|
161
|
+
self.logger.warning(f"Tests not found: {missing}")
|
|
162
|
+
|
|
163
|
+
# Resolve dependencies
|
|
164
|
+
tests_with_deps = self.resolve_dependencies(requested_tests)
|
|
165
|
+
|
|
166
|
+
return await self.run_tests(tests_with_deps, progress_callback)
|
|
167
|
+
|
|
168
|
+
async def run_all_tests(self, progress_callback=None) -> Dict[str, Any]:
|
|
169
|
+
"""Run all discovered tests with full dependency resolution."""
|
|
170
|
+
# Get all available tests
|
|
171
|
+
all_tests = self.discovery.discover_tests(str(self.base_path))
|
|
172
|
+
tests_list = list(all_tests.values())
|
|
173
|
+
|
|
174
|
+
# Use full dependency resolution for all tests
|
|
175
|
+
# This ensures if test A depends on B, B will be included even if not explicitly requested
|
|
176
|
+
tests_with_deps = self.resolve_dependencies(tests_list)
|
|
177
|
+
|
|
178
|
+
return await self.run_tests(tests_with_deps, progress_callback)
|
|
179
|
+
|
|
180
|
+
async def run_tests(self, tests: List[BaseTest], progress_callback=None) -> Dict[str, Any]:
|
|
181
|
+
"""Run tests with dependency resolution."""
|
|
182
|
+
if not tests:
|
|
183
|
+
return {"success": False, "message": "No tests found", "results": []}
|
|
184
|
+
|
|
185
|
+
# Sort tests by dependencies and build dependency maps
|
|
186
|
+
try:
|
|
187
|
+
ordered_tests = self.topological_sort(tests)
|
|
188
|
+
self.dependency_graph = self.build_dependency_graph(tests)
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
return {"success": False, "message": str(e), "results": []}
|
|
191
|
+
|
|
192
|
+
self.logger.info(f"Running {len(ordered_tests)} tests in dependency order")
|
|
193
|
+
|
|
194
|
+
# Track execution
|
|
195
|
+
completed_tests: Set[str] = set()
|
|
196
|
+
failed_tests: Set[str] = set()
|
|
197
|
+
self.test_states = {}
|
|
198
|
+
self.pending_teardowns = {}
|
|
199
|
+
|
|
200
|
+
# Use the parent class setup
|
|
201
|
+
await self._setup_test_run(ordered_tests, progress_callback)
|
|
202
|
+
|
|
203
|
+
for i, test in enumerate(ordered_tests):
|
|
204
|
+
if progress_callback:
|
|
205
|
+
progress_callback('start', test, i + 1, len(ordered_tests))
|
|
206
|
+
|
|
207
|
+
# Check if all dependencies passed
|
|
208
|
+
skip_reason = None
|
|
209
|
+
|
|
210
|
+
# Check explicit dependencies
|
|
211
|
+
for dep_name in test.depends_on:
|
|
212
|
+
if dep_name in failed_tests:
|
|
213
|
+
skip_reason = f"Dependency '{dep_name}' failed"
|
|
214
|
+
break
|
|
215
|
+
elif dep_name not in completed_tests:
|
|
216
|
+
skip_reason = f"Dependency '{dep_name}' not completed"
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if skip_reason:
|
|
221
|
+
# Skip this test
|
|
222
|
+
result = TestResult(
|
|
223
|
+
test.name,
|
|
224
|
+
False,
|
|
225
|
+
f"Skipped: {skip_reason}",
|
|
226
|
+
0.0
|
|
227
|
+
)
|
|
228
|
+
self.results.append(result)
|
|
229
|
+
self.test_states[test.name] = result
|
|
230
|
+
failed_tests.add(test.name)
|
|
231
|
+
|
|
232
|
+
if progress_callback:
|
|
233
|
+
progress_callback('complete', test, i + 1, len(ordered_tests), result)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Pass dependency results to the test
|
|
237
|
+
for dep_name in test.depends_on:
|
|
238
|
+
if dep_name in self.test_states:
|
|
239
|
+
test.set_dependency_result(dep_name, self.test_states[dep_name])
|
|
240
|
+
|
|
241
|
+
# Run the test
|
|
242
|
+
result = await self._run_single_test_with_managers(test)
|
|
243
|
+
self.results.append(result)
|
|
244
|
+
self.test_states[test.name] = result
|
|
245
|
+
|
|
246
|
+
if result.success:
|
|
247
|
+
completed_tests.add(test.name)
|
|
248
|
+
self.logger.info(f"ā Test '{test.name}' passed")
|
|
249
|
+
else:
|
|
250
|
+
failed_tests.add(test.name)
|
|
251
|
+
self.logger.error(f"ā Test '{test.name}' failed: {result.message}")
|
|
252
|
+
# Ensure trace is saved immediately for failed tests
|
|
253
|
+
if hasattr(self, '_shared_playwright_manager'):
|
|
254
|
+
try:
|
|
255
|
+
trace_saved = await self._shared_playwright_manager.ensure_trace_saved()
|
|
256
|
+
if trace_saved:
|
|
257
|
+
self.logger.info(f"Trace saved for failed test: {test.name}")
|
|
258
|
+
else:
|
|
259
|
+
self.logger.warning(f"Failed to save trace for failed test: {test.name}")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
self.logger.error(f"Error saving trace for failed test {test.name}: {e}")
|
|
262
|
+
|
|
263
|
+
# Run any pending teardowns that are now ready
|
|
264
|
+
await self.run_pending_teardowns(completed_tests, failed_tests)
|
|
265
|
+
|
|
266
|
+
if progress_callback:
|
|
267
|
+
progress_callback('complete', test, i + 1, len(ordered_tests), result)
|
|
268
|
+
|
|
269
|
+
# Run any remaining pending teardowns
|
|
270
|
+
if self.pending_teardowns:
|
|
271
|
+
self.logger.info(f"Running final teardowns for {len(self.pending_teardowns)} remaining tests")
|
|
272
|
+
for test_name, test_instance in self.pending_teardowns.items():
|
|
273
|
+
try:
|
|
274
|
+
self.logger.info(f"Running final teardown for {test_name}")
|
|
275
|
+
await test_instance.teardown()
|
|
276
|
+
except Exception as e:
|
|
277
|
+
self.logger.error(f"Error during final teardown for {test_name}: {e}")
|
|
278
|
+
self.pending_teardowns.clear()
|
|
279
|
+
|
|
280
|
+
# Generate final report
|
|
281
|
+
return await self._finalize_test_run()
|
|
282
|
+
|
|
283
|
+
async def _setup_test_run(self, tests: List[BaseTest], progress_callback):
|
|
284
|
+
"""Setup for test run (extracted from parent class)."""
|
|
285
|
+
import time
|
|
286
|
+
from datetime import datetime
|
|
287
|
+
from pathlib import Path
|
|
288
|
+
|
|
289
|
+
self.start_time = time.time()
|
|
290
|
+
self.results = []
|
|
291
|
+
self.progress_callback = progress_callback
|
|
292
|
+
|
|
293
|
+
# Setup logging for this test run
|
|
294
|
+
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
295
|
+
self.run_dir = self.output_dir / f"run_{run_id}"
|
|
296
|
+
self.run_dir.mkdir(exist_ok=True)
|
|
297
|
+
|
|
298
|
+
# Setup file logging
|
|
299
|
+
log_file = self.run_dir / "test_run.log"
|
|
300
|
+
file_handler = logging.FileHandler(log_file)
|
|
301
|
+
file_handler.setFormatter(logging.Formatter(
|
|
302
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
303
|
+
))
|
|
304
|
+
|
|
305
|
+
# Add file handler to all loggers
|
|
306
|
+
logging.getLogger().addHandler(file_handler)
|
|
307
|
+
self.file_handler = file_handler
|
|
308
|
+
|
|
309
|
+
async def _run_single_test_with_managers(self, test: BaseTest) -> TestResult:
|
|
310
|
+
"""Run single test with shared managers."""
|
|
311
|
+
import time
|
|
312
|
+
from .shared_cli_manager import TestCLIProxy
|
|
313
|
+
from .playwright_manager import PlaywrightManager
|
|
314
|
+
|
|
315
|
+
test_start = time.time()
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
# Setup shared CLI manager for this test
|
|
319
|
+
cli_manager = TestCLIProxy(test.name, str(self.run_dir / "cli_logs"))
|
|
320
|
+
|
|
321
|
+
# Setup shared playwright manager (reuse existing session if available)
|
|
322
|
+
if not hasattr(self, '_shared_playwright_manager'):
|
|
323
|
+
self._shared_playwright_manager = PlaywrightManager("shared_session", str(self.run_dir / "recordings"))
|
|
324
|
+
# Start shared session once
|
|
325
|
+
playwright_started = await self._shared_playwright_manager.start_session()
|
|
326
|
+
if not playwright_started:
|
|
327
|
+
return TestResult(
|
|
328
|
+
test.name, False,
|
|
329
|
+
"Failed to start shared Playwright session",
|
|
330
|
+
time.time() - test_start
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Set managers on test
|
|
334
|
+
test.set_cli_manager(cli_manager)
|
|
335
|
+
test.set_playwright_manager(self._shared_playwright_manager)
|
|
336
|
+
|
|
337
|
+
# Ensure CLI connection with --debug flag
|
|
338
|
+
cli_connected = await cli_manager.connect(debug=True)
|
|
339
|
+
if not cli_connected:
|
|
340
|
+
return TestResult(
|
|
341
|
+
test.name, False,
|
|
342
|
+
"Failed to establish CLI connection",
|
|
343
|
+
time.time() - test_start
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Run test setup
|
|
347
|
+
self.logger.info(f"Running setup for {test.name}")
|
|
348
|
+
await test.setup()
|
|
349
|
+
|
|
350
|
+
# Navigate to start URL if needed
|
|
351
|
+
await test.navigate_to_start_url()
|
|
352
|
+
|
|
353
|
+
# Run the actual test
|
|
354
|
+
self.logger.info(f"Executing test logic for {test.name}")
|
|
355
|
+
result = await test.run()
|
|
356
|
+
|
|
357
|
+
# Update duration
|
|
358
|
+
result.duration = time.time() - test_start
|
|
359
|
+
|
|
360
|
+
# Check if this test has dependents that are still running
|
|
361
|
+
if test.name in self.dependents_map and self.dependents_map[test.name]:
|
|
362
|
+
# This test has dependents, delay teardown until all dependents complete
|
|
363
|
+
self.logger.info(f"Test {test.name} has dependents {self.dependents_map[test.name]}, delaying teardown")
|
|
364
|
+
self.pending_teardowns[test.name] = test
|
|
365
|
+
else:
|
|
366
|
+
# No dependents or dependents already completed, run teardown immediately
|
|
367
|
+
self.logger.info(f"Running immediate teardown for {test.name}")
|
|
368
|
+
await test.teardown()
|
|
369
|
+
|
|
370
|
+
return result
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
# Get detailed error information
|
|
374
|
+
exc_type, exc_value, exc_traceback = sys.exc_info()
|
|
375
|
+
|
|
376
|
+
# Extract the most relevant line from traceback (user's test code)
|
|
377
|
+
tb_lines = traceback.format_tb(exc_traceback)
|
|
378
|
+
user_code_line = None
|
|
379
|
+
|
|
380
|
+
for line in tb_lines:
|
|
381
|
+
if 'test_modules/' in line or 'run(self)' in line:
|
|
382
|
+
user_code_line = line.strip()
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
# Create detailed error message
|
|
386
|
+
error_details = [f"Test execution failed: {str(e)}"]
|
|
387
|
+
|
|
388
|
+
if user_code_line:
|
|
389
|
+
error_details.append(f"Location: {user_code_line}")
|
|
390
|
+
|
|
391
|
+
# Add exception type
|
|
392
|
+
if exc_type:
|
|
393
|
+
error_details.append(f"Exception type: {exc_type.__name__}")
|
|
394
|
+
|
|
395
|
+
# Add full traceback to logs but keep UI message concise
|
|
396
|
+
full_traceback = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
397
|
+
self.logger.error(f"Full traceback for {test.name}:\n{full_traceback}")
|
|
398
|
+
|
|
399
|
+
error_msg = '\n'.join(error_details)
|
|
400
|
+
|
|
401
|
+
return TestResult(
|
|
402
|
+
test.name, False, error_msg,
|
|
403
|
+
time.time() - test_start
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
finally:
|
|
407
|
+
# Don't disconnect shared CLI connection, just log completion
|
|
408
|
+
try:
|
|
409
|
+
await cli_manager.disconnect() # This won't actually disconnect in shared mode
|
|
410
|
+
except Exception as e:
|
|
411
|
+
self.logger.error(f"Error during cleanup for {test.name}: {e}")
|
|
412
|
+
|
|
413
|
+
async def _finalize_test_run(self) -> Dict[str, Any]:
|
|
414
|
+
"""Finalize test run and generate reports."""
|
|
415
|
+
import time
|
|
416
|
+
from pathlib import Path
|
|
417
|
+
|
|
418
|
+
# Get recordings directory path and cleanup playwright
|
|
419
|
+
recordings_dir = None
|
|
420
|
+
if hasattr(self, '_shared_playwright_manager'):
|
|
421
|
+
try:
|
|
422
|
+
# Save the recordings directory path before cleanup
|
|
423
|
+
recordings_dir = Path(self._shared_playwright_manager.recordings_dir)
|
|
424
|
+
|
|
425
|
+
# Now cleanup which will save the trace
|
|
426
|
+
await self._shared_playwright_manager.cleanup()
|
|
427
|
+
except Exception as e:
|
|
428
|
+
self.logger.error(f"Error cleaning up shared playwright manager: {e}")
|
|
429
|
+
|
|
430
|
+
# Find trace path after cleanup
|
|
431
|
+
trace_path = None
|
|
432
|
+
if recordings_dir and recordings_dir.exists():
|
|
433
|
+
# Look for trace.zip in the recordings directory or its subdirectories
|
|
434
|
+
for trace_file in recordings_dir.rglob("trace.zip"):
|
|
435
|
+
trace_path = trace_file
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
# Open trace viewer if any tests failed (including skipped tests for debugging)
|
|
439
|
+
failed_tests = [result for result in self.results if not result.success]
|
|
440
|
+
|
|
441
|
+
if failed_tests and trace_path and trace_path.exists():
|
|
442
|
+
try:
|
|
443
|
+
self.logger.info(f"Opening trace viewer for {len(failed_tests)} failed tests...")
|
|
444
|
+
import subprocess
|
|
445
|
+
subprocess.Popen([
|
|
446
|
+
'npx', 'playwright', 'show-trace',
|
|
447
|
+
'--host', '0.0.0.0',
|
|
448
|
+
'--port', '9323',
|
|
449
|
+
str(trace_path)
|
|
450
|
+
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
451
|
+
print(f"\nš Trace viewer opened at: http://0.0.0.0:9323")
|
|
452
|
+
print(f" Trace file: {trace_path}")
|
|
453
|
+
except Exception as trace_error:
|
|
454
|
+
self.logger.warning(f"Could not open trace viewer: {trace_error}")
|
|
455
|
+
|
|
456
|
+
# Cleanup logging
|
|
457
|
+
try:
|
|
458
|
+
logging.getLogger().removeHandler(self.file_handler)
|
|
459
|
+
self.file_handler.close()
|
|
460
|
+
except:
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
self.end_time = time.time()
|
|
464
|
+
|
|
465
|
+
# Generate summary report
|
|
466
|
+
summary = await self._generate_summary_report(self.run_dir)
|
|
467
|
+
|
|
468
|
+
self.logger.info(f"Hierarchical test run completed. Results saved to: {self.run_dir}")
|
|
469
|
+
return summary
|
|
470
|
+
|
|
471
|
+
async def _wait_for_trace_file(self, recording_dir: Path, timeout: float = 30.0) -> Optional[Path]:
|
|
472
|
+
"""Wait for trace.zip file to be available with proper timeout."""
|
|
473
|
+
import time
|
|
474
|
+
|
|
475
|
+
start_time = time.time()
|
|
476
|
+
check_interval = 0.1 # Check every 100ms for responsiveness
|
|
477
|
+
last_log_time = start_time
|
|
478
|
+
|
|
479
|
+
while time.time() - start_time < timeout:
|
|
480
|
+
# First check direct path
|
|
481
|
+
direct_trace = recording_dir / "trace.zip"
|
|
482
|
+
if direct_trace.exists() and direct_trace.stat().st_size > 0:
|
|
483
|
+
self.logger.info(f"Found trace file at direct path: {direct_trace}")
|
|
484
|
+
return direct_trace
|
|
485
|
+
|
|
486
|
+
# Look for trace.zip in subdirectories (for shared sessions)
|
|
487
|
+
for subdir in recording_dir.glob("*/"):
|
|
488
|
+
potential_trace = subdir / "trace.zip"
|
|
489
|
+
if potential_trace.exists() and potential_trace.stat().st_size > 0:
|
|
490
|
+
self.logger.info(f"Found trace file in subdirectory: {potential_trace}")
|
|
491
|
+
return potential_trace
|
|
492
|
+
|
|
493
|
+
# Log progress every 5 seconds to avoid spam
|
|
494
|
+
if time.time() - last_log_time >= 5.0:
|
|
495
|
+
elapsed = time.time() - start_time
|
|
496
|
+
self.logger.info(f"Still waiting for trace file... ({elapsed:.1f}s elapsed)")
|
|
497
|
+
last_log_time = time.time()
|
|
498
|
+
|
|
499
|
+
await asyncio.sleep(check_interval)
|
|
500
|
+
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
async def _open_trace_on_failure(self, test_name: str, playwright_manager) -> None:
|
|
504
|
+
"""Open Playwright trace in browser when test fails."""
|
|
505
|
+
try:
|
|
506
|
+
# Get the trace file path from playwright manager
|
|
507
|
+
recording_dir = Path(playwright_manager.recordings_dir)
|
|
508
|
+
self.logger.info(f"Looking for trace in recording directory: {recording_dir}")
|
|
509
|
+
|
|
510
|
+
# Wait for trace file to be available with proper timeout
|
|
511
|
+
trace_file = await self._wait_for_trace_file(recording_dir, timeout=30.0)
|
|
512
|
+
|
|
513
|
+
if trace_file and trace_file.exists():
|
|
514
|
+
# Verify the file is not empty and not currently being written
|
|
515
|
+
file_size = trace_file.stat().st_size
|
|
516
|
+
if file_size == 0:
|
|
517
|
+
self.logger.warning(f"Trace file {trace_file} exists but is empty")
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
# Wait a moment to ensure file writing is complete
|
|
521
|
+
await asyncio.sleep(0.5)
|
|
522
|
+
|
|
523
|
+
# Open trace viewer in browser
|
|
524
|
+
self.logger.info(f"Opening trace viewer for failed test: {test_name}")
|
|
525
|
+
self.logger.info(f"Trace file size: {file_size} bytes")
|
|
526
|
+
|
|
527
|
+
# Use Playwright's trace viewer
|
|
528
|
+
import subprocess
|
|
529
|
+
|
|
530
|
+
# Try to open with playwright show-trace command with host/port options
|
|
531
|
+
try:
|
|
532
|
+
# Run playwright show-trace with host and port for remote access
|
|
533
|
+
subprocess.Popen([
|
|
534
|
+
'npx', 'playwright', 'show-trace',
|
|
535
|
+
'--host', '0.0.0.0',
|
|
536
|
+
'--port', '9323',
|
|
537
|
+
str(trace_file)
|
|
538
|
+
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
539
|
+
self.logger.info(f"Trace viewer opened for {test_name} at http://0.0.0.0:9323")
|
|
540
|
+
print(f"\nš Trace viewer opened at: http://0.0.0.0:9323")
|
|
541
|
+
print(f" Trace file: {trace_file} ({file_size} bytes)")
|
|
542
|
+
except (FileNotFoundError, subprocess.SubprocessError) as e:
|
|
543
|
+
self.logger.warning(f"Failed to open trace viewer with network access: {e}")
|
|
544
|
+
# Fallback: try without host/port options
|
|
545
|
+
try:
|
|
546
|
+
subprocess.Popen([
|
|
547
|
+
'npx', 'playwright', 'show-trace', str(trace_file)
|
|
548
|
+
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
549
|
+
self.logger.info(f"Trace viewer opened for {test_name} (local)")
|
|
550
|
+
print(f"\nš Trace viewer opened locally")
|
|
551
|
+
print(f" Trace file: {trace_file} ({file_size} bytes)")
|
|
552
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
553
|
+
# Final fallback: open trace directory in file manager
|
|
554
|
+
self.logger.warning("Playwright trace viewer not available, opening trace directory")
|
|
555
|
+
import os
|
|
556
|
+
if os.name == 'nt': # Windows
|
|
557
|
+
os.startfile(str(recording_dir))
|
|
558
|
+
elif os.name == 'posix': # Linux/Mac
|
|
559
|
+
subprocess.run(['xdg-open', str(recording_dir)], check=False)
|
|
560
|
+
else:
|
|
561
|
+
# Debug: show what we're looking for
|
|
562
|
+
self.logger.error(f"No trace file found for {test_name} after 30 second timeout.")
|
|
563
|
+
self.logger.error(f"Searched in recording directory: {recording_dir}")
|
|
564
|
+
if recording_dir.exists():
|
|
565
|
+
self.logger.error(f"Directory contents:")
|
|
566
|
+
for item in recording_dir.rglob("*"):
|
|
567
|
+
if item.is_file():
|
|
568
|
+
self.logger.error(f" - {item} ({item.stat().st_size} bytes)")
|
|
569
|
+
else:
|
|
570
|
+
self.logger.error(f" - {item}/ (directory)")
|
|
571
|
+
else:
|
|
572
|
+
self.logger.error(f"Recording directory does not exist: {recording_dir}")
|
|
573
|
+
|
|
574
|
+
except Exception as e:
|
|
575
|
+
self.logger.error(f"Failed to open trace for {test_name}: {e}")
|
|
576
|
+
import traceback
|
|
577
|
+
self.logger.error(f"Full traceback: {traceback.format_exc()}")
|