Compare commits
11 Commits
v1.1.0
...
e81c5262b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81c5262b0 | ||
|
|
f35ea04d66 | ||
|
|
37a17b536d | ||
|
|
c80fdf8d01 | ||
|
|
276833bd16 | ||
|
|
9e267ec83f | ||
|
|
20274985bc | ||
|
|
cf473d4f3f | ||
|
|
84323bd2aa | ||
|
|
1a65ceb015 | ||
|
|
81746f4b26 |
65
.github/workflows/publish-image.yml
vendored
65
.github/workflows/publish-image.yml
vendored
@@ -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
|
||||||
|
|||||||
15
.github/workflows/reusable-test.yml
vendored
15
.github/workflows/reusable-test.yml
vendored
@@ -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
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
53
Makefile
53
Makefile
@@ -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
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -1,4 +1,6 @@
|
|||||||
# matomo-bootstrap
|
# matomo-bootstrap
|
||||||
|
[](https://github.com/sponsors/kevinveenbirkenbach) [](https://www.patreon.com/c/kevinveenbirkenbach) [](https://buymeacoffee.com/kevinveenbirkenbach) [](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 Matomo’s `Login.logme` controller (cookie session)
|
* logs in using Matomo’s `Login.logme` controller (cookie session)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
128
tests/integration/test_web_installer_warnings.py
Normal file
128
tests/integration/test_web_installer_warnings.py
Normal 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()
|
||||||
Reference in New Issue
Block a user