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
|
- readiness checks
|
||||||
- admin/API token provisioning
|
- admin/API token provisioning
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = []
|
__all__ = []
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ class Config:
|
|||||||
token_description: str = "matomo-bootstrap"
|
token_description: str = "matomo-bootstrap"
|
||||||
timeout: int = 20
|
timeout: int = 20
|
||||||
debug: bool = False
|
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:
|
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).
|
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")
|
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_user = getattr(args, "admin_user", None) or os.environ.get(
|
||||||
admin_password = getattr(args, "admin_password", None) or os.environ.get("MATOMO_ADMIN_PASSWORD")
|
"MATOMO_ADMIN_USER"
|
||||||
admin_email = getattr(args, "admin_email", None) or os.environ.get("MATOMO_ADMIN_EMAIL")
|
)
|
||||||
|
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 = (
|
token_description = (
|
||||||
getattr(args, "token_description", None)
|
getattr(args, "token_description", None)
|
||||||
@@ -31,7 +39,9 @@ def config_from_env_and_args(args) -> Config:
|
|||||||
or "matomo-bootstrap"
|
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))
|
debug = bool(getattr(args, "debug", False))
|
||||||
|
|
||||||
matomo_container_name = (
|
matomo_container_name = (
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ from ..config import Config
|
|||||||
|
|
||||||
|
|
||||||
# Optional knobs (mostly for debugging / CI stability)
|
# Optional knobs (mostly for debugging / CI stability)
|
||||||
PLAYWRIGHT_HEADLESS = (
|
PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in (
|
||||||
os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ("0", "false", "False")
|
"0",
|
||||||
|
"false",
|
||||||
|
"False",
|
||||||
)
|
)
|
||||||
PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
|
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)
|
# Values used by the installer flow (recorded)
|
||||||
DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost")
|
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:
|
except Exception as exc:
|
||||||
last_err = exc
|
last_err = exc
|
||||||
if i % 5 == 0:
|
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)
|
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:
|
def is_installed(url: str) -> bool:
|
||||||
@@ -64,11 +72,19 @@ def is_installed(url: str) -> bool:
|
|||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||||
html = resp.read().decode(errors="ignore").lower()
|
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:
|
except urllib.error.HTTPError as exc:
|
||||||
try:
|
try:
|
||||||
html = exc.read().decode(errors="ignore").lower()
|
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:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -143,7 +159,9 @@ class WebInstaller(Installer):
|
|||||||
loc.first.click()
|
loc.first.click()
|
||||||
return
|
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")
|
page.goto(base_url, wait_until="domcontentloaded")
|
||||||
|
|
||||||
@@ -157,7 +175,9 @@ class WebInstaller(Installer):
|
|||||||
page.wait_for_load_state("domcontentloaded")
|
page.wait_for_load_state("domcontentloaded")
|
||||||
page.wait_for_timeout(200)
|
page.wait_for_timeout(200)
|
||||||
else:
|
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").click()
|
||||||
page.locator("#login-0").fill(config.admin_user)
|
page.locator("#login-0").fill(config.admin_user)
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ class MatomoApi:
|
|||||||
err_body = exc.read().decode("utf-8", errors="replace")
|
err_body = exc.read().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
err_body = ""
|
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(
|
def create_app_specific_token(
|
||||||
self,
|
self,
|
||||||
@@ -85,7 +88,9 @@ class MatomoApi:
|
|||||||
"""
|
"""
|
||||||
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
|
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
|
||||||
if env_token:
|
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
|
return env_token
|
||||||
|
|
||||||
self.login_via_logme(admin_user, admin_password)
|
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:
|
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)
|
data = _try_json(body)
|
||||||
token = data.get("value") if isinstance(data, dict) else None
|
token = data.get("value") if isinstance(data, dict) else None
|
||||||
|
|||||||
@@ -30,12 +30,18 @@ class TestMatomoBootstrapE2E(unittest.TestCase):
|
|||||||
"e2e-test-token",
|
"e2e-test-token",
|
||||||
]
|
]
|
||||||
|
|
||||||
token = subprocess.check_output(
|
token = (
|
||||||
|
subprocess.check_output(
|
||||||
cmd,
|
cmd,
|
||||||
env={**os.environ, "PYTHONPATH": "src"},
|
env={**os.environ, "PYTHONPATH": "src"},
|
||||||
).decode().strip()
|
)
|
||||||
|
.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 = (
|
api_url = (
|
||||||
f"{MATOMO_URL}/index.php"
|
f"{MATOMO_URL}/index.php"
|
||||||
|
|||||||
Reference in New Issue
Block a user