31 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
3d5bfd5401 Release version 1.1.12
Some checks failed
ci / tests (push) Has been cancelled
ci / detect-release (push) Has been cancelled
ci / publish-image (push) Has been cancelled
ci / tag-stable (push) Has been cancelled
2026-02-14 19:05:51 +01:00
Kevin Veen-Birkenbach
fa6adea3c1 fix(installer): harden setupSuperUser race and add slow-resource e2e
Some checks failed
ci / tests (push) Has been cancelled
ci / detect-release (push) Has been cancelled
ci / publish-image (push) Has been cancelled
ci / tag-stable (push) Has been cancelled
2026-02-14 19:01:55 +01:00
Kevin Veen-Birkenbach
7ecb26cc92 Release version 1.1.11
Some checks failed
ci / tests (push) Has been cancelled
ci / detect-release (push) Has been cancelled
ci / publish-image (push) Has been cancelled
ci / tag-stable (push) Has been cancelled
2026-02-14 15:03:35 +01:00
Kevin Veen-Birkenbach
5d4a2d59db Fix installer selectors for setupSuperUser UI variants 2026-02-14 15:00:34 +01:00
Kevin Veen-Birkenbach
1847c14b63 Release version 1.1.10 2026-02-14 12:19:43 +01:00
Kevin Veen-Birkenbach
29e812f584 fix(installer): harden navigation races in setupSuperUser flow 2026-02-14 12:14:06 +01:00
Kevin Veen-Birkenbach
1c8de40a05 Release version 1.1.9 2026-02-14 11:04:30 +01:00
Kevin Veen-Birkenbach
4bfa7433f4 ci: run on all branches but release only from main via git tag detection
- Trigger CI on push for all branches and on pull_request
- Detect SemVer release tags (vX.Y.Z) via git tag --points-at
- Run publish-image and stable-tag only for tagged commits on main
- Pass version_tag and sha to reusable workflows
- Prevent tag pushes from triggering additional workflows

https://chatgpt.com/share/e/699044d3-c1d8-8013-a40d-974d1fc69974
2026-02-14 11:02:58 +01:00
Kevin Veen-Birkenbach
00c012e553 Release version 1.1.8 2026-02-14 10:53:40 +01:00
Kevin Veen-Birkenbach
1bebeb8abc ci: make ci.yml the single coordinator workflow
- Trigger ci on push and pull_request only
- Convert publish-image and stable-tag to reusable workflows (workflow_call)
- Add detect-release job for strict SemVer tag detection (vX.Y.Z)
- Run tests first, then publish image, then move stable tag
- Remove direct tag/push triggers from publish-image and stable-tag

https://chatgpt.com/share/e/699044d3-c1d8-8013-a40d-974d1fc69974
2026-02-14 10:47:56 +01:00
Kevin Veen-Birkenbach
01d1626cf2 ci(docker): publish image without leading 'v' in version tag
Strip the leading 'v' from git tags (e.g. v1.1.7 -> 1.1.7)
when pushing Docker images to GHCR.

https://chatgpt.com/share/e/699041b9-8610-8013-8d04-fbef93b10d3c
2026-02-14 10:34:41 +01:00
Kevin Veen-Birkenbach
5bbe78b272 Release version 1.1.7 2026-02-14 05:43:32 +01:00
Kevin Veen-Birkenbach
865d5155d5 Lint 2026-02-14 05:39:01 +01:00
Kevin Veen-Birkenbach
209037cd64 Harden compose installer timeouts and e2e stack diagnostics 2026-02-14 05:37:29 +01:00
Kevin Veen-Birkenbach
ba2d84b6cb Fix nix e2e to use compose run instead of exec 2026-02-14 05:08:12 +01:00
Kevin Veen-Birkenbach
4f5c41753f Release version 1.1.6 2026-02-14 04:54:10 +01:00
Kevin Veen-Birkenbach
aac01810a1 Add tables-step timeout env knobs to compose, docs, and e2e 2026-02-14 04:52:26 +01:00
Kevin Veen-Birkenbach
fb42167b89 Release version 1.1.5 2026-02-14 04:49:49 +01:00
Kevin Veen-Birkenbach
7836dbacf9 Harden web installer flow for nix e2e 2026-02-14 04:45:00 +01:00
Kevin Veen-Birkenbach
d380b1493c Lint 2026-02-14 03:47:37 +01:00
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
18 changed files with 1787 additions and 203 deletions

View File

@@ -1,11 +1,76 @@
name: ci
on:
pull_request:
pull_request: {}
push:
branches:
- main
- "**"
permissions:
contents: write
packages: write
jobs:
tests:
uses: ./.github/workflows/reusable-test.yml
with:
python-version: "3.12"
matomo-token-description: "ci-token"
detect-release:
# Only consider releases on main branch pushes (not PRs, not other branches)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
is_semver_tag: ${{ steps.detect.outputs.is_semver_tag }}
version_tag: ${{ steps.detect.outputs.version_tag }}
steps:
- name: Checkout (full history for tags)
uses: actions/checkout@v4
with:
fetch-depth: 0
- id: detect
shell: bash
run: |
set -euo pipefail
git fetch --tags --force
# Tags that point to the current commit
TAGS="$(git tag --points-at "$GITHUB_SHA" || true)"
# Pick the first strict SemVer tag: vX.Y.Z
VERSION_TAG="$(echo "$TAGS" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)"
if [ -n "$VERSION_TAG" ]; then
echo "is_semver_tag=true" >> "$GITHUB_OUTPUT"
echo "version_tag=$VERSION_TAG" >> "$GITHUB_OUTPUT"
echo "Release tag detected on this commit: $VERSION_TAG"
else
echo "is_semver_tag=false" >> "$GITHUB_OUTPUT"
echo "version_tag=" >> "$GITHUB_OUTPUT"
echo "No SemVer tag on this commit."
fi
publish-image:
# Only on main, and only if detect-release found a SemVer tag on this commit
if: needs.detect-release.outputs.is_semver_tag == 'true'
needs: [tests, detect-release]
uses: ./.github/workflows/publish-image.yml
with:
version_tag: ${{ needs.detect-release.outputs.version_tag }}
sha: ${{ github.sha }}
permissions:
contents: read
packages: write
tag-stable:
# Only after tests + publish succeeded
if: needs.detect-release.outputs.is_semver_tag == 'true'
needs: [tests, detect-release, publish-image]
uses: ./.github/workflows/stable-tag.yml
with:
version_tag: ${{ needs.detect-release.outputs.version_tag }}
sha: ${{ github.sha }}
permissions:
contents: write

View File

@@ -1,25 +1,28 @@
name: publish-image
on:
push:
tags:
- "*"
workflow_call:
inputs:
version_tag:
type: string
required: true
sha:
type: string
required: true
jobs:
tests:
uses: ./.github/workflows/reusable-test.yml
build-and-push:
needs: tests
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
- name: Checkout (exact commit)
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -27,20 +30,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract tag
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: Login to GHCR
uses: docker/login-action@v3
with:
@@ -48,27 +37,23 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push (tag)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
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: Compute tags
id: meta
shell: bash
run: |
set -euo pipefail
IMAGE="ghcr.io/${{ github.repository }}"
RAW_TAG="${{ inputs.version_tag }}" # e.g. v1.1.8
TAG="${RAW_TAG#v}" # -> 1.1.8
echo "tags=$IMAGE:$TAG,$IMAGE:latest" >> "$GITHUB_OUTPUT"
- name: Build and push (latest)
if: steps.semver.outputs.is_semver == 'true'
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ github.repository }}:latest
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -52,6 +52,21 @@ jobs:
ruff 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:
runs-on: ubuntu-latest
timeout-minutes: 30

View File

@@ -1,24 +1,21 @@
name: Stable Tag
on:
push:
tags:
- "v*"
workflow_call:
inputs:
version_tag:
type: string
required: true
sha:
type: string
required: true
permissions:
contents: write
jobs:
test:
uses: ./.github/workflows/reusable-test.yml
with:
python-version: "3.12"
matomo-token-description: "stable-ci-token"
tag-stable:
runs-on: ubuntu-latest
needs: test
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout (full history for tags)
@@ -26,17 +23,17 @@ jobs:
with:
fetch-depth: 0
- name: Move stable tag to this version tag commit
- name: Move stable tag to the release commit
shell: bash
run: |
set -euo pipefail
echo "Triggered by tag: ${GITHUB_REF_NAME}"
echo "Commit: ${GITHUB_SHA}"
echo "Release tag: ${{ inputs.version_tag }}"
echo "Commit: ${{ inputs.sha }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch --tags --force
git tag -fa stable -m "stable -> ${GITHUB_REF_NAME} (${GITHUB_SHA})" "${GITHUB_SHA}"
git tag -fa stable -m "stable -> ${{ inputs.version_tag }} (${{ inputs.sha }})" "${{ inputs.sha }}"
git push --force origin stable

View File

@@ -1,3 +1,63 @@
## [1.1.12] - 2026-02-14
* This release fixes the intermittent Matomo installer failure in the setupSuperUser step by adding more robust waiting logic and introduces E2E tests for deployments under very tight resource constraints.
## [1.1.11] - 2026-02-14
* This release improves matomo-bootstrap installer resilience by adding robust setupSuperUser field and button detection to prevent intermittent bootstrap failures.
## [1.1.10] - 2026-02-14
* This release fixes a reproducible Playwright navigation race in the Matomo installer (setupSuperUser), hardens the Next/Continue flow, and adds integration tests for transient locator errors and progress detection without a visible Next button.
## [1.1.9] - 2026-02-14
* Reworked CI to run on all branches while restricting Docker image publishing and stable tagging to tagged commits on main, using git-based SemVer detection.
## [1.1.8] - 2026-02-14
* Refactored CI to use a single coordinator workflow with strict SemVer-based release gating and adjusted Docker image publishing to strip the leading v from version tags.
## [1.1.7] - 2026-02-14
* Harden compose installer timeouts and e2e stack diagnostics
## [1.1.6] - 2026-02-14
* Add installer table-step timeout env vars (MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S, MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S) to compose/docs and e2e checks.
## [1.1.5] - 2026-02-14
* Harden web installer flow for nix e2e
## [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
* Implemented bootstrap docker image to auto install matomo in docker compose

View File

@@ -34,6 +34,7 @@ COMPOSE_STACK := docker compose -f $(COMPOSE_STACK_FILE)
.PHONY: help \
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 \
stack-up stack-down stack-logs stack-ps stack-bootstrap stack-rebootstrap stack-clean stack-reset
@@ -49,6 +50,7 @@ help:
@echo " e2e Full cycle: up → install → test → down"
@echo " logs Show Matomo logs (E2E compose)"
@echo " clean Stop E2E containers + remove venv"
@echo " test-integration Run integration tests (unittest)"
@echo ""
@echo "Container image targets:"
@echo " image-build Build matomo-bootstrap container image"
@@ -76,8 +78,46 @@ help:
# ----------------------------
venv:
@test -x "$(VENV_PY)" || ($(PYTHON) -m venv $(VENV_DIR))
@$(VENV_PIP) -q install -U pip setuptools wheel >/dev/null
@set -e; \
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
@$(VENV_PIP) install -e ".[e2e]"
@@ -129,6 +169,13 @@ logs:
clean: e2e-down
rm -rf $(VENV_DIR)
# ----------------------------
# Integration tests
# ----------------------------
test-integration:
PYTHONPATH=src $(PYTHON) -m unittest discover -s tests/integration -v
# ----------------------------
# Container image workflow
# ----------------------------

View File

@@ -1,4 +1,6 @@
# 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.
---
@@ -170,7 +172,7 @@ services:
volumes:
- matomo_data:/var/www/html
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
timeout: 5s
retries: 60
@@ -207,10 +209,16 @@ services:
MATOMO_TIMEZONE: "Germany - Berlin"
# Optional stability knobs
MATOMO_TIMEOUT: "30"
MATOMO_TIMEOUT: "60"
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
MATOMO_INSTALLER_READY_TIMEOUT_S: "240"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "45"
MATOMO_INSTALLER_STEP_DEADLINE_S: "240"
MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S: "240"
MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S: "180"
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
restart: "no"
@@ -267,6 +275,8 @@ matomo-bootstrap
2. **Installation (if needed)**
* 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**
* logs in using Matomos `Login.logme` controller (cookie session)

View File

@@ -34,7 +34,7 @@ services:
volumes:
- matomo_data:/var/www/html
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
timeout: 5s
retries: 60
@@ -47,7 +47,7 @@ services:
container_name: matomo-bootstrap
depends_on:
matomo:
condition: service_started
condition: service_healthy
environment:
MATOMO_URL: "http://matomo"
MATOMO_ADMIN_USER: "administrator"
@@ -61,10 +61,16 @@ services:
MATOMO_TIMEZONE: "Germany - Berlin"
# Optional stability knobs
MATOMO_TIMEOUT: "30"
MATOMO_TIMEOUT: "60"
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
MATOMO_INSTALLER_READY_TIMEOUT_S: "240"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "45"
MATOMO_INSTALLER_STEP_DEADLINE_S: "240"
MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S: "240"
MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S: "180"
MATOMO_INSTALLER_DEBUG_DIR: "/tmp/matomo-bootstrap"
# bootstrap is a one-shot command that prints the token and exits
# if you want to re-run, do: docker compose run --rm bootstrap
restart: "no"

View File

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

View File

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

View File

@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "matomo-bootstrap"
version = "1.1.0"
version = "1.1.12"
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Kevin Veen-Birkenbach", email = "kevin@veen.world" }]
license = { text = "MIT" }
license = "MIT"
urls = { Homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap" }
dependencies = ["playwright>=1.46.0,<2"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
services:
db:
cpus: 0.35
mem_reservation: 192m
mem_limit: 320m
healthcheck:
interval: 10s
timeout: 5s
retries: 90
matomo:
cpus: 0.35
mem_reservation: 192m
mem_limit: 384m
healthcheck:
interval: 15s
timeout: 8s
retries: 120
start_period: 120s
bootstrap:
cpus: 0.75
mem_reservation: 512m
mem_limit: 1g
environment:
MATOMO_TIMEOUT: "120"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "120000"
MATOMO_INSTALLER_READY_TIMEOUT_S: "420"
MATOMO_INSTALLER_STEP_TIMEOUT_S: "120"
MATOMO_INSTALLER_STEP_DEADLINE_S: "420"
MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S: "360"
MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S: "240"

View File

@@ -1,5 +1,7 @@
import os
import re
import subprocess
import textwrap
import unittest
@@ -7,14 +9,24 @@ MATOMO_URL = os.environ.get("MATOMO_URL", "http://127.0.0.1:8080")
ADMIN_USER = os.environ.get("MATOMO_ADMIN_USER", "administrator")
ADMIN_PASSWORD = os.environ.get("MATOMO_ADMIN_PASSWORD", "AdminSecret123!")
ADMIN_EMAIL = os.environ.get("MATOMO_ADMIN_EMAIL", "administrator@example.org")
TOKEN_RE = re.compile(r"^[a-f0-9]{32,64}$")
class TestMatomoBootstrapE2ENix(unittest.TestCase):
def test_bootstrap_creates_api_token_via_nix(self) -> None:
script = f"""set -euo pipefail
script = textwrap.dedent(
f"""\
set -eux
export NIX_CONFIG='experimental-features = nix-command flakes'
export TERM='xterm'
# Improve CI resilience for slow installer pages.
export MATOMO_INSTALLER_READY_TIMEOUT_S="${{MATOMO_INSTALLER_READY_TIMEOUT_S:-240}}"
export MATOMO_INSTALLER_STEP_TIMEOUT_S="${{MATOMO_INSTALLER_STEP_TIMEOUT_S:-45}}"
export MATOMO_INSTALLER_STEP_DEADLINE_S="${{MATOMO_INSTALLER_STEP_DEADLINE_S:-240}}"
export MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S="${{MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S:-240}}"
export MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S="${{MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S:-180}}"
export MATOMO_INSTALLER_DEBUG_DIR="${{MATOMO_INSTALLER_DEBUG_DIR:-/tmp/matomo-bootstrap}}"
# Make sure we have a writable HOME (compose already sets HOME=/tmp/home)
mkdir -p "$HOME" "$HOME/.cache" "$HOME/.config" "$HOME/.local/share"
@@ -25,6 +37,16 @@ mkdir -p "$HOME" "$HOME/.cache" "$HOME/.config" "$HOME/.local/share"
# Mark it as safe explicitly.
git config --global --add safe.directory /work
# Preflight checks to surface "command not executable" failures (exit 126) clearly.
playwright_app="$(nix eval --raw .#apps.x86_64-linux.matomo-bootstrap-playwright-install.program)"
bootstrap_app="$(nix eval --raw .#apps.x86_64-linux.matomo-bootstrap.program)"
if [ -e "$playwright_app" ]; then
test -x "$playwright_app"
fi
if [ -e "$bootstrap_app" ]; then
test -x "$bootstrap_app"
fi
# 1) Install Playwright Chromium (cached in the container environment)
nix run --no-write-lock-file -L .#matomo-bootstrap-playwright-install
@@ -36,13 +58,18 @@ nix run --no-write-lock-file -L .#matomo-bootstrap -- \\
--admin-email '{ADMIN_EMAIL}' \\
--token-description 'e2e-test-token-nix'
"""
)
cmd = [
"docker",
"compose",
"-f",
"tests/e2e/docker-compose.yml",
"exec",
# Use `run` instead of `exec` to avoid runtime-specific
# `/etc/group` lookup issues seen with nix image + compose exec.
"run",
"--rm",
"--no-deps",
"-T",
"nix",
"sh",
@@ -50,8 +77,30 @@ nix run --no-write-lock-file -L .#matomo-bootstrap -- \\
script,
]
token = subprocess.check_output(cmd).decode().strip()
self.assertRegex(token, r"^[a-f0-9]{32,64}$")
result = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
self.fail(
"nix bootstrap command failed\n"
f"exit={result.returncode}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
stdout_lines = [
line.strip() for line in result.stdout.splitlines() if line.strip()
]
token = stdout_lines[-1] if stdout_lines else ""
self.assertRegex(
token,
TOKEN_RE,
f"Expected token on last stdout line, got stdout={result.stdout!r}",
)
if __name__ == "__main__":

View File

@@ -7,13 +7,21 @@ import urllib.request
COMPOSE_FILE = os.environ.get("MATOMO_STACK_COMPOSE_FILE", "docker-compose.yml")
SLOW_COMPOSE_FILE = os.environ.get(
"MATOMO_STACK_SLOW_COMPOSE_FILE", "tests/e2e/docker-compose.slow.yml"
)
# Pick a non-default port to avoid collisions with other CI stacks that use 8080
MATOMO_PORT = os.environ.get("MATOMO_PORT", "18080")
MATOMO_HOST_URL = os.environ.get("MATOMO_STACK_URL", f"http://127.0.0.1:{MATOMO_PORT}")
MATOMO_SLOW_PORT = os.environ.get("MATOMO_SLOW_PORT", "18081")
MATOMO_SLOW_HOST_URL = os.environ.get(
"MATOMO_SLOW_STACK_URL", f"http://127.0.0.1:{MATOMO_SLOW_PORT}"
)
# How long we wait for Matomo HTTP to respond at all (seconds)
WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180"))
SLOW_WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_SLOW_STACK_WAIT_TIMEOUT", "420"))
def _run(
@@ -32,8 +40,13 @@ def _run(
)
def _compose_cmd(*args: str) -> list[str]:
return ["docker", "compose", "-f", COMPOSE_FILE, *args]
def _compose_cmd(*args: str, compose_files: list[str] | None = None) -> list[str]:
files = compose_files or [COMPOSE_FILE]
cmd = ["docker", "compose"]
for compose_file in files:
cmd.extend(["-f", compose_file])
cmd.extend(args)
return cmd
def _wait_for_http_any_status(url: str, timeout_s: int) -> None:
@@ -56,6 +69,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})")
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):
"""
E2E test for repository root docker-compose.yml:
@@ -85,31 +121,89 @@ class TestRootDockerComposeStack(unittest.TestCase):
extra_env={"MATOMO_PORT": MATOMO_PORT},
)
def test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None:
# Build bootstrap image from Dockerfile (as defined in docker-compose.yml)
def _assert_stack_bootstraps_and_token_works(
self,
*,
compose_files: list[str],
matomo_port: str,
matomo_host_url: str,
wait_timeout_seconds: int,
bootstrap_retries: int = 2,
) -> None:
build = _run(
_compose_cmd("build", "bootstrap"),
check=True,
extra_env={"MATOMO_PORT": MATOMO_PORT},
_compose_cmd("build", "bootstrap", compose_files=compose_files),
check=False,
extra_env={"MATOMO_PORT": matomo_port},
)
self.assertEqual(
build.returncode,
0,
f"compose build failed\nstdout:\n{build.stdout}\nstderr:\n{build.stderr}",
)
self.assertEqual(build.returncode, 0, build.stderr)
# Start db + matomo (bootstrap is one-shot and started via "run")
up = _run(
_compose_cmd("up", "-d", "db", "matomo"),
check=True,
extra_env={"MATOMO_PORT": MATOMO_PORT},
_compose_cmd("up", "-d", "db", "matomo", compose_files=compose_files),
check=False,
extra_env={"MATOMO_PORT": matomo_port},
)
self.assertEqual(
up.returncode,
0,
f"compose up failed\nstdout:\n{up.stdout}\nstderr:\n{up.stderr}",
)
self.assertEqual(up.returncode, 0, up.stderr)
# Wait until Matomo answers on the published port
_wait_for_http_any_status(MATOMO_HOST_URL + "/", WAIT_TIMEOUT_SECONDS)
_wait_for_http_any_status(matomo_host_url + "/", wait_timeout_seconds)
# Run bootstrap: it should print ONLY the token to stdout
boot_attempts: list[subprocess.CompletedProcess] = []
for _ in range(bootstrap_retries):
boot = _run(
_compose_cmd("run", "--rm", "bootstrap"),
check=True,
extra_env={"MATOMO_PORT": MATOMO_PORT},
_compose_cmd("run", "--rm", "bootstrap", compose_files=compose_files),
check=False,
extra_env={"MATOMO_PORT": matomo_port},
)
boot_attempts.append(boot)
if boot.returncode == 0:
break
time.sleep(5)
if boot.returncode != 0:
matomo_logs = _run(
_compose_cmd(
"logs",
"--no-color",
"--tail=250",
"matomo",
compose_files=compose_files,
),
check=False,
extra_env={"MATOMO_PORT": matomo_port},
)
db_logs = _run(
_compose_cmd(
"logs",
"--no-color",
"--tail=200",
"db",
compose_files=compose_files,
),
check=False,
extra_env={"MATOMO_PORT": matomo_port},
)
attempts_dump = "\n\n".join(
[
(
f"[attempt {i}] rc={attempt.returncode}\n"
f"stdout:\n{attempt.stdout}\n"
f"stderr:\n{attempt.stderr}"
)
for i, attempt in enumerate(boot_attempts, 1)
]
)
self.fail(
"bootstrap container failed after retry.\n"
f"{attempts_dump}\n\n"
f"[matomo logs]\n{matomo_logs.stdout}\n{matomo_logs.stderr}\n\n"
f"[db logs]\n{db_logs.stdout}\n{db_logs.stderr}"
)
token = (boot.stdout or "").strip()
@@ -119,9 +213,8 @@ class TestRootDockerComposeStack(unittest.TestCase):
f"Expected token_auth on stdout, got stdout={boot.stdout!r} stderr={boot.stderr!r}",
)
# Verify token works against Matomo API
api_url = (
f"{MATOMO_HOST_URL}/index.php"
f"{matomo_host_url}/index.php"
f"?module=API&method=SitesManager.getSitesWithAtLeastViewAccess"
f"&format=json&token_auth={token}"
)
@@ -130,6 +223,75 @@ class TestRootDockerComposeStack(unittest.TestCase):
self.assertIsInstance(data, list)
def test_root_docker_compose_yml_stack_bootstraps_and_token_works(self) -> None:
self._assert_stack_bootstraps_and_token_works(
compose_files=[COMPOSE_FILE],
matomo_port=MATOMO_PORT,
matomo_host_url=MATOMO_HOST_URL,
wait_timeout_seconds=WAIT_TIMEOUT_SECONDS,
bootstrap_retries=2,
)
def test_root_docker_compose_yml_stack_bootstraps_under_resource_pressure(
self,
) -> None:
self._assert_stack_bootstraps_and_token_works(
compose_files=[COMPOSE_FILE, SLOW_COMPOSE_FILE],
matomo_port=MATOMO_SLOW_PORT,
matomo_host_url=MATOMO_SLOW_HOST_URL,
wait_timeout_seconds=SLOW_WAIT_TIMEOUT_SECONDS,
bootstrap_retries=3,
)
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)
self.assertIn("MATOMO_INSTALLER_TABLES_CREATION_TIMEOUT_S:", bootstrap_block)
self.assertIn("MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_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)
def test_slow_override_sets_tight_resources_and_longer_timeouts(self) -> None:
cfg = _run(
_compose_cmd("config", compose_files=[COMPOSE_FILE, SLOW_COMPOSE_FILE]),
check=True,
extra_env={"MATOMO_PORT": MATOMO_SLOW_PORT},
)
self.assertEqual(cfg.returncode, 0, cfg.stderr)
matomo_block = _extract_service_block(cfg.stdout, "matomo")
self.assertIn("cpus: 0.35", matomo_block)
self.assertIn('mem_limit: "402653184"', matomo_block)
self.assertIn("start_period: 2m0s", matomo_block)
db_block = _extract_service_block(cfg.stdout, "db")
self.assertIn("cpus: 0.35", db_block)
self.assertIn('mem_limit: "335544320"', db_block)
bootstrap_block = _extract_service_block(cfg.stdout, "bootstrap")
self.assertIn("MATOMO_INSTALLER_STEP_TIMEOUT_S:", bootstrap_block)
self.assertIn("MATOMO_INSTALLER_STEP_DEADLINE_S:", bootstrap_block)
self.assertIn("MATOMO_INSTALLER_READY_TIMEOUT_S:", bootstrap_block)
if __name__ == "__main__":
unittest.main()

View File

View File

@@ -0,0 +1,231 @@
import unittest
from matomo_bootstrap.installers.web import (
_click_next_with_wait,
_count_locator,
_wait_for_superuser_login_field,
)
class _FlakyLocator:
def __init__(self, outcomes):
self._outcomes = list(outcomes)
self.calls = 0
def count(self) -> int:
self.calls += 1
outcome = self._outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return int(outcome)
class _StaticLocator:
def __init__(self, page, selector: str):
self._page = page
self._selector = selector
def count(self) -> int:
if self._selector == "#login-0":
return 1 if self._page.login_visible else 0
if self._selector == "#siteName-0":
return 0
return 0
@property
def first(self):
return self
def is_visible(self) -> bool:
return self.count() > 0
class _RoleLocator:
def __init__(self, count_value: int):
self._count_value = count_value
def count(self) -> int:
return self._count_value
@property
def first(self):
return self
def is_visible(self) -> bool:
return self._count_value > 0
class _NameOnlyStaticLocator:
def __init__(self, page, selector: str):
self._page = page
self._selector = selector
def count(self) -> int:
if self._selector == "input[name='login']":
return 1 if self._page.login_visible else 0
if self._selector == "input[name='siteName']":
return 0
return 0
@property
def first(self):
return self
def is_visible(self) -> bool:
return self.count() > 0
class _NoNextButLoginAppearsPage:
def __init__(self):
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
self.login_visible = False
self._wait_calls = 0
def locator(self, selector: str):
return _StaticLocator(self, selector)
def get_by_role(self, role: str, name: str):
return _RoleLocator(0)
def get_by_text(self, *_args, **_kwargs):
return _RoleLocator(0)
def title(self) -> str:
return "setupSuperUser"
def wait_for_load_state(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, *_args, **_kwargs):
self._wait_calls += 1
if self._wait_calls >= 1:
self.login_visible = True
class _NoNextButNamedLoginAppearsPage:
def __init__(self):
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
self.login_visible = False
self._wait_calls = 0
def locator(self, selector: str):
return _NameOnlyStaticLocator(self, selector)
def get_by_role(self, role: str, name: str):
return _RoleLocator(0)
def get_by_text(self, *_args, **_kwargs):
return _RoleLocator(0)
def title(self) -> str:
return "setupSuperUser"
def wait_for_load_state(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, *_args, **_kwargs):
self._wait_calls += 1
if self._wait_calls >= 1:
self.login_visible = True
class _DelayedSuperuserLoginPage:
def __init__(self, *, reveal_after_wait_calls: int | None):
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
self.login_visible = False
self._wait_calls = 0
self._reveal_after_wait_calls = reveal_after_wait_calls
def locator(self, selector: str):
return _StaticLocator(self, selector)
def get_by_role(self, role: str, name: str):
return _RoleLocator(0)
def get_by_text(self, *_args, **_kwargs):
return _RoleLocator(0)
def title(self) -> str:
return "setupSuperUser"
def wait_for_load_state(self, *_args, **_kwargs):
return None
def wait_for_timeout(self, *_args, **_kwargs):
self._wait_calls += 1
if (
self._reveal_after_wait_calls is not None
and self._wait_calls >= self._reveal_after_wait_calls
):
self.login_visible = True
class TestWebInstallerLocatorCountIntegration(unittest.TestCase):
def test_retries_transient_navigation_error(self) -> None:
locator = _FlakyLocator(
[
RuntimeError(
"Locator.count: Execution context was destroyed, most likely because of a navigation"
),
RuntimeError(
"Locator.count: Execution context was destroyed, most likely because of a navigation"
),
1,
]
)
result = _count_locator(locator, timeout_s=0.5, retry_interval_s=0.0)
self.assertEqual(result, 1)
self.assertEqual(locator.calls, 3)
def test_raises_non_transient_error_without_retry(self) -> None:
locator = _FlakyLocator([RuntimeError("Locator is not attached to DOM")])
with self.assertRaises(RuntimeError):
_count_locator(locator, timeout_s=0.5, retry_interval_s=0.0)
self.assertEqual(locator.calls, 1)
def test_click_next_wait_treats_login_form_as_progress(self) -> None:
page = _NoNextButLoginAppearsPage()
step = _click_next_with_wait(page, timeout_s=1)
self.assertEqual(step, "Installation:setupSuperUser")
self.assertTrue(page.login_visible)
def test_click_next_wait_treats_named_login_form_as_progress(self) -> None:
page = _NoNextButNamedLoginAppearsPage()
step = _click_next_with_wait(page, timeout_s=1)
self.assertEqual(step, "Installation:setupSuperUser")
self.assertTrue(page.login_visible)
def test_wait_for_superuser_login_field_allows_delayed_form(self) -> None:
page = _DelayedSuperuserLoginPage(reveal_after_wait_calls=4)
visible = _wait_for_superuser_login_field(
page,
timeout_s=1.0,
poll_interval_ms=1,
)
self.assertTrue(visible)
self.assertTrue(page.login_visible)
def test_wait_for_superuser_login_field_times_out_when_absent(self) -> None:
page = _DelayedSuperuserLoginPage(reveal_after_wait_calls=None)
visible = _wait_for_superuser_login_field(
page,
timeout_s=0.01,
poll_interval_ms=1,
)
self.assertFalse(visible)
if __name__ == "__main__":
unittest.main()

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()