11 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
e81c5262b0 Release version 1.1.4
Some checks failed
ci / tests (push) Has been cancelled
publish-image / build-and-push (push) Has been cancelled
Stable Tag / test (push) Has been cancelled
Stable Tag / tag-stable (push) Has been cancelled
2026-02-13 18:32:19 +01:00
Kevin Veen-Birkenbach
f35ea04d66 Resolve merge conflict and preserve installer hardening 2026-02-13 18:24:34 +01:00
Kevin Veen-Birkenbach
37a17b536d Harden installer readiness and fix e2e healthcheck 2026-02-13 15:20:18 +01:00
Kevin Veen-Birkenbach
c80fdf8d01 Release version 1.1.3 2026-02-12 01:27:16 +01:00
Kevin Veen-Birkenbach
276833bd16 fix(matomo-bootstrap): increase Playwright step wait from 200ms to 1000ms to reduce CI flakiness
Increase page.wait_for_timeout from 200ms to 1000ms in WebInstaller to mitigate race conditions during Matomo web installation steps in slower CI environments.

https://chatgpt.com/share/698d1e2f-1f40-800f-92bc-10a736358b40
2026-02-12 01:26:09 +01:00
Kevin Veen-Birkenbach
9e267ec83f Added github sponsor buttons
Some checks failed
ci / tests (push) Has been cancelled
2026-01-02 13:00:00 +01:00
Kevin Veen-Birkenbach
20274985bc Release version 1.1.2
Some checks failed
ci / tests (push) Has been cancelled
publish-image / build-and-push (push) Has been cancelled
Stable Tag / test (push) Has been cancelled
Stable Tag / tag-stable (push) Has been cancelled
2025-12-24 17:28:48 +01:00
Kevin Veen-Birkenbach
cf473d4f3f Ruff formated
Some checks failed
ci / tests (push) Has been cancelled
2025-12-24 17:25:07 +01:00
Kevin Veen-Birkenbach
84323bd2aa test: add integration tests for installer warning detection
Some checks failed
ci / tests (push) Has been cancelled
- add make target test-integration and run it in reusable CI workflow
- add integration unittest covering _page_warnings stderr output + deduplication
- surface Matomo installer warnings during Playwright flow (stderr only)

https://chatgpt.com/share/694c1371-365c-800f-bdf8-ede2e850e648
2025-12-24 17:22:50 +01:00
Kevin Veen-Birkenbach
1a65ceb015 Release version 1.1.1
Some checks failed
ci / tests (push) Has been cancelled
publish-image / build-and-push (push) Has been cancelled
Stable Tag / test (push) Has been cancelled
Stable Tag / tag-stable (push) Has been cancelled
2025-12-24 08:39:59 +01:00
Kevin Veen-Birkenbach
81746f4b26 ci: publish Docker images for version tags and stable releases
Some checks failed
ci / tests (push) Has been cancelled
- Publish images on semantic version tags (vX.Y.Z) as :vX.Y.Z and :latest
- Publish :stable image via workflow_run after successful Stable Tag workflow
- Build stable image from exact commit marked as stable
- Remove duplicate build steps and unify tag computation

https://chatgpt.com/share/694b9758-58fc-800f-a586-8f3a341ece9d
2025-12-24 08:33:37 +01:00
13 changed files with 658 additions and 148 deletions

View File

@@ -3,16 +3,19 @@ name: publish-image
on: on:
push: push:
tags: tags:
- "*" - "v*.*.*"
workflow_run:
workflows: ["Stable Tag"] # MUST match stable-tag.yml -> name: Stable Tag
types: [completed]
jobs: jobs:
tests:
uses: ./.github/workflows/reusable-test.yml
build-and-push: build-and-push:
needs: tests if: |
runs-on: ubuntu-latest (github.event_name == 'push') ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -20,6 +23,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# push: checks out the tag ref
# workflow_run: checks out the exact commit that the Stable Tag workflow ran on
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -27,48 +34,34 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract tag - name: Login to GHCR
id: meta
run: |
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
- name: Check semver tag
id: semver
run: |
if [[ "${GITHUB_REF_NAME}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "is_semver=true" >> "$GITHUB_OUTPUT"
else
echo "is_semver=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (tag) - name: Compute tags
uses: docker/build-push-action@v6 id: meta
with: shell: bash
context: . run: |
file: ./Dockerfile set -euo pipefail
push: true IMAGE="ghcr.io/${{ github.repository }}"
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push (latest) if [ "${{ github.event_name }}" = "push" ]; then
if: steps.semver.outputs.is_semver == 'true' TAG="${{ github.ref_name }}" # e.g. v1.1.0
echo "tags=$IMAGE:$TAG,$IMAGE:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=$IMAGE:stable" >> "$GITHUB_OUTPUT"
fi
- name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: | tags: ${{ steps.meta.outputs.tags }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@@ -52,6 +52,21 @@ jobs:
ruff check . ruff check .
ruff format --check . ruff format --check .
integration:
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Integration tests
run: make test-integration
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30

View File

@@ -1,3 +1,23 @@
## [1.1.4] - 2026-02-13
* This release hardens Matomo bootstrap by adding installer UI readiness waits/retries.
## [1.1.3] - 2026-02-12
* Increase Playwright step wait from 200ms to 1000ms to improve CI stability during Matomo installation.
## [1.1.2] - 2025-12-24
* **Improved error visibility during Matomo installation**: When the setup fails (for example due to an invalid admin email or missing required fields), the installer now **prints the actual Matomo error messages to the logs**, instead of failing with a generic error.
## [1.1.1] - 2025-12-24
* Improved Docker image publishing: automatic `vX.Y.Z`, `latest`, and `stable` tags for releases.
## [1.1.0] - 2025-12-23 ## [1.1.0] - 2025-12-23
* Implemented bootstrap docker image to auto install matomo in docker compose * Implemented bootstrap docker image to auto install matomo in docker compose

View File

@@ -34,12 +34,13 @@ COMPOSE_STACK := docker compose -f $(COMPOSE_STACK_FILE)
.PHONY: help \ .PHONY: help \
venv deps-e2e playwright-install e2e-up e2e-install e2e-test e2e-down e2e logs clean \ venv deps-e2e playwright-install e2e-up e2e-install e2e-test e2e-down e2e logs clean \
test-integration \
image-build image-run image-shell image-push image-clean \ image-build image-run image-shell image-push image-clean \
stack-up stack-down stack-logs stack-ps stack-bootstrap stack-rebootstrap stack-clean stack-reset stack-up stack-down stack-logs stack-ps stack-bootstrap stack-rebootstrap stack-clean stack-reset
help: help:
@echo "Targets:" @echo "Targets:"
@echo " venv Create local venv in $(VENV_DIR)" @echo " venv Create local venv in $(VENV_DIR)"
@echo " deps-e2e Install package + E2E deps into venv" @echo " deps-e2e Install package + E2E deps into venv"
@echo " playwright-install Install Chromium for Playwright (inside venv)" @echo " playwright-install Install Chromium for Playwright (inside venv)"
@echo " e2e-up Start Matomo + DB for E2E tests" @echo " e2e-up Start Matomo + DB for E2E tests"
@@ -49,6 +50,7 @@ help:
@echo " e2e Full cycle: up → install → test → down" @echo " e2e Full cycle: up → install → test → down"
@echo " logs Show Matomo logs (E2E compose)" @echo " logs Show Matomo logs (E2E compose)"
@echo " clean Stop E2E containers + remove venv" @echo " clean Stop E2E containers + remove venv"
@echo " test-integration Run integration tests (unittest)"
@echo "" @echo ""
@echo "Container image targets:" @echo "Container image targets:"
@echo " image-build Build matomo-bootstrap container image" @echo " image-build Build matomo-bootstrap container image"
@@ -76,8 +78,46 @@ help:
# ---------------------------- # ----------------------------
venv: venv:
@test -x "$(VENV_PY)" || ($(PYTHON) -m venv $(VENV_DIR)) @set -e; \
@$(VENV_PIP) -q install -U pip setuptools wheel >/dev/null if [ ! -d "$(VENV_DIR)" ]; then \
echo "Creating $(VENV_DIR) ..."; \
$(PYTHON) -m venv "$(VENV_DIR)"; \
fi; \
if ! [ -x "$(VENV_PY)" ] || ! "$(VENV_PY)" -V >/dev/null 2>&1; then \
echo "Repairing $(VENV_PY) symlink ..."; \
fix_target=""; \
for cand in "$(VENV_DIR)/bin/python3.14" "$(VENV_DIR)/bin/python3.13" "$(VENV_DIR)/bin/python3.12" "$(VENV_DIR)/bin/python3.11" "$(VENV_DIR)/bin/python3.10"; do \
if [ -x "$$cand" ]; then \
fix_target="$$(basename "$$cand")"; \
break; \
fi; \
done; \
if [ -z "$$fix_target" ] && [ -x "$(VENV_PIP)" ]; then \
shebang="$$(head -n1 "$(VENV_PIP)" | sed 's/^#!//')"; \
if [ -n "$$shebang" ] && [ -x "$$shebang" ]; then \
fix_target="$$(basename "$$shebang")"; \
fi; \
fi; \
if [ -n "$$fix_target" ] && [ -x "$(VENV_DIR)/bin/$$fix_target" ]; then \
ln -sfn "$$fix_target" "$(VENV_PY)"; \
ln -sfn "$$fix_target" "$(VENV_DIR)/bin/python3"; \
fi; \
fi; \
if ! [ -x "$(VENV_PIP)" ] || ! "$(VENV_PIP)" --version >/dev/null 2>&1; then \
echo "Repairing pip via ensurepip ..."; \
"$(VENV_PY)" -m ensurepip --upgrade >/dev/null 2>&1 || true; \
fi; \
if ! [ -x "$(VENV_PY)" ] || ! "$(VENV_PY)" -V >/dev/null 2>&1; then \
echo "ERROR: Could not repair $(VENV_PY) in existing $(VENV_DIR)."; \
echo "Run 'make clean' once or remove $(VENV_DIR) manually."; \
exit 2; \
fi; \
if ! [ -x "$(VENV_PIP)" ] || ! "$(VENV_PIP)" --version >/dev/null 2>&1; then \
echo "ERROR: Could not repair $(VENV_PIP) in existing $(VENV_DIR)."; \
echo "Run 'make clean' once or remove $(VENV_DIR) manually."; \
exit 2; \
fi; \
"$(VENV_PIP)" -q install -U pip setuptools wheel >/dev/null
deps-e2e: venv deps-e2e: venv
@$(VENV_PIP) install -e ".[e2e]" @$(VENV_PIP) install -e ".[e2e]"
@@ -129,6 +169,13 @@ logs:
clean: e2e-down clean: e2e-down
rm -rf $(VENV_DIR) rm -rf $(VENV_DIR)
# ----------------------------
# Integration tests
# ----------------------------
test-integration:
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests/integration -v
# ---------------------------- # ----------------------------
# Container image workflow # Container image workflow
# ---------------------------- # ----------------------------

View File

@@ -1,4 +1,6 @@
# matomo-bootstrap # matomo-bootstrap
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
Headless bootstrap tooling for **Matomo**. Automates **first-time installation** and **API token provisioning** for fresh Matomo instances. Headless bootstrap tooling for **Matomo**. Automates **first-time installation** and **API token provisioning** for fresh Matomo instances.
--- ---
@@ -170,7 +172,7 @@ services:
volumes: volumes:
- matomo_data:/var/www/html - matomo_data:/var/www/html
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"] test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 60 retries: 60
@@ -211,6 +213,10 @@ services:
MATOMO_PLAYWRIGHT_HEADLESS: "1" MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000" MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0" MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
MATOMO_INSTALLER_READY_TIMEOUT_S: "180"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "30"
MATOMO_INSTALLER_STEP_DEADLINE_S: "180"
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
restart: "no" restart: "no"
@@ -267,6 +273,8 @@ matomo-bootstrap
2. **Installation (if needed)** 2. **Installation (if needed)**
* uses a recorded Playwright flow to complete the Matomo web installer * uses a recorded Playwright flow to complete the Matomo web installer
* waits until installer controls are interactive before clicking next steps
* writes screenshot/HTML debug artifacts on installer failure
3. **Authentication** 3. **Authentication**
* logs in using Matomos `Login.logme` controller (cookie session) * logs in using Matomos `Login.logme` controller (cookie session)

View File

@@ -34,7 +34,7 @@ services:
volumes: volumes:
- matomo_data:/var/www/html - matomo_data:/var/www/html
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"] test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 60 retries: 60
@@ -47,7 +47,7 @@ services:
container_name: matomo-bootstrap container_name: matomo-bootstrap
depends_on: depends_on:
matomo: matomo:
condition: service_started condition: service_healthy
environment: environment:
MATOMO_URL: "http://matomo" MATOMO_URL: "http://matomo"
MATOMO_ADMIN_USER: "administrator" MATOMO_ADMIN_USER: "administrator"
@@ -65,6 +65,10 @@ services:
MATOMO_PLAYWRIGHT_HEADLESS: "1" MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000" MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0" MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
MATOMO_INSTALLER_READY_TIMEOUT_S: "180"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "30"
MATOMO_INSTALLER_STEP_DEADLINE_S: "180"
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
# bootstrap is a one-shot command that prints the token and exits # bootstrap is a one-shot command that prints the token and exits
# if you want to re-run, do: docker compose run --rm bootstrap # if you want to re-run, do: docker compose run --rm bootstrap
restart: "no" restart: "no"

View File

@@ -27,3 +27,9 @@ MATOMO_TIMEZONE=Germany - Berlin
# MATOMO_PLAYWRIGHT_HEADLESS=1 # MATOMO_PLAYWRIGHT_HEADLESS=1
# MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS=60000 # MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS=60000
# MATOMO_PLAYWRIGHT_SLOWMO_MS=0 # MATOMO_PLAYWRIGHT_SLOWMO_MS=0
# Installer readiness / step guards
# MATOMO_INSTALLER_READY_TIMEOUT_S=180
# MATOMO_INSTALLER_STEP_TIMEOUT_S=30
# MATOMO_INSTALLER_STEP_DEADLINE_S=180
# MATOMO_INSTALLER_DEBUG_DIR=/tmp/matomo-bootstrap

View File

@@ -20,7 +20,7 @@
rec { rec {
matomo-bootstrap = python.pkgs.buildPythonApplication { matomo-bootstrap = python.pkgs.buildPythonApplication {
pname = "matomo-bootstrap"; pname = "matomo-bootstrap";
version = "1.1.0"; # keep in sync with pyproject.toml version = "1.1.4"; # keep in sync with pyproject.toml
pyproject = true; pyproject = true;
src = self; src = self;

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "matomo-bootstrap" name = "matomo-bootstrap"
version = "1.1.0" version = "1.1.4"
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)" description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -4,6 +4,7 @@ import os
import sys import sys
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from .base import Installer from .base import Installer
@@ -20,6 +21,16 @@ PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
PLAYWRIGHT_NAV_TIMEOUT_MS = int( PLAYWRIGHT_NAV_TIMEOUT_MS = int(
os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000") os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000")
) )
INSTALLER_READY_TIMEOUT_S = int(
os.environ.get("MATOMO_INSTALLER_READY_TIMEOUT_S", "180")
)
INSTALLER_STEP_TIMEOUT_S = int(os.environ.get("MATOMO_INSTALLER_STEP_TIMEOUT_S", "30"))
INSTALLER_STEP_DEADLINE_S = int(
os.environ.get("MATOMO_INSTALLER_STEP_DEADLINE_S", "180")
)
INSTALLER_DEBUG_DIR = os.environ.get(
"MATOMO_INSTALLER_DEBUG_DIR", "/tmp/matomo-bootstrap"
).rstrip("/")
# 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")
@@ -27,12 +38,275 @@ DEFAULT_SITE_URL = os.environ.get("MATOMO_SITE_URL", "http://localhost")
DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin") DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled") DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
NEXT_BUTTON_CANDIDATES: list[tuple[str, str]] = [
("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"),
]
def _log(msg: str) -> None: def _log(msg: str) -> None:
# IMPORTANT: logs must not pollute stdout (tests expect only token on stdout) # IMPORTANT: logs must not pollute stdout (tests expect only token on stdout)
print(msg, file=sys.stderr) print(msg, file=sys.stderr)
def _page_warnings(page, *, prefix: str = "[install]") -> list[str]:
"""
Detect Matomo installer warnings/errors on the current page.
- Does NOT change any click logic.
- Prints found warnings/errors to stderr (stdout stays clean).
- Returns a de-duplicated list of warning/error texts (empty if none found).
"""
def _safe(s: str | None) -> str:
return (s or "").strip()
# Helpful context (doesn't spam much, but makes failures traceable)
try:
url = page.url
except Exception:
url = "<unknown-url>"
try:
title = page.title()
except Exception:
title = "<unknown-title>"
selectors = [
# your originals
".warning",
".alert.alert-danger",
".alert.alert-warning",
".notification",
".message_container",
# common Matomo / UI patterns seen across versions
"#notificationContainer",
".system-check-error",
".system-check-warning",
".form-errors",
".error",
".errorMessage",
".invalid-feedback",
".help-block.error",
".ui-state-error",
".alert-danger",
".alert-warning",
"[role='alert']",
]
texts: list[str] = []
for sel in selectors:
loc = page.locator(sel)
try:
n = loc.count()
except Exception:
n = 0
if n <= 0:
continue
# collect all matches (not only .first)
for i in range(min(n, 50)): # avoid insane spam if page is weird
try:
t = _safe(loc.nth(i).inner_text())
except Exception:
t = ""
if t:
texts.append(t)
# Also catch HTML5 validation bubbles / inline field errors
# (Sometimes Matomo marks invalid inputs with aria-invalid + sibling text)
try:
invalid = page.locator("[aria-invalid='true']")
n_invalid = invalid.count()
except Exception:
n_invalid = 0
if n_invalid > 0:
texts.append(f"{n_invalid} field(s) marked aria-invalid=true.")
# De-duplicate while preserving order
seen: set[str] = set()
out: list[str] = []
for t in texts:
if t not in seen:
seen.add(t)
out.append(t)
if out:
print(
f"{prefix} page warnings/errors detected @ {url} ({title}):",
file=sys.stderr,
)
for idx, t in enumerate(out, 1):
print(f"{prefix} {idx}) {t}", file=sys.stderr)
return out
def _wait_dom_settled(page) -> None:
try:
page.wait_for_load_state("domcontentloaded")
except Exception:
pass
try:
# Best effort: helps when the UI needs a bit more rendering time.
page.wait_for_load_state("networkidle", timeout=2_000)
except Exception:
pass
page.wait_for_timeout(250)
def _get_step_hint(url: str) -> str:
try:
parsed = urllib.parse.urlparse(url)
qs = urllib.parse.parse_qs(parsed.query)
module = (qs.get("module") or [""])[0]
action = (qs.get("action") or [""])[0]
if module or action:
return f"{module}:{action}"
return parsed.path or url
except Exception:
return url
def _safe_page_snapshot_name() -> str:
return time.strftime("%Y%m%d-%H%M%S")
def _dump_failure_artifacts(page, reason: str) -> None:
os.makedirs(INSTALLER_DEBUG_DIR, exist_ok=True)
stamp = _safe_page_snapshot_name()
base = f"{INSTALLER_DEBUG_DIR}/installer-failure-{stamp}"
screenshot_path = f"{base}.png"
html_path = f"{base}.html"
meta_path = f"{base}.txt"
try:
page.screenshot(path=screenshot_path, full_page=True)
except Exception as exc:
_log(f"[install] Could not write screenshot: {exc}")
screenshot_path = "<unavailable>"
try:
html = page.content()
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
except Exception as exc:
_log(f"[install] Could not write HTML snapshot: {exc}")
html_path = "<unavailable>"
try:
url = page.url
except Exception:
url = "<unknown-url>"
try:
title = page.title()
except Exception:
title = "<unknown-title>"
try:
with open(meta_path, "w", encoding="utf-8") as f:
f.write(f"reason: {reason}\n")
f.write(f"url: {url}\n")
f.write(f"title: {title}\n")
f.write(f"step_hint: {_get_step_hint(url)}\n")
except Exception as exc:
_log(f"[install] Could not write metadata snapshot: {exc}")
meta_path = "<unavailable>"
_log("[install] Debug artifacts written:")
_log(f"[install] screenshot: {screenshot_path}")
_log(f"[install] html: {html_path}")
_log(f"[install] meta: {meta_path}")
def _first_next_locator(page):
for role, name in NEXT_BUTTON_CANDIDATES:
loc = page.get_by_role(role, name=name)
try:
if loc.count() > 0 and loc.first.is_visible():
return loc.first, f"{role}:{name}"
except Exception:
continue
text_loc = page.get_by_text("Next", exact=False)
try:
if text_loc.count() > 0 and text_loc.first.is_visible():
return text_loc.first, "text:Next*"
except Exception:
pass
return None, ""
def _installer_interactive(page) -> bool:
checks = [
page.locator("#login-0").count() > 0,
page.locator("#siteName-0").count() > 0,
page.get_by_role("button", name="Continue to Matomo »").count() > 0,
]
loc, _ = _first_next_locator(page)
return any(checks) or loc is not None
def _wait_for_installer_interactive(page, *, timeout_s: int) -> None:
_log(f"[install] Waiting for interactive installer UI (timeout={timeout_s}s)...")
deadline = time.time() + timeout_s
while time.time() < deadline:
_wait_dom_settled(page)
if _installer_interactive(page):
_log("[install] Installer UI looks interactive.")
return
page.wait_for_timeout(300)
raise RuntimeError(
f"Installer UI did not become interactive within {timeout_s}s "
f"(url={page.url}, step={_get_step_hint(page.url)})."
)
def _click_next_with_wait(page, *, timeout_s: int) -> str:
deadline = time.time() + timeout_s
while time.time() < deadline:
loc, label = _first_next_locator(page)
if loc is not None:
before_url = page.url
before_step = _get_step_hint(before_url)
try:
loc.click(timeout=2_000)
except Exception:
page.wait_for_timeout(250)
continue
_wait_dom_settled(page)
after_url = page.url
after_step = _get_step_hint(after_url)
_log(
f"[install] Clicked {label}; step {before_step} -> {after_step} "
f"(url {before_url} -> {after_url})"
)
return after_step
page.wait_for_timeout(300)
raise RuntimeError(
"Could not find a Next/Continue control in the installer UI "
f"within {timeout_s}s (url={page.url}, step={_get_step_hint(page.url)})."
)
def wait_http(url: str, timeout: int = 180) -> None: def wait_http(url: str, timeout: int = 180) -> None:
""" """
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500. Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
@@ -102,8 +376,7 @@ class WebInstaller(Installer):
wait_http(base_url) wait_http(base_url)
if is_installed(base_url): if is_installed(base_url):
if config.debug: _log("[install] Matomo already looks installed. Skipping installer.")
_log("[install] Matomo already looks installed. Skipping installer.")
return return
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
@@ -120,114 +393,82 @@ class WebInstaller(Installer):
page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS) page.set_default_navigation_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS) page.set_default_timeout(PLAYWRIGHT_NAV_TIMEOUT_MS)
def _dbg(msg: str) -> None:
if config.debug:
_log(f"[install] {msg}")
def click_next() -> None:
"""
Matomo installer mixes link/button variants and sometimes includes '»'.
We try common variants in a robust order.
"""
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
loc = page.get_by_text("Next", exact=False)
if loc.count() > 0:
_dbg("click_next(): fallback text 'Next'")
loc.first.click()
return
raise RuntimeError(
"Could not find a Next/Continue control in the installer UI."
)
page.goto(base_url, wait_until="domcontentloaded")
def superuser_form_visible() -> bool:
return page.locator("#login-0").count() > 0
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)."
)
page.locator("#login-0").click()
page.locator("#login-0").fill(config.admin_user)
page.locator("#password-0").click()
page.locator("#password-0").fill(config.admin_password)
if page.locator("#password_bis-0").count() > 0:
page.locator("#password_bis-0").click()
page.locator("#password_bis-0").fill(config.admin_password)
page.locator("#email-0").click()
page.locator("#email-0").fill(config.admin_email)
if page.get_by_role("button", name="Next »").count() > 0:
page.get_by_role("button", name="Next »").click()
else:
click_next()
if page.locator("#siteName-0").count() > 0:
page.locator("#siteName-0").click()
page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
if page.locator("#url-0").count() > 0:
page.locator("#url-0").click()
page.locator("#url-0").fill(DEFAULT_SITE_URL)
try: try:
page.get_by_role("combobox").first.click() page.goto(base_url, wait_until="domcontentloaded")
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click() _wait_for_installer_interactive(page, timeout_s=INSTALLER_READY_TIMEOUT_S)
except Exception: _page_warnings(page)
_dbg("Timezone selection skipped (not found / changed UI).")
try: progress_deadline = time.time() + INSTALLER_STEP_DEADLINE_S
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).")
click_next() while page.locator("#login-0").count() == 0:
page.wait_for_load_state("domcontentloaded") if time.time() >= progress_deadline:
raise RuntimeError(
"Installer did not reach superuser step "
f"within {INSTALLER_STEP_DEADLINE_S}s "
f"(url={page.url}, step={_get_step_hint(page.url)})."
)
_click_next_with_wait(page, timeout_s=INSTALLER_STEP_TIMEOUT_S)
_page_warnings(page)
if page.get_by_role("link", name="Next »").count() > 0: page.locator("#login-0").click()
page.get_by_role("link", name="Next »").click() page.locator("#login-0").fill(config.admin_user)
if page.get_by_role("button", name="Continue to Matomo »").count() > 0: page.locator("#password-0").click()
page.get_by_role("button", name="Continue to Matomo »").click() page.locator("#password-0").fill(config.admin_password)
context.close() if page.locator("#password_bis-0").count() > 0:
browser.close() page.locator("#password_bis-0").click()
page.locator("#password_bis-0").fill(config.admin_password)
page.locator("#email-0").click()
page.locator("#email-0").fill(config.admin_email)
_page_warnings(page)
_click_next_with_wait(page, timeout_s=INSTALLER_STEP_TIMEOUT_S)
_page_warnings(page)
if page.locator("#siteName-0").count() > 0:
page.locator("#siteName-0").click()
page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
if page.locator("#url-0").count() > 0:
page.locator("#url-0").click()
page.locator("#url-0").fill(DEFAULT_SITE_URL)
_page_warnings(page)
try:
page.get_by_role("combobox").first.click()
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
except Exception:
_log("Timezone selection skipped (not found / changed UI).")
try:
page.get_by_role("combobox").nth(2).click()
page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
except Exception:
_log("Ecommerce selection skipped (not found / changed UI).")
_page_warnings(page)
_click_next_with_wait(page, timeout_s=INSTALLER_STEP_TIMEOUT_S)
_page_warnings(page)
if page.get_by_role("link", name="Next »").count() > 0:
page.get_by_role("link", name="Next »").click()
_wait_dom_settled(page)
_page_warnings(page)
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
page.get_by_role("button", name="Continue to Matomo »").click()
_wait_dom_settled(page)
_page_warnings(page)
except Exception as exc:
_dump_failure_artifacts(page, reason=str(exc))
raise
finally:
context.close()
browser.close()
time.sleep(1) time.sleep(1)
if not is_installed(base_url): if not is_installed(base_url):

View File

@@ -56,6 +56,29 @@ def _wait_for_http_any_status(url: str, timeout_s: int) -> None:
raise RuntimeError(f"Matomo did not become reachable at {url} ({last_exc})") raise RuntimeError(f"Matomo did not become reachable at {url} ({last_exc})")
def _extract_service_block(compose_config: str, service_name: str) -> str:
lines = compose_config.splitlines()
marker = f" {service_name}:"
start = -1
for idx, line in enumerate(lines):
if line == marker:
start = idx
break
if start < 0:
raise AssertionError(
f"service block not found in compose config: {service_name}"
)
end = len(lines)
for idx in range(start + 1, len(lines)):
line = lines[idx]
if line.startswith(" ") and not line.startswith(" "):
end = idx
break
return "\n".join(lines[start:end])
class TestRootDockerComposeStack(unittest.TestCase): class TestRootDockerComposeStack(unittest.TestCase):
""" """
E2E test for repository root docker-compose.yml: E2E test for repository root docker-compose.yml:
@@ -131,5 +154,30 @@ class TestRootDockerComposeStack(unittest.TestCase):
self.assertIsInstance(data, list) self.assertIsInstance(data, list)
class TestRootDockerComposeDefinition(unittest.TestCase):
def test_bootstrap_service_waits_for_healthy_matomo_and_has_readiness_knobs(
self,
) -> None:
cfg = _run(
_compose_cmd("config"),
check=True,
extra_env={"MATOMO_PORT": MATOMO_PORT},
)
self.assertEqual(cfg.returncode, 0, cfg.stderr)
bootstrap_block = _extract_service_block(cfg.stdout, "bootstrap")
self.assertIn("depends_on:", bootstrap_block)
self.assertIn("matomo:", bootstrap_block)
self.assertIn("condition: service_healthy", bootstrap_block)
self.assertIn("MATOMO_INSTALLER_READY_TIMEOUT_S:", bootstrap_block)
self.assertIn("MATOMO_INSTALLER_STEP_TIMEOUT_S:", bootstrap_block)
self.assertIn("MATOMO_INSTALLER_STEP_DEADLINE_S:", bootstrap_block)
matomo_block = _extract_service_block(cfg.stdout, "matomo")
self.assertIn("healthcheck:", matomo_block)
self.assertIn("curl -fsS http://127.0.0.1/ >/dev/null || exit 1", matomo_block)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

View File

@@ -0,0 +1,128 @@
import io
import unittest
from contextlib import redirect_stderr
# Import the function under test.
# This keeps the test close to real integration behavior without requiring Playwright.
from matomo_bootstrap.installers.web import _page_warnings
class _FakeLocatorNth:
def __init__(self, text: str):
self._text = text
def inner_text(self) -> str:
return self._text
class _FakeLocator:
def __init__(self, texts: list[str]):
self._texts = texts
def count(self) -> int:
return len(self._texts)
def nth(self, i: int) -> _FakeLocatorNth:
return _FakeLocatorNth(self._texts[i])
class _FakePage:
"""
Minimal Playwright-like page stub:
- locator(selector) -> object with count() / nth(i).inner_text()
- url, title()
"""
def __init__(self, *, url: str, title: str, selector_texts: dict[str, list[str]]):
self.url = url
self._title = title
self._selector_texts = selector_texts
def title(self) -> str:
return self._title
def locator(self, selector: str) -> _FakeLocator:
return _FakeLocator(self._selector_texts.get(selector, []))
class TestWebInstallerWarningsIntegration(unittest.TestCase):
def test_detects_bootstrap_alert_warning_block(self) -> None:
"""
Matomo installer commonly renders validation errors like:
<div class="alert alert-warning"> ... <ul><li>...</li></ul> ... </div>
We must detect and print those messages to stderr.
"""
page = _FakePage(
url="http://matomo/index.php?action=setupSuperUser&module=Installation",
title="Superuser",
selector_texts={
# The key selector from the observed DOM
".alert.alert-warning": [
"Please fix the following errors:\n"
"Password required\n"
"Password (repeat) required\n"
"The email doesn't have a valid format."
],
},
)
buf = io.StringIO()
with redirect_stderr(buf):
warnings = _page_warnings(page, prefix="[install]")
# Function must return the warning text
self.assertEqual(len(warnings), 1)
self.assertIn("Please fix the following errors:", warnings[0])
self.assertIn("The email doesn't have a valid format.", warnings[0])
# And it must print it to stderr (stdout must remain token-only in the app)
out = buf.getvalue()
self.assertIn("[install] page warnings/errors detected", out)
self.assertIn("Superuser", out)
self.assertIn("The email doesn't have a valid format.", out)
def test_deduplicates_repeated_warning_blocks(self) -> None:
"""
Some Matomo versions repeat the same alert in multiple containers.
We must return/log each unique text only once.
"""
repeated = (
"Please fix the following errors:\nThe email doesn't have a valid format."
)
page = _FakePage(
url="http://matomo/index.php?action=setupSuperUser&module=Installation",
title="Superuser",
selector_texts={
".alert.alert-warning": [repeated, repeated],
},
)
buf = io.StringIO()
with redirect_stderr(buf):
warnings = _page_warnings(page, prefix="[install]")
self.assertEqual(warnings, [repeated])
out = buf.getvalue()
# Only a single numbered entry should be printed
self.assertIn("[install] 1) ", out)
self.assertNotIn("[install] 2) ", out)
def test_no_output_when_no_warnings(self) -> None:
page = _FakePage(
url="http://matomo/",
title="Welcome",
selector_texts={},
)
buf = io.StringIO()
with redirect_stderr(buf):
warnings = _page_warnings(page, prefix="[install]")
self.assertEqual(warnings, [])
self.assertEqual(buf.getvalue(), "")
if __name__ == "__main__":
unittest.main()