scitex 2.4.1__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.
Files changed (36) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/browser/__init__.py +53 -0
  3. scitex/browser/auth/__init__.py +35 -0
  4. scitex/browser/auth/google.py +381 -0
  5. scitex/browser/collaboration/__init__.py +5 -0
  6. scitex/browser/debugging/__init__.py +56 -0
  7. scitex/browser/debugging/_failure_capture.py +372 -0
  8. scitex/browser/debugging/_sync_session.py +259 -0
  9. scitex/browser/debugging/_test_monitor.py +284 -0
  10. scitex/browser/debugging/_visual_cursor.py +432 -0
  11. scitex/scholar/citation_graph/README.md +117 -0
  12. scitex/scholar/citation_graph/__init__.py +29 -0
  13. scitex/scholar/citation_graph/builder.py +214 -0
  14. scitex/scholar/citation_graph/database.py +246 -0
  15. scitex/scholar/citation_graph/example.py +96 -0
  16. scitex/scholar/citation_graph/models.py +80 -0
  17. scitex/scholar/config/ScholarConfig.py +23 -3
  18. scitex/scholar/config/default.yaml +56 -0
  19. scitex/scholar/core/Paper.py +102 -0
  20. scitex/scholar/core/__init__.py +44 -0
  21. scitex/scholar/core/journal_normalizer.py +524 -0
  22. scitex/scholar/core/oa_cache.py +285 -0
  23. scitex/scholar/core/open_access.py +457 -0
  24. scitex/scholar/metadata_engines/ScholarEngine.py +9 -1
  25. scitex/scholar/metadata_engines/individual/CrossRefLocalEngine.py +82 -21
  26. scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
  27. scitex/scholar/pdf_download/strategies/__init__.py +6 -0
  28. scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
  29. scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +27 -9
  30. scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +24 -8
  31. scitex/scholar/search_engines/ScholarSearchEngine.py +6 -1
  32. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/METADATA +1 -1
  33. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/RECORD +36 -20
  34. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/WHEEL +0 -0
  35. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/entry_points.txt +0 -0
  36. {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -0,0 +1,432 @@
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/_visual_cursor.py
5
+
6
+ """
7
+ Visual cursor and click effects for E2E test feedback.
8
+
9
+ Provides visual feedback during browser automation:
10
+ - Visual cursor indicator that follows mouse movements
11
+ - Click ripple effects
12
+ - Drag state visualization
13
+ - Step progress messages
14
+
15
+ Works with both async and sync Playwright APIs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Union
21
+
22
+ if TYPE_CHECKING:
23
+ from playwright.async_api import Page as AsyncPage
24
+ from playwright.sync_api import Page as SyncPage
25
+
26
+ # CSS styles for visual effects
27
+ VISUAL_EFFECTS_CSS = """
28
+ /* Visual cursor indicator */
29
+ #_scitex_cursor {
30
+ position: fixed;
31
+ width: 24px;
32
+ height: 24px;
33
+ border: 3px solid #FF4444;
34
+ border-radius: 50%;
35
+ pointer-events: none;
36
+ z-index: 2147483647;
37
+ transform: translate(-50%, -50%);
38
+ transition: all 0.15s ease-out;
39
+ box-shadow: 0 0 15px rgba(255, 68, 68, 0.6);
40
+ display: none;
41
+ }
42
+ #_scitex_cursor.clicking {
43
+ transform: translate(-50%, -50%) scale(0.6);
44
+ background: rgba(255, 68, 68, 0.4);
45
+ box-shadow: 0 0 25px rgba(255, 68, 68, 0.8);
46
+ }
47
+ #_scitex_cursor.dragging {
48
+ border-color: #28A745;
49
+ box-shadow: 0 0 15px rgba(40, 167, 69, 0.6);
50
+ width: 28px;
51
+ height: 28px;
52
+ }
53
+
54
+ /* Click ripple effect */
55
+ .scitex-click-ripple {
56
+ position: fixed;
57
+ border-radius: 50%;
58
+ border: 3px solid #FF4444;
59
+ pointer-events: none;
60
+ z-index: 2147483646;
61
+ animation: clickRipple 0.5s ease-out forwards;
62
+ }
63
+ @keyframes clickRipple {
64
+ 0% { width: 0; height: 0; opacity: 1; transform: translate(-50%, -50%); }
65
+ 100% { width: 80px; height: 80px; opacity: 0; transform: translate(-50%, -50%); }
66
+ }
67
+
68
+ /* Step message container */
69
+ #_scitex_step_messages {
70
+ position: fixed;
71
+ top: 10px;
72
+ left: 10px;
73
+ z-index: 2147483647;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 8px;
77
+ max-width: 600px;
78
+ pointer-events: none;
79
+ }
80
+ .scitex-step-msg {
81
+ background: rgba(0, 0, 0, 0.9);
82
+ color: white;
83
+ padding: 14px 24px;
84
+ border-radius: 8px;
85
+ font-size: 16px;
86
+ font-family: 'Courier New', monospace;
87
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
88
+ word-wrap: break-word;
89
+ animation: stepSlideIn 0.3s ease-out;
90
+ }
91
+ @keyframes stepSlideIn {
92
+ 0% { opacity: 0; transform: translateX(-20px); }
93
+ 100% { opacity: 1; transform: translateX(0); }
94
+ }
95
+
96
+ /* Test result banner */
97
+ #_scitex_result_banner {
98
+ position: fixed;
99
+ top: 50%;
100
+ left: 50%;
101
+ transform: translate(-50%, -50%);
102
+ padding: 40px 80px;
103
+ border-radius: 16px;
104
+ font-size: 48px;
105
+ font-weight: bold;
106
+ font-family: 'Arial', sans-serif;
107
+ z-index: 2147483647;
108
+ pointer-events: none;
109
+ animation: resultPulse 0.5s ease-out;
110
+ }
111
+ #_scitex_result_banner.success {
112
+ background: rgba(40, 167, 69, 0.95);
113
+ color: white;
114
+ box-shadow: 0 0 50px rgba(40, 167, 69, 0.8);
115
+ }
116
+ #_scitex_result_banner.failure {
117
+ background: rgba(220, 53, 69, 0.95);
118
+ color: white;
119
+ box-shadow: 0 0 50px rgba(220, 53, 69, 0.8);
120
+ }
121
+ @keyframes resultPulse {
122
+ 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0; }
123
+ 50% { transform: translate(-50%, -50%) scale(1.1); }
124
+ 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
125
+ }
126
+ """
127
+
128
+ # JavaScript to inject visual effects
129
+ INJECT_EFFECTS_JS = f"""
130
+ () => {{
131
+ if (document.getElementById('_scitex_visual_effects')) return;
132
+
133
+ const style = document.createElement('style');
134
+ style.id = '_scitex_visual_effects';
135
+ style.textContent = `{VISUAL_EFFECTS_CSS}`;
136
+ document.head.appendChild(style);
137
+
138
+ // Create cursor element
139
+ const cursor = document.createElement('div');
140
+ cursor.id = '_scitex_cursor';
141
+ document.body.appendChild(cursor);
142
+
143
+ // Create step messages container
144
+ const msgContainer = document.createElement('div');
145
+ msgContainer.id = '_scitex_step_messages';
146
+ document.body.appendChild(msgContainer);
147
+ }}
148
+ """
149
+
150
+
151
+ def inject_visual_effects(page: Union["AsyncPage", "SyncPage"]) -> None:
152
+ """Inject CSS and elements for visual effects (sync version)."""
153
+ page.evaluate(INJECT_EFFECTS_JS)
154
+
155
+
156
+ async def inject_visual_effects_async(page: "AsyncPage") -> None:
157
+ """Inject CSS and elements for visual effects (async version)."""
158
+ await page.evaluate(INJECT_EFFECTS_JS)
159
+
160
+
161
+ def show_cursor_at(
162
+ page: Union["AsyncPage", "SyncPage"],
163
+ x: float,
164
+ y: float,
165
+ state: str = "normal"
166
+ ) -> None:
167
+ """Move visual cursor to position (sync version).
168
+
169
+ Args:
170
+ page: Playwright page object
171
+ x: X coordinate
172
+ y: Y coordinate
173
+ state: Cursor state - "normal", "clicking", or "dragging"
174
+ """
175
+ page.evaluate("""
176
+ ([x, y, state]) => {
177
+ let cursor = document.getElementById('_scitex_cursor');
178
+ if (!cursor) {
179
+ cursor = document.createElement('div');
180
+ cursor.id = '_scitex_cursor';
181
+ document.body.appendChild(cursor);
182
+ }
183
+ cursor.style.display = 'block';
184
+ cursor.style.left = x + 'px';
185
+ cursor.style.top = y + 'px';
186
+ cursor.className = state === 'clicking' ? 'clicking' :
187
+ state === 'dragging' ? 'dragging' : '';
188
+ if (state === 'dragging') {
189
+ cursor.style.borderColor = '#28A745';
190
+ cursor.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.6)';
191
+ } else {
192
+ cursor.style.borderColor = '#FF4444';
193
+ cursor.style.boxShadow = '0 0 15px rgba(255, 68, 68, 0.6)';
194
+ }
195
+ }
196
+ """, [x, y, state])
197
+
198
+
199
+ async def show_cursor_at_async(
200
+ page: "AsyncPage",
201
+ x: float,
202
+ y: float,
203
+ state: str = "normal"
204
+ ) -> None:
205
+ """Move visual cursor to position (async version)."""
206
+ await page.evaluate("""
207
+ ([x, y, state]) => {
208
+ let cursor = document.getElementById('_scitex_cursor');
209
+ if (!cursor) {
210
+ cursor = document.createElement('div');
211
+ cursor.id = '_scitex_cursor';
212
+ document.body.appendChild(cursor);
213
+ }
214
+ cursor.style.display = 'block';
215
+ cursor.style.left = x + 'px';
216
+ cursor.style.top = y + 'px';
217
+ cursor.className = state === 'clicking' ? 'clicking' :
218
+ state === 'dragging' ? 'dragging' : '';
219
+ if (state === 'dragging') {
220
+ cursor.style.borderColor = '#28A745';
221
+ cursor.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.6)';
222
+ } else {
223
+ cursor.style.borderColor = '#FF4444';
224
+ cursor.style.boxShadow = '0 0 15px rgba(255, 68, 68, 0.6)';
225
+ }
226
+ }
227
+ """, [x, y, state])
228
+
229
+
230
+ def show_click_effect(page: Union["AsyncPage", "SyncPage"], x: float, y: float) -> None:
231
+ """Show click ripple effect at position (sync version)."""
232
+ page.evaluate("""
233
+ ([x, y]) => {
234
+ const ripple = document.createElement('div');
235
+ ripple.className = 'scitex-click-ripple';
236
+ ripple.style.left = x + 'px';
237
+ ripple.style.top = y + 'px';
238
+ document.body.appendChild(ripple);
239
+ setTimeout(() => ripple.remove(), 600);
240
+
241
+ const cursor = document.getElementById('_scitex_cursor');
242
+ if (cursor) {
243
+ cursor.classList.add('clicking');
244
+ setTimeout(() => cursor.classList.remove('clicking'), 150);
245
+ }
246
+ }
247
+ """, [x, y])
248
+
249
+
250
+ async def show_click_effect_async(page: "AsyncPage", x: float, y: float) -> None:
251
+ """Show click ripple effect at position (async version)."""
252
+ await page.evaluate("""
253
+ ([x, y]) => {
254
+ const ripple = document.createElement('div');
255
+ ripple.className = 'scitex-click-ripple';
256
+ ripple.style.left = x + 'px';
257
+ ripple.style.top = y + 'px';
258
+ document.body.appendChild(ripple);
259
+ setTimeout(() => ripple.remove(), 600);
260
+
261
+ const cursor = document.getElementById('_scitex_cursor');
262
+ if (cursor) {
263
+ cursor.classList.add('clicking');
264
+ setTimeout(() => cursor.classList.remove('clicking'), 150);
265
+ }
266
+ }
267
+ """, [x, y])
268
+
269
+
270
+ def show_step(
271
+ page: Union["AsyncPage", "SyncPage"],
272
+ step: int,
273
+ total: int,
274
+ message: str,
275
+ level: str = "info"
276
+ ) -> None:
277
+ """Show numbered step message in browser (sync version).
278
+
279
+ Args:
280
+ page: Playwright page object
281
+ step: Current step number
282
+ total: Total number of steps
283
+ message: Message to display
284
+ level: Message level - "info", "success", "warning", or "error"
285
+ """
286
+ color_map = {
287
+ "info": "#17A2B8",
288
+ "success": "#28A745",
289
+ "warning": "#FFC107",
290
+ "error": "#DC3545",
291
+ }
292
+ color = color_map.get(level, color_map["info"])
293
+
294
+ page.evaluate("""
295
+ ([step, total, message, color]) => {
296
+ let container = document.getElementById('_scitex_step_messages');
297
+ if (!container) {
298
+ container = document.createElement('div');
299
+ container.id = '_scitex_step_messages';
300
+ container.style.cssText = `
301
+ position: fixed; top: 10px; left: 10px; z-index: 2147483647;
302
+ display: flex; flex-direction: column; gap: 8px;
303
+ max-width: 600px; pointer-events: none;
304
+ `;
305
+ document.body.appendChild(container);
306
+ }
307
+ const popup = document.createElement('div');
308
+ popup.className = 'scitex-step-msg';
309
+ popup.innerHTML = `<strong>[${step}/${total}] ${message}</strong>`;
310
+ popup.style.cssText = `
311
+ background: rgba(0, 0, 0, 0.9); color: white;
312
+ padding: 14px 24px; border-radius: 8px; font-size: 16px;
313
+ font-family: 'Courier New', monospace;
314
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
315
+ border-left: 6px solid ${color}; word-wrap: break-word;
316
+ `;
317
+ container.appendChild(popup);
318
+ while (container.children.length > 5) container.removeChild(container.firstChild);
319
+ setTimeout(() => { if (popup.parentNode) popup.parentNode.removeChild(popup); }, 8000);
320
+ }
321
+ """, [step, total, message, color])
322
+ page.wait_for_timeout(200)
323
+
324
+
325
+ async def show_step_async(
326
+ page: "AsyncPage",
327
+ step: int,
328
+ total: int,
329
+ message: str,
330
+ level: str = "info"
331
+ ) -> None:
332
+ """Show numbered step message in browser (async version)."""
333
+ color_map = {
334
+ "info": "#17A2B8",
335
+ "success": "#28A745",
336
+ "warning": "#FFC107",
337
+ "error": "#DC3545",
338
+ }
339
+ color = color_map.get(level, color_map["info"])
340
+
341
+ await page.evaluate("""
342
+ ([step, total, message, color]) => {
343
+ let container = document.getElementById('_scitex_step_messages');
344
+ if (!container) {
345
+ container = document.createElement('div');
346
+ container.id = '_scitex_step_messages';
347
+ container.style.cssText = `
348
+ position: fixed; top: 10px; left: 10px; z-index: 2147483647;
349
+ display: flex; flex-direction: column; gap: 8px;
350
+ max-width: 600px; pointer-events: none;
351
+ `;
352
+ document.body.appendChild(container);
353
+ }
354
+ const popup = document.createElement('div');
355
+ popup.className = 'scitex-step-msg';
356
+ popup.innerHTML = `<strong>[${step}/${total}] ${message}</strong>`;
357
+ popup.style.cssText = `
358
+ background: rgba(0, 0, 0, 0.9); color: white;
359
+ padding: 14px 24px; border-radius: 8px; font-size: 16px;
360
+ font-family: 'Courier New', monospace;
361
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
362
+ border-left: 6px solid ${color}; word-wrap: break-word;
363
+ `;
364
+ container.appendChild(popup);
365
+ while (container.children.length > 5) container.removeChild(container.firstChild);
366
+ setTimeout(() => { if (popup.parentNode) popup.parentNode.removeChild(popup); }, 8000);
367
+ }
368
+ """, [step, total, message, color])
369
+ await page.wait_for_timeout(200)
370
+
371
+
372
+ def show_test_result(
373
+ page: Union["AsyncPage", "SyncPage"],
374
+ success: bool,
375
+ message: str = "",
376
+ delay_ms: int = 3000
377
+ ) -> None:
378
+ """Show test result banner (PASS/FAIL) and wait (sync version).
379
+
380
+ Args:
381
+ page: Playwright page object
382
+ success: True for PASS, False for FAIL
383
+ message: Optional message to display
384
+ delay_ms: How long to display before continuing
385
+ """
386
+ status = "PASS" if success else "FAIL"
387
+ css_class = "success" if success else "failure"
388
+ display_text = f"{status}" + (f": {message}" if message else "")
389
+
390
+ page.evaluate("""
391
+ ([displayText, cssClass]) => {
392
+ // Remove existing banner
393
+ const existing = document.getElementById('_scitex_result_banner');
394
+ if (existing) existing.remove();
395
+
396
+ const banner = document.createElement('div');
397
+ banner.id = '_scitex_result_banner';
398
+ banner.className = cssClass;
399
+ banner.textContent = displayText;
400
+ document.body.appendChild(banner);
401
+ }
402
+ """, [display_text, css_class])
403
+ page.wait_for_timeout(delay_ms)
404
+
405
+
406
+ async def show_test_result_async(
407
+ page: "AsyncPage",
408
+ success: bool,
409
+ message: str = "",
410
+ delay_ms: int = 3000
411
+ ) -> None:
412
+ """Show test result banner (PASS/FAIL) and wait (async version)."""
413
+ status = "PASS" if success else "FAIL"
414
+ css_class = "success" if success else "failure"
415
+ display_text = f"{status}" + (f": {message}" if message else "")
416
+
417
+ await page.evaluate("""
418
+ ([displayText, cssClass]) => {
419
+ const existing = document.getElementById('_scitex_result_banner');
420
+ if (existing) existing.remove();
421
+
422
+ const banner = document.createElement('div');
423
+ banner.id = '_scitex_result_banner';
424
+ banner.className = cssClass;
425
+ banner.textContent = displayText;
426
+ document.body.appendChild(banner);
427
+ }
428
+ """, [display_text, css_class])
429
+ await page.wait_for_timeout(delay_ms)
430
+
431
+
432
+ # EOF