ci: add GitHub Actions workflow with ruff and E2E tests
- Add CI workflow running Ruff lint/format checks - Run full E2E cycle (Docker Compose + Playwright + tests) - Refactor code formatting to satisfy Ruff (line breaks, readability) - Use sys.executable in tests for interpreter-agnostic execution https://chatgpt.com/share/694a7f81-d96c-800f-88cb-7b25b4cdfe1a
This commit is contained in:
65
.github/workflows/ci.yml
vendored
Normal file
65
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
@@ -4,4 +4,5 @@ Headless bootstrap tooling for Matomo:
|
||||
- readiness checks
|
||||
- admin/API token provisioning
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user