mirror of
https://github.com/daijro/camoufox.git
synced 2026-08-21 00:01:07 +00:00
278 lines
9.2 KiB
Python
278 lines
9.2 KiB
Python
"""
|
|
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 queue
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
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 _read_until(process, expected: str, timeout: float = 5) -> str:
|
|
lines = queue.Queue()
|
|
|
|
def read_stdout():
|
|
for line in process.stdout:
|
|
lines.put(line)
|
|
|
|
threading.Thread(target=read_stdout, daemon=True).start()
|
|
output = []
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
line = lines.get(timeout=deadline - time.monotonic())
|
|
except queue.Empty:
|
|
break
|
|
output.append(line)
|
|
if expected in line:
|
|
return "".join(output)
|
|
raise AssertionError(f"Did not receive {expected!r}; stdout was {''.join(output)!r}")
|
|
|
|
|
|
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_script_closes_server_gracefully(tmp_path):
|
|
driver = tmp_path / "driver"
|
|
driver.mkdir()
|
|
close_marker = tmp_path / "closed"
|
|
(driver / "index.js").write_text(
|
|
"const fs = require('fs');\n"
|
|
"module.exports = { firefox: { launchServer: async () => ({\n"
|
|
" wsEndpoint: () => 'ws://test-server',\n"
|
|
" close: async () => fs.writeFileSync(process.env.CLOSE_MARKER, 'closed')\n"
|
|
"}) } };\n"
|
|
)
|
|
env = os.environ.copy()
|
|
env["CLOSE_MARKER"] = str(close_marker)
|
|
process = subprocess.Popen(
|
|
[get_nodejs(), str(server.LAUNCH_SCRIPT), str(driver)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
assert process.stdin is not None
|
|
try:
|
|
config = base64.b64encode(orjson.dumps({})).decode()
|
|
midpoint = len(config) // 2
|
|
process.stdin.write(config[:midpoint])
|
|
process.stdin.flush()
|
|
process.stdin.write(config[midpoint:] + "\nignored-after-first-frame\n")
|
|
process.stdin.flush()
|
|
|
|
output = _read_until(process, "ws://test-server")
|
|
assert "Launching server..." in output
|
|
assert process.poll() is None
|
|
assert not close_marker.exists()
|
|
|
|
process.stdin.close()
|
|
assert process.wait(timeout=5) == 0
|
|
assert close_marker.read_text() == "closed"
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
if not process.stdin.closed:
|
|
process.stdin.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("destroy_argument", "expected_returncode"),
|
|
[("", 0), ("new Error('control stream failed')", 1)],
|
|
)
|
|
def test_launch_script_closes_server_on_stdin_close_or_error(
|
|
tmp_path, destroy_argument, expected_returncode
|
|
):
|
|
driver = tmp_path / "driver"
|
|
driver.mkdir()
|
|
close_marker = tmp_path / "closed"
|
|
(driver / "index.js").write_text(
|
|
"const fs = require('fs');\n"
|
|
"module.exports = { firefox: { launchServer: async () => {\n"
|
|
f" setTimeout(() => process.stdin.destroy({destroy_argument}), 25);\n"
|
|
" return {\n"
|
|
" wsEndpoint: () => 'ws://test-server',\n"
|
|
" close: async () => fs.writeFileSync(process.env.CLOSE_MARKER, 'closed')\n"
|
|
" };\n"
|
|
"} } };\n"
|
|
)
|
|
env = os.environ.copy()
|
|
env["CLOSE_MARKER"] = str(close_marker)
|
|
process = subprocess.Popen(
|
|
[get_nodejs(), str(server.LAUNCH_SCRIPT), str(driver)],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
assert process.stdin is not None
|
|
try:
|
|
config = base64.b64encode(orjson.dumps({})).decode()
|
|
process.stdin.write(config + "\n")
|
|
process.stdin.flush()
|
|
_read_until(process, "ws://test-server")
|
|
|
|
assert process.wait(timeout=5) == expected_returncode
|
|
assert close_marker.read_text() == "closed"
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
if not process.stdin.closed:
|
|
process.stdin.close()
|
|
|
|
|
|
@pytest.mark.parametrize("interrupt_at", ["write", "flush"])
|
|
def test_launch_server_reaps_child_when_sending_config_is_interrupted(
|
|
monkeypatch, interrupt_at
|
|
):
|
|
class InterruptingStdin:
|
|
def __init__(self):
|
|
self.closed = False
|
|
|
|
def write(self, data):
|
|
if interrupt_at == "write":
|
|
raise KeyboardInterrupt
|
|
return len(data)
|
|
|
|
def flush(self):
|
|
if interrupt_at == "flush":
|
|
raise KeyboardInterrupt
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
class FakeProcess:
|
|
def __init__(self):
|
|
self.stdin = InterruptingStdin()
|
|
self.returncode = None
|
|
self.wait_timeouts = []
|
|
self.terminated = False
|
|
self.killed = False
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def wait(self, timeout=None):
|
|
self.wait_timeouts.append(timeout)
|
|
self.returncode = 0
|
|
return 0
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
process = FakeProcess()
|
|
monkeypatch.setattr(server, "get_nodejs", lambda: "/node")
|
|
monkeypatch.setattr(server, "launch_options", lambda **kwargs: {})
|
|
monkeypatch.setattr(server.subprocess, "Popen", lambda *args, **kwargs: process)
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
server.launch_server()
|
|
|
|
assert process.stdin.closed
|
|
assert process.wait_timeouts == [15]
|
|
assert not process.terminated
|
|
assert not process.killed
|
|
|
|
|
|
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)
|