diff --git a/pytest_pyodide/__init__.py b/pytest_pyodide/__init__.py index 57d68eab..092c88b2 100644 --- a/pytest_pyodide/__init__.py +++ b/pytest_pyodide/__init__.py @@ -3,6 +3,9 @@ from .config import get_global_config from .decorator import copy_files_to_pyodide, run_in_pyodide from .runner import ( + BrowserWorkerChromeRunner, + BrowserWorkerFirefoxRunner, + BrowserWorkerSafariRunner, NodeRunner, PlaywrightChromeRunner, PlaywrightFirefoxRunner, @@ -20,6 +23,9 @@ pass __all__ = [ + "BrowserWorkerChromeRunner", + "BrowserWorkerFirefoxRunner", + "BrowserWorkerSafariRunner", "NodeRunner", "PlaywrightChromeRunner", "PlaywrightFirefoxRunner", diff --git a/pytest_pyodide/_templates/module_webworker_runner.js b/pytest_pyodide/_templates/module_webworker_runner.js new file mode 100644 index 00000000..e2f8ae5e --- /dev/null +++ b/pytest_pyodide/_templates/module_webworker_runner.js @@ -0,0 +1,86 @@ +// Persistent worker used by BrowserWorkerRunner. +// +// Unlike module_webworker_dev.js (which loads Pyodide and runs a single +// Python script per message), this worker is a long-lived RPC endpoint: +// the main page posts {id, code} messages whose `code` is an async +// JavaScript function body. The worker evaluates it and posts back +// {id, ok, result} on success or {id, ok: false, error, stack} on error. +// +// The worker does NOT automatically load Pyodide. The main-thread runner +// sends the standard load-pyodide script as its first RPC, just like it +// would on the page. We expose ``loadPyodide`` on ``self`` so that script +// works unchanged. + +// Install diagnostic handlers *before* doing anything that can throw, so +// that top-level errors (e.g. a failed `import` below) surface with a +// useful message instead of an opaque `[object Event]` on the main page. +self.addEventListener("error", (ev) => { + // Re-post as a message so the main page always sees *something* + // useful, even if it missed the `error` event (e.g. because the + // listener wasn't attached yet). + try { + self.postMessage({ + id: -1, + ok: false, + error: `Worker uncaught error: ${ev.message || ""} at ${ev.filename || "?"}:${ev.lineno || 0}:${ev.colno || 0}`, + message: ev.message || "", + stack: (ev.error && ev.error.stack) || "", + }); + } catch (_e) { + // postMessage may fail if the worker is already in a bad state. + } +}); +self.addEventListener("unhandledrejection", (ev) => { + const reason = ev.reason; + try { + self.postMessage({ + id: -1, + ok: false, + error: `Worker unhandled rejection: ${(reason && reason.message) || String(reason)}`, + message: (reason && reason.message) || String(reason), + stack: (reason && reason.stack) || "", + }); + } catch (_e) { + // ignore + } +}); + +import { loadPyodide } from "./pyodide.mjs"; +self.loadPyodide = loadPyodide; + +self.logs = []; +for (const level of ["log", "warn", "info", "error"]) { + const orig = console[level].bind(console); + console[level] = function (message) { + self.logs.push(message); + try { + orig(message); + } catch (_e) { + // ignore logging errors + } + }; +} + +onmessage = async function (e) { + const { id, code } = e.data ?? {}; + if (typeof id === "undefined") { + return; + } + try { + // `code` is the body of an async function. It may reference `self`, + // `pyodide`, etc. It should `return` a JSON-serializable value. + const fn = new Function( + "return (async () => { " + code + " })();", + ); + const result = await fn.call(self); + self.postMessage({ id, ok: true, result }); + } catch (err) { + self.postMessage({ + id, + ok: false, + error: err?.toString?.() ?? String(err), + message: err?.message ?? "", + stack: err?.stack ?? "", + }); + } +}; diff --git a/pytest_pyodide/fixture.py b/pytest_pyodide/fixture.py index 3034429c..a74c9687 100644 --- a/pytest_pyodide/fixture.py +++ b/pytest_pyodide/fixture.py @@ -10,6 +10,9 @@ from .config import get_global_config from .hook import pytest_wrapper from .runner import ( + BrowserWorkerChromeRunner, + BrowserWorkerFirefoxRunner, + BrowserWorkerSafariRunner, NodeRunner, PlaywrightChromeRunner, PlaywrightFirefoxRunner, @@ -72,6 +75,41 @@ def _playwright_browsers(request): browser.close() +@functools.cache +def _runner_map() -> dict[tuple[str, str, str], type[_BrowserBaseRunner]]: + def runner_key(runner: type[_BrowserBaseRunner]) -> tuple[str, str, str]: + worker = "worker" if getattr(runner, "_worker", False) else "main" + return (runner.runner, runner.browser, worker) + + runners = [ + SeleniumFirefoxRunner, + SeleniumChromeRunner, + SeleniumSafariRunner, + PlaywrightFirefoxRunner, + PlaywrightChromeRunner, + BrowserWorkerChromeRunner, + BrowserWorkerFirefoxRunner, + BrowserWorkerSafariRunner, + NodeRunner, + ] + return {runner_key(rt): rt for rt in runners} + + +def _get_runner_cls( + runtime: str, runner_type: str, is_worker: bool +) -> type[_BrowserBaseRunner]: + if runtime == "node": + runner_type = "node" + thread = "worker" if is_worker else "main" + + runner_cls = _runner_map().get((runner_type, runtime, thread)) + if runner_cls is None: + raise AssertionError( + f"Unknown runner or browser: {runner_type} / {runtime} / {thread}" + ) + return runner_cls + + @contextlib.contextmanager def selenium_common( request, @@ -80,6 +118,7 @@ def selenium_common( load_pyodide=True, browsers=None, jspi=False, + worker=False, ): """Returns an initialized selenium object. @@ -88,21 +127,7 @@ def selenium_common( """ server_hostname, server_port, server_log = web_server_main - runner_type = request.config.option.runner.lower() - - runner_set: dict[tuple[str, str], type[_BrowserBaseRunner]] = { - ("selenium", "firefox"): SeleniumFirefoxRunner, - ("selenium", "chrome"): SeleniumChromeRunner, - ("selenium", "safari"): SeleniumSafariRunner, - ("selenium", "node"): NodeRunner, - ("playwright", "firefox"): PlaywrightFirefoxRunner, - ("playwright", "chrome"): PlaywrightChromeRunner, - ("playwright", "node"): NodeRunner, - } - - runner_cls = runner_set.get((runner_type, runtime)) - if runner_cls is None: - raise AssertionError(f"Unknown runner or browser: {runner_type} / {runtime}") + runner_cls = _get_runner_cls(runtime, request.config.option.runner.lower(), worker) dist_dir = Path(os.getcwd(), request.config.getoption("--dist-dir")) runner = runner_cls( @@ -278,6 +303,48 @@ def selenium(request, selenium_module_scope): yield selenium +# selenium instance cached at the module level +@pytest.fixture(scope="module") +def selenium_worker_module_scope( + request, runtime, web_server_main, playwright_browsers +): + if runtime == "node": + pytest.skip("selenium_worker has no support in node") + + with selenium_common( + request, runtime, web_server_main, browsers=playwright_browsers, worker=True + ) as selenium: + yield selenium + + +# Hypothesis is unhappy with function scope fixtures. Instead, use the +# module scope fixture `selenium_module_scope` and use: +# `with selenium_context_manager(selenium_module_scope) as selenium` +@contextlib.contextmanager +def selenium_worker_context_manager(selenium_worker_module_scope): + try: + selenium_worker_module_scope.clean_logs() + yield selenium_worker_module_scope + finally: + try: + print(selenium_worker_module_scope.logs) + except ValueError: + # For reasons I don't entirely understand, it is possible for + # selenium to be closed before this is executed. In that case, just + # skip printing the logs and we can exit cleanly. + pass + + +@pytest.fixture +def selenium_worker(request, selenium_worker_module_scope): + with selenium_worker_context_manager( + selenium_worker_module_scope + ) as selenium, set_webdriver_script_timeout( + selenium, script_timeout=parse_driver_timeout(request.node) + ): + yield selenium + + @pytest.fixture def selenium_jspi(request, runtime, web_server_main, playwright_browsers): yield from selenium_jspi_inner( @@ -285,13 +352,29 @@ def selenium_jspi(request, runtime, web_server_main, playwright_browsers): ) -def selenium_jspi_inner(request, runtime, web_server_main, playwright_browsers): +@pytest.fixture +def selenium_jspi_worker(request, runtime, web_server_main, playwright_browsers): + if runtime == "node": + pytest.skip("selenium_worker has no support in node") + yield from selenium_jspi_inner( + request, runtime, web_server_main, playwright_browsers, worker=True + ) + + +def selenium_jspi_inner( + request, runtime, web_server_main, playwright_browsers, worker=False +): if runtime in ["firefox", "safari"]: pytest.skip(f"jspi not supported in {runtime}") if request.config.option.runner.lower() == "playwright": pytest.skip("jspi not supported with playwright") with selenium_common( - request, runtime, web_server_main, browsers=playwright_browsers, jspi=True + request, + runtime, + web_server_main, + browsers=playwright_browsers, + jspi=True, + worker=worker, ) as selenium, set_webdriver_script_timeout( selenium, script_timeout=parse_driver_timeout(request.node) ): diff --git a/pytest_pyodide/runner.py b/pytest_pyodide/runner.py index e243b29b..e2ad81a2 100644 --- a/pytest_pyodide/runner.py +++ b/pytest_pyodide/runner.py @@ -103,6 +103,7 @@ def __str__(self): class _BrowserBaseRunner: browser: RUNTIMES = "" # type: ignore[assignment] + runner: str = "" script_timeout = 20 JavascriptException = JavascriptException @@ -130,6 +131,7 @@ def __init__( self.base_url = f"http://{self.server_hostname}:{self.server_port}" self.server_log = server_log self.dist_dir = dist_dir + self.jspi = jspi self.driver = self.get_driver(jspi) self.set_script_timeout(self.script_timeout) @@ -352,6 +354,8 @@ def load_package(self, packages): class _SeleniumBaseRunner(_BrowserBaseRunner): + runner = "selenium" + def goto(self, page): self.driver.get(page) @@ -394,6 +398,8 @@ def urls(self): class _PlaywrightBaseRunner(_BrowserBaseRunner): + runner = "playwright" + def __init__(self, browsers, *args, **kwargs): self.browsers = browsers super().__init__(*args, **kwargs) @@ -536,6 +542,102 @@ def get_driver(self, jspi=False): return instance +class _BrowserWorkerRunnerMixin(_BrowserBaseRunner): + """Mixin that makes a Selenium-based runner execute JS inside a + persistent Web Worker instead of on the main page. + + The main page still drives the browser (via Selenium), but each + ``run_js_inner`` call is proxied to a long-lived worker. This lets us + test Pyodide code paths that differ between the main thread and a + worker context (e.g. ``importScripts``, ``FileReaderSync``, lack of + DOM, etc.) across Chrome, Firefox, and Safari. + """ + + _worker = True + + WORKER_FILE = "module_webworker_runner.js" + + def prepare_driver(self): + super().prepare_driver() + # Boot a persistent worker on the page and install a small RPC + # helper (``self.__workerCall``) that returns a promise resolved + # with the worker's response for a given message id. + worker_url = f"{self.base_url}/{self.WORKER_FILE}" + bootstrap = f""" + const worker = new Worker({worker_url!r}, {{ type: 'module' }}); + self.__worker = worker; + self.__workerPending = new Map(); + self.__workerNextId = 1; + worker.onmessage = (ev) => {{ + const {{ id }} = ev.data ?? {{}}; + const entry = self.__workerPending.get(id); + if (!entry) {{ return; }} + self.__workerPending.delete(id); + entry(ev.data); + }}; + worker.onerror = (ev) => {{ + // Reject every pending call with the worker error. + const err = {{ + ok: false, + error: ev.message ?? String(ev), + message: ev.message ?? "", + stack: ev.filename + ? `${{ev.filename}}:${{ev.lineno}}:${{ev.colno}}` + : "", + }}; + for (const [id, entry] of self.__workerPending) {{ + self.__workerPending.delete(id); + entry({{ id, ...err }}); + }} + }}; + self.__workerCall = function (code) {{ + const id = self.__workerNextId++; + return new Promise((resolve) => {{ + self.__workerPending.set(id, resolve); + worker.postMessage({{ id, code }}); + }}); + }}; + """ + # Run the bootstrap on the main page itself + super().run_js_inner(bootstrap, "") + + def run_js_inner(self, code, check_code): + # Run ``code`` inside the worker; ``check_code`` must also run + # inside the worker because it references ``globalThis.pyodide`` + # and ``pyodide._module`` which only exist on the worker's + # ``self`` (they are set there by ``load_pyodide``). We fold both + # into a single async IIFE so ``code``'s ``return`` value is + # returned only after ``check_code`` has run. + worker_body = f""" + const __result = await (async () => {{ {code} }})(); + {check_code} + return __result; + """ + wrapper = f""" + const __res = await self.__workerCall({worker_body!r}); + if (__res.ok) {{ + return __res.result; + }} + const __e = new Error(__res.message || __res.error); + __e.stack = __res.stack; + throw __e; + """ + # check_code here is run on the page. We already handled it in worker_body + return super().run_js_inner(wrapper, "") + + +class BrowserWorkerChromeRunner(_BrowserWorkerRunnerMixin, SeleniumChromeRunner): + pass + + +class BrowserWorkerFirefoxRunner(_BrowserWorkerRunnerMixin, SeleniumFirefoxRunner): + pass + + +class BrowserWorkerSafariRunner(_BrowserWorkerRunnerMixin, SeleniumSafariRunner): + pass + + class PlaywrightChromeRunner(_PlaywrightBaseRunner): browser = "chrome" @@ -550,6 +652,7 @@ class PlaywrightFirefoxRunner(_PlaywrightBaseRunner): class NodeRunner(_BrowserBaseRunner): browser = "node" + runner = "node" def init_node(self, jspi=False): curdir = Path(__file__).parent diff --git a/tests/test_selenium_worker.py b/tests/test_selenium_worker.py new file mode 100644 index 00000000..9d783840 --- /dev/null +++ b/tests/test_selenium_worker.py @@ -0,0 +1,144 @@ +""" +Tests for the ``selenium_worker`` fixture, which drives Pyodide inside a +Web Worker rather than on the main browser thread. + +These tests mirror the coverage of the main-thread ``selenium`` fixture +so we know the worker runner behaves the same way end-to-end. +""" + +import pytest + +from pytest_pyodide import run_in_pyodide +from pytest_pyodide.runner import ( + BrowserWorkerChromeRunner, + BrowserWorkerFirefoxRunner, + BrowserWorkerSafariRunner, + _BrowserWorkerRunnerMixin, +) + +# --------------------------------------------------------------------------- +# Host-only tests (no browser required) +# --------------------------------------------------------------------------- + + +def test_worker_runner_classes_inherit_mixin(): + """Each BrowserWorker* runner must compose the mixin with the matching + Selenium runner, so it inherits driver setup and worker dispatch.""" + for cls in ( + BrowserWorkerChromeRunner, + BrowserWorkerFirefoxRunner, + BrowserWorkerSafariRunner, + ): + assert issubclass(cls, _BrowserWorkerRunnerMixin) + # The mixin's overrides must win over the Selenium base. + assert cls.run_js_inner is _BrowserWorkerRunnerMixin.run_js_inner + assert cls.prepare_driver is _BrowserWorkerRunnerMixin.prepare_driver + + +def test_worker_runner_browser_attributes(): + assert BrowserWorkerChromeRunner.browser == "chrome" + assert BrowserWorkerFirefoxRunner.browser == "firefox" + assert BrowserWorkerSafariRunner.browser == "safari" + + +def test_worker_template_is_served(): + """The worker bootstrap script must be exposed by the dev web server.""" + from pytest_pyodide.server import _default_templates + + tpls = _default_templates() + assert "/module_webworker_runner.js" in tpls + body = tpls["/module_webworker_runner.js"].decode() + # Module worker must pull loadPyodide so ``load_pyodide`` works. + assert "import { loadPyodide }" in body + assert "self.loadPyodide = loadPyodide" in body + # And it must implement the RPC protocol used by the mixin. + assert "onmessage" in body + assert "postMessage" in body + + +# --------------------------------------------------------------------------- +# Worker fixture tests (require a browser) +# --------------------------------------------------------------------------- + + +def test_selenium_worker_basic(selenium_worker): + """The fixture must yield an initialized runner with Pyodide loaded + inside the worker.""" + assert selenium_worker.pyodide_loaded is True + + +def test_selenium_worker_runs_in_worker_context(selenium_worker): + """Confirm we're actually executing inside a DedicatedWorkerGlobalScope, + not on the page's Window.""" + ctor_name = selenium_worker.run_js( + "return self.constructor.name;", + pyodide_checks=False, + ) + assert "Worker" in ctor_name or ctor_name == "DedicatedWorkerGlobalScope" + # There's no DOM in a worker. + has_document = selenium_worker.run_js( + "return typeof document !== 'undefined';", + pyodide_checks=False, + ) + assert has_document is False + + +def test_selenium_worker_run_python(selenium_worker): + """Round-trip a Python expression through the worker.""" + assert selenium_worker.run("1 + 2") == 3 + assert selenium_worker.run("'hello' + ' world'") == "hello world" + + +def test_selenium_worker_run_python_async(selenium_worker): + selenium_worker.run_async( + """ + import asyncio + await asyncio.sleep(0) + """ + ) + + +def test_selenium_worker_js_exception(selenium_worker): + """Errors raised inside the worker must surface as JavascriptException + on the host.""" + with pytest.raises(selenium_worker.JavascriptException): + selenium_worker.run_js( + "throw new Error('boom from worker');", + pyodide_checks=False, + ) + + +def test_selenium_worker_python_error_propagates(selenium_worker): + """``run_js``'s pyodide error check runs inside the worker too.""" + with pytest.raises(selenium_worker.JavascriptException): + selenium_worker.run("raise RuntimeError('nope')") + + +def test_selenium_worker_logs(selenium_worker): + """console.log inside the worker should be captured by the worker's + ``self.logs`` and exposed via ``runner.logs``.""" + selenium_worker.clean_logs() + selenium_worker.run_js( + "console.log('hello from worker');", + pyodide_checks=False, + ) + assert "hello from worker" in selenium_worker.logs + selenium_worker.clean_logs() + assert "hello from worker" not in selenium_worker.logs + + +@run_in_pyodide +def test_selenium_worker_run_in_pyodide(selenium_worker): + """``run_in_pyodide`` should work with the worker fixture just like it + does with the main-thread fixture.""" + import sys + + assert sys.platform == "emscripten" + + +@run_in_pyodide +def test_selenium_worker_no_window(selenium_worker): + """Workers have no ``window`` global (no DOM).""" + import js + + assert not hasattr(js, "window")