From 5bf8081aecdaa11fd9a4fa413656271419cf0848 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:31:36 +0530 Subject: [PATCH 1/3] Fix server launch on Playwright 1.60 Playwright 1.60 bundled its internals and removed the private lib/browserServerImpl.js that launchServer.js required, so `python -m camoufox server` died with MODULE_NOT_FOUND. Load the driver's package entrypoint instead, which is a bundled playwright-core and exposes launchServer as public API. The driver path is now passed explicitly rather than inferred from process.cwd(). Fixes #656 --- pythonlib/camoufox/launchServer.js | 25 ++++++++++++++++++------- pythonlib/camoufox/server.py | 6 +++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pythonlib/camoufox/launchServer.js b/pythonlib/camoufox/launchServer.js index ea56e79..1e03cd8 100644 --- a/pythonlib/camoufox/launchServer.js +++ b/pythonlib/camoufox/launchServer.js @@ -1,7 +1,21 @@ -// Workaround that accesses Playwright's undocumented `launchServer` method in Python -// Without having to use the Node.js Playwright library. +// Workaround that accesses Playwright's `launchServer` method in Python +// Without having to install the Node.js Playwright library. -const { BrowserServerLauncherImpl } = require(`${process.cwd()}/lib/browserServerImpl.js`) +const path = require('path') + +// The driver shipped with playwright-python is a copy of playwright-core, so its +// entrypoint exposes `launchServer`. Resolve through the entrypoint rather than lib/ +// internals, whose layout is private and changes between releases: 1.60 bundled +// lib/browserServerImpl.js away, which broke this script. +const driverPackage = process.argv[2] + +let playwright +try { + playwright = require(path.join(driverPackage, 'index.js')) +} catch (error) { + console.error(`Error loading the Playwright driver from ${driverPackage}:`, error.message) + process.exit(1) +} function collectData() { return new Promise((resolve) => { @@ -22,10 +36,7 @@ collectData().then((options) => { console.time('Server launched'); console.info('Launching server...'); - const server = new BrowserServerLauncherImpl('firefox') - - // Call Playwright's `launchServer` method - server.launchServer(options).then(browserServer => { + playwright.firefox.launchServer(options).then(browserServer => { console.timeEnd('Server launched'); console.log('Websocket endpoint:\x1b[93m', browserServer.wsEndpoint(), '\x1b[0m'); // Continue forever diff --git a/pythonlib/camoufox/server.py b/pythonlib/camoufox/server.py index 1103aac..0f9b856 100644 --- a/pythonlib/camoufox/server.py +++ b/pythonlib/camoufox/server.py @@ -50,12 +50,16 @@ def launch_server(**kwargs) -> NoReturn: data = orjson.dumps(to_camel_case_dict(config)) + # The Playwright driver's package directory, which bundles playwright-core. + driver_package = Path(nodejs).parent / "package" + process = subprocess.Popen( # nosec [ nodejs, str(LAUNCH_SCRIPT), + str(driver_package), ], - cwd=Path(nodejs).parent / "package", + cwd=driver_package, stdin=subprocess.PIPE, text=True, ) From 84fadb7481263e30602400a38c43bcd38dda6473 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:31:52 +0530 Subject: [PATCH 2/3] Report server exit code instead of pipe error When the node server exits early, writing its config to the dead stdin raised BrokenPipeError (EINVAL on Windows), burying the real cause. communicate() ignores both, so the underlying failure stays visible. Refs #656 --- pythonlib/camoufox/server.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pythonlib/camoufox/server.py b/pythonlib/camoufox/server.py index 0f9b856..d3e7b23 100644 --- a/pythonlib/camoufox/server.py +++ b/pythonlib/camoufox/server.py @@ -63,13 +63,12 @@ def launch_server(**kwargs) -> NoReturn: stdin=subprocess.PIPE, text=True, ) - # Write data to stdin and close the stream - if process.stdin: - process.stdin.write(base64.b64encode(data).decode()) - process.stdin.close() - - # Wait forever - process.wait() + # Write data to stdin, close the stream, and wait forever. + # communicate() tolerates the pipe closing early if the server exits before reading + # its config, keeping that error visible instead of masking it with an OSError. + process.communicate(input=base64.b64encode(data).decode()) # Add an explicit return statement to satisfy the NoReturn type hint - raise RuntimeError("Server process terminated unexpectedly") + raise RuntimeError( + f"Server process terminated unexpectedly with exit code {process.returncode}" + ) From ab20eca72d4a30f454d01cade638453b738eee9a Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:32:05 +0530 Subject: [PATCH 3/3] Add regression tests for camoufox server Cover both failure modes from #656 and pin the driver entrypoint contract, so a future Playwright reshuffle fails in CI rather than in a user's terminal. No browser download or launch, so they run anywhere. Refs #656 --- pythonlib/tests/test_server.py | 104 +++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 pythonlib/tests/test_server.py diff --git a/pythonlib/tests/test_server.py b/pythonlib/tests/test_server.py new file mode 100644 index 0000000..5999cfa --- /dev/null +++ b/pythonlib/tests/test_server.py @@ -0,0 +1,104 @@ +""" +Tests for camoufox.server. + +Regression cover for #656: `python -m camoufox server` broke on Playwright +1.60, which bundled away the private `lib/browserServerImpl.js` that +launchServer.js reached into. The two tests below pin the invariants the fix +relies on, so the next time Playwright reshuffles its internals this fails in +CI rather than in a user's terminal. + +These need Playwright's driver (a dependency) but never download or launch a +browser, so they stay fast enough to run anywhere. + +Run with: + cd pythonlib && python -m pytest tests/test_server.py -v +""" + +import base64 +import os +import subprocess +import sys +from pathlib import Path + +import orjson +import pytest + +# Make `import camoufox` resolve to the in-tree pythonlib without an install. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from camoufox import server # noqa: E402 +from camoufox.server import get_nodejs # noqa: E402 + +# Anything on the driver's private lib/ path is fair game for Playwright to +# move between releases; only the package entrypoint is a supported contract. +MODULE_ERRORS = ("Cannot find module", "MODULE_NOT_FOUND") + + +def _driver_package() -> Path: + return Path(get_nodejs()).parent / "package" + + +def test_driver_entrypoint_exposes_launch_server(): + # launchServer.js calls playwright.firefox.launchServer() through the + # driver's entrypoint. The driver is a bundled copy of playwright-core, so + # this is public API -- but assert it rather than assume it, since the whole + # bug was an assumption about driver layout going stale. + nodejs = get_nodejs() + result = subprocess.run( + [ + nodejs, + "-e", + "const pw = require(process.argv[1]);" + "console.log(typeof pw.firefox.launchServer)", + str(_driver_package() / "index.js"), + ], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "function", result.stdout + + +def test_launch_script_resolves_driver_against_installed_playwright(): + # The #656 symptom exactly: launchServer.js died at require() time with + # MODULE_NOT_FOUND before it ever read its config. Drive the real script + # with a config pointing at a binary that does not exist -- reaching a + # browser-launch failure proves require() and config parsing both worked. + nodejs = get_nodejs() + package = _driver_package() + result = subprocess.run( + [nodejs, str(server.LAUNCH_SCRIPT), str(package)], + input=base64.b64encode( + orjson.dumps({"executablePath": "/nonexistent/camoufox-bin"}) + ).decode(), + capture_output=True, + text=True, + timeout=120, + ) + combined = result.stdout + result.stderr + for error in MODULE_ERRORS: + assert error not in combined, f"driver failed to resolve:\n{combined}" + assert "Launching server..." in combined, combined + assert "executable doesn't exist" in combined, combined + + +def test_launch_server_surfaces_child_exit_instead_of_pipe_error(monkeypatch, tmp_path): + # The traceback in #656 was masked twice over: node died, then writing the + # config to its dead stdin raised BrokenPipeError (EINVAL on Windows), + # burying the real cause. launch_server() must report the child's exit. + script = tmp_path / "dies_immediately.js" + script.write_text("process.exit(3);\n") + + # Oversized so the write cannot fit in the pipe buffer and must hit the + # closed pipe -- otherwise a small config lands in the buffer and the + # regression stays invisible. + monkeypatch.setattr( + server, "launch_options", lambda **kwargs: {"pad": "x" * 500_000} + ) + monkeypatch.setattr(server, "LAUNCH_SCRIPT", script) + + with pytest.raises(RuntimeError) as excinfo: + server.launch_server() + + assert "3" in str(excinfo.value), str(excinfo.value)