fix(e2e): use Playwright web installer + session-based token creation for Matomo 5.3

- make Playwright a runtime dependency (installer requires it)
- record-and-replay Matomo web installer flow (more stable than container bootstrap)
- replace removed UsersManager.getTokenAuth with cookie-session login + createAppSpecificTokenAuth
- make HttpClient return (status, body) for HTTPError responses
- set deterministic container names in docker-compose and pass MATOMO_CONTAINER_NAME

https://chatgpt.com/share/694a7b30-3dd4-800f-ba48-ae7083cfa4d8
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-23 12:21:13 +01:00
parent da261e21e9
commit b63ea71902
8 changed files with 353 additions and 164 deletions

View File

@@ -63,6 +63,7 @@ e2e-install: playwright-install
MATOMO_ADMIN_PASSWORD="$(MATOMO_ADMIN_PASSWORD)" \ MATOMO_ADMIN_PASSWORD="$(MATOMO_ADMIN_PASSWORD)" \
MATOMO_ADMIN_EMAIL="$(MATOMO_ADMIN_EMAIL)" \ MATOMO_ADMIN_EMAIL="$(MATOMO_ADMIN_EMAIL)" \
MATOMO_TOKEN_DESCRIPTION="$(MATOMO_TOKEN_DESCRIPTION)" \ MATOMO_TOKEN_DESCRIPTION="$(MATOMO_TOKEN_DESCRIPTION)" \
MATOMO_CONTAINER_NAME="e2e-matomo" \
PYTHONPATH=src $(VENV_PY) -m matomo_bootstrap PYTHONPATH=src $(VENV_PY) -m matomo_bootstrap
e2e-test: deps-e2e e2e-test: deps-e2e

View File

@@ -12,13 +12,14 @@ authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
license = { text = "All rights reserved by Kevin Veen-Birkenbach" } license = { text = "All rights reserved by Kevin Veen-Birkenbach" }
urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" } urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
dependencies = [] # Playwright is needed at runtime to run the web installer when Matomo is not yet installed.
dependencies = [
[project.optional-dependencies]
e2e = [
"playwright>=1.40.0", "playwright>=1.40.0",
] ]
[project.optional-dependencies]
e2e = []
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src" } package-dir = { "" = "src" }

View File

@@ -1,56 +1,83 @@
import hashlib import hashlib
import json import json
import os
import urllib.error
from .errors import TokenCreationError from .errors import TokenCreationError
from .http import HttpClient from .http import HttpClient
def get_token_auth(client: HttpClient, admin_user: str, admin_password: str) -> str: def _md5(text: str) -> str:
""" return hashlib.md5(text.encode("utf-8")).hexdigest()
Get the user's token_auth via UsersManager.getTokenAuth.
This is the most robust way to authenticate subsequent API calls without relying
on UI sessions/cookies.
"""
md5_password = hashlib.md5(admin_password.encode("utf-8")).hexdigest()
status, body = client.get(
"/index.php",
{
"module": "API",
"method": "UsersManager.getTokenAuth",
"userLogin": admin_user,
"md5Password": md5_password,
"format": "json",
},
)
if status != 200:
raise TokenCreationError(f"HTTP {status} during getTokenAuth: {body[:200]}")
def _try_json(body: str) -> object:
try: try:
data = json.loads(body) return json.loads(body)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise TokenCreationError(f"Invalid JSON from getTokenAuth: {body[:200]}") from exc raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
# Matomo returns either {"value": "..."} or sometimes a plain string depending on setup/version
if isinstance(data, dict) and data.get("value"):
return str(data["value"])
if isinstance(data, str) and data:
return data
raise TokenCreationError(f"Unexpected getTokenAuth response: {data}")
def create_app_token( def _login_via_logme(client: HttpClient, admin_user: str, admin_password: str, debug: bool) -> None:
"""
Create an authenticated Matomo session (cookie jar) using the classic Login controller.
Matomo accepts the md5 hashed password in the `password` parameter for action=logme.
We rely on urllib's opener to follow redirects and store cookies.
If this ever stops working in a future Matomo version, the next step would be:
- GET the login page, extract CSRF/nonce, then POST the login form.
"""
md5_password = _md5(admin_password)
# Hit the login endpoint; cookies should be set in the client's CookieJar.
# We treat any HTTP response as "we reached the login controller" later API call will tell us if session is valid.
try:
status, body = client.get(
"/index.php",
{
"module": "Login",
"action": "logme",
"login": admin_user,
"password": md5_password,
},
)
if debug:
print(f"[auth] login via logme returned HTTP {status} (body preview: {body[:120]!r})")
except urllib.error.HTTPError as exc:
# Even 4xx/5xx can still set cookies; continue and let the API call validate.
if debug:
try:
err_body = exc.read().decode("utf-8", errors="replace")
except Exception:
err_body = ""
print(f"[auth] login via logme raised HTTPError {exc.code} (body preview: {err_body[:120]!r})")
def create_app_token_via_session(
*,
client: HttpClient, client: HttpClient,
admin_token_auth: str,
admin_user: str, admin_user: str,
admin_password: str, admin_password: str,
description: str, description: str,
debug: bool = False,
) -> str: ) -> str:
""" """
Create an app-specific token using token_auth authentication. Create an app-specific token using an authenticated SESSION (cookies),
not via UsersManager.getTokenAuth (removed/not available in Matomo 5.3.x images).
If MATOMO_BOOTSTRAP_TOKEN_AUTH is already set, we return it.
""" """
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
if env_token:
if debug:
print("[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.")
return env_token
# 1) Establish logged-in session
_login_via_logme(client, admin_user, admin_password, debug=debug)
# 2) Use the session cookie to create an app specific token
status, body = client.post( status, body = client.post(
"/index.php", "/index.php",
{ {
@@ -60,20 +87,20 @@ def create_app_token(
"passwordConfirmation": admin_password, "passwordConfirmation": admin_password,
"description": description, "description": description,
"format": "json", "format": "json",
"token_auth": admin_token_auth,
}, },
) )
if status != 200: if debug:
raise TokenCreationError(f"HTTP {status} during token creation: {body[:200]}") print(f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}")
try: if status != 200:
data = json.loads(body) raise TokenCreationError(f"HTTP {status} during token creation: {body[:400]}")
except json.JSONDecodeError as exc:
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:200]}") from exc data = _try_json(body)
token = data.get("value") if isinstance(data, dict) else None token = data.get("value") if isinstance(data, dict) else None
if not token: if not token:
raise TokenCreationError(f"Unexpected response: {data}") # Matomo may return {"result":"error","message":"..."}.
raise TokenCreationError(f"Unexpected response from token creation: {data}")
return str(token) return str(token)

View File

@@ -1,6 +1,6 @@
from argparse import Namespace from argparse import Namespace
from .api_tokens import create_app_token, get_token_auth from .api_tokens import create_app_token_via_session
from .health import assert_matomo_ready from .health import assert_matomo_ready
from .http import HttpClient from .http import HttpClient
from .install.web_installer import ensure_installed from .install.web_installer import ensure_installed
@@ -19,25 +19,19 @@ def run_bootstrap(args: Namespace) -> str:
# 2) Now the UI/API should be reachable and "installed" # 2) Now the UI/API should be reachable and "installed"
assert_matomo_ready(args.base_url, timeout=args.timeout) assert_matomo_ready(args.base_url, timeout=args.timeout)
# 3) Create authenticated API token flow (no UI session needed) # 3) Create app-specific token via authenticated session (cookie-based)
client = HttpClient( client = HttpClient(
base_url=args.base_url, base_url=args.base_url,
timeout=args.timeout, timeout=args.timeout,
debug=args.debug, debug=args.debug,
) )
admin_token_auth = get_token_auth( token = create_app_token_via_session(
client=client, client=client,
admin_user=args.admin_user, admin_user=args.admin_user,
admin_password=args.admin_password, admin_password=args.admin_password,
)
token = create_app_token(
client=client,
admin_token_auth=admin_token_auth,
admin_user=args.admin_user,
admin_password=args.admin_password,
description=args.token_description, description=args.token_description,
debug=args.debug,
) )
return token return token

View File

@@ -1,4 +1,5 @@
import http.cookiejar import http.cookiejar
import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from typing import Dict, Tuple from typing import Dict, Tuple
@@ -23,9 +24,18 @@ class HttpClient:
print(f"[HTTP] GET {url}") print(f"[HTTP] GET {url}")
req = urllib.request.Request(url, method="GET") req = urllib.request.Request(url, method="GET")
with self.opener.open(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace") try:
return resp.status, body with self.opener.open(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
return resp.status, body
except urllib.error.HTTPError as exc:
# urllib raises HTTPError for 4xx/5xx but it still contains status + body
try:
body = exc.read().decode("utf-8", errors="replace")
except Exception:
body = str(exc)
return exc.code, body
def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]: def post(self, path: str, data: Dict[str, str]) -> Tuple[int, str]:
url = self.base_url + path url = self.base_url + path
@@ -35,6 +45,14 @@ class HttpClient:
print(f"[HTTP] POST {url} keys={list(data.keys())}") print(f"[HTTP] POST {url} keys={list(data.keys())}")
req = urllib.request.Request(url, data=encoded, method="POST") req = urllib.request.Request(url, data=encoded, method="POST")
with self.opener.open(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace") try:
return resp.status, body with self.opener.open(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
return resp.status, body
except urllib.error.HTTPError as exc:
try:
body = exc.read().decode("utf-8", errors="replace")
except Exception:
body = str(exc)
return exc.code, body

View File

@@ -1,34 +1,113 @@
import subprocess import subprocess
import time
from typing import Optional
def ensure_installed( MATOMO_ROOT = "/var/www/html"
container_name: str = "e2e-matomo-1", CONSOLE = f"{MATOMO_ROOT}/console"
def _run(cmd: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(cmd, text=True, capture_output=True)
def _container_state(container_name: str) -> str:
res = _run(["docker", "inspect", "-f", "{{.State.Status}}", container_name])
return (res.stdout or "").strip()
def _wait_container_running(container_name: str, timeout: int = 60) -> None:
last = ""
for _ in range(timeout):
state = _container_state(container_name)
last = state
if state == "running":
return
time.sleep(1)
raise RuntimeError(f"Container '{container_name}' did not become running (last state: {last})")
def _exec(container_name: str, argv: list[str]) -> subprocess.CompletedProcess:
return _run(["docker", "exec", container_name, *argv])
def _sh(container_name: str, script: str) -> subprocess.CompletedProcess:
# Use sh -lc so PATH + cwd behave more like interactive container sessions
return _exec(container_name, ["sh", "-lc", script])
def _console_exists(container_name: str) -> bool:
res = _sh(container_name, f"test -x {CONSOLE} && echo yes || echo no")
return (res.stdout or "").strip() == "yes"
def _is_installed(container_name: str) -> bool:
res = _sh(container_name, f"test -f {MATOMO_ROOT}/config/config.ini.php && echo yes || echo no")
return (res.stdout or "").strip() == "yes"
def _console_list(container_name: str) -> str:
# --no-ansi for stable parsing
res = _sh(container_name, f"{CONSOLE} list --no-ansi 2>/dev/null || true")
return (res.stdout or "") + "\n" + (res.stderr or "")
def _has_command(console_list_output: str, command: str) -> bool:
# cheap but robust enough
return f" {command} " in console_list_output or f"\n{command}\n" in console_list_output or command in console_list_output
def ensure_installed_via_console(
*,
container_name: str,
admin_user: str,
admin_password: str,
admin_email: str,
debug: bool = False,
) -> None: ) -> None:
""" """
Ensure Matomo is installed by executing PHP bootstrap inside container. Ensure Matomo is installed using the container's console if possible.
Idempotent: safe to run multiple times. If no known install command exists, we do NOT guess: we raise with diagnostics.
""" """
_wait_container_running(container_name, timeout=90)
cmd = [ if _is_installed(container_name):
"docker", if debug:
"exec", print("[install] Matomo already installed (config.ini.php exists).")
container_name, return
"php",
"-r",
r"""
if (file_exists('/var/www/html/config/config.ini.php')) {
echo "Matomo already installed\n";
exit(0);
}
require '/var/www/html/core/bootstrap.php'; if not _console_exists(container_name):
raise RuntimeError(f"Matomo console not found/executable at {CONSOLE} inside container '{container_name}'.")
\Piwik\FrontController::getInstance()->init(); listing = _console_list(container_name)
\Piwik\Plugins\Installation\Installation::install(); if debug:
print("[install] Matomo console list obtained.")
echo "Matomo installed\n"; # Matomo versions differ; we discover what exists.
""" # Historically: core:install. Your earlier log showed it does NOT exist in 5.3.2 image.
] # Therefore we refuse to guess and provide the list in the exception.
if _has_command(listing, "core:install"):
subprocess.check_call(cmd) # If this ever exists, use it.
cmd = (
f"{CONSOLE} core:install --no-ansi "
f"--database-host=db "
f"--database-username=matomo "
f"--database-password=matomo_pw "
f"--database-name=matomo "
f"--login={admin_user} "
f"--password={admin_password} "
f"--email={admin_email} "
f"--url=http://localhost "
)
res = _sh(container_name, cmd)
if res.returncode != 0:
raise RuntimeError(f"Matomo CLI install failed.\nexit={res.returncode}\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}")
return
# No install command -> fail with diagnostics (dont keep burning time).
raise RuntimeError(
"Matomo is not installed yet, but no supported CLI install command was found in this image.\n"
"This Matomo image likely expects the web installer.\n"
"\n[console list]\n"
f"{listing}\n"
)

View File

@@ -1,15 +1,19 @@
import os import os
import sys
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
DB_HOST = os.environ.get("MATOMO_DB_HOST", "db") # Optional knobs (mostly for debugging / CI stability)
DB_USER = os.environ.get("MATOMO_DB_USER", "matomo") PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ("0", "false", "False")
DB_PASS = os.environ.get("MATOMO_DB_PASS", "matomo_pw") PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
DB_NAME = os.environ.get("MATOMO_DB_NAME", "matomo") PLAYWRIGHT_NAV_TIMEOUT_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000"))
DB_PREFIX = os.environ.get("MATOMO_DB_PREFIX", "matomo_")
# Values used by the installer flow (recorded)
DEFAULT_SITE_NAME = os.environ.get("MATOMO_SITE_NAME", "localhost")
DEFAULT_SITE_URL = os.environ.get("MATOMO_SITE_URL", "http://localhost")
DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
def wait_http(url: str, timeout: int = 180) -> None: def wait_http(url: str, timeout: int = 180) -> None:
@@ -27,7 +31,6 @@ def wait_http(url: str, timeout: int = 180) -> None:
print("[install] Matomo HTTP reachable (2xx/3xx).") print("[install] Matomo HTTP reachable (2xx/3xx).")
return return
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
# 4xx/5xx means the server answered -> reachable
print(f"[install] Matomo HTTP reachable (HTTP {exc.code}).") print(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
return return
except Exception as exc: except Exception as exc:
@@ -50,7 +53,6 @@ def is_installed(url: str) -> bool:
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:
# Even if it's 500, read body and try heuristic.
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)
@@ -70,101 +72,166 @@ def ensure_installed(
""" """
Ensure Matomo is installed. Ensure Matomo is installed.
NO-OP if already installed. NO-OP if already installed.
This implementation ONLY uses the Playwright web installer (recorded flow).
""" """
wait_http(base_url) wait_http(base_url)
if is_installed(base_url): if is_installed(base_url):
if debug: if debug:
print("[install] Matomo already looks installed. Skipping web installer.") print("[install] Matomo already looks installed. Skipping installer.")
return return
try: from playwright.sync_api import sync_playwright
from playwright.sync_api import sync_playwright
except Exception as exc:
print("[install] Playwright not available.", file=sys.stderr)
print(
"Install with: python3 -m pip install playwright && python3 -m playwright install chromium",
file=sys.stderr,
)
raise RuntimeError(f"Playwright missing: {exc}") from exc
print("[install] Running Matomo web installer via headless browser...") print("[install] Running Matomo web installer via Playwright (recorded flow)...")
with sync_playwright() as p: with sync_playwright() as p:
browser = p.chromium.launch(headless=True) browser = p.chromium.launch(
page = browser.new_page() headless=PLAYWRIGHT_HEADLESS,
slow_mo=PLAYWRIGHT_SLOWMO_MS if PLAYWRIGHT_SLOWMO_MS > 0 else None,
)
context = browser.new_context()
page = context.new_page()
page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
# Load installer (may be 500 in curl, but browser can still render the Matomo error/installer flow) def _dbg(msg: str) -> None:
page.goto(base_url, wait_until="domcontentloaded") if debug:
print(f"[install] {msg}")
def click_next() -> None: def click_next() -> None:
# Buttons vary slightly with locales/versions """
for label in ["Next", "Continue", "Start Installation", "Proceed", "Weiter", "Fortfahren"]: Matomo installer mixes link/button variants and sometimes includes '»'.
btn = page.get_by_role("button", name=label) We try common variants in a robust order.
if btn.count() > 0: """
btn.first.click() candidates = [
("link", "Next »"),
("button", "Next »"),
("link", "Next"),
("button", "Next"),
("link", "Continue"),
("button", "Continue"),
("link", "Proceed"),
("button", "Proceed"),
("link", "Start Installation"),
("button", "Start Installation"),
("link", "Weiter"),
("button", "Weiter"),
("link", "Fortfahren"),
("button", "Fortfahren"),
]
for role, name in candidates:
loc = page.get_by_role(role, name=name)
if loc.count() > 0:
_dbg(f"click_next(): {role} '{name}'")
loc.first.click()
return return
# Sometimes it's a link styled as button
for text in ["Next", "Continue", "Start Installation", "Proceed", "Weiter", "Fortfahren"]:
a = page.get_by_text(text, exact=False)
if a.count() > 0:
a.first.click()
return
raise RuntimeError("Could not find a 'Next/Continue' control in installer UI.")
# Welcome / System check # last resort: some pages use same text but different element types
page.wait_for_timeout(700) loc = page.get_by_text("Next", exact=False)
click_next() if loc.count() > 0:
page.wait_for_timeout(700) _dbg("click_next(): fallback text 'Next'")
click_next() loc.first.click()
return
# Database setup raise RuntimeError("Could not find a Next/Continue control in the installer UI.")
page.wait_for_timeout(700)
page.get_by_label("Database Server").fill(DB_HOST)
page.get_by_label("Login").fill(DB_USER)
page.get_by_label("Password").fill(DB_PASS)
page.get_by_label("Database Name").fill(DB_NAME)
try:
page.get_by_label("Tables Prefix").fill(DB_PREFIX)
except Exception:
pass
click_next()
# Tables creation # --- Recorded-ish flow, but made variable-based + more stable ---
page.wait_for_timeout(700) page.goto(base_url, wait_until="domcontentloaded")
click_next()
# Super user # The first few screens can vary slightly (welcome/system check/db etc.).
page.wait_for_timeout(700) # In your recording, you clicked through multiple Next pages without DB input (env already set in container).
page.get_by_label("Login").fill(admin_user) # We mimic that: keep clicking "Next" until we see the superuser fields.
page.get_by_label("Password").fill(admin_password) #
try: # Stop condition: superuser login field appears.
page.get_by_label("Password (repeat)").fill(admin_password) def superuser_form_visible() -> bool:
except Exception: # In your recording, the superuser "Login" field was "#login-0".
pass return page.locator("#login-0").count() > 0
page.get_by_label("Email").fill(admin_email)
click_next() # Click next until the superuser page shows up (cap to avoid infinite loops).
for _ in range(12):
if superuser_form_visible():
break
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
else:
raise RuntimeError("Installer did not reach superuser step (login-0 not found).")
# Superuser step
page.locator("#login-0").click()
page.locator("#login-0").fill(admin_user)
page.locator("#password-0").click()
page.locator("#password-0").fill(admin_password)
# Repeat password (some versions have it)
if page.locator("#password_bis-0").count() > 0:
page.locator("#password_bis-0").click()
page.locator("#password_bis-0").fill(admin_password)
page.locator("#email-0").click()
page.locator("#email-0").fill(admin_email)
# Next
page.get_by_role("button", name="Next »").click()
# First website # First website
page.wait_for_timeout(700) if page.locator("#siteName-0").count() > 0:
try: page.locator("#siteName-0").click()
page.get_by_label("Name").fill("Bootstrap Site") page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
except Exception:
pass
try:
page.get_by_label("URL").fill("http://example.invalid")
except Exception:
pass
click_next()
# Finish if page.locator("#url-0").count() > 0:
page.wait_for_timeout(700) page.locator("#url-0").click()
click_next() page.locator("#url-0").fill(DEFAULT_SITE_URL)
# Timezone dropdown (best-effort)
try:
# recording: page.get_by_role("combobox").first.click() then listbox text
page.get_by_role("combobox").first.click()
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
except Exception:
_dbg("Timezone selection skipped (not found / changed UI).")
# Ecommerce dropdown (best-effort)
try:
# recording: combobox nth(2)
page.get_by_role("combobox").nth(2).click()
page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
except Exception:
_dbg("Ecommerce selection skipped (not found / changed UI).")
# Next pages to finish
click_next()
page.wait_for_load_state("domcontentloaded")
# In recording: Next link, then Continue to Matomo button
if page.get_by_role("link", name="Next »").count() > 0:
page.get_by_role("link", name="Next »").click()
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
page.get_by_role("button", name="Continue to Matomo »").click()
# Optional: login once (not strictly required for token flow, but harmless and matches your recording).
# Some UIs have fancy-icon labels; we follow your recorded selectors best-effort.
try:
user_box = page.get_by_role("textbox", name=" Username or e-mail")
pass_box = page.get_by_role("textbox", name=" Password")
if user_box.count() > 0 and pass_box.count() > 0:
user_box.click()
user_box.fill(admin_user)
pass_box.fill(admin_password)
if page.get_by_role("button", name="Sign in").count() > 0:
page.get_by_role("button", name="Sign in").click()
except Exception:
_dbg("Post-install login skipped (UI differs).")
context.close()
browser.close() browser.close()
# Verify installed time.sleep(1)
time.sleep(2)
if not is_installed(base_url): if not is_installed(base_url):
raise RuntimeError("[install] Installer did not reach installed state.") raise RuntimeError("[install] Installer did not reach installed state.")

View File

@@ -1,6 +1,7 @@
services: services:
db: db:
image: mariadb:11 image: mariadb:11
container_name: e2e-db
environment: environment:
MARIADB_DATABASE: matomo MARIADB_DATABASE: matomo
MARIADB_USER: matomo MARIADB_USER: matomo
@@ -14,6 +15,7 @@ services:
matomo: matomo:
image: matomo:5.3.2 image: matomo:5.3.2
container_name: e2e-matomo
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy