Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pytest_pyodide/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,6 +23,9 @@
pass

__all__ = [
"BrowserWorkerChromeRunner",
"BrowserWorkerFirefoxRunner",
"BrowserWorkerSafariRunner",
"NodeRunner",
"PlaywrightChromeRunner",
"PlaywrightFirefoxRunner",
Expand Down
86 changes: 86 additions & 0 deletions pytest_pyodide/_templates/module_webworker_runner.js
Original file line number Diff line number Diff line change
@@ -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 ?? "",
});
}
};
117 changes: 100 additions & 17 deletions pytest_pyodide/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from .config import get_global_config
from .hook import pytest_wrapper
from .runner import (
BrowserWorkerChromeRunner,
BrowserWorkerFirefoxRunner,
BrowserWorkerSafariRunner,
NodeRunner,
PlaywrightChromeRunner,
PlaywrightFirefoxRunner,
Expand Down Expand Up @@ -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,
Expand All @@ -80,6 +118,7 @@ def selenium_common(
load_pyodide=True,
browsers=None,
jspi=False,
worker=False,
):
"""Returns an initialized selenium object.

Expand All @@ -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(
Expand Down Expand Up @@ -278,20 +303,78 @@ 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):
Comment thread
hoodmane marked this conversation as resolved.
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(
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)
):
Expand Down
Loading
Loading