20 Commits
v1.0.1 ... main

Author SHA1 Message Date
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
Kevin Veen-Birkenbach
45867ece38 Release version 1.1.0
Some checks failed
ci / tests (push) Has been cancelled
publish-image / tests (push) Has been cancelled
Stable Tag / test (push) Has been cancelled
publish-image / build-and-push (push) Has been cancelled
Stable Tag / tag-stable (push) Has been cancelled
2025-12-23 22:07:11 +01:00
Kevin Veen-Birkenbach
33ed86adf1 Updated README.md
Some checks failed
ci / tests (push) Has been cancelled
2025-12-23 22:05:52 +01:00
Kevin Veen-Birkenbach
f86f84ff93 Added missing constraints.txt
Some checks failed
ci / tests (push) Has been cancelled
2025-12-23 21:54:55 +01:00
Kevin Veen-Birkenbach
b429644d9e Relax Playwright dependency for Nix, pin exact version in Docker
Some checks failed
ci / tests (push) Has been cancelled
- Allow playwright>=1.46.0,<2 in pyproject.toml for Nix compatibility
- Add pip constraints.txt to pin playwright==1.46.0 in container builds
- Enforce constraints during Docker image build

https://chatgpt.com/share/694b0180-2734-800f-830e-44e15d0a527d
2025-12-23 21:54:16 +01:00
Kevin Veen-Birkenbach
9fbdce2972 Solved
Some checks failed
ci / tests (push) Has been cancelled
Error: Invalid installation targets: 'chromium-headless-shell'. Expecting one of: chromium, chrome, chrome-beta, msedge, msedge-beta, msedge-dev, firefox, webkit
make: *** [Makefile:86: playwright-install] Error 1
2025-12-23 21:38:27 +01:00
Kevin Veen-Birkenbach
1b3ee2c3fd fix: pin Playwright to 1.46.0 to match Docker base image
Some checks failed
ci / tests (push) Has been cancelled
The Docker image is based on mcr.microsoft.com/playwright/python:v1.46.0.
Using an open-ended dependency (playwright>=1.46.0) allowed newer
Playwright versions to be installed in CI, causing driver/browser
mismatches and bootstrap crashes (exit code 3).

Pinning Playwright to 1.46.0 ensures version consistency between
Python package and container image and fixes the docker-compose
E2E stack failure.

https://chatgpt.com/share/694afaed-7c1c-800f-a2da-ec8c06e6ebe1
2025-12-23 21:26:03 +01:00
Kevin Veen-Birkenbach
1f448f4457 test(e2e): make root docker-compose stack port-configurable
Some checks failed
ci / tests (push) Has been cancelled
Parameterize Matomo’s published port via MATOMO_PORT to avoid CI collisions,
update installer site URL accordingly, and adapt the root docker-compose E2E
test to run on a non-default port with explicit environment injection.

https://chatgpt.com/share/694af650-a484-800f-ace7-0a634d57b0a0
2025-12-23 21:16:51 +01:00
Kevin Veen-Birkenbach
7fa8b580d2 test(e2e): validate root docker-compose stack bootstrap flow
Some checks failed
ci / tests (push) Has been cancelled
Adds an end-to-end test that brings up the root docker-compose.yml stack,
runs the one-shot bootstrap container, verifies token-only stdout, and
checks the token via Matomo API, with full cleanup via down -v.

https://chatgpt.com/share/694af650-a484-800f-ace7-0a634d57b0a0
2025-12-23 21:06:16 +01:00
Kevin Veen-Birkenbach
bf69c110a7 fix(nix): use nixpkgs playwright-driver and disable Playwright browser downloads
Some checks failed
ci / tests (push) Has been cancelled
https://chatgpt.com/share/694af26e-fb6c-800f-b0e3-e710cd035798
2025-12-23 20:49:57 +01:00
Kevin Veen-Birkenbach
a582e8be13 ci: split reusable workflow into lint + e2e jobs
Some checks failed
ci / tests (push) Has been cancelled
- Run ruff in a dedicated lint job (faster feedback)
- Keep e2e separate and install dependencies only there
- Force Playwright Chromium install to avoid cache-related missing binaries
- Trigger CI on PRs and pushes to main

https://chatgpt.com/share/694ae842-1588-800f-9418-31e7d02ac45e
2025-12-23 20:35:09 +01:00
Kevin Veen-Birkenbach
e38051a92f fix(playwright): install chromium-headless-shell for headless runs
Some checks failed
CI / test (push) Has been cancelled
Playwright v1.46 expects the separate chromium_headless_shell binary in
headless mode. Install chromium-headless-shell alongside chromium in both
the Makefile and Nix flake installer to prevent ENOENT launch errors.

https://chatgpt.com/share/694ae842-1588-800f-9418-31e7d02ac45e
2025-12-23 20:06:30 +01:00
Kevin Veen-Birkenbach
8162d337b5 nix: disable pythonImportsCheck to avoid Playwright side effects during build
Some checks failed
CI / test (push) Has been cancelled
https://chatgpt.com/share/694ae5ee-de04-800f-9944-9d7deb74f300
2025-12-23 19:56:42 +01:00
Kevin Veen-Birkenbach
bac453c435 ci: add GHCR publish workflow gated by tests
Some checks failed
CI / test (push) Has been cancelled
- build & push image on tag
- publish :latest only for semver tags
- multi-arch (amd64, arm64)

https://chatgpt.com/share/694ae4b0-ca28-800f-863c-0916a2f62a43
2025-12-23 19:51:10 +01:00
Kevin Veen-Birkenbach
a2010cd914 feat(container): add pinned Playwright Docker image and compose stack for Matomo bootstrap
Some checks failed
CI / test (push) Has been cancelled
- Add Dockerfile based on pinned Playwright image (v1.46.0-jammy) for reproducible browser runtime
- Introduce docker-compose stack (MariaDB + Matomo + one-shot bootstrap)
- Extend Makefile with container image and stack management targets
- Add env.sample for environment-driven bootstrap configuration
- Relax Playwright dependency to >=1.46.0 to keep Nix builds compatible
- Add E2E test ensuring docker-compose bootstrap exits with 0 and prints token
2025-12-23 19:41:31 +01:00
16 changed files with 1014 additions and 118 deletions

View File

@@ -1,12 +1,11 @@
name: CI
name: ci
on:
push:
pull_request:
push:
branches:
- main
jobs:
test:
tests:
uses: ./.github/workflows/reusable-test.yml
with:
python-version: "3.12"
matomo-token-description: "ci-token"

67
.github/workflows/publish-image.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: publish-image
on:
push:
tags:
- "v*.*.*"
workflow_run:
workflows: ["Stable Tag"] # MUST match stable-tag.yml -> name: Stable Tag
types: [completed]
jobs:
build-and-push:
if: |
(github.event_name == 'push') ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
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
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
shell: bash
run: |
set -euo pipefail
IMAGE="ghcr.io/${{ github.repository }}"
if [ "${{ github.event_name }}" = "push" ]; then
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
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,4 +1,4 @@
name: Reusable Test (ruff + e2e)
name: reusable-test
on:
workflow_call:
@@ -29,10 +29,47 @@ on:
default: "ci-token"
jobs:
test:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: "pip"
- name: Install lint deps
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Ruff
run: |
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
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -48,22 +85,16 @@ jobs:
sudo apt-get update
sudo apt-get install -y curl
- name: Install Python deps
- name: Install Python deps (editable + e2e)
run: |
python -m pip install --upgrade pip
pip install -e ".[e2e]"
pip install ruff
- name: Ruff
run: |
ruff check .
ruff format --check .
- name: Install Playwright Chromium
run: |
python -m playwright install --with-deps chromium
python -m playwright install --with-deps --force chromium
- name: E2E (docker compose + installer + tests)
- name: Run E2E (docker compose)
env:
MATOMO_URL: ${{ inputs.matomo-url }}
MATOMO_ADMIN_USER: ${{ inputs.matomo-admin-user }}

View File

@@ -1,3 +1,18 @@
## [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
## [1.0.1] - 2025-12-23
* * Support for running `matomo-bootstrap` **fully via Nix** in a clean, containerized environment.

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Playwright Python image with Chromium + all required OS dependencies
# Version should roughly match your playwright requirement
FROM mcr.microsoft.com/playwright/python:v1.46.0-jammy
# Keep stdout clean (token-only), logs go to stderr
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install matomo-bootstrap
# Option A: from PyPI (recommended once published)
# RUN pip install --no-cache-dir matomo-bootstrap==1.0.1
# Option B: build from source (current repo)
COPY pyproject.toml README.md LICENSE /app/
COPY constraints.txt /app/
COPY src /app/src
RUN pip install --no-cache-dir -c /app/constraints.txt .
# Default entrypoint: environment-driven bootstrap
ENTRYPOINT ["matomo-bootstrap"]

140
Makefile
View File

@@ -1,4 +1,8 @@
PYTHON ?= python3
# ----------------------------
# E2E (existing)
# ----------------------------
COMPOSE_FILE := tests/e2e/docker-compose.yml
COMPOSE := docker compose -f $(COMPOSE_FILE)
@@ -13,7 +17,26 @@ MATOMO_ADMIN_PASSWORD ?= AdminSecret123!
MATOMO_ADMIN_EMAIL ?= administrator@example.org
MATOMO_TOKEN_DESCRIPTION ?= e2e-make-token
.PHONY: help venv deps-e2e playwright-install e2e-up e2e-install e2e-test e2e-down e2e logs clean
# ----------------------------
# Container image (production-like)
# ----------------------------
IMAGE_NAME ?= ghcr.io/kevinveenbirkenbach/matomo-bootstrap
IMAGE_VERSION ?= 1.0.1
# Optional .env file for container runs
ENV_FILE ?= .env
# ----------------------------
# docker-compose stack (Matomo + MariaDB + Bootstrap)
# ----------------------------
COMPOSE_STACK_FILE ?= docker-compose.yml
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
help:
@echo "Targets:"
@@ -25,11 +48,34 @@ help:
@echo " e2e-test Run E2E tests (unittest)"
@echo " e2e-down Stop and remove E2E containers"
@echo " e2e Full cycle: up → install → test → down"
@echo " logs Show Matomo logs"
@echo " clean Stop containers + remove venv"
@echo " logs Show Matomo logs (E2E compose)"
@echo " clean Stop E2E containers + remove venv"
@echo " test-integration Run integration tests (unittest)"
@echo ""
@echo "Variables (override like: make e2e MATOMO_URL=http://127.0.0.1:8081):"
@echo " MATOMO_URL, MATOMO_ADMIN_USER, MATOMO_ADMIN_PASSWORD, MATOMO_ADMIN_EMAIL, MATOMO_TOKEN_DESCRIPTION"
@echo "Container image targets:"
@echo " image-build Build matomo-bootstrap container image"
@echo " image-run Run container bootstrap using $(ENV_FILE) (token-only stdout)"
@echo " image-shell Start interactive shell in container"
@echo " image-push Push image tags ($(IMAGE_VERSION) + latest)"
@echo " image-clean Remove local image tags"
@echo ""
@echo "docker-compose stack targets (docker-compose.yml):"
@echo " stack-up Start MariaDB + Matomo (no bootstrap)"
@echo " stack-bootstrap Run one-shot bootstrap (prints token to stdout)"
@echo " stack-reset Full reset: down -v → build → up → bootstrap"
@echo " stack-down Stop stack"
@echo " stack-clean Stop stack and REMOVE volumes (DANGER)"
@echo " stack-logs Follow Matomo logs (stack)"
@echo " stack-ps Show stack status"
@echo ""
@echo "Variables:"
@echo " E2E: MATOMO_URL, MATOMO_ADMIN_USER, MATOMO_ADMIN_PASSWORD, MATOMO_ADMIN_EMAIL, MATOMO_TOKEN_DESCRIPTION"
@echo " IMG: IMAGE_NAME, IMAGE_VERSION, ENV_FILE"
@echo " STK: COMPOSE_STACK_FILE"
# ----------------------------
# E2E targets
# ----------------------------
venv:
@test -x "$(VENV_PY)" || ($(PYTHON) -m venv $(VENV_DIR))
@@ -72,6 +118,11 @@ e2e-test: deps-e2e
e2e-down:
$(COMPOSE) down -v
e2e-nix:
docker compose -f tests/e2e/docker-compose.yml up -d
python3 -m unittest -v tests/e2e/test_bootstrap_nix.py
docker compose -f tests/e2e/docker-compose.yml down -v
e2e: e2e-up e2e-install e2e-test e2e-down
logs:
@@ -79,3 +130,82 @@ 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
# ----------------------------
image-build:
docker build -t $(IMAGE_NAME):$(IMAGE_VERSION) -t $(IMAGE_NAME):latest .
image-run:
@test -f "$(ENV_FILE)" || (echo "Missing $(ENV_FILE). Create it from env.sample."; exit 1)
docker run --rm \
--env-file "$(ENV_FILE)" \
--network host \
$(IMAGE_NAME):$(IMAGE_VERSION)
image-shell:
@test -f "$(ENV_FILE)" || (echo "Missing $(ENV_FILE). Create it from env.sample."; exit 1)
docker run --rm -it \
--env-file "$(ENV_FILE)" \
--network host \
--entrypoint /bin/bash \
$(IMAGE_NAME):$(IMAGE_VERSION)
image-push:
docker push $(IMAGE_NAME):$(IMAGE_VERSION)
docker push $(IMAGE_NAME):latest
image-clean:
docker rmi $(IMAGE_NAME):$(IMAGE_VERSION) $(IMAGE_NAME):latest || true
# ----------------------------
# docker-compose stack workflow
# ----------------------------
## Start MariaDB + Matomo (without bootstrap)
stack-up:
$(COMPOSE_STACK) up -d db matomo
@echo "Matomo is starting on http://127.0.0.1:8080"
## Run one-shot bootstrap (prints token to stdout)
stack-bootstrap:
$(COMPOSE_STACK) run --rm bootstrap
## Re-run bootstrap (forces a fresh one-shot run)
stack-rebootstrap:
$(COMPOSE_STACK) rm -f bootstrap || true
$(COMPOSE_STACK) run --rm bootstrap
## Follow Matomo logs (stack)
stack-logs:
$(COMPOSE_STACK) logs -f matomo
## Show running services (stack)
stack-ps:
$(COMPOSE_STACK) ps
## Stop stack
stack-down:
$(COMPOSE_STACK) down
## Stop stack and REMOVE volumes (DANGER)
stack-clean:
$(COMPOSE_STACK) down -v
## Full reset: down -v → rebuild bootstrap → up → bootstrap
stack-reset:
$(COMPOSE_STACK) down -v
$(COMPOSE_STACK) build --no-cache bootstrap
$(COMPOSE_STACK) up -d db matomo
@echo "Waiting for Matomo to become reachable..."
@sleep 10
$(COMPOSE_STACK) run --rm bootstrap

274
README.md
View File

@@ -2,78 +2,69 @@
[![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 **installation** (via recorded Playwright flow) and **API token provisioning** for fresh Matomo instances.
This tool is designed for **CI, containers, and reproducible environments**, where no interactive browser access is available.
Headless bootstrap tooling for **Matomo**. Automates **first-time installation** and **API token provisioning** for fresh Matomo instances.
---
## Features
* 🚀 **Fully headless Matomo installation**
* Drives the official Matomo web installer using **Playwright**
* Automatically skips installation if Matomo is already installed
* 🔐 **API token provisioning**
* Creates an *app-specific token* via authenticated Matomo session
* Compatible with Matomo 5.3.x Docker images
* 🧪 **E2E-tested**
* Docker-based end-to-end tests included
* ❄️ **First-class Nix support**
* Flake-based packaging
* Reproducible CLI and dev environments
* 🐍 **Standard Python CLI**
* Installable via `pip`
* Clean stdout (token only), logs on stderr
- 🚀 **Fully headless Matomo installation**
- Drives the official Matomo web installer using **Playwright**
- Automatically skips the installer if Matomo is already installed
- 🔐 **API token provisioning**
- Creates an **app-specific token** via an authenticated Matomo session
- Compatible with **Matomo 5.3.x** Docker images
- 🧪 **E2E-tested**
- Docker-based end-to-end tests included
- ❄️ **First-class Nix support**
- Flake-based packaging and pinned `flake.lock`
- Uses `nixpkgs` browsers via `playwright-driver` (no Playwright downloads)
- 🧼 **Token-only stdout contract**
- **stdout contains only the token** (safe for scripting)
- Logs go to **stderr**
---
## Requirements
* A running Matomo instance (e.g. Docker)
* For fresh installs:
* Chromium (managed automatically by Playwright)
- A running Matomo instance (e.g. via Docker)
- For fresh installs:
- Chromium (provided by Playwright or by the Playwright base container image)
---
## Installation
### Using **Nix** (recommended)
### Nix (recommended)
If you use **Nix** with flakes:
Run directly from the repository:
```bash
nix run github:kevinveenbirkenbach/matomo-bootstrap
```
Install Playwrights Chromium browser (one-time):
```bash
nix run github:kevinveenbirkenbach/matomo-bootstrap#matomo-bootstrap-playwright-install
```
This installs Chromium into the user cache used by Playwright.
In Nix mode, browsers are provided via `nixpkgs` (`playwright-driver`) and Playwright downloads are disabled.
---
### Using **Python / pip**
### Python / pip
Requires **Python ≥ 3.10**
Requires **Python ≥ 3.10**:
```bash
pip install matomo-bootstrap
python -m playwright install chromium
```
Install Chromium for Playwright:
---
### Docker image (GHCR)
Pull the prebuilt image:
```bash
python -m playwright install chromium
docker pull ghcr.io/kevinveenbirkenbach/matomo-bootstrap:stable
# or:
docker pull ghcr.io/kevinveenbirkenbach/matomo-bootstrap:latest
```
---
@@ -86,11 +77,12 @@ python -m playwright install chromium
matomo-bootstrap \
--base-url http://127.0.0.1:8080 \
--admin-user administrator \
--admin-password AdminSecret123! \
--admin-email administrator@example.org
--admin-password 'AdminSecret123!' \
--admin-email administrator@example.org \
--token-description my-ci-token
```
On success, the command prints **only the API token** to stdout:
On success, the command prints **only the token** to stdout:
```text
6c7a8c2b0e9e4a3c8e1d0c4e8a6b9f21
@@ -98,14 +90,14 @@ On success, the command prints **only the API token** to stdout:
---
### Environment Variables
### Environment variables
All options can be provided via environment variables:
```bash
export MATOMO_URL=http://127.0.0.1:8080
export MATOMO_ADMIN_USER=administrator
export MATOMO_ADMIN_PASSWORD=AdminSecret123!
export MATOMO_ADMIN_PASSWORD='AdminSecret123!'
export MATOMO_ADMIN_EMAIL=administrator@example.org
export MATOMO_TOKEN_DESCRIPTION=my-ci-token
@@ -114,9 +106,9 @@ matomo-bootstrap
---
### Debug Mode
### Debug mode
Enable verbose logs (stderr only):
Enable verbose logs (**stderr only**):
```bash
matomo-bootstrap --debug
@@ -124,27 +116,172 @@ matomo-bootstrap --debug
---
## How It Works
## Docker Compose integration (one-shot bootstrap)
1. **Reachability check**
### Why “one-shot”?
* Waits until Matomo responds over HTTP (any status)
2. **Installation (if needed)**
The bootstrap container is meant to:
* Uses a recorded Playwright flow to complete the Matomo web installer
3. **Authentication**
1. Run once,
2. Print the token to stdout,
3. Exit with code `0`.
* Logs in using the `Login.logme` controller
4. **Token creation**
You should **not** start it automatically on every `docker compose up`.
Instead, start Matomo normally, then run the bootstrap via `docker compose run`.
* Calls `UsersManager.createAppSpecificTokenAuth`
5. **Output**
* Prints the token to stdout (safe for scripting)
The cleanest Compose pattern is to put `bootstrap` behind a **profile**.
---
## End-to-End Tests
### Example `docker-compose.yml` (recommended: `profiles`)
```yaml
services:
db:
image: mariadb:11
container_name: matomo-db
restart: unless-stopped
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: matomo_pw
MARIADB_ROOT_PASSWORD: root_pw
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -uroot -proot_pw --silent"]
interval: 5s
timeout: 3s
retries: 60
matomo:
image: matomo:5.3.2
container_name: matomo
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${MATOMO_PORT:-8080}:80"
environment:
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: matomo_pw
MATOMO_DATABASE_DBNAME: matomo
volumes:
- matomo_data:/var/www/html
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 60
bootstrap:
# This prevents automatic startup during a normal `docker compose up`
profiles: ["bootstrap"]
# Option A: use the published image (recommended)
image: ghcr.io/kevinveenbirkenbach/matomo-bootstrap:1.0.1
# Option B: build locally from the repository checkout
# build:
# context: .
# dockerfile: Dockerfile
# image: matomo-bootstrap:local
container_name: matomo-bootstrap
depends_on:
matomo:
condition: service_started
environment:
# inside the compose network, Matomo is reachable via the service name
MATOMO_URL: "http://matomo"
MATOMO_ADMIN_USER: "administrator"
MATOMO_ADMIN_PASSWORD: "AdminSecret123!"
MATOMO_ADMIN_EMAIL: "administrator@example.org"
MATOMO_TOKEN_DESCRIPTION: "docker-compose-bootstrap"
# Values used by the recorded installer flow
MATOMO_SITE_NAME: "Matomo (docker-compose)"
MATOMO_SITE_URL: "http://127.0.0.1:${MATOMO_PORT:-8080}"
MATOMO_TIMEZONE: "Germany - Berlin"
# Optional stability knobs
MATOMO_TIMEOUT: "30"
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
restart: "no"
volumes:
mariadb_data:
matomo_data:
```
---
### Commands
Start DB + Matomo **without** bootstrap:
```bash
docker compose up -d db matomo
```
Run bootstrap once (prints token to stdout):
```bash
docker compose --profile bootstrap run --rm bootstrap
```
Re-run bootstrap (creates a new token by default):
```bash
docker compose --profile bootstrap run --rm bootstrap
```
---
## Idempotency / avoiding new tokens on every run
By default, `UsersManager.createAppSpecificTokenAuth` creates a new token each time.
If you want strictly idempotent runs in automation, you can provide an existing token
and make the bootstrap return it instead of creating a new one:
```bash
export MATOMO_BOOTSTRAP_TOKEN_AUTH="0123456789abcdef..."
matomo-bootstrap
```
> This is useful for CI re-runs or configuration management tools.
---
## How it works
1. **Reachability check**
* waits until Matomo responds via HTTP (any status is considered “reachable”)
2. **Installation (if needed)**
* uses a recorded Playwright flow to complete the Matomo web installer
3. **Authentication**
* logs in using Matomos `Login.logme` controller (cookie session)
4. **Token creation**
* calls `UsersManager.createAppSpecificTokenAuth`
5. **Output**
* prints the token to stdout (token-only contract)
---
## End-to-end tests
Run the full E2E cycle locally:
@@ -157,27 +294,18 @@ This will:
1. Start Matomo + MariaDB via Docker
2. Install Matomo headlessly
3. Create an API token
4. Validate the token via Matomo API
4. Validate the token via the Matomo API
5. Tear everything down again
---
## Project Status
* ✔ Stable for CI / automation
* ✔ Tested against Matomo 5.3.x Docker images
* ⚠ Installer flow is UI-recorded (robust, but may need updates for future Matomo UI changes)
---
## Author
**Kevin Veen-Birkenbach**
🌐 [https://www.veen.world/](https://www.veen.world/)
[https://www.veen.world/](https://www.veen.world/)
---
## License
MIT License
See [LICENSE](LICENSE)
MIT — see [LICENSE](LICENSE)

1
constraints.txt Normal file
View File

@@ -0,0 +1 @@
playwright==1.46.0

74
docker-compose.yml Normal file
View File

@@ -0,0 +1,74 @@
services:
db:
image: mariadb:11
container_name: matomo-db
restart: unless-stopped
environment:
MARIADB_DATABASE: matomo
MARIADB_USER: matomo
MARIADB_PASSWORD: matomo_pw
MARIADB_ROOT_PASSWORD: root_pw
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -uroot -proot_pw --silent"]
interval: 5s
timeout: 3s
retries: 60
matomo:
image: matomo:5.3.2
container_name: matomo
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "${MATOMO_PORT:-8080}:80"
environment:
MATOMO_DATABASE_HOST: db
MATOMO_DATABASE_ADAPTER: mysql
MATOMO_DATABASE_USERNAME: matomo
MATOMO_DATABASE_PASSWORD: matomo_pw
MATOMO_DATABASE_DBNAME: matomo
volumes:
- matomo_data:/var/www/html
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 60
bootstrap:
build:
context: .
dockerfile: Dockerfile
image: matomo-bootstrap:local
container_name: matomo-bootstrap
depends_on:
matomo:
condition: service_started
environment:
MATOMO_URL: "http://matomo"
MATOMO_ADMIN_USER: "administrator"
MATOMO_ADMIN_PASSWORD: "AdminSecret123!"
MATOMO_ADMIN_EMAIL: "administrator@example.org"
MATOMO_TOKEN_DESCRIPTION: "docker-compose-bootstrap"
# Installer flow values
MATOMO_SITE_NAME: "Matomo (docker-compose)"
MATOMO_SITE_URL: "http://127.0.0.1:${MATOMO_PORT:-8080}"
MATOMO_TIMEZONE: "Germany - Berlin"
# Optional stability knobs
MATOMO_TIMEOUT: "30"
MATOMO_PLAYWRIGHT_HEADLESS: "1"
MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS: "60000"
MATOMO_PLAYWRIGHT_SLOWMO_MS: "0"
# 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"
volumes:
mariadb_data:
matomo_data:

29
env.sample Normal file
View File

@@ -0,0 +1,29 @@
# --- REQUIRED ---
MATOMO_URL=http://127.0.0.1:8080
MATOMO_ADMIN_USER=administrator
MATOMO_ADMIN_PASSWORD=AdminSecret123!
MATOMO_ADMIN_EMAIL=administrator@example.org
# --- OPTIONAL ---
# Description for the app-specific token
MATOMO_TOKEN_DESCRIPTION=ansible-bootstrap
# Timeout (seconds)
MATOMO_TIMEOUT=30
# Debug logs to stderr (stdout stays token-only)
# MATOMO_DEBUG=1
# If set, bootstrap will NOT create a new token
# but return this one instead (idempotent runs)
# MATOMO_BOOTSTRAP_TOKEN_AUTH=0123456789abcdef...
# Values used by the recorded installer flow
MATOMO_SITE_NAME=Matomo
MATOMO_SITE_URL=http://127.0.0.1:8080
MATOMO_TIMEZONE=Germany - Berlin
# Playwright knobs (usually not needed)
# MATOMO_PLAYWRIGHT_HEADLESS=1
# MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS=60000
# MATOMO_PLAYWRIGHT_SLOWMO_MS=0

View File

@@ -15,17 +15,25 @@
let
pkgs = import nixpkgs { inherit system; };
python = pkgs.python312;
playwrightDriver = pkgs.playwright-driver;
in
rec {
matomo-bootstrap = python.pkgs.buildPythonApplication {
pname = "matomo-bootstrap";
version = "1.0.1"; # keep in sync with pyproject.toml
version = "1.1.2"; # keep in sync with pyproject.toml
pyproject = true;
src = self;
nativeBuildInputs = with python.pkgs; [
# disable import-check phase (prevents Playwright/installer side effects)
pythonImportsCheck = [ ];
nativeBuildInputs =
(with python.pkgs; [
setuptools
wheel
])
++ [
pkgs.makeWrapper
];
propagatedBuildInputs = with python.pkgs; [
@@ -34,6 +42,20 @@
doCheck = false;
# IMPORTANT (Nix):
# Do NOT let Playwright download ubuntu/fhs browser binaries into ~/.cache/ms-playwright.
# Instead, point Playwright to nixpkgs-provided browsers (playwright-driver).
#
# This fixes errors like:
# BrowserType.launch ... headless_shell ENOENT
#
# ...which happens when Playwright downloads a fallback ubuntu build that cannot run on NixOS.
postFixup = ''
wrapProgram "$out/bin/matomo-bootstrap" \
--set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD 1 \
--set PLAYWRIGHT_BROWSERS_PATH "${playwrightDriver.browsers}"
'';
meta = with pkgs.lib; {
description = "Headless bootstrap tooling for Matomo (installation + API token provisioning)";
homepage = "https://github.com/kevinveenbirkenbach/matomo-bootstrap";
@@ -50,6 +72,7 @@
let
pkgs = import nixpkgs { inherit system; };
python = pkgs.python312;
playwrightDriver = pkgs.playwright-driver;
pythonPlaywright = python.withPackages (ps: [
ps.playwright
@@ -62,9 +85,20 @@
runtimeInputs = [ pythonPlaywright ];
text = ''
# Install Playwright browsers.
# Nix mode: NO browser downloads.
#
# Playwright upstream "install" downloads ubuntu/fhs browser binaries into ~/.cache/ms-playwright.
# Those binaries often don't run on NixOS, producing ENOENT on launch (missing loader/libs).
#
# We keep this app for backwards-compat (tests/docs call it), but it is intentionally a NO-OP.
#
# IMPORTANT: Do not print anything to stdout (tests expect token-only stdout).
exec ${pythonPlaywright}/bin/python -m playwright install chromium 1>&2
{
echo "Playwright browsers are provided by nixpkgs (playwright-driver)."
echo "Using PLAYWRIGHT_BROWSERS_PATH=${playwrightDriver.browsers}"
echo "Set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 to prevent downloads."
} 1>&2
exit 0
'';
};
in

View File

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

View File

@@ -33,6 +33,100 @@ def _log(msg: str) -> None:
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_http(url: str, timeout: int = 180) -> None:
"""
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
@@ -102,7 +196,6 @@ class WebInstaller(Installer):
wait_http(base_url)
if is_installed(base_url):
if config.debug:
_log("[install] Matomo already looks installed. Skipping installer.")
return
@@ -120,10 +213,6 @@ class WebInstaller(Installer):
page.set_default_navigation_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 '»'.
@@ -149,13 +238,11 @@ class WebInstaller(Installer):
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
@@ -164,6 +251,7 @@ class WebInstaller(Installer):
)
page.goto(base_url, wait_until="domcontentloaded")
_page_warnings(page)
def superuser_form_visible() -> bool:
return page.locator("#login-0").count() > 0
@@ -174,6 +262,7 @@ class WebInstaller(Installer):
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_page_warnings(page)
else:
raise RuntimeError(
"Installer did not reach superuser step (login-0 not found)."
@@ -191,12 +280,17 @@ class WebInstaller(Installer):
page.locator("#email-0").click()
page.locator("#email-0").fill(config.admin_email)
_page_warnings(page)
if page.get_by_role("button", name="Next »").count() > 0:
page.get_by_role("button", name="Next »").click()
else:
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_page_warnings(page)
if page.locator("#siteName-0").count() > 0:
page.locator("#siteName-0").click()
page.locator("#siteName-0").fill(DEFAULT_SITE_NAME)
@@ -205,26 +299,38 @@ class WebInstaller(Installer):
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:
_dbg("Timezone selection skipped (not found / changed UI).")
_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:
_dbg("Ecommerce selection skipped (not found / changed UI).")
_log("Ecommerce selection skipped (not found / changed UI).")
_page_warnings(page)
click_next()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_page_warnings(page)
if page.get_by_role("link", name="Next »").count() > 0:
page.get_by_role("link", name="Next »").click()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_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()
page.wait_for_load_state("domcontentloaded")
page.wait_for_timeout(200)
_page_warnings(page)
context.close()
browser.close()

View File

@@ -0,0 +1,135 @@
import json
import os
import subprocess
import time
import unittest
import urllib.request
COMPOSE_FILE = os.environ.get("MATOMO_STACK_COMPOSE_FILE", "docker-compose.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}")
# How long we wait for Matomo HTTP to respond at all (seconds)
WAIT_TIMEOUT_SECONDS = int(os.environ.get("MATOMO_STACK_WAIT_TIMEOUT", "180"))
def _run(
cmd: list[str],
*,
check: bool = True,
extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
return subprocess.run(
cmd,
check=check,
env={**os.environ, **(extra_env or {})},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def _compose_cmd(*args: str) -> list[str]:
return ["docker", "compose", "-f", COMPOSE_FILE, *args]
def _wait_for_http_any_status(url: str, timeout_s: int) -> None:
"""
Consider the service "up" once the HTTP server answers anything.
urllib raises HTTPError on 4xx/5xx, but that's still "reachable".
"""
deadline = time.time() + timeout_s
last_exc: Exception | None = None
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as resp:
_ = resp.read(64)
return
except Exception as exc: # includes HTTPError
last_exc = exc
time.sleep(1)
raise RuntimeError(f"Matomo did not become reachable at {url} ({last_exc})")
class TestRootDockerComposeStack(unittest.TestCase):
"""
E2E test for repository root docker-compose.yml:
1) docker compose down -v
2) docker compose build bootstrap
3) docker compose up -d db matomo
4) wait for Matomo HTTP on host port (default 8080, overridden here)
5) docker compose run --rm bootstrap -> token on stdout
6) validate token via Matomo API call
7) docker compose down -v (cleanup)
"""
def setUp(self) -> None:
# Always start from a clean slate (also clears volumes)
_run(
_compose_cmd("down", "-v"),
check=False,
extra_env={"MATOMO_PORT": MATOMO_PORT},
)
def tearDown(self) -> None:
# Cleanup even if assertions fail
_run(
_compose_cmd("down", "-v"),
check=False,
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)
build = _run(
_compose_cmd("build", "bootstrap"),
check=True,
extra_env={"MATOMO_PORT": MATOMO_PORT},
)
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},
)
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)
# Run bootstrap: it should print ONLY the token to stdout
boot = _run(
_compose_cmd("run", "--rm", "bootstrap"),
check=True,
extra_env={"MATOMO_PORT": MATOMO_PORT},
)
token = (boot.stdout or "").strip()
self.assertRegex(
token,
r"^[a-f0-9]{32,64}$",
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"?module=API&method=SitesManager.getSitesWithAtLeastViewAccess"
f"&format=json&token_auth={token}"
)
with urllib.request.urlopen(api_url, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
self.assertIsInstance(data, list)
if __name__ == "__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()