diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd3afbe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + pull_request: + +jobs: + lint-and-e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install system deps (curl) + run: | + sudo apt-get update + sudo apt-get install -y curl + + - name: Install Python deps + run: | + python -m pip install --upgrade pip + pip install -e ".[e2e]" + pip install ruff + + - name: Ruff + run: | + ruff check . + ruff format --check . + + # Playwright needs browser binaries on the runner + - name: Install Playwright Chromium + run: | + python -m playwright install --with-deps chromium + + # Run your full E2E cycle using the existing Makefile + - name: E2E (docker compose + installer + tests) + env: + MATOMO_URL: "http://127.0.0.1:8080" + MATOMO_ADMIN_USER: "administrator" + MATOMO_ADMIN_PASSWORD: "AdminSecret123!" + MATOMO_ADMIN_EMAIL: "administrator@example.org" + MATOMO_TOKEN_DESCRIPTION: "ci-token" + run: | + make e2e + + # If E2E fails, this is helpful for debugging + - name: Docker logs (on failure) + if: failure() + run: | + docker compose -f tests/e2e/docker-compose.yml ps || true + docker compose -f tests/e2e/docker-compose.yml logs --no-color --tail=300 matomo || true + docker compose -f tests/e2e/docker-compose.yml logs --no-color --tail=300 db || true + + - name: Cleanup (always) + if: always() + run: | + docker compose -f tests/e2e/docker-compose.yml down -v || true diff --git a/src/matomo_bootstrap/__init__.py b/src/matomo_bootstrap/__init__.py index 9160ca0..0a85973 100644 --- a/src/matomo_bootstrap/__init__.py +++ b/src/matomo_bootstrap/__init__.py @@ -4,4 +4,5 @@ Headless bootstrap tooling for Matomo: - readiness checks - admin/API token provisioning """ + __all__ = [] diff --git a/src/matomo_bootstrap/config.py b/src/matomo_bootstrap/config.py index 06da4a6..39c09cb 100644 --- a/src/matomo_bootstrap/config.py +++ b/src/matomo_bootstrap/config.py @@ -13,7 +13,9 @@ class Config: token_description: str = "matomo-bootstrap" timeout: int = 20 debug: bool = False - matomo_container_name: str | None = None # optional, for future console installer usage + matomo_container_name: str | None = ( + None # optional, for future console installer usage + ) def config_from_env_and_args(args) -> Config: @@ -21,9 +23,15 @@ def config_from_env_and_args(args) -> Config: Build a Config object from CLI args (preferred) and environment variables (fallback). """ base_url = getattr(args, "base_url", None) or os.environ.get("MATOMO_URL") - admin_user = getattr(args, "admin_user", None) or os.environ.get("MATOMO_ADMIN_USER") - admin_password = getattr(args, "admin_password", None) or os.environ.get("MATOMO_ADMIN_PASSWORD") - admin_email = getattr(args, "admin_email", None) or os.environ.get("MATOMO_ADMIN_EMAIL") + admin_user = getattr(args, "admin_user", None) or os.environ.get( + "MATOMO_ADMIN_USER" + ) + admin_password = getattr(args, "admin_password", None) or os.environ.get( + "MATOMO_ADMIN_PASSWORD" + ) + admin_email = getattr(args, "admin_email", None) or os.environ.get( + "MATOMO_ADMIN_EMAIL" + ) token_description = ( getattr(args, "token_description", None) @@ -31,7 +39,9 @@ def config_from_env_and_args(args) -> Config: or "matomo-bootstrap" ) - timeout = int(getattr(args, "timeout", None) or os.environ.get("MATOMO_TIMEOUT") or "20") + timeout = int( + getattr(args, "timeout", None) or os.environ.get("MATOMO_TIMEOUT") or "20" + ) debug = bool(getattr(args, "debug", False)) matomo_container_name = ( diff --git a/src/matomo_bootstrap/installers/web.py b/src/matomo_bootstrap/installers/web.py index 8df4bf5..6580dc8 100644 --- a/src/matomo_bootstrap/installers/web.py +++ b/src/matomo_bootstrap/installers/web.py @@ -11,11 +11,15 @@ from ..config import Config # Optional knobs (mostly for debugging / CI stability) -PLAYWRIGHT_HEADLESS = ( - os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ("0", "false", "False") +PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ( + "0", + "false", + "False", ) PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0")) -PLAYWRIGHT_NAV_TIMEOUT_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000")) +PLAYWRIGHT_NAV_TIMEOUT_MS = int( + os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000") +) # Values used by the installer flow (recorded) DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost") @@ -49,10 +53,14 @@ def wait_http(url: str, timeout: int = 180) -> None: except Exception as exc: last_err = exc if i % 5 == 0: - _log(f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})") + _log( + f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})" + ) time.sleep(1) - raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url} ({last_err})") + raise RuntimeError( + f"Matomo did not become reachable after {timeout}s: {url} ({last_err})" + ) def is_installed(url: str) -> bool: @@ -64,11 +72,19 @@ def is_installed(url: str) -> bool: try: with urllib.request.urlopen(url, timeout=5) as resp: html = resp.read().decode(errors="ignore").lower() - return ("module=login" in html) or ("matomo › login" in html) or ("matomo/login" in html) + return ( + ("module=login" in html) + or ("matomo › login" in html) + or ("matomo/login" in html) + ) except urllib.error.HTTPError as exc: try: html = exc.read().decode(errors="ignore").lower() - return ("module=login" in html) or ("matomo › login" in html) or ("matomo/login" in html) + return ( + ("module=login" in html) + or ("matomo › login" in html) + or ("matomo/login" in html) + ) except Exception: return False except Exception: @@ -143,7 +159,9 @@ class WebInstaller(Installer): loc.first.click() return - raise RuntimeError("Could not find a Next/Continue control in the installer UI.") + raise RuntimeError( + "Could not find a Next/Continue control in the installer UI." + ) page.goto(base_url, wait_until="domcontentloaded") @@ -157,7 +175,9 @@ class WebInstaller(Installer): page.wait_for_load_state("domcontentloaded") page.wait_for_timeout(200) else: - raise RuntimeError("Installer did not reach superuser step (login-0 not found).") + raise RuntimeError( + "Installer did not reach superuser step (login-0 not found)." + ) page.locator("#login-0").click() page.locator("#login-0").fill(config.admin_user) diff --git a/src/matomo_bootstrap/matomo_api.py b/src/matomo_bootstrap/matomo_api.py index 03f3962..ce6d6e9 100644 --- a/src/matomo_bootstrap/matomo_api.py +++ b/src/matomo_bootstrap/matomo_api.py @@ -70,7 +70,10 @@ class MatomoApi: err_body = exc.read().decode("utf-8", errors="replace") except Exception: err_body = "" - _dbg(f"[auth] logme HTTPError {exc.code} body[:120]={err_body[:120]!r}", self.debug) + _dbg( + f"[auth] logme HTTPError {exc.code} body[:120]={err_body[:120]!r}", + self.debug, + ) def create_app_specific_token( self, @@ -85,7 +88,9 @@ class MatomoApi: """ env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH") if env_token: - _dbg("[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.", self.debug) + _dbg( + "[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.", self.debug + ) return env_token self.login_via_logme(admin_user, admin_password) @@ -102,10 +107,15 @@ class MatomoApi: }, ) - _dbg(f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}", self.debug) + _dbg( + f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}", + self.debug, + ) if status != 200: - raise TokenCreationError(f"HTTP {status} during token creation: {body[:400]}") + raise TokenCreationError( + f"HTTP {status} during token creation: {body[:400]}" + ) data = _try_json(body) token = data.get("value") if isinstance(data, dict) else None diff --git a/tests/e2e/test_bootstrap.py b/tests/e2e/test_bootstrap.py index 4a096b1..5d8e821 100644 --- a/tests/e2e/test_bootstrap.py +++ b/tests/e2e/test_bootstrap.py @@ -30,12 +30,18 @@ class TestMatomoBootstrapE2E(unittest.TestCase): "e2e-test-token", ] - token = subprocess.check_output( - cmd, - env={**os.environ, "PYTHONPATH": "src"}, - ).decode().strip() + token = ( + subprocess.check_output( + cmd, + env={**os.environ, "PYTHONPATH": "src"}, + ) + .decode() + .strip() + ) - self.assertRegex(token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}") + self.assertRegex( + token, r"^[a-f0-9]{32,64}$", f"Expected token_auth, got: {token}" + ) api_url = ( f"{MATOMO_URL}/index.php"