From b0593ab43189a28fd69740fd888adb4759adf77b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 15 Feb 2026 14:54:12 +0100 Subject: [PATCH] Harden setupSuperUser installer readiness and submit fallbacks --- src/matomo_bootstrap/installers/web.py | 238 +++++++++++++----- .../test_web_installer_locator_count.py | 84 +++++++ 2 files changed, 254 insertions(+), 68 deletions(-) diff --git a/src/matomo_bootstrap/installers/web.py b/src/matomo_bootstrap/installers/web.py index 76ce5ad..bc71e50 100644 --- a/src/matomo_bootstrap/installers/web.py +++ b/src/matomo_bootstrap/installers/web.py @@ -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 " diff --git a/tests/integration/test_web_installer_locator_count.py b/tests/integration/test_web_installer_locator_count.py index 7502651..2c7f502 100644 --- a/tests/integration/test_web_installer_locator_count.py +++ b/tests/integration/test_web_installer_locator_count.py @@ -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)