diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a276fdb1d..5e2231bc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,15 @@ jobs: cache: pip - run: pip install -r requirements.txt if: steps.docs-check.outputs.docs_only != 'true' + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: steps.docs-check.outputs.docs_only != 'true' + with: + node-version: "20" + cache: npm + - run: npm ci + if: steps.docs-check.outputs.docs_only != 'true' + - run: npx playwright install --with-deps chromium + if: steps.docs-check.outputs.docs_only != 'true' - run: mkdir -p data # sqlite DB lives at ./data/app.db if: steps.docs-check.outputs.docs_only != 'true' - run: python -m pytest -q diff --git a/tests/conftest.py b/tests/conftest.py index 2f6defa1c..0e8faebde 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import os import types import importlib.util from unittest.mock import MagicMock +import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -93,3 +94,52 @@ def pytest_collection_modifyitems(config, items): path = getattr(item, "path", None) or item.fspath for marker_name in markers_for_path(path): item.add_marker(getattr(pytest.mark, marker_name)) + + +@pytest.fixture(scope="session", autouse=True) +def _serve_test_static(): + """Ensure static assets are available on loopback port 7011 for browser integration tests.""" + import socket + import threading + import http.server + import socketserver + from pathlib import Path + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + is_bound = (sock.connect_ex(("127.0.0.1", 7011)) == 0) + finally: + sock.close() + + if is_bound: + yield + return + + root_dir = Path(__file__).resolve().parent.parent + + class _Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(root_dir), **kwargs) + + def log_message(self, format, *args): + pass + + def guess_type(self, path): + if path.endswith(".js") or path.endswith(".mjs"): + return "application/javascript" + if path.endswith(".css"): + return "text/css" + return super().guess_type(path) + + class _Server(socketserver.TCPServer): + allow_reuse_address = True + + try: + server = _Server(("127.0.0.1", 7011), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield + server.shutdown() + server.server_close() + except Exception: + yield