From fda0f5a5a3f4432446fd4621e7b1c5c94c7c4969 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Wed, 18 Feb 2026 23:03:54 +0100 Subject: [PATCH] Added basic integration tests focussing on javascript --- integ_tests/README.md | 28 ++++++++ integ_tests/test_basic_operation.py | 104 ++++++++++++++++++++++++++++ pyproject.toml | 8 ++- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 integ_tests/README.md create mode 100644 integ_tests/test_basic_operation.py diff --git a/integ_tests/README.md b/integ_tests/README.md new file mode 100644 index 00000000..72984e3d --- /dev/null +++ b/integ_tests/README.md @@ -0,0 +1,28 @@ +# Integration tests + +These tests use Playwright to run the tests agains a full Radicale instance. + +## First time install + +You need to install playwright. The easiest way to do this is to create a .venv with playwright inside. You can do this by trying to run the integ tests and then entering the .venv + +Assuming you use uv to run/create your .venvs, note that the invocation will fail: + +``` lang=shell +uv run --extra integ_test pytest integ_tests +source .venv/bin/activate +``` + +then run + +``` lang=shell +playwright install --with-deps +``` + +## Running the tests + +if you use uv, you can run + +``` lang=shell +uv run --extra integ_test pytest integ_tests +``` diff --git a/integ_tests/test_basic_operation.py b/integ_tests/test_basic_operation.py new file mode 100644 index 00000000..e0d07994 --- /dev/null +++ b/integ_tests/test_basic_operation.py @@ -0,0 +1,104 @@ +import os +import socket +import subprocess +import sys +import time + +import pytest +from playwright.sync_api import Page, expect + + +def get_free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def radicale_server(tmp_path): + port = get_free_port() + config_path = tmp_path / "config" + user_path = tmp_path / "users" + storage_path = tmp_path / "collections" + + # Create a local config file + with open(config_path, "w") as f: + f.write( + f"""[server] +hosts = 127.0.0.1:{port} +[storage] +filesystem_folder = {storage_path} +[auth] +type = htpasswd +htpasswd_filename = {user_path} +[web] +type = internal +""" + ) + with open(user_path, "w") as f: + f.write( + """admin:adminpassword +""" + ) + + env = os.environ.copy() + # Ensure the radicale package is in PYTHONPATH + # Assuming this test file is in /integ_tests/ + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") + + # Run the server + process = subprocess.Popen( + [sys.executable, "-m", "radicale", "--config", str(config_path)], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Wait for the server to start listening + start_time = time.time() + while time.time() - start_time < 10: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except (OSError, ConnectionRefusedError): + if process.poll() is not None: + stdout, stderr = process.communicate() + raise RuntimeError( + f"Radicale failed to start (code {process.returncode}):\n{stderr.decode()}" + ) + time.sleep(0.1) + else: + process.terminate() + process.wait() + raise RuntimeError("Timeout waiting for Radicale to start") + + yield f"http://127.0.0.1:{port}" + + # Cleanup + process.terminate() + process.wait() + + +def test_index_html_loads(page: Page, radicale_server): + """Test that the index.html loads from the server.""" + console_msgs = [] + page.on("console", lambda msg: console_msgs.append(msg.text)) + page.goto(radicale_server) + expect(page).to_have_title("Radicale Web Interface") + # There should be no errors on the console + assert len(console_msgs) == 0 + + +def test_user_login_works(page: Page, radicale_server): + """Test that the login form works.""" + page.goto(radicale_server) + # Fill in the login form + page.fill('#loginscene input[data-name="user"]', "admin") + page.fill('#loginscene input[data-name="password"]', "adminpassword") + page.click('button:has-text("Next")') + + # After login, we should see the collections list (which is empty) + expect( + page.locator('span[data-name="user"]', has_text="admin's Collections") + ).to_be_visible() diff --git a/pyproject.toml b/pyproject.toml index 80e7fe37..91bff149 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ test = ["pytest>=7", "waitress", "bcrypt", "argon2-cffi"] bcrypt = ["bcrypt"] argon2 = ["argon2-cffi"] ldap = ["ldap3"] +integ_test = ["pytest", "pytest-playwright"] [project.scripts] radicale = "radicale.__main__:run" @@ -79,13 +80,16 @@ deps = ["mypy==1.11.0"] commands = [["mypy", "--install-types", "--non-interactive", "."]] skip_install = true +[tool.pytest.ini_options] +norecursedirs = ["integ_tests"] + [tool.setuptools] platforms = ["Any"] include-package-data = false [tool.setuptools.packages.find] -exclude = ["*.tests"] # *.tests.*; tests.*; tests +exclude = ["*.tests", "integ_tests"] # *.tests.*; tests.*; tests namespaces = false [tool.setuptools.package-data] @@ -116,7 +120,7 @@ exclude = "(^|/)build($|/)" [tool.coverage.run] branch = true source = ["radicale"] -omit = ["tests/*", "*/tests/*"] +omit = ["tests/*", "*/tests/*", "integ_tests/*", "*/integ_tests/*"] [tool.coverage.report] # Regexes for lines to exclude from consideration