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.
- scitex/__version__.py +1 -1
- scitex/browser/__init__.py +53 -0
- scitex/browser/auth/__init__.py +35 -0
- scitex/browser/auth/google.py +381 -0
- scitex/browser/collaboration/__init__.py +5 -0
- scitex/browser/debugging/__init__.py +56 -0
- scitex/browser/debugging/_failure_capture.py +372 -0
- scitex/browser/debugging/_sync_session.py +259 -0
- scitex/browser/debugging/_test_monitor.py +284 -0
- scitex/browser/debugging/_visual_cursor.py +432 -0
- scitex/scholar/citation_graph/README.md +117 -0
- scitex/scholar/citation_graph/__init__.py +29 -0
- scitex/scholar/citation_graph/builder.py +214 -0
- scitex/scholar/citation_graph/database.py +246 -0
- scitex/scholar/citation_graph/example.py +96 -0
- scitex/scholar/citation_graph/models.py +80 -0
- scitex/scholar/config/ScholarConfig.py +23 -3
- scitex/scholar/config/default.yaml +56 -0
- scitex/scholar/core/Paper.py +102 -0
- scitex/scholar/core/__init__.py +44 -0
- scitex/scholar/core/journal_normalizer.py +524 -0
- scitex/scholar/core/oa_cache.py +285 -0
- scitex/scholar/core/open_access.py +457 -0
- scitex/scholar/metadata_engines/ScholarEngine.py +9 -1
- scitex/scholar/metadata_engines/individual/CrossRefLocalEngine.py +82 -21
- scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
- scitex/scholar/pdf_download/strategies/__init__.py +6 -0
- scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
- scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +27 -9
- scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +24 -8
- scitex/scholar/search_engines/ScholarSearchEngine.py +6 -1
- {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/METADATA +1 -1
- {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/RECORD +36 -20
- {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/WHEEL +0 -0
- {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/entry_points.txt +0 -0
- {scitex-2.4.1.dist-info → scitex-2.4.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,372 @@
|
|
|
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/_failure_capture.py
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Automatic failure capture utilities for Playwright E2E tests.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Console log collection with source file/line tracking
|
|
11
|
+
- Error interception (JS errors, unhandled promise rejections, resource failures)
|
|
12
|
+
- Screenshot capture on test failure
|
|
13
|
+
- Page HTML capture for debugging
|
|
14
|
+
- DevTools-like formatted output
|
|
15
|
+
- Pytest integration via fixtures
|
|
16
|
+
|
|
17
|
+
Based on scitex-cloud's console-interceptor.ts functionality.
|
|
18
|
+
|
|
19
|
+
Usage in conftest.py:
|
|
20
|
+
from scitex.browser.debugging import (
|
|
21
|
+
setup_console_interceptor,
|
|
22
|
+
collect_console_logs,
|
|
23
|
+
save_failure_artifacts,
|
|
24
|
+
create_failure_capture_fixture,
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from playwright.sync_api import Page
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# JavaScript code for advanced console interception
|
|
37
|
+
# Mirrors functionality from scitex-cloud/static/shared/ts/utils/console-interceptor.ts
|
|
38
|
+
CONSOLE_INTERCEPTOR_JS = """
|
|
39
|
+
() => {
|
|
40
|
+
if (window._scitex_console_interceptor_setup) return;
|
|
41
|
+
|
|
42
|
+
// Store for captured logs with full details
|
|
43
|
+
window._scitex_console_logs = [];
|
|
44
|
+
window._scitex_console_history = [];
|
|
45
|
+
const maxHistory = 2000;
|
|
46
|
+
|
|
47
|
+
// Store original console methods
|
|
48
|
+
const originalConsole = {
|
|
49
|
+
log: console.log,
|
|
50
|
+
info: console.info,
|
|
51
|
+
warn: console.warn,
|
|
52
|
+
error: console.error,
|
|
53
|
+
debug: console.debug
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Get source file and line number from stack trace
|
|
57
|
+
function getSource() {
|
|
58
|
+
try {
|
|
59
|
+
const stack = new Error().stack;
|
|
60
|
+
if (!stack) return '';
|
|
61
|
+
const lines = stack.split('\\n');
|
|
62
|
+
// Skip Error, getSource, capture, and intercepted console method
|
|
63
|
+
for (let i = 4; i < lines.length; i++) {
|
|
64
|
+
const line = lines[i];
|
|
65
|
+
const match = line.match(/(?:https?:\\/\\/[^\\/]+)?([^\\s]+):(\\d+):(\\d+)/);
|
|
66
|
+
if (match) {
|
|
67
|
+
const [, file, lineNum, col] = match;
|
|
68
|
+
const cleanFile = file.split('/').slice(-2).join('/');
|
|
69
|
+
return `${cleanFile}:${lineNum}:${col}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Format message from arguments
|
|
77
|
+
function formatMessage(args) {
|
|
78
|
+
return args.map(arg => {
|
|
79
|
+
if (typeof arg === 'object') {
|
|
80
|
+
try { return JSON.stringify(arg, null, 2); }
|
|
81
|
+
catch { return String(arg); }
|
|
82
|
+
}
|
|
83
|
+
return String(arg);
|
|
84
|
+
}).join(' ');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Capture log entry
|
|
88
|
+
function capture(level, args) {
|
|
89
|
+
const message = formatMessage(args);
|
|
90
|
+
const source = getSource();
|
|
91
|
+
const entry = {
|
|
92
|
+
level,
|
|
93
|
+
message,
|
|
94
|
+
source,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
url: window.location.href
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
window._scitex_console_history.push(entry);
|
|
100
|
+
if (window._scitex_console_history.length > maxHistory) {
|
|
101
|
+
window._scitex_console_history.shift();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Also store simple format for backwards compatibility
|
|
105
|
+
window._scitex_console_logs.push(`[${level.toUpperCase()}] ${source ? source + ' ' : ''}${message}`);
|
|
106
|
+
if (window._scitex_console_logs.length > 500) {
|
|
107
|
+
window._scitex_console_logs.shift();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Intercept console methods
|
|
112
|
+
['log', 'info', 'warn', 'error', 'debug'].forEach(level => {
|
|
113
|
+
console[level] = function(...args) {
|
|
114
|
+
originalConsole[level].apply(console, args);
|
|
115
|
+
capture(level, args);
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Capture unhandled JavaScript errors
|
|
120
|
+
window.addEventListener('error', (event) => {
|
|
121
|
+
let entry;
|
|
122
|
+
if (event.target && event.target.tagName) {
|
|
123
|
+
// Resource loading error
|
|
124
|
+
const target = event.target;
|
|
125
|
+
const src = target.src || target.href || '';
|
|
126
|
+
if (src) {
|
|
127
|
+
entry = {
|
|
128
|
+
level: 'error',
|
|
129
|
+
message: `Failed to load resource: ${src}`,
|
|
130
|
+
source: src.split('/').pop() || '',
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
url: window.location.href
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// JavaScript error
|
|
137
|
+
entry = {
|
|
138
|
+
level: 'error',
|
|
139
|
+
message: event.message,
|
|
140
|
+
source: `${event.filename}:${event.lineno}:${event.colno}`,
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
url: window.location.href
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (entry) {
|
|
146
|
+
window._scitex_console_history.push(entry);
|
|
147
|
+
window._scitex_console_logs.push(`[ERROR] ${entry.source} ${entry.message}`);
|
|
148
|
+
}
|
|
149
|
+
}, true);
|
|
150
|
+
|
|
151
|
+
// Capture unhandled promise rejections
|
|
152
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
153
|
+
const entry = {
|
|
154
|
+
level: 'error',
|
|
155
|
+
message: `Uncaught (in promise): ${event.reason}`,
|
|
156
|
+
source: '',
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
url: window.location.href
|
|
159
|
+
};
|
|
160
|
+
window._scitex_console_history.push(entry);
|
|
161
|
+
window._scitex_console_logs.push(`[ERROR] Uncaught (in promise): ${event.reason}`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
window._scitex_console_interceptor_setup = true;
|
|
165
|
+
}
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def setup_console_interceptor(page: "Page") -> None:
|
|
170
|
+
"""Set up console log interceptor with source tracking and error capture.
|
|
171
|
+
|
|
172
|
+
Features (mirroring console-interceptor.ts):
|
|
173
|
+
- Intercepts console.log, info, warn, error, debug
|
|
174
|
+
- Captures source file and line number
|
|
175
|
+
- Captures unhandled JS errors
|
|
176
|
+
- Captures unhandled promise rejections
|
|
177
|
+
- Captures resource loading failures
|
|
178
|
+
|
|
179
|
+
Call this at the start of each test to begin capturing logs.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
page.evaluate(CONSOLE_INTERCEPTOR_JS)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def collect_console_logs(page: "Page") -> list:
|
|
188
|
+
"""Collect all captured console logs from the browser.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of log strings in format "[LEVEL] source message"
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
logs = page.evaluate("""
|
|
195
|
+
() => {
|
|
196
|
+
if (window._scitex_console_logs) {
|
|
197
|
+
return window._scitex_console_logs;
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
""")
|
|
202
|
+
return logs or []
|
|
203
|
+
except Exception:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def collect_console_logs_detailed(page: "Page") -> list:
|
|
208
|
+
"""Collect all captured console logs with full details.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of dicts with keys: level, message, source, timestamp, url
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
history = page.evaluate("""
|
|
215
|
+
() => {
|
|
216
|
+
if (window._scitex_console_history) {
|
|
217
|
+
return window._scitex_console_history;
|
|
218
|
+
}
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
""")
|
|
222
|
+
return history or []
|
|
223
|
+
except Exception:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def format_logs_devtools_style(logs: list) -> str:
|
|
228
|
+
"""Format logs in DevTools-like style.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
logs: List of detailed log entries from collect_console_logs_detailed()
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Formatted string like browser DevTools output
|
|
235
|
+
"""
|
|
236
|
+
if not logs:
|
|
237
|
+
return "No console logs captured."
|
|
238
|
+
|
|
239
|
+
level_icons = {
|
|
240
|
+
"error": "[ERROR]",
|
|
241
|
+
"warn": "[WARN]",
|
|
242
|
+
"info": "[INFO]",
|
|
243
|
+
"debug": "[DEBUG]",
|
|
244
|
+
"log": "[LOG]",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
output = []
|
|
248
|
+
for entry in logs:
|
|
249
|
+
if isinstance(entry, dict):
|
|
250
|
+
level = entry.get("level", "log")
|
|
251
|
+
source = entry.get("source", "")
|
|
252
|
+
message = entry.get("message", "")
|
|
253
|
+
icon = level_icons.get(level, "[LOG]")
|
|
254
|
+
source_str = f" {source}" if source else ""
|
|
255
|
+
output.append(f"{icon}{source_str} {message}")
|
|
256
|
+
else:
|
|
257
|
+
output.append(str(entry))
|
|
258
|
+
|
|
259
|
+
return "\n".join(output)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def save_failure_artifacts(
|
|
263
|
+
page: "Page",
|
|
264
|
+
test_name: str,
|
|
265
|
+
artifacts_dir: Path | str,
|
|
266
|
+
console_logs: list | None = None,
|
|
267
|
+
) -> dict:
|
|
268
|
+
"""Save screenshot, console logs, and page HTML on test failure.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
page: Playwright page object
|
|
272
|
+
test_name: Name of the failed test (e.g., request.node.nodeid)
|
|
273
|
+
artifacts_dir: Directory to save artifacts
|
|
274
|
+
console_logs: Pre-collected console logs (optional, will collect if None)
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Dict with paths to saved artifacts
|
|
278
|
+
"""
|
|
279
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
280
|
+
safe_test_name = test_name.replace("::", "_").replace("[", "_").replace("]", "").replace("/", "_")
|
|
281
|
+
|
|
282
|
+
# Create artifacts directory with timestamp
|
|
283
|
+
artifacts_path = Path(artifacts_dir) / timestamp
|
|
284
|
+
artifacts_path.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
|
|
286
|
+
saved_files = {}
|
|
287
|
+
|
|
288
|
+
# Collect console logs if not provided
|
|
289
|
+
if console_logs is None:
|
|
290
|
+
console_logs = collect_console_logs(page)
|
|
291
|
+
|
|
292
|
+
# Save screenshot
|
|
293
|
+
try:
|
|
294
|
+
screenshot_path = artifacts_path / f"{safe_test_name}_screenshot.png"
|
|
295
|
+
page.screenshot(path=str(screenshot_path), full_page=True)
|
|
296
|
+
saved_files["screenshot"] = screenshot_path
|
|
297
|
+
print(f"\n[FAILURE] Screenshot saved: {screenshot_path}")
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print(f"\n[FAILURE] Failed to save screenshot: {e}")
|
|
300
|
+
|
|
301
|
+
# Save console logs
|
|
302
|
+
try:
|
|
303
|
+
logs_path = artifacts_path / f"{safe_test_name}_console.log"
|
|
304
|
+
with open(logs_path, "w") as f:
|
|
305
|
+
f.write(f"Test: {test_name}\n")
|
|
306
|
+
f.write(f"Timestamp: {timestamp}\n")
|
|
307
|
+
f.write(f"URL: {page.url}\n")
|
|
308
|
+
f.write("=" * 80 + "\n\n")
|
|
309
|
+
f.write("Console Logs:\n")
|
|
310
|
+
f.write("-" * 40 + "\n")
|
|
311
|
+
for log in console_logs:
|
|
312
|
+
f.write(f"{log}\n")
|
|
313
|
+
saved_files["console_logs"] = logs_path
|
|
314
|
+
print(f"[FAILURE] Console logs saved: {logs_path}")
|
|
315
|
+
except Exception as e:
|
|
316
|
+
print(f"[FAILURE] Failed to save console logs: {e}")
|
|
317
|
+
|
|
318
|
+
# Save page HTML
|
|
319
|
+
try:
|
|
320
|
+
html_path = artifacts_path / f"{safe_test_name}_page.html"
|
|
321
|
+
html_content = page.content()
|
|
322
|
+
with open(html_path, "w") as f:
|
|
323
|
+
f.write(html_content)
|
|
324
|
+
saved_files["page_html"] = html_path
|
|
325
|
+
print(f"[FAILURE] Page HTML saved: {html_path}")
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(f"[FAILURE] Failed to save page HTML: {e}")
|
|
328
|
+
|
|
329
|
+
return saved_files
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def create_failure_capture_fixture(artifacts_dir: Path | str):
|
|
333
|
+
"""Create a pytest fixture for automatic failure capture.
|
|
334
|
+
|
|
335
|
+
Usage in conftest.py:
|
|
336
|
+
from scitex.browser.debugging import create_failure_capture_fixture
|
|
337
|
+
|
|
338
|
+
capture_on_failure = create_failure_capture_fixture(
|
|
339
|
+
Path(__file__).parent / "artifacts"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
artifacts_dir: Directory to save failure artifacts
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
A pytest fixture function
|
|
347
|
+
"""
|
|
348
|
+
import pytest
|
|
349
|
+
|
|
350
|
+
@pytest.fixture(autouse=True)
|
|
351
|
+
def capture_on_failure(request, page):
|
|
352
|
+
"""Automatically capture console logs and screenshot on test failure."""
|
|
353
|
+
setup_console_interceptor(page)
|
|
354
|
+
yield
|
|
355
|
+
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
|
|
356
|
+
console_logs = collect_console_logs(page)
|
|
357
|
+
save_failure_artifacts(page, request.node.nodeid, artifacts_dir, console_logs)
|
|
358
|
+
|
|
359
|
+
return capture_on_failure
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Pytest hook for capturing test results - add to conftest.py
|
|
363
|
+
PYTEST_HOOK_CODE = '''
|
|
364
|
+
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
|
365
|
+
def pytest_runtest_makereport(item, call):
|
|
366
|
+
"""Hook to capture test outcome for use in fixture."""
|
|
367
|
+
outcome = yield
|
|
368
|
+
rep = outcome.get_result()
|
|
369
|
+
setattr(item, f"rep_{rep.when}", rep)
|
|
370
|
+
'''
|
|
371
|
+
|
|
372
|
+
# EOF
|
|
@@ -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
|