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,447 @@
1
+ """Test runner with selective execution and comprehensive reporting."""
2
+
3
+ import asyncio
4
+ import time
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Optional, Set
8
+ import json
9
+ import shutil
10
+ import traceback
11
+ import webbrowser
12
+ import os
13
+ import sys
14
+ from datetime import datetime
15
+
16
+ from .base_test import BaseTest, TestResult, TestCategory
17
+ from .test_discovery import TestDiscovery
18
+ from .cli_manager import CLIManager
19
+ from .shared_cli_manager import SharedCLIManager, TestCLIProxy
20
+ from .playwright_manager import PlaywrightManager
21
+
22
+
23
+ class TestRunner:
24
+ """Main test runner that orchestrates test execution."""
25
+
26
+ def __init__(self, base_path: str = ".", output_dir: str = "test_results", clear_results: bool = False):
27
+ self.base_path = Path(base_path)
28
+ self.output_dir = Path(output_dir)
29
+
30
+ # Clear results directory if requested
31
+ if clear_results and self.output_dir.exists():
32
+ shutil.rmtree(self.output_dir)
33
+
34
+ self.output_dir.mkdir(exist_ok=True)
35
+
36
+ self.discovery = TestDiscovery()
37
+ self.logger = logging.getLogger("test_runner")
38
+
39
+ # Results tracking
40
+ self.results: List[TestResult] = []
41
+ self.start_time: Optional[float] = None
42
+ self.end_time: Optional[float] = None
43
+
44
+ async def run_all_tests(self, progress_callback=None) -> Dict[str, Any]:
45
+ """Run all discovered tests."""
46
+ tests = self.discovery.discover_tests(str(self.base_path))
47
+ return await self.run_tests(list(tests.values()), progress_callback)
48
+
49
+ async def run_tests_by_category(self, category: TestCategory, progress_callback=None) -> Dict[str, Any]:
50
+ """Run all tests in a specific category."""
51
+ self.discovery.discover_tests(str(self.base_path))
52
+ tests = self.discovery.get_tests_by_category(category)
53
+ return await self.run_tests(tests, progress_callback)
54
+
55
+ async def run_tests_by_tags(self, tags: Set[str], progress_callback=None) -> Dict[str, Any]:
56
+ """Run all tests that have any of the specified tags."""
57
+ self.discovery.discover_tests(str(self.base_path))
58
+ tests = self.discovery.get_tests_by_tags(tags)
59
+ return await self.run_tests(tests, progress_callback)
60
+
61
+ async def run_tests_by_names(self, test_names: List[str], progress_callback=None) -> Dict[str, Any]:
62
+ """Run specific tests by name."""
63
+ all_tests = self.discovery.discover_tests(str(self.base_path))
64
+ tests = [all_tests[name] for name in test_names if name in all_tests]
65
+
66
+ if len(tests) != len(test_names):
67
+ found_names = {test.name for test in tests}
68
+ missing = set(test_names) - found_names
69
+ # Only log to file, not console
70
+ pass
71
+
72
+ return await self.run_tests(tests, progress_callback)
73
+
74
+ async def run_tests_by_pattern(self, pattern: str, progress_callback=None) -> Dict[str, Any]:
75
+ """Run tests whose names match the pattern."""
76
+ self.discovery.discover_tests(str(self.base_path))
77
+ tests = self.discovery.get_tests_by_name_pattern(pattern)
78
+ return await self.run_tests(tests, progress_callback)
79
+
80
+ async def run_tests(self, tests: List[BaseTest], progress_callback=None) -> Dict[str, Any]:
81
+ """Run a list of tests with full orchestration."""
82
+ if not tests:
83
+ return {"success": False, "message": "No tests found", "results": []}
84
+
85
+ self.start_time = time.time()
86
+ self.results = []
87
+ self.progress_callback = progress_callback
88
+
89
+ # Setup logging for this test run
90
+ run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
91
+ run_dir = self.output_dir / f"run_{run_id}"
92
+ run_dir.mkdir(exist_ok=True)
93
+
94
+ # Setup file logging
95
+ log_file = run_dir / "test_run.log"
96
+ file_handler = logging.FileHandler(log_file)
97
+ file_handler.setFormatter(logging.Formatter(
98
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
99
+ ))
100
+
101
+ # Add file handler to all loggers
102
+ logging.getLogger().addHandler(file_handler)
103
+
104
+ # We'll establish the CLI connection when the first test runs
105
+
106
+ try:
107
+ for i, test in enumerate(tests):
108
+ # Notify progress
109
+ if self.progress_callback:
110
+ self.progress_callback('start', test, i + 1, len(tests))
111
+
112
+ # Setup managers for this test - use shared CLI
113
+ cli_manager = TestCLIProxy(test.name, str(run_dir / "cli_logs"))
114
+ playwright_manager = PlaywrightManager(test.name, str(run_dir / "recordings"))
115
+
116
+ test.set_cli_manager(cli_manager)
117
+ test.set_playwright_manager(playwright_manager)
118
+
119
+ # Run the test
120
+ result = await self._run_single_test(test, cli_manager, playwright_manager)
121
+ self.results.append(result)
122
+
123
+ # Notify completion
124
+ if self.progress_callback:
125
+ self.progress_callback('complete', test, i + 1, len(tests), result)
126
+
127
+ finally:
128
+ # Remove file handler
129
+ logging.getLogger().removeHandler(file_handler)
130
+ file_handler.close()
131
+
132
+ self.end_time = time.time()
133
+
134
+ # Generate summary report
135
+ summary = await self._generate_summary_report(run_dir)
136
+
137
+ self.logger.info(f"Test run completed. Results saved to: {run_dir}")
138
+ return summary
139
+
140
+ async def _run_single_test(self, test: BaseTest,
141
+ cli_manager,
142
+ playwright_manager: PlaywrightManager) -> TestResult:
143
+ """Run a single test with full setup and teardown."""
144
+ test_start = time.time()
145
+
146
+ try:
147
+ # Step 1: Ensure CLI connection (will reuse existing if available)
148
+ cli_connected = await cli_manager.connect()
149
+ if not cli_connected:
150
+ return TestResult(
151
+ test.name, False,
152
+ "Failed to establish CLI connection",
153
+ time.time() - test_start
154
+ )
155
+
156
+ # Step 2: Start Playwright session
157
+ self.logger.info(f"Starting Playwright session for {test.name}")
158
+ playwright_started = await playwright_manager.start_session()
159
+
160
+ if not playwright_started:
161
+ return TestResult(
162
+ test.name, False,
163
+ "Failed to start Playwright session",
164
+ time.time() - test_start
165
+ )
166
+
167
+ # Step 3: Run test setup
168
+ self.logger.info(f"Running setup for {test.name}")
169
+ await test.setup()
170
+
171
+ # Step 3.5: Navigate to start URL if needed
172
+ await test.navigate_to_start_url()
173
+
174
+ # Step 4: Run the actual test
175
+ self.logger.info(f"Executing test logic for {test.name}")
176
+ result = await test.run()
177
+
178
+ # Update duration
179
+ result.duration = time.time() - test_start
180
+
181
+ # Step 5: Run test teardown
182
+ self.logger.info(f"Running teardown for {test.name}")
183
+ await test.teardown()
184
+
185
+ return result
186
+
187
+ except Exception as e:
188
+ # Get detailed error information
189
+ exc_type, exc_value, exc_traceback = sys.exc_info()
190
+
191
+ # Extract the most relevant line from traceback (user's test code)
192
+ tb_lines = traceback.format_tb(exc_traceback)
193
+ user_code_line = None
194
+
195
+ # Look for the LAST occurrence in user test code (most specific failure point)
196
+ for line in reversed(tb_lines):
197
+ if 'test_modules/' in line and '.py' in line:
198
+ user_code_line = line.strip()
199
+ break
200
+
201
+ # If no test_modules line found, look for any line with async context
202
+ if not user_code_line:
203
+ for line in reversed(tb_lines):
204
+ if 'await' in line or 'async' in line:
205
+ user_code_line = line.strip()
206
+ break
207
+
208
+ # Create detailed error message
209
+ error_details = [f"Test execution failed: {str(e)}"]
210
+
211
+ if user_code_line:
212
+ error_details.append(f"Location: {user_code_line}")
213
+
214
+ # Add exception type
215
+ if exc_type:
216
+ error_details.append(f"Exception type: {exc_type.__name__}")
217
+
218
+ # Add full traceback to logs but keep UI message concise
219
+ full_traceback = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
220
+ self.logger.error(f"Full traceback for {test.name}:\n{full_traceback}")
221
+
222
+ error_msg = '\n'.join(error_details)
223
+
224
+ # Don't open trace here - let hierarchical runner handle it at the end
225
+
226
+ return TestResult(
227
+ test.name, False, error_msg,
228
+ time.time() - test_start
229
+ )
230
+
231
+ finally:
232
+ # Cleanup
233
+ try:
234
+ await playwright_manager.cleanup()
235
+ await cli_manager.disconnect() # This won't actually disconnect shared connection
236
+ except Exception as e:
237
+ self.logger.error(f"Error during cleanup for {test.name}: {e}")
238
+
239
+ async def _open_trace_on_failure(self, test_name: str, playwright_manager) -> None:
240
+ """Open Playwright trace in browser when test fails."""
241
+ try:
242
+ # Get the trace file path from playwright manager
243
+ recording_dir = Path(playwright_manager.recordings_dir)
244
+ self.logger.info(f"Looking for trace in recording directory: {recording_dir}")
245
+
246
+ # Retry logic to wait for trace file to be written
247
+ trace_file = None
248
+ max_retries = 10
249
+ retry_delay = 0.5
250
+
251
+ for attempt in range(max_retries):
252
+ # First check direct path
253
+ direct_trace = recording_dir / "trace.zip"
254
+ self.logger.info(f"Attempt {attempt + 1}: Checking direct trace {direct_trace} (exists: {direct_trace.exists()})")
255
+ if direct_trace.exists():
256
+ trace_file = direct_trace
257
+ break
258
+
259
+ # Look for trace.zip in subdirectories (for shared sessions)
260
+ for subdir in recording_dir.glob("*/"):
261
+ potential_trace = subdir / "trace.zip"
262
+ self.logger.info(f"Attempt {attempt + 1}: Checking subdir trace {potential_trace} (exists: {potential_trace.exists()})")
263
+ if potential_trace.exists():
264
+ trace_file = potential_trace
265
+ break
266
+
267
+ if trace_file:
268
+ break
269
+
270
+ if attempt < max_retries - 1:
271
+ self.logger.info(f"Trace file not found yet (attempt {attempt + 1}/{max_retries}), waiting...")
272
+ await asyncio.sleep(retry_delay)
273
+
274
+ if trace_file:
275
+ self.logger.info(f"Found trace file after {attempt + 1} attempts: {trace_file}")
276
+
277
+ if trace_file and trace_file.exists():
278
+ # Open trace viewer in browser
279
+ self.logger.info(f"Opening trace viewer for failed test: {test_name}")
280
+
281
+ # Use Playwright's trace viewer
282
+ import subprocess
283
+
284
+ # Try to open with playwright show-trace command with host/port options
285
+ try:
286
+ # Run playwright show-trace with host and port for remote access
287
+ subprocess.Popen([
288
+ 'npx', 'playwright', 'show-trace',
289
+ '--host', '0.0.0.0',
290
+ '--port', '9323',
291
+ str(trace_file)
292
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
293
+ self.logger.info(f"Trace viewer opened for {test_name} at http://0.0.0.0:9323")
294
+ print(f"\n🔍 Trace viewer opened at: http://0.0.0.0:9323")
295
+ print(f" Trace file: {trace_file}")
296
+ except (FileNotFoundError, subprocess.SubprocessError) as e:
297
+ # Fallback: try without host/port options
298
+ try:
299
+ subprocess.Popen([
300
+ 'npx', 'playwright', 'show-trace', str(trace_file)
301
+ ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
302
+ self.logger.info(f"Trace viewer opened for {test_name} (local)")
303
+ except (FileNotFoundError, subprocess.SubprocessError):
304
+ # Final fallback: open trace directory in file manager
305
+ self.logger.warning("Playwright trace viewer not available, opening trace directory")
306
+ if os.name == 'nt': # Windows
307
+ os.startfile(str(recording_dir))
308
+ elif os.name == 'posix': # Linux/Mac
309
+ subprocess.run(['xdg-open', str(recording_dir)], check=False)
310
+ else:
311
+ # Debug: show what we're looking for
312
+ self.logger.warning(f"No trace file found for {test_name}. Searched in:")
313
+ self.logger.warning(f" - Direct path: {recording_dir / 'trace.zip'}")
314
+ for subdir in recording_dir.glob("*/"):
315
+ self.logger.warning(f" - Subdir path: {subdir / 'trace.zip'} (exists: {(subdir / 'trace.zip').exists()})")
316
+
317
+ except Exception as e:
318
+ self.logger.error(f"Failed to open trace for {test_name}: {e}")
319
+
320
+ async def _generate_summary_report(self, run_dir: Path) -> Dict[str, Any]:
321
+ """Generate a comprehensive summary report."""
322
+ total_tests = len(self.results)
323
+ passed_tests = sum(1 for r in self.results if r.success)
324
+ failed_tests = total_tests - passed_tests
325
+
326
+ total_duration = self.end_time - self.start_time if self.start_time and self.end_time else 0
327
+
328
+ summary = {
329
+ "run_info": {
330
+ "start_time": datetime.fromtimestamp(self.start_time).isoformat() if self.start_time else None,
331
+ "end_time": datetime.fromtimestamp(self.end_time).isoformat() if self.end_time else None,
332
+ "duration": total_duration,
333
+ "run_directory": str(run_dir)
334
+ },
335
+ "statistics": {
336
+ "total_tests": total_tests,
337
+ "passed": passed_tests,
338
+ "failed": failed_tests,
339
+ "success_rate": (passed_tests / total_tests * 100) if total_tests > 0 else 0
340
+ },
341
+ "results": [
342
+ {
343
+ "test_name": result.test_name,
344
+ "success": result.success,
345
+ "message": result.message,
346
+ "duration": result.duration,
347
+ "artifacts": result.artifacts
348
+ }
349
+ for result in self.results
350
+ ]
351
+ }
352
+
353
+ # Save summary to file
354
+ summary_file = run_dir / "summary.json"
355
+ with open(summary_file, 'w') as f:
356
+ json.dump(summary, f, indent=2)
357
+
358
+ # Generate HTML report
359
+ await self._generate_html_report(run_dir, summary)
360
+
361
+ self.logger.info(f"Summary report saved to: {summary_file}")
362
+ return summary
363
+
364
+ async def _generate_html_report(self, run_dir: Path, summary: Dict[str, Any]):
365
+ """Generate an HTML report for easy viewing."""
366
+ html_content = f"""
367
+ <!DOCTYPE html>
368
+ <html lang="en">
369
+ <head>
370
+ <meta charset="UTF-8">
371
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
372
+ <title>Test Run Report</title>
373
+ <style>
374
+ body {{ font-family: Arial, sans-serif; margin: 20px; }}
375
+ .header {{ background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 20px; }}
376
+ .stats {{ display: flex; gap: 20px; margin: 20px 0; }}
377
+ .stat {{ background: #e9ecef; padding: 15px; border-radius: 5px; text-align: center; flex: 1; }}
378
+ .passed {{ background: #d4edda; color: #155724; }}
379
+ .failed {{ background: #f8d7da; color: #721c24; }}
380
+ .test-result {{ margin: 10px 0; padding: 15px; border-radius: 5px; border-left: 4px solid; }}
381
+ .test-passed {{ background: #d4edda; border-color: #28a745; }}
382
+ .test-failed {{ background: #f8d7da; border-color: #dc3545; }}
383
+ .duration {{ color: #6c757d; font-size: 0.9em; }}
384
+ </style>
385
+ </head>
386
+ <body>
387
+ <div class="header">
388
+ <h1>Test Run Report</h1>
389
+ <p><strong>Start Time:</strong> {summary['run_info']['start_time']}</p>
390
+ <p><strong>Duration:</strong> {summary['run_info']['duration']:.2f} seconds</p>
391
+ <p><strong>Run Directory:</strong> {summary['run_info']['run_directory']}</p>
392
+ </div>
393
+
394
+ <div class="stats">
395
+ <div class="stat">
396
+ <h3>{summary['statistics']['total_tests']}</h3>
397
+ <p>Total Tests</p>
398
+ </div>
399
+ <div class="stat passed">
400
+ <h3>{summary['statistics']['passed']}</h3>
401
+ <p>Passed</p>
402
+ </div>
403
+ <div class="stat failed">
404
+ <h3>{summary['statistics']['failed']}</h3>
405
+ <p>Failed</p>
406
+ </div>
407
+ <div class="stat">
408
+ <h3>{summary['statistics']['success_rate']:.1f}%</h3>
409
+ <p>Success Rate</p>
410
+ </div>
411
+ </div>
412
+
413
+ <h2>Test Results</h2>
414
+ """
415
+
416
+ for result in summary['results']:
417
+ status_class = "test-passed" if result['success'] else "test-failed"
418
+ status_text = "PASSED" if result['success'] else "FAILED"
419
+
420
+ html_content += f"""
421
+ <div class="test-result {status_class}">
422
+ <h3>{result['test_name']} - {status_text}</h3>
423
+ <p class="duration">Duration: {result['duration']:.2f}s</p>
424
+ {f"<p><strong>Message:</strong> {result['message']}</p>" if result['message'] else ""}
425
+ </div>
426
+ """
427
+
428
+ html_content += """
429
+ </body>
430
+ </html>
431
+ """
432
+
433
+ html_file = run_dir / "report.html"
434
+ with open(html_file, 'w') as f:
435
+ f.write(html_content)
436
+
437
+ self.logger.info(f"HTML report saved to: {html_file}")
438
+
439
+ def list_available_tests(self) -> Dict[str, Any]:
440
+ """List all available tests with their information."""
441
+ tests = self.discovery.discover_tests(str(self.base_path))
442
+ return {
443
+ "total_tests": len(tests),
444
+ "categories": list(self.discovery.list_all_categories()),
445
+ "tags": list(self.discovery.list_all_tags()),
446
+ "tests": self.discovery.get_test_info()
447
+ }
@@ -0,0 +1,234 @@
1
+ """Shared CLI connection manager for the entire test session."""
2
+
3
+ import asyncio
4
+ import threading
5
+ import subprocess
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any
11
+ import logging
12
+ import atexit
13
+
14
+
15
+ class SharedCLIManager:
16
+ """Singleton CLI manager that maintains one connection for the entire test session."""
17
+
18
+ _instance: Optional['SharedCLIManager'] = None
19
+ _lock = threading.Lock()
20
+
21
+ def __new__(cls):
22
+ with cls._lock:
23
+ if cls._instance is None:
24
+ cls._instance = super().__new__(cls)
25
+ cls._instance._initialized = False
26
+ return cls._instance
27
+
28
+ def __init__(self):
29
+ if self._initialized:
30
+ return
31
+
32
+ self.log_dir = Path("test_results/shared_cli")
33
+ self.log_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ self.connection_thread: Optional[threading.Thread] = None
36
+ self.cli_process: Optional[subprocess.Popen] = None
37
+ self.is_connected = False
38
+ self.connection_lock = threading.Lock()
39
+
40
+ # Create shared log file
41
+ timestamp = int(time.time())
42
+ self.log_file = self.log_dir / f"shared_cli_connection_{timestamp}.log"
43
+
44
+ self.logger = logging.getLogger("shared_cli_manager")
45
+ self.logger.setLevel(logging.WARNING)
46
+
47
+ # Register cleanup on exit
48
+ atexit.register(self.cleanup_on_exit)
49
+
50
+ self._initialized = True
51
+
52
+ @classmethod
53
+ def get_instance(cls) -> 'SharedCLIManager':
54
+ """Get the singleton instance."""
55
+ return cls()
56
+
57
+ async def ensure_connected(self, debug: bool = True, timeout: int = 30) -> bool:
58
+ """Ensure CLI connection is active, start if needed."""
59
+ if self.is_connection_active():
60
+ return True
61
+
62
+ return await self.connect(debug, timeout)
63
+
64
+ async def connect(self, debug: bool = True, timeout: int = 30) -> bool:
65
+ """Start CLI connection in background thread with output redirection."""
66
+ with self.connection_lock:
67
+ if self.is_connected:
68
+ return True
69
+
70
+ try:
71
+ # Import the CLI function
72
+ from portacode.cli import cli
73
+
74
+
75
+ # Start connection in separate thread
76
+ self.connection_thread = threading.Thread(
77
+ target=self._run_cli_connection,
78
+ args=(debug,),
79
+ daemon=True
80
+ )
81
+ self.connection_thread.start()
82
+
83
+ # Wait for connection to establish
84
+ start_time = time.time()
85
+ while not self.is_connection_active() and (time.time() - start_time) < timeout:
86
+ await asyncio.sleep(0.5)
87
+
88
+ if self.is_connection_active():
89
+ self.logger.info("Shared CLI connection established successfully")
90
+ return True
91
+ else:
92
+ self.logger.error("Failed to establish shared CLI connection within timeout")
93
+ return False
94
+
95
+ except Exception as e:
96
+ self.logger.error(f"Error starting shared CLI connection: {e}")
97
+ return False
98
+
99
+ def _run_cli_connection(self, debug: bool = True):
100
+ """Run CLI connection in separate thread with output redirection."""
101
+ try:
102
+
103
+ with open(self.log_file, 'w') as log_file:
104
+ log_file.write(f"=== Shared CLI Connection Log ===\\n")
105
+ log_file.write(f"Started at: {time.ctime()}\\n")
106
+ log_file.write("=" * 50 + "\\n\\n")
107
+ log_file.flush()
108
+
109
+ # Import the CLI function
110
+ from portacode.cli import cli
111
+
112
+ # Mark as connected (we assume the import worked)
113
+ with self.connection_lock:
114
+ self.is_connected = True
115
+
116
+
117
+ # Just keep the thread alive since we're sharing the connection
118
+ # The actual CLI connection will be managed by individual tests
119
+ while self.is_connected:
120
+ time.sleep(1)
121
+
122
+ except Exception as e:
123
+ with self.connection_lock:
124
+ self.is_connected = False
125
+ self.logger.error(f"Shared CLI connection failed: {e}")
126
+
127
+ # Write error to log file
128
+ try:
129
+ with open(self.log_file, 'a') as log_file:
130
+ log_file.write(f"\\nERROR: {e}\\n")
131
+ except:
132
+ pass
133
+
134
+ def get_log_content(self) -> str:
135
+ """Get the current content of the CLI log file."""
136
+ try:
137
+ with open(self.log_file, 'r') as f:
138
+ return f.read()
139
+ except Exception as e:
140
+ self.logger.error(f"Error reading log file: {e}")
141
+ return ""
142
+
143
+ def is_connection_active(self) -> bool:
144
+ """Check if the CLI connection is still active."""
145
+ with self.connection_lock:
146
+ return self.is_connected and (
147
+ self.connection_thread is not None and
148
+ self.connection_thread.is_alive()
149
+ )
150
+
151
+ def get_connection_info(self) -> Dict[str, Any]:
152
+ """Get information about the current connection."""
153
+ return {
154
+ "is_connected": self.is_connection_active(),
155
+ "log_file": str(self.log_file),
156
+ "log_exists": self.log_file.exists(),
157
+ "log_size": self.log_file.stat().st_size if self.log_file.exists() else 0,
158
+ "connection_type": "shared"
159
+ }
160
+
161
+ def cleanup_on_exit(self):
162
+ """Cleanup method called on program exit."""
163
+ try:
164
+ with self.connection_lock:
165
+ self.is_connected = False
166
+
167
+ if self.cli_process:
168
+ self.cli_process.terminate()
169
+ try:
170
+ self.cli_process.wait(timeout=5)
171
+ except subprocess.TimeoutExpired:
172
+ self.cli_process.kill()
173
+
174
+ except Exception as e:
175
+ self.logger.error(f"Error during cleanup: {e}")
176
+
177
+
178
+ class TestCLIProxy:
179
+ """Proxy class that provides the same interface as CLIManager but uses shared connection."""
180
+
181
+ _actual_cli_manager = None
182
+ _cli_initialized = False
183
+
184
+ def __init__(self, test_name: str, log_dir: str = None):
185
+ self.test_name = test_name
186
+ self.shared_manager = SharedCLIManager.get_instance()
187
+ self.logger = logging.getLogger(f"cli_proxy.{test_name}")
188
+ self.logger.setLevel(logging.WARNING)
189
+
190
+ async def connect(self, debug: bool = True, timeout: int = 30) -> bool:
191
+ """Connect using shared manager - creates actual CLI connection if needed."""
192
+ # Import here to avoid circular imports
193
+ from .cli_manager import CLIManager
194
+
195
+ # Create one actual CLI connection that all tests share
196
+ if not TestCLIProxy._cli_initialized:
197
+ TestCLIProxy._actual_cli_manager = CLIManager("shared_connection", "test_results/shared_cli")
198
+ connected = await TestCLIProxy._actual_cli_manager.connect(debug, timeout)
199
+ if connected:
200
+ TestCLIProxy._cli_initialized = True
201
+ return connected
202
+ else:
203
+ # Reuse existing connection
204
+ return TestCLIProxy._actual_cli_manager.is_connection_active()
205
+
206
+ def get_log_content(self) -> str:
207
+ """Get log content from actual CLI manager."""
208
+ if TestCLIProxy._actual_cli_manager:
209
+ return TestCLIProxy._actual_cli_manager.get_log_content()
210
+ return ""
211
+
212
+ def is_connection_active(self) -> bool:
213
+ """Check if actual CLI connection is active."""
214
+ if TestCLIProxy._actual_cli_manager:
215
+ return TestCLIProxy._actual_cli_manager.is_connection_active()
216
+ return False
217
+
218
+ async def disconnect(self):
219
+ """Disconnect - but don't actually disconnect shared connection."""
220
+ # Don't disconnect the shared connection, just log that this test is done
221
+ self.logger.info(f"Test {self.test_name} finished using shared CLI connection")
222
+
223
+ def get_connection_info(self) -> Dict[str, Any]:
224
+ """Get connection info with test name context."""
225
+ if TestCLIProxy._actual_cli_manager:
226
+ info = TestCLIProxy._actual_cli_manager.get_connection_info()
227
+ info["test_name"] = self.test_name
228
+ info["connection_type"] = "shared"
229
+ return info
230
+ return {
231
+ "test_name": self.test_name,
232
+ "is_connected": False,
233
+ "connection_type": "shared"
234
+ }