Skip to content

Commit 188eb39

Browse files
committed
Add a selenium_worker fixture
So that running code in a worker is on parity with running code in the main thread.
1 parent b1b1b17 commit 188eb39

5 files changed

Lines changed: 440 additions & 17 deletions

File tree

pytest_pyodide/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from .config import get_global_config
44
from .decorator import copy_files_to_pyodide, run_in_pyodide
55
from .runner import (
6+
BrowserWorkerChromeRunner,
7+
BrowserWorkerFirefoxRunner,
8+
BrowserWorkerSafariRunner,
69
NodeRunner,
710
PlaywrightChromeRunner,
811
PlaywrightFirefoxRunner,
@@ -20,6 +23,9 @@
2023
pass
2124

2225
__all__ = [
26+
"BrowserWorkerChromeRunner",
27+
"BrowserWorkerFirefoxRunner",
28+
"BrowserWorkerSafariRunner",
2329
"NodeRunner",
2430
"PlaywrightChromeRunner",
2531
"PlaywrightFirefoxRunner",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Persistent worker used by BrowserWorkerRunner.
2+
//
3+
// Unlike module_webworker_dev.js (which loads Pyodide and runs a single
4+
// Python script per message), this worker is a long-lived RPC endpoint:
5+
// the main page posts {id, code} messages whose `code` is an async
6+
// JavaScript function body. The worker evaluates it and posts back
7+
// {id, ok, result} on success or {id, ok: false, error, stack} on error.
8+
//
9+
// The worker does NOT automatically load Pyodide. The main-thread runner
10+
// sends the standard load-pyodide script as its first RPC, just like it
11+
// would on the page. We expose ``loadPyodide`` on ``self`` so that script
12+
// works unchanged.
13+
14+
// Install diagnostic handlers *before* doing anything that can throw, so
15+
// that top-level errors (e.g. a failed `import` below) surface with a
16+
// useful message instead of an opaque `[object Event]` on the main page.
17+
self.addEventListener("error", (ev) => {
18+
// Re-post as a message so the main page always sees *something*
19+
// useful, even if it missed the `error` event (e.g. because the
20+
// listener wasn't attached yet).
21+
try {
22+
self.postMessage({
23+
id: -1,
24+
ok: false,
25+
error: `Worker uncaught error: ${ev.message || ""} at ${ev.filename || "?"}:${ev.lineno || 0}:${ev.colno || 0}`,
26+
message: ev.message || "",
27+
stack: (ev.error && ev.error.stack) || "",
28+
});
29+
} catch (_e) {
30+
// postMessage may fail if the worker is already in a bad state.
31+
}
32+
});
33+
self.addEventListener("unhandledrejection", (ev) => {
34+
const reason = ev.reason;
35+
try {
36+
self.postMessage({
37+
id: -1,
38+
ok: false,
39+
error: `Worker unhandled rejection: ${(reason && reason.message) || String(reason)}`,
40+
message: (reason && reason.message) || String(reason),
41+
stack: (reason && reason.stack) || "",
42+
});
43+
} catch (_e) {
44+
// ignore
45+
}
46+
});
47+
48+
import { loadPyodide } from "./pyodide.mjs";
49+
self.loadPyodide = loadPyodide;
50+
51+
self.logs = [];
52+
for (const level of ["log", "warn", "info", "error"]) {
53+
const orig = console[level].bind(console);
54+
console[level] = function (message) {
55+
self.logs.push(message);
56+
try {
57+
orig(message);
58+
} catch (_e) {
59+
// ignore logging errors
60+
}
61+
};
62+
}
63+
64+
onmessage = async function (e) {
65+
const { id, code } = e.data ?? {};
66+
if (typeof id === "undefined") {
67+
return;
68+
}
69+
try {
70+
// `code` is the body of an async function. It may reference `self`,
71+
// `pyodide`, etc. It should `return` a JSON-serializable value.
72+
const fn = new Function(
73+
"return (async () => { " + code + " })();",
74+
);
75+
const result = await fn.call(self);
76+
self.postMessage({ id, ok: true, result });
77+
} catch (err) {
78+
self.postMessage({
79+
id,
80+
ok: false,
81+
error: err?.toString?.() ?? String(err),
82+
message: err?.message ?? "",
83+
stack: err?.stack ?? "",
84+
});
85+
}
86+
};

pytest_pyodide/fixture.py

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from .config import get_global_config
1111
from .hook import pytest_wrapper
1212
from .runner import (
13+
BrowserWorkerChromeRunner,
14+
BrowserWorkerFirefoxRunner,
15+
BrowserWorkerSafariRunner,
1316
NodeRunner,
1417
PlaywrightChromeRunner,
1518
PlaywrightFirefoxRunner,
@@ -72,6 +75,41 @@ def _playwright_browsers(request):
7275
browser.close()
7376

7477

78+
@functools.cache
79+
def _runner_map() -> dict[tuple[str, str, str], type[_BrowserBaseRunner]]:
80+
def runner_key(runner: type[_BrowserBaseRunner]) -> tuple[str, str, str]:
81+
worker = "worker" if getattr(runner, "_worker", False) else "main"
82+
return (runner.runner, runner.browser, worker)
83+
84+
runners = [
85+
SeleniumFirefoxRunner,
86+
SeleniumChromeRunner,
87+
SeleniumSafariRunner,
88+
PlaywrightFirefoxRunner,
89+
PlaywrightChromeRunner,
90+
BrowserWorkerChromeRunner,
91+
BrowserWorkerFirefoxRunner,
92+
BrowserWorkerSafariRunner,
93+
NodeRunner,
94+
]
95+
return {runner_key(rt): rt for rt in runners}
96+
97+
98+
def _get_runner_cls(
99+
runtime: str, runner_type: str, is_worker: bool
100+
) -> type[_BrowserBaseRunner]:
101+
if runtime == "node":
102+
runner_type = "node"
103+
thread = "worker" if is_worker else "main"
104+
105+
runner_cls = _runner_map().get((runner_type, runtime, thread))
106+
if runner_cls is None:
107+
raise AssertionError(
108+
f"Unknown runner or browser: {runner_type} / {runtime} / {thread}"
109+
)
110+
return runner_cls
111+
112+
75113
@contextlib.contextmanager
76114
def selenium_common(
77115
request,
@@ -80,6 +118,7 @@ def selenium_common(
80118
load_pyodide=True,
81119
browsers=None,
82120
jspi=False,
121+
worker=False,
83122
):
84123
"""Returns an initialized selenium object.
85124
@@ -88,21 +127,7 @@ def selenium_common(
88127
"""
89128

90129
server_hostname, server_port, server_log = web_server_main
91-
runner_type = request.config.option.runner.lower()
92-
93-
runner_set: dict[tuple[str, str], type[_BrowserBaseRunner]] = {
94-
("selenium", "firefox"): SeleniumFirefoxRunner,
95-
("selenium", "chrome"): SeleniumChromeRunner,
96-
("selenium", "safari"): SeleniumSafariRunner,
97-
("selenium", "node"): NodeRunner,
98-
("playwright", "firefox"): PlaywrightFirefoxRunner,
99-
("playwright", "chrome"): PlaywrightChromeRunner,
100-
("playwright", "node"): NodeRunner,
101-
}
102-
103-
runner_cls = runner_set.get((runner_type, runtime))
104-
if runner_cls is None:
105-
raise AssertionError(f"Unknown runner or browser: {runner_type} / {runtime}")
130+
runner_cls = _get_runner_cls(runtime, request.config.option.runner.lower(), worker)
106131

107132
dist_dir = Path(os.getcwd(), request.config.getoption("--dist-dir"))
108133
runner = runner_cls(
@@ -278,20 +303,78 @@ def selenium(request, selenium_module_scope):
278303
yield selenium
279304

280305

306+
# selenium instance cached at the module level
307+
@pytest.fixture(scope="module")
308+
def selenium_worker_module_scope(
309+
request, runtime, web_server_main, playwright_browsers
310+
):
311+
if runtime == "node":
312+
pytest.skip("selenium_worker has no support in node")
313+
314+
with selenium_common(
315+
request, runtime, web_server_main, browsers=playwright_browsers, worker=True
316+
) as selenium:
317+
yield selenium
318+
319+
320+
# Hypothesis is unhappy with function scope fixtures. Instead, use the
321+
# module scope fixture `selenium_module_scope` and use:
322+
# `with selenium_context_manager(selenium_module_scope) as selenium`
323+
@contextlib.contextmanager
324+
def selenium_worker_context_manager(selenium_worker_module_scope):
325+
try:
326+
selenium_worker_module_scope.clean_logs()
327+
yield selenium_worker_module_scope
328+
finally:
329+
try:
330+
print(selenium_worker_module_scope.logs)
331+
except ValueError:
332+
# For reasons I don't entirely understand, it is possible for
333+
# selenium to be closed before this is executed. In that case, just
334+
# skip printing the logs and we can exit cleanly.
335+
pass
336+
337+
338+
@pytest.fixture
339+
def selenium_worker(request, selenium_worker_module_scope):
340+
with selenium_worker_context_manager(
341+
selenium_worker_module_scope
342+
) as selenium, set_webdriver_script_timeout(
343+
selenium, script_timeout=parse_driver_timeout(request.node)
344+
):
345+
yield selenium
346+
347+
281348
@pytest.fixture
282349
def selenium_jspi(request, runtime, web_server_main, playwright_browsers):
283350
yield from selenium_jspi_inner(
284351
request, runtime, web_server_main, playwright_browsers
285352
)
286353

287354

288-
def selenium_jspi_inner(request, runtime, web_server_main, playwright_browsers):
355+
@pytest.fixture
356+
def selenium_jspi_worker(request, runtime, web_server_main, playwright_browsers):
357+
if runtime == "node":
358+
pytest.skip("selenium_worker has no support in node")
359+
yield from selenium_jspi_inner(
360+
request, runtime, web_server_main, playwright_browsers, worker=True
361+
)
362+
363+
364+
def selenium_jspi_inner(
365+
request, runtime, web_server_main, playwright_browsers, worker=False
366+
):
289367
if runtime in ["firefox", "safari"]:
290368
pytest.skip(f"jspi not supported in {runtime}")
291369
if request.config.option.runner.lower() == "playwright":
292370
pytest.skip("jspi not supported with playwright")
293371
with selenium_common(
294-
request, runtime, web_server_main, browsers=playwright_browsers, jspi=True
372+
request,
373+
runtime,
374+
web_server_main,
375+
browsers=playwright_browsers,
376+
jspi=True,
377+
worker=worker,
295378
) as selenium, set_webdriver_script_timeout(
296379
selenium, script_timeout=parse_driver_timeout(request.node)
297380
):

0 commit comments

Comments
 (0)