scitex 2.4.2__py3-none-any.whl → 2.4.3__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.
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/browser/debugging/_sync_session.py
5
+
6
+ """
7
+ Sync browser session context manager for pytest-playwright E2E tests.
8
+
9
+ Ensures proper cleanup of browser processes to prevent zombies.
10
+
11
+ Usage in conftest.py:
12
+ from scitex.browser import SyncBrowserSession
13
+
14
+ @pytest.fixture
15
+ def browser_session(page: Page):
16
+ with SyncBrowserSession(page) as session:
17
+ yield session
18
+ # Cleanup happens automatically even on exceptions
19
+
20
+ Or use the fixture factory:
21
+ from scitex.browser import create_browser_session_fixture
22
+ browser_session = create_browser_session_fixture()
23
+ """
24
+
25
+ import atexit
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ from contextlib import contextmanager
30
+ from typing import TYPE_CHECKING, Callable, Optional
31
+
32
+ if TYPE_CHECKING:
33
+ from playwright.sync_api import Page
34
+
35
+
36
+ class SyncBrowserSession:
37
+ """
38
+ Sync context manager for playwright browser sessions.
39
+
40
+ Ensures zombie process cleanup on test failures, timeouts, or crashes.
41
+ Tracks browser PIDs and kills orphaned processes on exit.
42
+ """
43
+
44
+ # Class-level tracking of active sessions for emergency cleanup
45
+ _active_sessions: list["SyncBrowserSession"] = []
46
+ _cleanup_registered = False
47
+
48
+ def __init__(
49
+ self,
50
+ page: "Page",
51
+ timeout: int = 60,
52
+ on_enter: Optional[Callable[["Page"], None]] = None,
53
+ on_exit: Optional[Callable[["Page", bool], None]] = None,
54
+ ):
55
+ """
56
+ Initialize sync browser session.
57
+
58
+ Args:
59
+ page: Playwright page instance from pytest-playwright
60
+ timeout: Default timeout for operations in seconds
61
+ on_enter: Callback when entering context
62
+ on_exit: Callback when exiting context (receives page and success flag)
63
+ """
64
+ self.page = page
65
+ self.timeout = timeout
66
+ self.on_enter = on_enter
67
+ self.on_exit = on_exit
68
+ self._browser_pid = None
69
+ self._context_pid = None
70
+ self._success = True
71
+
72
+ # Register class-level emergency cleanup
73
+ if not SyncBrowserSession._cleanup_registered:
74
+ atexit.register(SyncBrowserSession._emergency_cleanup)
75
+ SyncBrowserSession._cleanup_registered = True
76
+
77
+ def __enter__(self) -> "SyncBrowserSession":
78
+ """Enter context - track browser PIDs and run setup callback."""
79
+ # Track this session
80
+ SyncBrowserSession._active_sessions.append(self)
81
+
82
+ # Try to get browser PID for tracking
83
+ try:
84
+ if self.page.context.browser:
85
+ # Get the browser process
86
+ browser = self.page.context.browser
87
+ # Browser PID is available via internal _impl
88
+ if hasattr(browser, '_impl'):
89
+ impl = browser._impl
90
+ if hasattr(impl, '_process'):
91
+ self._browser_pid = impl._process.pid
92
+ except Exception:
93
+ pass # PID tracking is best-effort
94
+
95
+ # Run setup callback
96
+ if self.on_enter:
97
+ self.on_enter(self.page)
98
+
99
+ return self
100
+
101
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
102
+ """Exit context - ensure cleanup happens."""
103
+ self._success = exc_type is None
104
+
105
+ # Remove from active sessions
106
+ try:
107
+ SyncBrowserSession._active_sessions.remove(self)
108
+ except ValueError:
109
+ pass
110
+
111
+ # Run exit callback
112
+ if self.on_exit:
113
+ try:
114
+ self.on_exit(self.page, self._success)
115
+ except Exception:
116
+ pass # Don't fail on callback errors
117
+
118
+ # If there was an exception, try to close gracefully
119
+ if exc_type is not None:
120
+ try:
121
+ self.page.close()
122
+ except Exception:
123
+ pass
124
+
125
+ try:
126
+ self.page.context.close()
127
+ except Exception:
128
+ pass
129
+
130
+ # Kill orphaned browser process if we have the PID
131
+ if self._browser_pid and not self._success:
132
+ self._kill_process_tree(self._browser_pid)
133
+
134
+ # Don't suppress the exception
135
+ return False
136
+
137
+ @staticmethod
138
+ def _kill_process_tree(pid: int):
139
+ """Kill a process and all its children (zombies)."""
140
+ try:
141
+ # Try SIGTERM first
142
+ os.kill(pid, signal.SIGTERM)
143
+ except ProcessLookupError:
144
+ return # Already dead
145
+ except PermissionError:
146
+ return # Can't kill
147
+
148
+ # Give it a moment
149
+ import time
150
+ time.sleep(0.5)
151
+
152
+ # Force kill if still running
153
+ try:
154
+ os.kill(pid, signal.SIGKILL)
155
+ except (ProcessLookupError, PermissionError):
156
+ pass
157
+
158
+ @classmethod
159
+ def _emergency_cleanup(cls):
160
+ """Emergency cleanup of all active sessions on process exit."""
161
+ for session in cls._active_sessions[:]: # Copy list to avoid mutation
162
+ if session._browser_pid:
163
+ cls._kill_process_tree(session._browser_pid)
164
+ cls._active_sessions.clear()
165
+
166
+ @staticmethod
167
+ def kill_zombie_browsers():
168
+ """Kill all zombie chromium/chrome processes from failed tests.
169
+
170
+ Call this at the start of test sessions to clean up from previous runs.
171
+ """
172
+ try:
173
+ # Find orphaned chromium processes
174
+ result = subprocess.run(
175
+ ["pgrep", "-f", "chromium|chrome"],
176
+ capture_output=True,
177
+ text=True,
178
+ )
179
+ if result.returncode == 0:
180
+ pids = result.stdout.strip().split('\n')
181
+ for pid in pids:
182
+ if pid:
183
+ try:
184
+ os.kill(int(pid), signal.SIGKILL)
185
+ except (ProcessLookupError, PermissionError, ValueError):
186
+ pass
187
+ except FileNotFoundError:
188
+ pass # pgrep not available
189
+
190
+
191
+ @contextmanager
192
+ def sync_browser_session(
193
+ page: "Page",
194
+ timeout: int = 60,
195
+ on_enter: Optional[Callable[["Page"], None]] = None,
196
+ on_exit: Optional[Callable[["Page", bool], None]] = None,
197
+ ):
198
+ """
199
+ Context manager for sync playwright sessions.
200
+
201
+ Usage:
202
+ with sync_browser_session(page) as session:
203
+ session.page.goto(url)
204
+ # ... test code
205
+ # Cleanup happens automatically
206
+ """
207
+ session = SyncBrowserSession(page, timeout, on_enter, on_exit)
208
+ with session:
209
+ yield session
210
+
211
+
212
+ def create_browser_session_fixture(
213
+ timeout: int = 60,
214
+ setup: Optional[Callable[["Page"], None]] = None,
215
+ teardown: Optional[Callable[["Page", bool], None]] = None,
216
+ kill_zombies_on_start: bool = True,
217
+ ):
218
+ """
219
+ Create a pytest fixture for browser session with cleanup.
220
+
221
+ Usage in conftest.py:
222
+ from scitex.browser import create_browser_session_fixture
223
+
224
+ browser_session = create_browser_session_fixture(
225
+ timeout=60,
226
+ setup=lambda page: print(f"Starting test"),
227
+ teardown=lambda page, success: print(f"Test {'passed' if success else 'failed'}"),
228
+ kill_zombies_on_start=True,
229
+ )
230
+
231
+ Args:
232
+ timeout: Default timeout for operations
233
+ setup: Callback when entering session
234
+ teardown: Callback when exiting (receives page and success flag)
235
+ kill_zombies_on_start: Kill orphaned browsers before first test
236
+
237
+ Returns:
238
+ A pytest fixture function
239
+ """
240
+ import pytest
241
+
242
+ _zombies_cleaned = False
243
+
244
+ @pytest.fixture
245
+ def browser_session(page: "Page"):
246
+ nonlocal _zombies_cleaned
247
+
248
+ # Clean up zombies from previous runs (once per session)
249
+ if kill_zombies_on_start and not _zombies_cleaned:
250
+ SyncBrowserSession.kill_zombie_browsers()
251
+ _zombies_cleaned = True
252
+
253
+ with SyncBrowserSession(page, timeout, setup, teardown) as session:
254
+ yield session
255
+
256
+ return browser_session
257
+
258
+
259
+ # EOF
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/browser/debugging/_test_monitor.py
5
+
6
+ """
7
+ Test monitoring with periodic screenshots using scitex.capture.
8
+
9
+ Provides automated visual monitoring during E2E tests:
10
+ - Periodic screenshots at configurable intervals
11
+ - Integration with scitex.capture for WSL/Windows support
12
+ - GIF generation from test sessions
13
+ - Pytest fixture integration
14
+
15
+ Usage in conftest.py:
16
+ from scitex.browser.debugging import (
17
+ create_test_monitor,
18
+ TestMonitor,
19
+ )
20
+
21
+ @pytest.fixture
22
+ def test_monitor():
23
+ monitor = TestMonitor(interval=2.0, verbose=True)
24
+ monitor.start()
25
+ yield monitor
26
+ monitor.stop()
27
+ monitor.create_gif() # Optional: create GIF from screenshots
28
+ """
29
+
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING, Optional
33
+
34
+ if TYPE_CHECKING:
35
+ from playwright.sync_api import Page
36
+
37
+
38
+ class TestMonitor:
39
+ """
40
+ Monitor E2E tests with periodic screenshots using scitex.capture.
41
+
42
+ Captures screenshots at regular intervals during test execution,
43
+ allowing visual inspection of test progress and debugging.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ output_dir: str | Path = None,
49
+ interval: float = 2.0,
50
+ quality: int = 70,
51
+ verbose: bool = False,
52
+ test_name: str = None,
53
+ ):
54
+ """
55
+ Initialize test monitor.
56
+
57
+ Args:
58
+ output_dir: Directory for screenshots (default: ~/.scitex/test_monitor)
59
+ interval: Seconds between screenshots (default: 2.0)
60
+ quality: JPEG quality 1-100 (default: 70)
61
+ verbose: Print capture messages
62
+ test_name: Optional test name for session identification
63
+ """
64
+ self.output_dir = Path(output_dir or Path.home() / ".scitex" / "test_monitor")
65
+ self.interval = interval
66
+ self.quality = quality
67
+ self.verbose = verbose
68
+ self.test_name = test_name
69
+ self.session_id = None
70
+ self._worker = None
71
+ self._capture_manager = None
72
+
73
+ def start(self, test_name: str = None) -> str:
74
+ """
75
+ Start periodic screenshot capture.
76
+
77
+ Args:
78
+ test_name: Optional test name to include in session
79
+
80
+ Returns:
81
+ Session ID for this capture session
82
+ """
83
+ try:
84
+ from scitex.capture import CaptureManager
85
+ except ImportError:
86
+ if self.verbose:
87
+ print("[TestMonitor] scitex.capture not available")
88
+ return None
89
+
90
+ # Generate session ID
91
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
92
+ name = test_name or self.test_name or "test"
93
+ safe_name = name.replace("::", "_").replace("[", "_").replace("]", "").replace("/", "_")
94
+ self.session_id = f"{timestamp}_{safe_name}"
95
+
96
+ # Create session directory
97
+ session_dir = self.output_dir / self.session_id
98
+ session_dir.mkdir(parents=True, exist_ok=True)
99
+
100
+ # Start capture
101
+ self._capture_manager = CaptureManager()
102
+ self._worker = self._capture_manager.start_capture(
103
+ output_dir=str(session_dir),
104
+ interval=self.interval,
105
+ jpeg=True,
106
+ quality=self.quality,
107
+ verbose=self.verbose,
108
+ )
109
+
110
+ if self.verbose:
111
+ print(f"[TestMonitor] Started: {session_dir} (interval: {self.interval}s)")
112
+
113
+ return self.session_id
114
+
115
+ def stop(self) -> dict:
116
+ """
117
+ Stop screenshot capture.
118
+
119
+ Returns:
120
+ Status dict with session info
121
+ """
122
+ if self._capture_manager:
123
+ self._capture_manager.stop_capture()
124
+
125
+ status = self.get_status()
126
+
127
+ if self.verbose and self._worker:
128
+ print(f"[TestMonitor] Stopped: {self._worker.screenshot_count} screenshots")
129
+
130
+ return status
131
+
132
+ def get_status(self) -> dict:
133
+ """Get current monitor status."""
134
+ if self._worker:
135
+ return self._worker.get_status()
136
+ return {
137
+ "running": False,
138
+ "session_id": self.session_id,
139
+ "output_dir": str(self.output_dir),
140
+ }
141
+
142
+ def take_snapshot(self, message: str = None) -> Optional[str]:
143
+ """
144
+ Take an immediate snapshot (in addition to periodic captures).
145
+
146
+ Args:
147
+ message: Optional message to include in filename
148
+
149
+ Returns:
150
+ Path to saved screenshot
151
+ """
152
+ try:
153
+ from scitex.capture import snap
154
+ return snap(message=message, output_dir=str(self.output_dir))
155
+ except ImportError:
156
+ return None
157
+
158
+ def create_gif(self, duration: float = 0.5, output_path: str = None) -> Optional[str]:
159
+ """
160
+ Create GIF from captured screenshots.
161
+
162
+ Args:
163
+ duration: Duration per frame in seconds
164
+ output_path: Output path for GIF (auto-generated if None)
165
+
166
+ Returns:
167
+ Path to created GIF
168
+ """
169
+ if not self.session_id:
170
+ return None
171
+
172
+ try:
173
+ from scitex.capture import create_gif_from_session
174
+
175
+ session_dir = self.output_dir / self.session_id
176
+ if output_path is None:
177
+ output_path = str(session_dir / f"{self.session_id}.gif")
178
+
179
+ return create_gif_from_session(
180
+ session_id=self.session_id,
181
+ output_path=output_path,
182
+ duration=duration,
183
+ )
184
+ except ImportError:
185
+ if self.verbose:
186
+ print("[TestMonitor] GIF creation requires scitex.capture")
187
+ return None
188
+
189
+ def get_screenshots(self) -> list:
190
+ """Get list of captured screenshot paths."""
191
+ if not self.session_id:
192
+ return []
193
+
194
+ session_dir = self.output_dir / self.session_id
195
+ if not session_dir.exists():
196
+ return []
197
+
198
+ return sorted(session_dir.glob("*.jpg")) + sorted(session_dir.glob("*.png"))
199
+
200
+
201
+ def create_test_monitor_fixture(
202
+ output_dir: str | Path = None,
203
+ interval: float = 2.0,
204
+ auto_gif: bool = False,
205
+ ):
206
+ """
207
+ Create a pytest fixture for test monitoring.
208
+
209
+ Usage in conftest.py:
210
+ from scitex.browser.debugging import create_test_monitor_fixture
211
+
212
+ test_monitor = create_test_monitor_fixture(interval=2.0, auto_gif=True)
213
+
214
+ Args:
215
+ output_dir: Directory for screenshots
216
+ interval: Seconds between screenshots
217
+ auto_gif: Create GIF automatically on test completion
218
+
219
+ Returns:
220
+ A pytest fixture function
221
+ """
222
+ import pytest
223
+
224
+ @pytest.fixture
225
+ def test_monitor(request):
226
+ """Pytest fixture for visual test monitoring."""
227
+ monitor = TestMonitor(
228
+ output_dir=output_dir,
229
+ interval=interval,
230
+ verbose=True,
231
+ test_name=request.node.nodeid,
232
+ )
233
+ monitor.start()
234
+ yield monitor
235
+ monitor.stop()
236
+ if auto_gif:
237
+ gif_path = monitor.create_gif()
238
+ if gif_path:
239
+ print(f"[TestMonitor] GIF created: {gif_path}")
240
+
241
+ return test_monitor
242
+
243
+
244
+ # Convenience function for quick monitoring
245
+ def monitor_test(
246
+ test_func=None,
247
+ interval: float = 2.0,
248
+ auto_gif: bool = False,
249
+ ):
250
+ """
251
+ Decorator for monitoring tests with periodic screenshots.
252
+
253
+ Usage:
254
+ @monitor_test(interval=1.0, auto_gif=True)
255
+ def test_my_feature(page):
256
+ # test code...
257
+
258
+ Args:
259
+ test_func: Test function (for use without parentheses)
260
+ interval: Seconds between screenshots
261
+ auto_gif: Create GIF on completion
262
+ """
263
+ def decorator(func):
264
+ def wrapper(*args, **kwargs):
265
+ monitor = TestMonitor(
266
+ interval=interval,
267
+ verbose=True,
268
+ test_name=func.__name__,
269
+ )
270
+ monitor.start()
271
+ try:
272
+ return func(*args, **kwargs)
273
+ finally:
274
+ monitor.stop()
275
+ if auto_gif:
276
+ monitor.create_gif()
277
+ return wrapper
278
+
279
+ if test_func is not None:
280
+ return decorator(test_func)
281
+ return decorator
282
+
283
+
284
+ # EOF