2 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
cc9b706b70 Release version 1.1.13
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-15 15:18:43 +01:00
Kevin Veen-Birkenbach
b0593ab431 Harden setupSuperUser installer readiness and submit fallbacks
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-15 14:54:12 +01:00
5 changed files with 261 additions and 70 deletions

View File

@@ -1,3 +1,8 @@
## [1.1.13] - 2026-02-15
* This release fixes the intermittent setupSuperUser bootstrap timeout by making superuser-form detection and submission more robust across timing and DOM variations, with added integration coverage and full passing E2E/integration tests.
## [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.

View File

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

View File

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

View File

@@ -34,6 +34,9 @@ INSTALLER_TABLES_CREATION_TIMEOUT_S = int(
INSTALLER_TABLES_ERASE_TIMEOUT_S = int(
os.environ.get("MATOMO_INSTALLER_TABLES_ERASE_TIMEOUT_S", "120")
)
INSTALLER_SUPERUSER_RELOAD_INTERVAL_S = int(
os.environ.get("MATOMO_INSTALLER_SUPERUSER_RELOAD_INTERVAL_S", "30")
)
INSTALLER_DEBUG_DIR = os.environ.get(
"MATOMO_INSTALLER_DEBUG_DIR", "/tmp/matomo-bootstrap"
).rstrip("/")
@@ -74,6 +77,11 @@ SUPERUSER_LOGIN_SELECTORS = (
"input[name='login']",
"form#generalsetupform input[name='login']",
)
SUPERUSER_FORM_SELECTORS = (
"form#generalsetupform",
"form[action*='setupSuperUser']",
"form[action*='action=setupSuperUser']",
)
SUPERUSER_PASSWORD_SELECTORS = (
"#password-0",
"#password",
@@ -376,6 +384,19 @@ def _has_superuser_login_field(page, *, timeout_s: float = 0.2) -> bool:
return loc is not None
def _has_superuser_form_container(page, *, timeout_s: float = 0.2) -> bool:
loc, _ = _first_present_css_locator(
page, SUPERUSER_FORM_SELECTORS, timeout_s=timeout_s
)
return loc is not None
def _superuser_form_ready(page, *, timeout_s: float = 0.2) -> bool:
return _has_superuser_login_field(
page, timeout_s=timeout_s
) or _has_superuser_form_container(page, timeout_s=timeout_s)
def _has_first_website_name_field(page, *, timeout_s: float = 0.2) -> bool:
loc, _ = _first_present_css_locator(
page, FIRST_WEBSITE_NAME_SELECTORS, timeout_s=timeout_s
@@ -392,20 +413,38 @@ def _wait_for_superuser_login_field(
page, *, timeout_s: float, poll_interval_ms: int = 300
) -> bool:
if timeout_s <= 0:
return _has_superuser_login_field(page, timeout_s=0.2)
return _superuser_form_ready(page, timeout_s=0.2)
deadline = time.time() + timeout_s
last_wait_log_at = 0.0
last_reload_at = time.time()
while time.time() < deadline:
_wait_dom_settled(page)
if _has_superuser_login_field(page, timeout_s=0.2):
if _superuser_form_ready(page, timeout_s=0.2):
return True
now = time.time()
if (
INSTALLER_SUPERUSER_RELOAD_INTERVAL_S > 0
and now - last_reload_at >= INSTALLER_SUPERUSER_RELOAD_INTERVAL_S
):
try:
page.reload(wait_until="domcontentloaded")
_wait_dom_settled(page)
_log(
"[install] Reloaded setupSuperUser page while waiting "
"for superuser form."
)
except Exception as exc:
_log(f"[install] setupSuperUser reload attempt failed: {exc}")
last_reload_at = now
if _superuser_form_ready(page, timeout_s=0.2):
return True
if now - last_wait_log_at >= 5:
_log(
"[install] setupSuperUser reached but login form is not visible yet; "
"[install] setupSuperUser reached but superuser form is not visible yet; "
f"waiting (url={page.url}, step={_get_step_hint(page.url)})"
)
_page_warnings(page)
@@ -413,7 +452,7 @@ def _wait_for_superuser_login_field(
page.wait_for_timeout(poll_interval_ms)
return _has_superuser_login_field(page, timeout_s=0.2)
return _superuser_form_ready(page, timeout_s=0.2)
def _fill_required_input(page, selectors, value: str, *, label: str) -> None:
@@ -445,6 +484,7 @@ def _fill_optional_input(page, selectors, value: str) -> bool:
def _installer_interactive(page) -> bool:
checks = [
_has_superuser_login_field(page),
_has_superuser_form_container(page),
_has_first_website_name_field(page),
_has_continue_to_matomo_action(page),
]
@@ -452,6 +492,95 @@ def _installer_interactive(page) -> bool:
return any(checks) or loc is not None
def _submit_superuser_form_via_dom(
page, *, user: str, password: str, email: str
) -> bool:
try:
return bool(
page.evaluate(
"""
([user, password, email]) => {
const form =
document.querySelector("form#generalsetupform")
|| document.querySelector("form[action*='setupSuperUser']")
|| document.querySelector("form[action*='action=setupSuperUser']");
if (!form) return false;
const pick = (selectors) => {
for (const selector of selectors) {
const candidate = form.querySelector(selector);
if (candidate) return candidate;
}
return null;
};
const loginInput = pick([
"input[name='login']",
"input#login",
"input[id^='login-']",
"input[name*='login']",
"input[name*='user']",
"input[type='text']",
]);
const passwordInput = pick([
"input[name='password']",
"input#password",
"input[id^='password-']",
"input[type='password']:not([name='password_bis'])",
"input[type='password']",
]);
const repeatPasswordInput = pick([
"input[name='password_bis']",
"input#password_bis",
"input[id^='password_bis-']",
"input[name*='repeat']",
]);
const emailInput = pick([
"input[name='email']",
"input#email",
"input[id^='email-']",
"input[type='email']",
"input[name*='mail']",
]);
if (!loginInput || !passwordInput || !emailInput) return false;
const setValue = (element, value) => {
element.value = value;
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
};
setValue(loginInput, user);
setValue(passwordInput, password);
if (repeatPasswordInput) {
setValue(repeatPasswordInput, password);
}
setValue(emailInput, email);
const submit = form.querySelector(
"button[type='submit'],input[type='submit']"
);
if (submit) {
submit.click();
return true;
}
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.submit();
}
return true;
}
""",
[user, password, email],
)
)
except Exception:
return False
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
@@ -509,6 +638,12 @@ def _click_next_with_wait(page, *, timeout_s: int) -> str:
f"staying on step {current_step} (url {current_url})"
)
return current_step
if _has_superuser_form_container(page, timeout_s=0.2):
_log(
"[install] Superuser form container became available without explicit click; "
f"staying on step {current_step} (url {current_url})"
)
return current_step
if _has_first_website_name_field(page, timeout_s=0.2):
_log(
"[install] First website form became available without explicit click; "
@@ -763,11 +898,11 @@ class WebInstaller(Installer):
progress_deadline = time.time() + INSTALLER_STEP_DEADLINE_S
while not _has_superuser_login_field(page):
while not _superuser_form_ready(page):
now = time.time()
if now >= progress_deadline:
raise RuntimeError(
"Installer did not reach superuser step "
"Installer did not reach superuser form "
f"within {INSTALLER_STEP_DEADLINE_S}s "
f"(url={page.url}, step={_get_step_hint(page.url)})."
)
@@ -792,73 +927,40 @@ class WebInstaller(Installer):
_click_next_with_wait(page, timeout_s=step_timeout)
_page_warnings(page)
_fill_required_input(
submitted_superuser = _submit_superuser_form_via_dom(
page,
SUPERUSER_LOGIN_SELECTORS,
config.admin_user,
label="superuser login",
user=config.admin_user,
password=config.admin_password,
email=config.admin_email,
)
_fill_required_input(
page,
SUPERUSER_PASSWORD_SELECTORS,
config.admin_password,
label="superuser password",
)
_fill_optional_input(
page, SUPERUSER_PASSWORD_REPEAT_SELECTORS, config.admin_password
)
_fill_required_input(
page,
SUPERUSER_EMAIL_SELECTORS,
config.admin_email,
label="superuser email",
)
_page_warnings(page)
submitted_superuser = False
try:
submitted_superuser = bool(
page.evaluate(
"""
([user, password, email]) => {
const form = document.querySelector("form#generalsetupform");
if (!form) return false;
const loginInput = form.querySelector("input[name='login']");
const passwordInput = form.querySelector("input[name='password']");
const repeatPasswordInput = form.querySelector("input[name='password_bis']");
const emailInput = form.querySelector("input[name='email']");
if (!loginInput || !passwordInput || !emailInput) return false;
loginInput.value = user;
passwordInput.value = password;
if (repeatPasswordInput) {
repeatPasswordInput.value = password;
}
emailInput.value = email;
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.submit();
}
return true;
}
""",
[
config.admin_user,
config.admin_password,
config.admin_email,
],
)
)
except Exception:
submitted_superuser = False
if submitted_superuser:
_wait_dom_settled(page)
_log("[install] Submitted superuser form via form.requestSubmit().")
else:
_fill_required_input(
page,
SUPERUSER_LOGIN_SELECTORS,
config.admin_user,
label="superuser login",
)
_fill_required_input(
page,
SUPERUSER_PASSWORD_SELECTORS,
config.admin_password,
label="superuser password",
)
_fill_optional_input(
page, SUPERUSER_PASSWORD_REPEAT_SELECTORS, config.admin_password
)
_fill_required_input(
page,
SUPERUSER_EMAIL_SELECTORS,
config.admin_email,
label="superuser email",
)
_page_warnings(page)
submit_loc, submit_label = _first_present_css_locator(
page, SUPERUSER_SUBMIT_SELECTORS, timeout_s=0.5
)
@@ -875,10 +977,10 @@ class WebInstaller(Installer):
superuser_progress_deadline = time.time() + INSTALLER_STEP_TIMEOUT_S
while time.time() < superuser_progress_deadline:
_wait_dom_settled(page)
if not _has_superuser_login_field(page):
if not _superuser_form_ready(page):
break
page.wait_for_timeout(300)
if _has_superuser_login_field(page):
if _superuser_form_ready(page):
_page_warnings(page)
raise RuntimeError(
"Superuser form submit did not progress to first website setup "

View File

@@ -28,6 +28,8 @@ class _StaticLocator:
def count(self) -> int:
if self._selector == "#login-0":
return 1 if self._page.login_visible else 0
if self._selector == "form#generalsetupform":
return 1 if getattr(self._page, "form_visible", False) else 0
if self._selector == "#siteName-0":
return 0
return 0
@@ -79,6 +81,7 @@ class _NoNextButLoginAppearsPage:
def __init__(self):
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
self.login_visible = False
self.form_visible = False
self._wait_calls = 0
def locator(self, selector: str):
@@ -129,10 +132,39 @@ class _NoNextButNamedLoginAppearsPage:
self.login_visible = True
class _NoNextButSuperuserFormContainerAppearsPage:
def __init__(self):
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
self.login_visible = False
self.form_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.form_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.form_visible = False
self._wait_calls = 0
self._reveal_after_wait_calls = reveal_after_wait_calls
@@ -160,6 +192,38 @@ class _DelayedSuperuserLoginPage:
self.login_visible = True
class _DelayedSuperuserFormContainerPage:
def __init__(self, *, reveal_after_wait_calls: int | None):
self.url = "http://matomo/index.php?action=setupSuperUser&module=Installation"
self.login_visible = False
self.form_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.form_visible = True
class TestWebInstallerLocatorCountIntegration(unittest.TestCase):
def test_retries_transient_navigation_error(self) -> None:
locator = _FlakyLocator(
@@ -203,6 +267,14 @@ class TestWebInstallerLocatorCountIntegration(unittest.TestCase):
self.assertEqual(step, "Installation:setupSuperUser")
self.assertTrue(page.login_visible)
def test_click_next_wait_treats_superuser_form_container_as_progress(self) -> None:
page = _NoNextButSuperuserFormContainerAppearsPage()
step = _click_next_with_wait(page, timeout_s=1)
self.assertEqual(step, "Installation:setupSuperUser")
self.assertTrue(page.form_visible)
def test_wait_for_superuser_login_field_allows_delayed_form(self) -> None:
page = _DelayedSuperuserLoginPage(reveal_after_wait_calls=4)
@@ -215,6 +287,18 @@ class TestWebInstallerLocatorCountIntegration(unittest.TestCase):
self.assertTrue(visible)
self.assertTrue(page.login_visible)
def test_wait_for_superuser_login_field_allows_delayed_form_container(self) -> None:
page = _DelayedSuperuserFormContainerPage(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.form_visible)
def test_wait_for_superuser_login_field_times_out_when_absent(self) -> None:
page = _DelayedSuperuserLoginPage(reveal_after_wait_calls=None)