From 7ff1eb047b531e5e359358d83fa0452d0b315e7f Mon Sep 17 00:00:00 2001 From: DerThorsten Date: Fri, 6 Nov 2026 10:50:14 +0100 Subject: [PATCH 1/3] browser seletion --- pyjs_code_runner/backend/backend.py | 29 +++++ .../backend/browser_main/browser_main.py | 21 ++-- .../backend/browser_worker/browser_worker.py | 108 ++++++++++++------ pyjs_code_runner/cli/run.py | 18 ++- 4 files changed, 128 insertions(+), 48 deletions(-) diff --git a/pyjs_code_runner/backend/backend.py b/pyjs_code_runner/backend/backend.py index e5e5927..f2acd90 100644 --- a/pyjs_code_runner/backend/backend.py +++ b/pyjs_code_runner/backend/backend.py @@ -13,6 +13,35 @@ class BackendType(str, Enum): node = "node" +class BrowserType(str, Enum): + """Browser engines available via playwright. + + `webkit` is playwright's build of WebKit, the engine behind Safari. + """ + + chromium = "chromium" + firefox = "firefox" + webkit = "webkit" + + +async def launch_browser(playwright, browser, headless, slow_mo=None): + """Launch `browser` (a BrowserType or its name) with playwright.""" + browser = BrowserType(browser) + launcher = getattr(playwright, browser.value) + kwargs = dict(headless=headless) + if slow_mo is not None: + kwargs["slow_mo"] = slow_mo + try: + return await launcher.launch(**kwargs) + except Exception as e: + if "Executable doesn't exist" in str(e): + raise RuntimeError( + f"playwright browser `{browser.value}` is not installed, " + f"install it with:\n\n playwright install {browser.value}\n" + ) from e + raise + + def get_backend_type_family(backend): if backend == BackendType.node: return BackendFamilyType.node diff --git a/pyjs_code_runner/backend/browser_main/browser_main.py b/pyjs_code_runner/backend/browser_main/browser_main.py index 88d1a74..afef30a 100644 --- a/pyjs_code_runner/backend/browser_main/browser_main.py +++ b/pyjs_code_runner/backend/browser_main/browser_main.py @@ -1,6 +1,7 @@ import os import asyncio from ..backend_base import BackendBase +from ..backend import BrowserType, launch_browser from ...constants import JS_DIR, HTML_DIR from .server import server_context, find_free_port from playwright.async_api import async_playwright @@ -13,7 +14,15 @@ class BrowserMainBackend(BackendBase): def __init__( - self, host_work_dir, work_dir, script, async_main, port, headless, slow_mo + self, + host_work_dir, + work_dir, + script, + async_main, + port, + headless, + slow_mo, + browser=BrowserType.chromium, ): super().__init__( host_work_dir=host_work_dir, @@ -26,6 +35,7 @@ def __init__( self.port = port self.headless = headless self.slow_mo = slow_mo + self.browser = BrowserType(browser) def run(self): browser_main_html = "browser_main.html" @@ -43,12 +53,9 @@ def run(self): async def playwright_run_in_main_thread(self, page_url): async with async_playwright() as p: - if self.slow_mo is None: - browser = await p.chromium.launch(headless=self.headless) - else: - browser = await p.chromium.launch( - headless=self.headless, slow_mo=self.slow_mo - ) + browser = await launch_browser( + p, self.browser, headless=self.headless, slow_mo=self.slow_mo + ) page = await browser.new_page() await page.goto(page_url) # n min = n_min * 60 * 1000 ms diff --git a/pyjs_code_runner/backend/browser_worker/browser_worker.py b/pyjs_code_runner/backend/browser_worker/browser_worker.py index 1e7a965..353ceb6 100644 --- a/pyjs_code_runner/backend/browser_worker/browser_worker.py +++ b/pyjs_code_runner/backend/browser_worker/browser_worker.py @@ -1,16 +1,38 @@ import os import asyncio from ..backend_base import BackendBase +from ..backend import BrowserType, launch_browser from ...constants import JS_DIR, HTML_DIR from ..browser_main.server import server_context, find_free_port from playwright.async_api import async_playwright +from playwright.async_api import Error as PlaywrightError from pathlib import Path import shutil +def _is_target_closed_error(e): + # TargetClosedError is not part of playwright's public api (and older + # versions raise a plain Error with a "Target closed" message) + msg = str(e) + return ( + type(e).__name__ == "TargetClosedError" + or "Target closed" in msg + or "has been closed" in msg + ) + + class BrowserWorkerBackend(BackendBase): def __init__( - self, host_work_dir, work_dir, script, async_main, port, headless, slow_mo, preload_shared_libs=True + self, + host_work_dir, + work_dir, + script, + async_main, + port, + headless, + slow_mo, + preload_shared_libs=True, + browser=BrowserType.chromium, ): super().__init__( host_work_dir=host_work_dir, @@ -23,6 +45,7 @@ def __init__( self.port = port self.headless = headless self.slow_mo = slow_mo + self.browser = BrowserType(browser) self.preload_shared_libs = preload_shared_libs def run(self): @@ -47,12 +70,9 @@ def run(self): async def playwright_run_in_worker_thread(self, page_url): async with async_playwright() as p: - if self.slow_mo is None: - browser = await p.chromium.launch(headless=self.headless) - else: - browser = await p.chromium.launch( - headless=self.headless, slow_mo=self.slow_mo - ) + browser = await launch_browser( + p, self.browser, headless=self.headless, slow_mo=self.slow_mo + ) page = await browser.new_page() # n min = n_min * 60 * 1000 ms @@ -60,37 +80,16 @@ async def playwright_run_in_worker_thread(self, page_url): page.set_default_timeout(n_min * 60 * 1000) async def handle_worker(worker): - test_output = await worker.evaluate_handle( - f"""async () => - {{ - - {self.js_utils()} - - var collected_prints = "" - const print = (text) => {{ - console.log(text) - collected_prints += text; - collected_prints += "\\n"; - }} - - - var pyjs = await make_pyjs(print, print, {str(self.preload_shared_libs).lower()}); - - var r = eval_main_script(pyjs, "{self.work_dir}","{self.script}"); - if({int(self.async_main)}){{ - r = r || await run_async_python_main(pyjs); - }} - - msg = {{ - return_code : r, - collected_prints : collected_prints - }} - self.postMessage(msg) - return r - }}""" - ) - if int(str(test_output)) != 0: - raise RuntimeError(f"tests failed with return code: {test_output}") + try: + await self._run_in_worker(worker) + except PlaywrightError as e: + if not _is_target_closed_error(e): + raise + # the page terminates the worker as soon as the worker has + # posted its result (see browser_worker.html), which may + # happen before playwright receives the evaluate result. + # The return code is read from the page below. + pass page.on("worker", handle_worker) @@ -117,3 +116,36 @@ async def handle_console(msg): ) return_code = int(str(await test_output.get_property("return_code"))) return return_code + + async def _run_in_worker(self, worker): + test_output = await worker.evaluate_handle( + f"""async () => + {{ + + {self.js_utils()} + + var collected_prints = "" + const print = (text) => {{ + console.log(text) + collected_prints += text; + collected_prints += "\\n"; + }} + + + var pyjs = await make_pyjs(print, print, {str(self.preload_shared_libs).lower()}); + + var r = eval_main_script(pyjs, "{self.work_dir}","{self.script}"); + if({int(self.async_main)}){{ + r = r || await run_async_python_main(pyjs); + }} + + msg = {{ + return_code : r, + collected_prints : collected_prints + }} + self.postMessage(msg) + return r + }}""" + ) + if int(str(test_output)) != 0: + raise RuntimeError(f"tests failed with return code: {test_output}") diff --git a/pyjs_code_runner/cli/run.py b/pyjs_code_runner/cli/run.py index 268c002..ea3519e 100644 --- a/pyjs_code_runner/cli/run.py +++ b/pyjs_code_runner/cli/run.py @@ -8,7 +8,7 @@ from .utils import require_option, make_names from ..run import run -from ..backend.backend import BackendType +from ..backend.backend import BackendType, BrowserType from ..get_cache_dir import get_cache_dir from ..get_file_filter import get_file_filter from ..constants import EMSCRIPTEN_HOME @@ -113,6 +113,12 @@ def parse_mounts(mounts): help="""run browser in slow-mo (only when not in headless mode)""", ) +browser_option = typer.Option( + BrowserType.chromium, + "--browser", + help="""browser engine to run in (webkit is the engine behind Safari)""", +) + @script_app.command() def browser_main( @@ -130,6 +136,7 @@ def browser_main( port: Optional[int] = port_option, headless: bool = headless_option, slow_mo: Optional[int] = slowmo_option, + browser: BrowserType = browser_option, ): run_script( backend_type=BackendType.browser_main, @@ -144,7 +151,9 @@ def browser_main( cache_dir=cache_dir, use_cache=cache, host_work_dir=host_work_dir, - backend_kwargs=dict(port=port, headless=headless, slow_mo=slow_mo), + backend_kwargs=dict( + port=port, headless=headless, slow_mo=slow_mo, browser=browser + ), ) @@ -164,6 +173,7 @@ def browser_worker( port: Optional[int] = port_option, headless: bool = headless_option, slow_mo: Optional[int] = slowmo_option, + browser: BrowserType = browser_option, ): run_script( backend_type=BackendType.browser_worker, @@ -178,7 +188,9 @@ def browser_worker( cache_dir=cache_dir, use_cache=cache, host_work_dir=host_work_dir, - backend_kwargs=dict(port=port, headless=headless, slow_mo=slow_mo), + backend_kwargs=dict( + port=port, headless=headless, slow_mo=slow_mo, browser=browser + ), ) From 42ce6c88ce0e01ac3748435c1ae34b7f9fc4dbf9 Mon Sep 17 00:00:00 2001 From: DerThorsten Date: Fri, 6 Nov 2026 11:02:13 +0100 Subject: [PATCH 2/3] ci --- .github/workflows/main.yaml | 75 ++++++++++++++----------------------- dev-env.yml | 3 +- test/conftest.py | 23 +++++++++--- 3 files changed, 48 insertions(+), 53 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 8f66d3c..5ca319e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -14,40 +14,28 @@ defaults: jobs: test_with_conda: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + env: + PYJS_CODE_RUNNER_TEST_BROWSERS: ${{ matrix.browser }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - - name: Get number of CPU cores - uses: SimenB/github-actions-cpu-cores@v1 - - - name: apt-install - run: | - sudo apt-get update -y && sudo apt-get install -y \ - libwoff1 libvpx9 libevent-2.1-7t64 libopus0 libgstreamer-plugins-base1.0-0 \ - libgstreamer-gl1.0-0 \ - libgstreamer-plugins-bad1.0-0 \ - libflite1 \ - libavif16 \ - libharfbuzz-icu0 \ - libsecret-1-0 \ - libhyphen0 \ - libmanette-0.2-0 \ - libgles2 \ - gstreamer1.0-libav - - name: Install mamba uses: mamba-org/setup-micromamba@v2 with: environment-file: dev-env.yml environment-name: dev-env init-shell: >- - bash - - - name: Install Playwright + bash + + # installs the browser and the system libraries it needs (via apt) + - name: Install Playwright browser shell: bash -el {0} run: | - playwright install + playwright install --with-deps ${{ matrix.browser }} - name: Install pyjs-code-runner shell: bash -el {0} @@ -57,31 +45,19 @@ jobs: - name: Run Tests shell: bash -el {0} run: | - pytest -s -vvvvv + pytest -s -vvvvv test_with_pip: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + env: + PYJS_CODE_RUNNER_TEST_BROWSERS: ${{ matrix.browser }} steps: - - uses: actions/checkout@v2 - - - name: Get number of CPU cores - uses: SimenB/github-actions-cpu-cores@v1 - - - name: apt-install - run: | - sudo apt-get update -y && sudo apt-get install -y \ - libwoff1 libvpx9 libevent-2.1-7t64 libopus0 libgstreamer-plugins-base1.0-0 \ - libgstreamer-gl1.0-0 \ - libgstreamer-plugins-bad1.0-0 \ - libflite1 \ - libavif16 \ - libharfbuzz-icu0 \ - libsecret-1-0 \ - libhyphen0 \ - libmanette-0.2-0 \ - libgles2 \ - gstreamer1.0-libav + - uses: actions/checkout@v4 - name: Install mamba uses: mamba-org/setup-micromamba@v2 @@ -93,9 +69,14 @@ jobs: shell: bash -el {0} run: | python -m pip install . - playwright install - + + # installs the browser and the system libraries it needs (via apt) + - name: Install Playwright browser + shell: bash -el {0} + run: | + playwright install --with-deps ${{ matrix.browser }} + - name: Run Tests shell: bash -el {0} run: | - pytest -s -vvvvv + pytest -s -vvvvv diff --git a/dev-env.yml b/dev-env.yml index 97104d2..b3aac02 100644 --- a/dev-env.yml +++ b/dev-env.yml @@ -9,6 +9,7 @@ dependencies: - typer - appdirs - empack >=3,<4 - - microsoft::playwright + - playwright + - playwright-python - emsdk - rich diff --git a/test/conftest.py b/test/conftest.py index 790af44..4b8ab8e 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -35,19 +35,32 @@ def env_prefix(tmp_path_factory): return env_prefix +# browsers to test the browser-{main,worker} backends with, +# e.g. PYJS_CODE_RUNNER_TEST_BROWSERS="chromium firefox webkit" +TEST_BROWSERS = os.environ.get("PYJS_CODE_RUNNER_TEST_BROWSERS", "chromium").split() + + # @pytest.fixture(params=["node"]) # , "browser-main", "browser-worker"]) -@pytest.fixture(params=["browser-main", "browser-worker"]) +@pytest.fixture( + params=[ + (backend, browser) + for backend in ["browser-main", "browser-worker"] + for browser in TEST_BROWSERS + ], + ids=lambda p: f"{p[0]}-{p[1]}", +) def backend_cli_settings(request): - if request.param == "node": + backend, browser = request.param + if backend == "node": if ON_GITHUB_ACTIONS: - return request.param, [ + return backend, [ "--node-binary", "/home/runner/micromamba-root/envs/dev-env/bin/node", ] else: - return request.param, [] + return backend, [] else: - return request.param, ["--headless"] + return backend, ["--headless", "--browser", browser] @pytest.fixture From 05e33a4b8b97915f44f5d7a59291f3b91e6bfce0 Mon Sep 17 00:00:00 2001 From: DerThorsten Date: Fri, 6 Nov 2026 11:04:36 +0100 Subject: [PATCH 3/3] ci --- test/conftest.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 4b8ab8e..164e364 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -20,11 +20,9 @@ def env_prefix(tmp_path_factory): if env_prefix.exists(): shutil.rmtree(env_prefix) - channels = ( - "-c https://repo.mamba.pm/emscripten-forge -c https://repo.mamba.pm/conda-forge" - ) + channels = "-c https://repo.prefix.dev/emscripten-forge/6x -c conda-forge" cmd = [ - f"""$MAMBA_EXE create {channels} --yes --prefix {env_prefix} --platform=emscripten-32 python numpy pyjs>=2.7.1""" + f"""$MAMBA_EXE create {channels} --yes --prefix {env_prefix} --platform=emscripten-wasm32 python numpy "pyjs-dev>=4.1.0" """ ] ret = subprocess.run(cmd, shell=True)