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,336 @@
1
+ """Base test class and category definitions."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from enum import Enum
8
+ from typing import Dict, Any, Optional, List, Set, Callable, Union
9
+ from pathlib import Path
10
+ import logging
11
+
12
+
13
+ class TestCategory(Enum):
14
+ """Test categories for organization and selective execution."""
15
+ SMOKE = "smoke"
16
+ INTEGRATION = "integration"
17
+ UI = "ui"
18
+ API = "api"
19
+ PERFORMANCE = "performance"
20
+ SECURITY = "security"
21
+ CUSTOM = "custom"
22
+
23
+
24
+ class TestResult:
25
+ """Represents the result of a test execution."""
26
+
27
+ def __init__(self, test_name: str, success: bool, message: str = "",
28
+ duration: float = 0.0, artifacts: Optional[Dict[str, Any]] = None):
29
+ self.test_name = test_name
30
+ self.success = success
31
+ self.message = message
32
+ self.duration = duration
33
+ self.artifacts = artifacts or {}
34
+
35
+
36
+ class TestStats:
37
+ """Simple statistics tracking for tests."""
38
+
39
+ def __init__(self, test_name: str):
40
+ self.test_name = test_name
41
+ self.stats: Dict[str, Any] = {}
42
+ self.timings: Dict[str, float] = {}
43
+ self._start_times: Dict[str, float] = {}
44
+
45
+ def start_timer(self, name: str):
46
+ """Start timing an operation."""
47
+ import time
48
+ self._start_times[name] = time.time()
49
+
50
+ def end_timer(self, name: str) -> float:
51
+ """End timing an operation and return duration in milliseconds."""
52
+ import time
53
+ if name in self._start_times:
54
+ duration = (time.time() - self._start_times[name]) * 1000 # Convert to ms
55
+ self.timings[name] = duration
56
+ del self._start_times[name]
57
+ return duration
58
+ return 0.0
59
+
60
+ def record_stat(self, name: str, value: Any):
61
+ """Record a statistic."""
62
+ self.stats[name] = value
63
+
64
+ def get_stats(self) -> Dict[str, Any]:
65
+ """Get all recorded stats and timings."""
66
+ return {
67
+ "stats": self.stats,
68
+ "timings": self.timings
69
+ }
70
+
71
+
72
+ class TestAssert:
73
+ """Simple assertion utilities for tests."""
74
+
75
+ def __init__(self, test_name: str):
76
+ self.test_name = test_name
77
+ self.failures: List[str] = []
78
+
79
+ def eq(self, actual: Any, expected: Any, message: str = ""):
80
+ """Assert equality."""
81
+ if actual != expected:
82
+ msg = f"{message}: Expected {expected}, got {actual}" if message else f"Expected {expected}, got {actual}"
83
+ self.failures.append(msg)
84
+ return self
85
+
86
+ def contains(self, container: Any, item: Any, message: str = ""):
87
+ """Assert item is in container."""
88
+ if item not in container:
89
+ msg = f"{message}: Expected {container} to contain {item}" if message else f"Expected {container} to contain {item}"
90
+ self.failures.append(msg)
91
+ return self
92
+
93
+ def is_true(self, value: Any, message: str = ""):
94
+ """Assert value is truthy."""
95
+ if not value:
96
+ msg = f"{message}: Expected truthy value, got {value}" if message else f"Expected truthy value, got {value}"
97
+ self.failures.append(msg)
98
+ return self
99
+
100
+ def is_false(self, value: Any, message: str = ""):
101
+ """Assert value is falsy."""
102
+ if value:
103
+ msg = f"{message}: Expected falsy value, got {value}" if message else f"Expected falsy value, got {value}"
104
+ self.failures.append(msg)
105
+ return self
106
+
107
+ def status_ok(self, response, message: str = ""):
108
+ """Assert HTTP response is 200."""
109
+ if not response or response.status != 200:
110
+ status = response.status if response else "No response"
111
+ msg = f"{message}: Expected 200, got {status}" if message else f"Expected 200, got {status}"
112
+ self.failures.append(msg)
113
+ return self
114
+
115
+ def url_contains(self, page, path: str, message: str = ""):
116
+ """Assert URL contains path."""
117
+ if path not in page.url:
118
+ msg = f"{message}: Expected URL to contain '{path}', got '{page.url}'" if message else f"Expected URL to contain '{path}', got '{page.url}'"
119
+ self.failures.append(msg)
120
+ return self
121
+
122
+ def element_visible(self, page, selector: str, message: str = ""):
123
+ """Assert element is visible (for async use: await assert.element_visible(...))."""
124
+ async def check():
125
+ try:
126
+ visible = await page.is_visible(selector)
127
+ if not visible:
128
+ msg = f"{message}: Element '{selector}' not visible" if message else f"Element '{selector}' not visible"
129
+ self.failures.append(msg)
130
+ except:
131
+ msg = f"{message}: Element '{selector}' not found" if message else f"Element '{selector}' not found"
132
+ self.failures.append(msg)
133
+ return self
134
+ return check()
135
+
136
+ def websocket_message(self, messages: List[Dict], message_type: str, contains: Optional[Dict] = None, message: str = ""):
137
+ """Assert websocket message exists."""
138
+ found = False
139
+ for msg in messages:
140
+ if msg.get("type") == message_type:
141
+ if contains:
142
+ if all(msg.get(k) == v for k, v in contains.items()):
143
+ found = True
144
+ break
145
+ else:
146
+ found = True
147
+ break
148
+
149
+ if not found:
150
+ msg_text = f"{message}: WebSocket message type '{message_type}'" if message else f"WebSocket message type '{message_type}'"
151
+ if contains:
152
+ msg_text += f" with {contains}"
153
+ msg_text += " not found"
154
+ self.failures.append(msg_text)
155
+ return self
156
+
157
+ def debug_file_contains(self, file_path: str, key: str, expected_value: Any = None, message: str = ""):
158
+ """Assert debug file contains key/value."""
159
+ try:
160
+ with open(file_path, 'r') as f:
161
+ data = json.load(f)
162
+
163
+ if key not in data:
164
+ msg = f"{message}: Key '{key}' not found in {file_path}" if message else f"Key '{key}' not found in {file_path}"
165
+ self.failures.append(msg)
166
+ elif expected_value is not None and data[key] != expected_value:
167
+ msg = f"{message}: Key '{key}' in {file_path} expected {expected_value}, got {data[key]}" if message else f"Key '{key}' in {file_path} expected {expected_value}, got {data[key]}"
168
+ self.failures.append(msg)
169
+ except Exception as e:
170
+ msg = f"{message}: Error reading {file_path}: {e}" if message else f"Error reading {file_path}: {e}"
171
+ self.failures.append(msg)
172
+ return self
173
+
174
+ def has_failures(self) -> bool:
175
+ """Check if any assertions failed."""
176
+ return len(self.failures) > 0
177
+
178
+ def get_failure_message(self) -> str:
179
+ """Get formatted failure message."""
180
+ if not self.failures:
181
+ return ""
182
+ return f"Assertions failed: {'; '.join(self.failures)}"
183
+
184
+
185
+ class DebugInspector:
186
+ """Helper for inspecting debug files and CLI state."""
187
+
188
+ @staticmethod
189
+ def load_client_sessions() -> List[Dict[str, Any]]:
190
+ """Load client_sessions.json."""
191
+ try:
192
+ with open("client_sessions.json", 'r') as f:
193
+ return json.load(f)
194
+ except:
195
+ return []
196
+
197
+ @staticmethod
198
+ def load_project_state() -> Dict[str, Any]:
199
+ """Load project_state_debug.json."""
200
+ try:
201
+ with open("project_state_debug.json", 'r') as f:
202
+ return json.load(f)
203
+ except:
204
+ return {}
205
+
206
+ @staticmethod
207
+ def get_active_sessions() -> List[str]:
208
+ """Get list of active session channel names."""
209
+ sessions = DebugInspector.load_client_sessions()
210
+ if isinstance(sessions, list):
211
+ return [session.get("channel_name", "") for session in sessions if session.get("channel_name")]
212
+ return []
213
+
214
+ @staticmethod
215
+ def get_session_info(channel_name: str) -> Dict[str, Any]:
216
+ """Get info for specific session by channel name."""
217
+ sessions = DebugInspector.load_client_sessions()
218
+ if isinstance(sessions, list):
219
+ for session in sessions:
220
+ if session.get("channel_name") == channel_name:
221
+ return session
222
+ return {}
223
+
224
+ @staticmethod
225
+ def get_project_files() -> List[str]:
226
+ """Get list of project files from state."""
227
+ state = DebugInspector.load_project_state()
228
+ return state.get("files", [])
229
+
230
+
231
+ class BaseTest(ABC):
232
+ """Base class for all tests in the framework."""
233
+
234
+ def __init__(self, name: str, category: TestCategory = TestCategory.CUSTOM,
235
+ description: str = "", tags: Optional[List[str]] = None,
236
+ depends_on: Optional[List[str]] = None, start_url: Optional[str] = None):
237
+ self.name = name
238
+ self.category = category
239
+ self.description = description
240
+ self.tags = tags or []
241
+ self.depends_on = depends_on or []
242
+ self.start_url = start_url
243
+ self.logger = logging.getLogger(f"test.{self.name}")
244
+ self.cli_manager = None
245
+ self.playwright_manager = None
246
+
247
+ # Test state tracking
248
+ self._dependency_results: Dict[str, TestResult] = {}
249
+
250
+ def assert_that(self) -> TestAssert:
251
+ """Get assertion helper."""
252
+ return TestAssert(self.name)
253
+
254
+ def inspect(self) -> DebugInspector:
255
+ """Get debug inspector."""
256
+ return DebugInspector()
257
+
258
+ def stats(self) -> TestStats:
259
+ """Get statistics helper."""
260
+ return TestStats(self.name)
261
+
262
+ @abstractmethod
263
+ async def run(self) -> TestResult:
264
+ """Execute the test and return results."""
265
+ pass
266
+
267
+ async def setup(self) -> None:
268
+ """Setup method called before test execution."""
269
+ pass
270
+
271
+ async def navigate_to_start_url(self) -> None:
272
+ """Navigate to the start URL if specified and different from current URL."""
273
+ if not self.start_url or not self.playwright_manager or not self.playwright_manager.page:
274
+ return
275
+
276
+ current_url = self.playwright_manager.page.url
277
+
278
+ # Extract path from current URL and start URL for proper comparison
279
+ try:
280
+ from urllib.parse import urlparse
281
+ current_path = urlparse(current_url).path
282
+ start_path = urlparse(self.start_url).path if self.start_url.startswith('http') else self.start_url
283
+
284
+ # Normalize paths (remove trailing slashes for comparison)
285
+ current_path = current_path.rstrip('/')
286
+ start_path = start_path.rstrip('/')
287
+
288
+ if current_path == start_path:
289
+ self.logger.debug(f"Already at correct URL: {current_url}")
290
+ return
291
+ except Exception as e:
292
+ self.logger.warning(f"URL comparison failed: {e}, falling back to navigation")
293
+
294
+ # Construct full URL if start_url is a relative path
295
+ target_url = self.start_url
296
+ if self.start_url.startswith('/') and hasattr(self.playwright_manager, 'base_url'):
297
+ # Extract base URL (protocol + host) and combine with relative path
298
+ base_parts = urlparse(self.playwright_manager.base_url)
299
+ target_url = f"{base_parts.scheme}://{base_parts.netloc}{self.start_url}"
300
+ elif self.start_url.startswith('/'):
301
+ # Fallback: extract base from current URL
302
+ current_parts = urlparse(current_url)
303
+ target_url = f"{current_parts.scheme}://{current_parts.netloc}{self.start_url}"
304
+
305
+ self.logger.info(f"Navigating from {current_url} to {target_url}")
306
+ await self.playwright_manager.page.goto(target_url)
307
+
308
+ # Wait for page to be ready
309
+ await self.playwright_manager.page.wait_for_load_state('domcontentloaded')
310
+
311
+ async def teardown(self) -> None:
312
+ """Teardown method called after test execution."""
313
+ pass
314
+
315
+ def set_cli_manager(self, cli_manager):
316
+ """Set the CLI manager for this test."""
317
+ self.cli_manager = cli_manager
318
+
319
+ def set_playwright_manager(self, playwright_manager):
320
+ """Set the Playwright manager for this test."""
321
+ self.playwright_manager = playwright_manager
322
+
323
+ def set_dependency_result(self, test_name: str, result: TestResult):
324
+ """Set result from dependent test."""
325
+ self._dependency_results[test_name] = result
326
+
327
+ def get_dependency_result(self, test_name: str) -> Optional[TestResult]:
328
+ """Get result from dependent test."""
329
+ return self._dependency_results.get(test_name)
330
+
331
+ def all_dependencies_passed(self) -> bool:
332
+ """Check if all dependencies passed."""
333
+ return all(
334
+ self._dependency_results.get(dep_name, TestResult(dep_name, False)).success
335
+ for dep_name in self.depends_on
336
+ )
@@ -0,0 +1,177 @@
1
+ """CLI connection manager with threading and file output."""
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
+
13
+
14
+ class CLIManager:
15
+ """Manages CLI connections with background threading and output redirection."""
16
+
17
+ def __init__(self, test_name: str, log_dir: str = "test_results"):
18
+ self.test_name = test_name
19
+ self.log_dir = Path(log_dir)
20
+ self.log_dir.mkdir(exist_ok=True)
21
+
22
+ self.connection_thread: Optional[threading.Thread] = None
23
+ self.cli_process: Optional[subprocess.Popen] = None
24
+ self.is_connected = False
25
+ self.connection_lock = threading.Lock()
26
+
27
+ # Create unique log file for this test
28
+ timestamp = int(time.time())
29
+ self.log_file = self.log_dir / f"{self.test_name}_{timestamp}_cli.log"
30
+
31
+ self.logger = logging.getLogger(f"cli_manager.{test_name}")
32
+ self.logger.setLevel(logging.WARNING) # Only show warnings and errors
33
+
34
+ async def connect(self, debug: bool = True, timeout: int = 30) -> bool:
35
+ """Start CLI connection in background thread with output redirection."""
36
+ try:
37
+ # Import the CLI function
38
+ from portacode.cli import cli
39
+
40
+ self.logger.info(f"Starting CLI connection for test: {self.test_name}")
41
+
42
+ # Start connection in separate thread
43
+ self.connection_thread = threading.Thread(
44
+ target=self._run_cli_connection,
45
+ args=(debug,),
46
+ daemon=True
47
+ )
48
+ self.connection_thread.start()
49
+
50
+ # Wait for connection to establish
51
+ start_time = time.time()
52
+ while not self.is_connected and (time.time() - start_time) < timeout:
53
+ await asyncio.sleep(0.5)
54
+
55
+ if self.is_connected:
56
+ self.logger.info("CLI connection established successfully")
57
+ return True
58
+ else:
59
+ self.logger.error("Failed to establish CLI connection within timeout")
60
+ return False
61
+
62
+ except Exception as e:
63
+ self.logger.error(f"Error starting CLI connection: {e}")
64
+ return False
65
+
66
+ def _run_cli_connection(self, debug: bool = True):
67
+ """Run CLI connection in separate thread with output redirection."""
68
+ try:
69
+ # Redirect stdout and stderr to log file
70
+ with open(self.log_file, 'w') as log_file:
71
+ log_file.write(f"=== CLI Connection Log for Test: {self.test_name} ===\\n")
72
+ log_file.write(f"Started at: {time.ctime()}\\n")
73
+ log_file.write("=" * 50 + "\\n\\n")
74
+ log_file.flush()
75
+
76
+ # Import and run CLI
77
+ from portacode.cli import cli
78
+
79
+ # Capture original stdout/stderr
80
+ original_stdout = sys.stdout
81
+ original_stderr = sys.stderr
82
+
83
+ try:
84
+ # Redirect output to both log file and capture
85
+ class TeeOutput:
86
+ def __init__(self, file_obj, original_stream):
87
+ self.file_obj = file_obj
88
+ self.original_stream = original_stream
89
+
90
+ def write(self, text):
91
+ self.file_obj.write(text)
92
+ self.file_obj.flush()
93
+ # Also write to original stream for debugging
94
+ if hasattr(self.original_stream, 'write'):
95
+ self.original_stream.write(text)
96
+
97
+ def flush(self):
98
+ self.file_obj.flush()
99
+ if hasattr(self.original_stream, 'flush'):
100
+ self.original_stream.flush()
101
+
102
+ # Set up tee outputs
103
+ sys.stdout = TeeOutput(log_file, original_stdout)
104
+ sys.stderr = TeeOutput(log_file, original_stderr)
105
+
106
+ # Mark as connected before starting CLI
107
+ with self.connection_lock:
108
+ self.is_connected = True
109
+
110
+ # Run CLI with connect command
111
+ args = ['connect', '--non-interactive']
112
+ if debug:
113
+ args.append('--debug')
114
+
115
+ cli(args)
116
+
117
+ finally:
118
+ # Restore original stdout/stderr
119
+ sys.stdout = original_stdout
120
+ sys.stderr = original_stderr
121
+
122
+ except Exception as e:
123
+ with self.connection_lock:
124
+ self.is_connected = False
125
+ self.logger.error(f"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
+ async def disconnect(self):
152
+ """Disconnect the CLI connection."""
153
+ try:
154
+ with self.connection_lock:
155
+ self.is_connected = False
156
+
157
+ if self.cli_process:
158
+ self.cli_process.terminate()
159
+ try:
160
+ self.cli_process.wait(timeout=5)
161
+ except subprocess.TimeoutExpired:
162
+ self.cli_process.kill()
163
+
164
+ self.logger.info("CLI connection disconnected")
165
+
166
+ except Exception as e:
167
+ self.logger.error(f"Error disconnecting CLI: {e}")
168
+
169
+ def get_connection_info(self) -> Dict[str, Any]:
170
+ """Get information about the current connection."""
171
+ return {
172
+ "test_name": self.test_name,
173
+ "is_connected": self.is_connection_active(),
174
+ "log_file": str(self.log_file),
175
+ "log_exists": self.log_file.exists(),
176
+ "log_size": self.log_file.stat().st_size if self.log_file.exists() else 0
177
+ }