portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,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
|
+
}
|