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.

Files changed (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {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()}")