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,520 @@
|
|
|
1
|
+
"""Playwright session management with comprehensive recording and logging."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import asyncio
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Dict, Any, List
|
|
7
|
+
import logging
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
|
15
|
+
PLAYWRIGHT_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
PLAYWRIGHT_AVAILABLE = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PlaywrightManager:
|
|
21
|
+
"""Manages Playwright sessions with comprehensive recording and logging."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, test_name: str, recordings_dir: str = "test_recordings"):
|
|
24
|
+
if not PLAYWRIGHT_AVAILABLE:
|
|
25
|
+
raise ImportError("Playwright is not installed. Run: pip install playwright")
|
|
26
|
+
|
|
27
|
+
self.test_name = test_name
|
|
28
|
+
self.recordings_dir = Path(recordings_dir)
|
|
29
|
+
self.recordings_dir.mkdir(exist_ok=True)
|
|
30
|
+
|
|
31
|
+
# Create subdirectory for this test
|
|
32
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
33
|
+
self.test_recordings_dir = self.recordings_dir / f"{test_name}_{timestamp}"
|
|
34
|
+
self.test_recordings_dir.mkdir(exist_ok=True)
|
|
35
|
+
|
|
36
|
+
self.playwright = None
|
|
37
|
+
self.browser: Optional[Browser] = None
|
|
38
|
+
self.context: Optional[BrowserContext] = None
|
|
39
|
+
self.page: Optional[Page] = None
|
|
40
|
+
|
|
41
|
+
self.logger = logging.getLogger(f"playwright_manager.{test_name}")
|
|
42
|
+
self.logger.setLevel(logging.WARNING) # Only show warnings and errors
|
|
43
|
+
|
|
44
|
+
# Recording and logging paths
|
|
45
|
+
self.video_path = self.test_recordings_dir / "recording.webm"
|
|
46
|
+
self.screenshot_dir = self.test_recordings_dir / "screenshots"
|
|
47
|
+
self.screenshot_dir.mkdir(exist_ok=True)
|
|
48
|
+
self.trace_path = self.test_recordings_dir / "trace.zip"
|
|
49
|
+
self.har_path = self.test_recordings_dir / "network.har"
|
|
50
|
+
self.console_log_path = self.test_recordings_dir / "console.log"
|
|
51
|
+
self.actions_log_path = self.test_recordings_dir / "actions.json"
|
|
52
|
+
self.websocket_log_path = self.test_recordings_dir / "websockets.json"
|
|
53
|
+
|
|
54
|
+
# Action tracking
|
|
55
|
+
self.actions_log: List[Dict[str, Any]] = []
|
|
56
|
+
self.screenshot_counter = 0
|
|
57
|
+
self.websocket_logs: List[Dict[str, Any]] = []
|
|
58
|
+
|
|
59
|
+
async def start_session(self,
|
|
60
|
+
url: Optional[str] = None,
|
|
61
|
+
username: Optional[str] = None,
|
|
62
|
+
password: Optional[str] = None,
|
|
63
|
+
browser_type: Optional[str] = None,
|
|
64
|
+
headless: Optional[bool] = None) -> bool:
|
|
65
|
+
"""Start Playwright session with comprehensive recording."""
|
|
66
|
+
try:
|
|
67
|
+
# Load environment variables
|
|
68
|
+
env_url = os.getenv('TEST_BASE_URL', 'http://192.168.1.188:8001/')
|
|
69
|
+
env_username = os.getenv('TEST_USERNAME')
|
|
70
|
+
env_password = os.getenv('TEST_PASSWORD')
|
|
71
|
+
env_browser = os.getenv('TEST_BROWSER', 'chromium')
|
|
72
|
+
env_headless = os.getenv('TEST_HEADLESS', 'false').lower() in ('true', '1', 'yes')
|
|
73
|
+
env_video_width = int(os.getenv('TEST_VIDEO_WIDTH', '1920'))
|
|
74
|
+
env_video_height = int(os.getenv('TEST_VIDEO_HEIGHT', '1080'))
|
|
75
|
+
env_viewport_width = os.getenv('TEST_VIEWPORT_WIDTH')
|
|
76
|
+
env_viewport_height = os.getenv('TEST_VIEWPORT_HEIGHT')
|
|
77
|
+
env_device_scale = os.getenv('TEST_DEVICE_SCALE_FACTOR')
|
|
78
|
+
env_is_mobile = os.getenv('TEST_IS_MOBILE')
|
|
79
|
+
env_has_touch = os.getenv('TEST_HAS_TOUCH')
|
|
80
|
+
env_user_agent = os.getenv('TEST_USER_AGENT')
|
|
81
|
+
automation_token = os.getenv('TEST_RUNNER_BYPASS_TOKEN')
|
|
82
|
+
|
|
83
|
+
# Use provided values or fall back to environment
|
|
84
|
+
self.base_url = url or env_url
|
|
85
|
+
self.username = username or env_username
|
|
86
|
+
self.password = password or env_password
|
|
87
|
+
browser_type = browser_type or env_browser
|
|
88
|
+
headless = headless if headless is not None else env_headless
|
|
89
|
+
|
|
90
|
+
if not self.username or not self.password:
|
|
91
|
+
self.logger.error("Username and password must be provided via parameters or environment variables")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
self.logger.info(f"Starting Playwright session for test: {self.test_name}")
|
|
95
|
+
self.logger.info(f"Target URL: {self.base_url}")
|
|
96
|
+
self.logger.info(f"Browser: {browser_type}, Headless: {headless}")
|
|
97
|
+
|
|
98
|
+
# Start Playwright
|
|
99
|
+
try:
|
|
100
|
+
self.playwright = await async_playwright().start()
|
|
101
|
+
self.logger.info("Playwright started successfully")
|
|
102
|
+
except Exception as e:
|
|
103
|
+
raise Exception(f"Failed to start Playwright: {e}")
|
|
104
|
+
|
|
105
|
+
# Launch browser with optimized settings for video recording
|
|
106
|
+
try:
|
|
107
|
+
# Common args for better video recording quality
|
|
108
|
+
launch_args = [
|
|
109
|
+
'--disable-blink-features=AutomationControlled',
|
|
110
|
+
'--disable-dev-shm-usage',
|
|
111
|
+
'--disable-gpu' if headless else '--force-gpu-mem-available-mb=2048',
|
|
112
|
+
'--no-sandbox',
|
|
113
|
+
'--disable-background-timer-throttling',
|
|
114
|
+
'--disable-backgrounding-occluded-windows',
|
|
115
|
+
'--disable-renderer-backgrounding'
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if browser_type == "firefox":
|
|
119
|
+
self.browser = await self.playwright.firefox.launch(
|
|
120
|
+
headless=headless,
|
|
121
|
+
args=launch_args[:3] # Firefox doesn't support all Chromium args
|
|
122
|
+
)
|
|
123
|
+
elif browser_type == "webkit":
|
|
124
|
+
self.browser = await self.playwright.webkit.launch(headless=headless)
|
|
125
|
+
else:
|
|
126
|
+
self.browser = await self.playwright.chromium.launch(
|
|
127
|
+
headless=headless,
|
|
128
|
+
args=launch_args
|
|
129
|
+
)
|
|
130
|
+
self.logger.info(f"Browser ({browser_type}) launched successfully with optimized recording settings")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
raise Exception(f"Failed to launch {browser_type} browser: {e}")
|
|
133
|
+
|
|
134
|
+
# Create context with recording enabled and proper viewport
|
|
135
|
+
viewport_size = {
|
|
136
|
+
"width": int(env_viewport_width) if env_viewport_width else env_video_width,
|
|
137
|
+
"height": int(env_viewport_height) if env_viewport_height else env_video_height
|
|
138
|
+
}
|
|
139
|
+
video_size = {"width": env_video_width, "height": env_video_height}
|
|
140
|
+
context_kwargs = {
|
|
141
|
+
"record_video_dir": str(self.test_recordings_dir),
|
|
142
|
+
"record_video_size": video_size,
|
|
143
|
+
"record_har_path": str(self.har_path),
|
|
144
|
+
"record_har_omit_content": False,
|
|
145
|
+
"viewport": viewport_size
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if env_device_scale:
|
|
149
|
+
try:
|
|
150
|
+
context_kwargs["device_scale_factor"] = float(env_device_scale)
|
|
151
|
+
except ValueError:
|
|
152
|
+
self.logger.warning(f"Invalid TEST_DEVICE_SCALE_FACTOR '{env_device_scale}' - ignoring")
|
|
153
|
+
|
|
154
|
+
if env_is_mobile:
|
|
155
|
+
context_kwargs["is_mobile"] = env_is_mobile.lower() in ('true', '1', 'yes')
|
|
156
|
+
|
|
157
|
+
if env_has_touch:
|
|
158
|
+
context_kwargs["has_touch"] = env_has_touch.lower() in ('true', '1', 'yes')
|
|
159
|
+
|
|
160
|
+
if env_user_agent:
|
|
161
|
+
context_kwargs["user_agent"] = env_user_agent
|
|
162
|
+
|
|
163
|
+
self.context = await self.browser.new_context(**context_kwargs)
|
|
164
|
+
self.logger.info(
|
|
165
|
+
"Viewport configured: %sx%s (device scale: %s, mobile: %s, touch: %s)",
|
|
166
|
+
viewport_size["width"],
|
|
167
|
+
viewport_size["height"],
|
|
168
|
+
context_kwargs.get("device_scale_factor", 1.0),
|
|
169
|
+
context_kwargs.get("is_mobile", False),
|
|
170
|
+
context_kwargs.get("has_touch", False),
|
|
171
|
+
)
|
|
172
|
+
if automation_token:
|
|
173
|
+
parsed_base = urlparse(self.base_url)
|
|
174
|
+
target_host = parsed_base.hostname
|
|
175
|
+
target_scheme = parsed_base.scheme or "http"
|
|
176
|
+
header_name = "X-Portacode-Automation"
|
|
177
|
+
|
|
178
|
+
async def automation_header_route(route, request):
|
|
179
|
+
headers = dict(request.headers)
|
|
180
|
+
parsed_request = urlparse(request.url)
|
|
181
|
+
if parsed_request.hostname == target_host and parsed_request.scheme == target_scheme:
|
|
182
|
+
headers[header_name] = automation_token
|
|
183
|
+
else:
|
|
184
|
+
headers.pop(header_name, None)
|
|
185
|
+
await route.continue_(headers=headers)
|
|
186
|
+
|
|
187
|
+
await self.context.route("**/*", automation_header_route)
|
|
188
|
+
self.logger.info("Automation bypass header restricted to same-origin requests")
|
|
189
|
+
|
|
190
|
+
self.logger.info(f"Video recording configured: {env_video_width}x{env_video_height}")
|
|
191
|
+
|
|
192
|
+
# Start tracing
|
|
193
|
+
await self.context.tracing.start(
|
|
194
|
+
screenshots=True,
|
|
195
|
+
snapshots=True,
|
|
196
|
+
sources=True
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Create page
|
|
200
|
+
self.page = await self.context.new_page()
|
|
201
|
+
|
|
202
|
+
# Set up console logging
|
|
203
|
+
self.console_logs = []
|
|
204
|
+
self.page.on("console", self._handle_console_message)
|
|
205
|
+
|
|
206
|
+
# Set up WebSocket logging
|
|
207
|
+
self.page.on("websocket", self._handle_websocket)
|
|
208
|
+
|
|
209
|
+
# Set up request/response logging
|
|
210
|
+
self.page.on("request", self._handle_request)
|
|
211
|
+
self.page.on("response", self._handle_response)
|
|
212
|
+
|
|
213
|
+
# Navigate to base URL
|
|
214
|
+
await self.log_action("navigate", {"url": self.base_url})
|
|
215
|
+
await self.page.goto(self.base_url)
|
|
216
|
+
await self.take_screenshot("initial_load")
|
|
217
|
+
|
|
218
|
+
# Perform login if credentials provided
|
|
219
|
+
if self.username and self.password:
|
|
220
|
+
await self._perform_login()
|
|
221
|
+
|
|
222
|
+
self.logger.info("Playwright session started successfully")
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
error_msg = f"Failed to start Playwright session: {e}"
|
|
227
|
+
self.logger.error(error_msg)
|
|
228
|
+
await self.cleanup()
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
async def _perform_login(self):
|
|
232
|
+
"""Perform login using provided credentials."""
|
|
233
|
+
try:
|
|
234
|
+
# Navigate to login page first
|
|
235
|
+
login_url = f"{self.base_url}accounts/login/"
|
|
236
|
+
await self.page.goto(login_url)
|
|
237
|
+
await self.log_action("navigate_to_login", {"url": login_url})
|
|
238
|
+
await self.take_screenshot("login_page")
|
|
239
|
+
|
|
240
|
+
await self.log_action("login_start", {"username": self.username})
|
|
241
|
+
|
|
242
|
+
# Look for common login form elements
|
|
243
|
+
username_selectors = [
|
|
244
|
+
'input[name="username"]',
|
|
245
|
+
'input[name="email"]',
|
|
246
|
+
'input[type="email"]',
|
|
247
|
+
'input[id="username"]',
|
|
248
|
+
'input[id="email"]',
|
|
249
|
+
'#id_username',
|
|
250
|
+
'#id_email'
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
password_selectors = [
|
|
254
|
+
'input[name="password"]',
|
|
255
|
+
'input[type="password"]',
|
|
256
|
+
'input[id="password"]',
|
|
257
|
+
'#id_password'
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
submit_selectors = [
|
|
261
|
+
'button[type="submit"]',
|
|
262
|
+
'input[type="submit"]',
|
|
263
|
+
'button:has-text("Login")',
|
|
264
|
+
'button:has-text("Sign In")',
|
|
265
|
+
'.btn-primary',
|
|
266
|
+
'#login-button'
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
# Find and fill username
|
|
270
|
+
username_filled = False
|
|
271
|
+
for selector in username_selectors:
|
|
272
|
+
try:
|
|
273
|
+
if await self.page.is_visible(selector):
|
|
274
|
+
await self.page.fill(selector, self.username)
|
|
275
|
+
await self.log_action("fill_username", {"selector": selector})
|
|
276
|
+
username_filled = True
|
|
277
|
+
break
|
|
278
|
+
except:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
if not username_filled:
|
|
282
|
+
raise Exception("Could not find username input field")
|
|
283
|
+
|
|
284
|
+
# Find and fill password
|
|
285
|
+
password_filled = False
|
|
286
|
+
for selector in password_selectors:
|
|
287
|
+
try:
|
|
288
|
+
if await self.page.is_visible(selector):
|
|
289
|
+
await self.page.fill(selector, self.password)
|
|
290
|
+
await self.log_action("fill_password", {"selector": selector})
|
|
291
|
+
password_filled = True
|
|
292
|
+
break
|
|
293
|
+
except:
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
if not password_filled:
|
|
297
|
+
raise Exception("Could not find password input field")
|
|
298
|
+
|
|
299
|
+
await self.take_screenshot("login_form_filled")
|
|
300
|
+
|
|
301
|
+
# Submit form
|
|
302
|
+
submitted = False
|
|
303
|
+
for selector in submit_selectors:
|
|
304
|
+
try:
|
|
305
|
+
if await self.page.is_visible(selector):
|
|
306
|
+
await self.page.click(selector)
|
|
307
|
+
await self.log_action("click_submit", {"selector": selector})
|
|
308
|
+
submitted = True
|
|
309
|
+
break
|
|
310
|
+
except:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
if not submitted:
|
|
314
|
+
# Try pressing Enter on password field
|
|
315
|
+
for selector in password_selectors:
|
|
316
|
+
try:
|
|
317
|
+
await self.page.press(selector, "Enter")
|
|
318
|
+
await self.log_action("press_enter", {"selector": selector})
|
|
319
|
+
submitted = True
|
|
320
|
+
break
|
|
321
|
+
except:
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
if not submitted:
|
|
325
|
+
raise Exception("Could not submit login form")
|
|
326
|
+
|
|
327
|
+
# Wait for navigation or login success
|
|
328
|
+
await self.page.wait_for_load_state("networkidle")
|
|
329
|
+
await self.take_screenshot("post_login")
|
|
330
|
+
|
|
331
|
+
await self.log_action("login_complete", {"success": True})
|
|
332
|
+
self.logger.info("Login completed successfully")
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
await self.log_action("login_error", {"error": str(e)})
|
|
336
|
+
self.logger.error(f"Login failed: {e}")
|
|
337
|
+
await self.take_screenshot("login_error")
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
async def take_screenshot(self, name: str) -> Path:
|
|
341
|
+
"""Take a screenshot with automatic naming."""
|
|
342
|
+
if not self.page:
|
|
343
|
+
raise RuntimeError("No active page for screenshot")
|
|
344
|
+
|
|
345
|
+
self.screenshot_counter += 1
|
|
346
|
+
screenshot_path = self.screenshot_dir / f"{self.screenshot_counter:03d}_{name}.png"
|
|
347
|
+
|
|
348
|
+
await self.page.screenshot(path=str(screenshot_path))
|
|
349
|
+
await self.log_action("screenshot", {
|
|
350
|
+
"name": name,
|
|
351
|
+
"path": str(screenshot_path),
|
|
352
|
+
"counter": self.screenshot_counter
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
self.logger.info(f"Screenshot saved: {screenshot_path}")
|
|
356
|
+
return screenshot_path
|
|
357
|
+
|
|
358
|
+
async def log_action(self, action_type: str, details: Dict[str, Any]):
|
|
359
|
+
"""Log an action with timestamp and details."""
|
|
360
|
+
action_entry = {
|
|
361
|
+
"timestamp": datetime.now().isoformat(),
|
|
362
|
+
"action_type": action_type,
|
|
363
|
+
"details": details
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
self.actions_log.append(action_entry)
|
|
367
|
+
|
|
368
|
+
# Write to actions log file
|
|
369
|
+
try:
|
|
370
|
+
with open(self.actions_log_path, 'w') as f:
|
|
371
|
+
json.dump(self.actions_log, f, indent=2)
|
|
372
|
+
except Exception as e:
|
|
373
|
+
self.logger.error(f"Failed to write actions log: {e}")
|
|
374
|
+
|
|
375
|
+
async def log_timeline_marker(self, phase: str, description: str = ""):
|
|
376
|
+
"""Log a timeline marker for better test debugging and trace correlation."""
|
|
377
|
+
timestamp = datetime.now().isoformat()
|
|
378
|
+
marker_details = {
|
|
379
|
+
"phase": phase,
|
|
380
|
+
"description": description,
|
|
381
|
+
"timestamp": timestamp
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Log to actions for timeline tracking
|
|
385
|
+
await self.log_action("TIMELINE_MARKER", marker_details)
|
|
386
|
+
|
|
387
|
+
# Also log to console for visibility in trace viewer
|
|
388
|
+
if self.page:
|
|
389
|
+
try:
|
|
390
|
+
# Inject a console log into the page that will show up in traces
|
|
391
|
+
script = f"""
|
|
392
|
+
console.log('๐งช TEST PHASE: {phase}' + ({repr(description)} ? ' - ' + {repr(description)} : ''));
|
|
393
|
+
"""
|
|
394
|
+
asyncio.create_task(self.page.evaluate(script))
|
|
395
|
+
except Exception as e:
|
|
396
|
+
self.logger.warning(f"Could not inject timeline marker into page: {e}")
|
|
397
|
+
|
|
398
|
+
def _handle_console_message(self, msg):
|
|
399
|
+
"""Handle console messages from the page."""
|
|
400
|
+
console_entry = {
|
|
401
|
+
"timestamp": datetime.now().isoformat(),
|
|
402
|
+
"type": msg.type,
|
|
403
|
+
"text": msg.text
|
|
404
|
+
}
|
|
405
|
+
self.console_logs.append(console_entry)
|
|
406
|
+
|
|
407
|
+
# Write to console log file
|
|
408
|
+
try:
|
|
409
|
+
with open(self.console_log_path, 'w') as f:
|
|
410
|
+
json.dump(self.console_logs, f, indent=2)
|
|
411
|
+
except:
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
def _handle_websocket(self, websocket):
|
|
415
|
+
"""Handle WebSocket connections."""
|
|
416
|
+
self.logger.info(f"WebSocket opened: {websocket.url}")
|
|
417
|
+
websocket.on("framesent", lambda payload: self._log_websocket_message("sent", websocket.url, payload))
|
|
418
|
+
websocket.on("framereceived", lambda payload: self._log_websocket_message("received", websocket.url, payload))
|
|
419
|
+
websocket.on("close", lambda: self.logger.info(f"WebSocket closed: {websocket.url}"))
|
|
420
|
+
|
|
421
|
+
def _log_websocket_message(self, direction: str, url: str, payload: Any):
|
|
422
|
+
"""Log a WebSocket message."""
|
|
423
|
+
try:
|
|
424
|
+
parsed_payload = json.loads(payload)
|
|
425
|
+
except (json.JSONDecodeError, TypeError):
|
|
426
|
+
parsed_payload = payload
|
|
427
|
+
|
|
428
|
+
message_entry = {
|
|
429
|
+
"timestamp": datetime.now().isoformat(),
|
|
430
|
+
"direction": direction,
|
|
431
|
+
"url": url,
|
|
432
|
+
"payload": parsed_payload
|
|
433
|
+
}
|
|
434
|
+
self.websocket_logs.append(message_entry)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
with open(self.websocket_log_path, 'w') as f:
|
|
438
|
+
json.dump(self.websocket_logs, f, indent=2)
|
|
439
|
+
except Exception as e:
|
|
440
|
+
self.logger.error(f"Failed to write websocket log: {e}")
|
|
441
|
+
|
|
442
|
+
def _handle_request(self, request):
|
|
443
|
+
"""Handle network requests."""
|
|
444
|
+
self.logger.debug(f"Request: {request.method} {request.url}")
|
|
445
|
+
|
|
446
|
+
def _handle_response(self, response):
|
|
447
|
+
"""Handle network responses."""
|
|
448
|
+
self.logger.debug(f"Response: {response.status} {response.url}")
|
|
449
|
+
|
|
450
|
+
async def cleanup(self):
|
|
451
|
+
"""Clean up Playwright resources and finalize recordings."""
|
|
452
|
+
try:
|
|
453
|
+
if self.context:
|
|
454
|
+
# Stop tracing
|
|
455
|
+
await self.context.tracing.stop(path=str(self.trace_path))
|
|
456
|
+
|
|
457
|
+
if self.page:
|
|
458
|
+
await self.page.close()
|
|
459
|
+
|
|
460
|
+
if self.context:
|
|
461
|
+
await self.context.close()
|
|
462
|
+
|
|
463
|
+
if self.browser:
|
|
464
|
+
await self.browser.close()
|
|
465
|
+
|
|
466
|
+
if self.playwright:
|
|
467
|
+
await self.playwright.stop()
|
|
468
|
+
|
|
469
|
+
# Generate summary report
|
|
470
|
+
await self._generate_summary_report()
|
|
471
|
+
|
|
472
|
+
self.logger.info(f"Playwright session cleaned up. Recordings saved to: {self.test_recordings_dir}")
|
|
473
|
+
|
|
474
|
+
except Exception as e:
|
|
475
|
+
self.logger.error(f"Error during cleanup: {e}")
|
|
476
|
+
|
|
477
|
+
async def _generate_summary_report(self):
|
|
478
|
+
"""Generate a summary report of the test session."""
|
|
479
|
+
try:
|
|
480
|
+
summary = {
|
|
481
|
+
"test_name": self.test_name,
|
|
482
|
+
"start_time": self.actions_log[0]["timestamp"] if self.actions_log else None,
|
|
483
|
+
"end_time": datetime.now().isoformat(),
|
|
484
|
+
"total_actions": len(self.actions_log),
|
|
485
|
+
"total_screenshots": self.screenshot_counter,
|
|
486
|
+
"total_console_logs": len(getattr(self, 'console_logs', [])),
|
|
487
|
+
"total_websocket_logs": len(self.websocket_logs),
|
|
488
|
+
"recordings": {
|
|
489
|
+
"video": str(self.video_path) if self.video_path.exists() else None,
|
|
490
|
+
"trace": str(self.trace_path) if self.trace_path.exists() else None,
|
|
491
|
+
"har": str(self.har_path) if self.har_path.exists() else None,
|
|
492
|
+
"screenshots_dir": str(self.screenshot_dir),
|
|
493
|
+
"console_log": str(self.console_log_path),
|
|
494
|
+
"actions_log": str(self.actions_log_path),
|
|
495
|
+
"websocket_log": str(self.websocket_log_path)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
summary_path = self.test_recordings_dir / "summary.json"
|
|
500
|
+
with open(summary_path, 'w') as f:
|
|
501
|
+
json.dump(summary, f, indent=2)
|
|
502
|
+
|
|
503
|
+
self.logger.info(f"Summary report generated: {summary_path}")
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
self.logger.error(f"Failed to generate summary report: {e}")
|
|
507
|
+
|
|
508
|
+
def get_recordings_info(self) -> Dict[str, Any]:
|
|
509
|
+
"""Get information about all recordings for this test."""
|
|
510
|
+
return {
|
|
511
|
+
"test_name": self.test_name,
|
|
512
|
+
"recordings_dir": str(self.test_recordings_dir),
|
|
513
|
+
"video_path": str(self.video_path) if self.video_path.exists() else None,
|
|
514
|
+
"trace_path": str(self.trace_path) if self.trace_path.exists() else None,
|
|
515
|
+
"har_path": str(self.har_path) if self.har_path.exists() else None,
|
|
516
|
+
"screenshot_count": self.screenshot_counter,
|
|
517
|
+
"actions_count": len(self.actions_log),
|
|
518
|
+
"console_logs_count": len(getattr(self, 'console_logs', [])),
|
|
519
|
+
"websocket_logs_count": len(self.websocket_logs)
|
|
520
|
+
}
|