portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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 +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
- portacode/connection/handlers/__init__.py +28 -1
- portacode/connection/handlers/base.py +78 -16
- 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 -2185
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +53 -46
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +214 -24
- 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/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.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.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.19.dev4.dist-info/METADATA +0 -241
- portacode-0.3.19.dev4.dist-info/RECORD +0 -30
- portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.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
|
+
}
|