"""uptime-kuma — §4.3 prescribed: wizard + create-a-monitor + real probe assertion. Phase kuma: resolves the DEFERRED.md entry "2026-05-28 — uptime-kuma create-a-monitor". Approach: **Playwright (option b)** — python-socketio is NOT in the cc-ci harness environment (Nix site-packages: playwright + pytest only; no socketio wheel); Playwright drives the real browser which handles Socket.IO automatically, is version-robust via stable `data-cy` / `data-testid` attributes confirmed present in the 2.2.1 compiled bundle, and reuses the existing harness browser stack (no Nix changes needed). Justification recorded in DECISIONS.md. Test flow (§4.3 contract): 1. Complete first-run setup wizard (admin create) via the real browser UI. 2. Create an HTTP monitor whose target the harness controls: the app's own root URL (`https://{live_app}/`) — guaranteed reachable since the deploy already passed its health gate before this test tier runs. 3. Wait ≤90 s for the first heartbeat to arrive and report **Up** (uptime-kuma probes immediately on monitor creation; typical first-result time is < 15 s). 4. Assert `data-testid="monitor-status"` text is "Up" AND the important-heartbeat table has at least one row with a real datetime stamp (proves a probe ran, not a config echo). 5. **Negative teeth:** create a second monitor targeting `http://127.0.0.1:19999/dead` (a port guaranteed to refuse connections inside the container → instant DOWN). Wait ≤60 s; assert status is "Down". This proves the probe engine actually makes outbound checks and is not a stub. Runtime budget: connection-refused completes in < 1 s, so the DOWN detection is fast. Runtime budget: wizard ~10 s, two monitor create+wait cycles ≤90 s total. Well within the ≤ ~90 s added-to-functional-tier target from the phase plan. Secret safety: admin password generated per-run as a 64-char UUID hex string; it is NEVER printed, logged, or included in assertion messages. The fixture uses `_pw` (leading underscore convention) and no error path includes its value. """ from __future__ import annotations import os import re import sys import uuid sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) from harness import browser as harness_browser # noqa: E402 # Pattern for uptime-kuma's datetime format ("YYYY-MM-DD HH:mm:ss") rendered by its # Datetime.vue component (format confirmed from mixin source — datetimeFormat calls # dayjs with "YYYY-MM-DD HH:mm:ss"). _DATETIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}") _SETUP_TIMEOUT_MS = 30_000 # wizard + auto-login settle _FORM_TIMEOUT_MS = 15_000 # form element wait _PROBE_UP_MS = 90_000 # first UP probe (typically < 15 s, but bound generously) _PROBE_DOWN_MS = 60_000 # connection-refused DOWN (typically < 5 s) _DETAIL_URL_MS = 20_000 # wait for /dashboard/:id after Save def _wait_for_status(page, expected_text: str, timeout_ms: int) -> None: """Poll the monitor-status badge until its text matches expected_text. Uses page.wait_for_function so the wait is driven by Playwright's internal event loop (no sleep), bounded by timeout_ms. Raises on timeout. """ escaped = expected_text.replace('"', '\\"') page.wait_for_function( f"() => {{" f" const el = document.querySelector('[data-testid=\"monitor-status\"]');" f' return el && el.textContent.trim() === "{escaped}";' f"}}", timeout=timeout_ms, ) def _fill_monitor_form(page, name: str, url: str) -> None: """Fill the EditMonitor form (at /add) and click Save. Waits for the friendly-name input to be ready (signals the form rendered). Uses stable data-testid attributes confirmed in the 2.2.1 compiled bundle. """ page.wait_for_selector('[data-testid="friendly-name-input"]', timeout=_FORM_TIMEOUT_MS) page.locator('[data-testid="friendly-name-input"]').fill(name) # url-input: HTTP(s) type is the default; the URL field (id="url") has data-testid="url-input" page.locator('[data-testid="url-input"]').first.fill(url) page.locator('[data-testid="save-button"]').click() def test_monitor_wizard_and_probe(live_app): """Setup wizard → create self-probe monitor (→ Up) + dead-port monitor (→ Down).""" from playwright.sync_api import sync_playwright # Per-run admin credentials. Password is 64 hex chars and NEVER logged. _admin_user = "admin" _pw = uuid.uuid4().hex + uuid.uuid4().hex # noqa: S311 — not a crypto secret, test-only base = f"https://{live_app}" with sync_playwright() as p: browser = p.chromium.launch(args=["--no-sandbox"]) try: ctx = browser.new_context(ignore_https_errors=True) page = ctx.new_page() # ── 1. Setup wizard ────────────────────────────────────────────────── # Navigate to root; uptime-kuma 2.x redirects to /setup on first boot. harness_browser.goto_with_retry( page, f"{base}/", accept_statuses=(200,), wait_until="domcontentloaded", deadline_seconds=60, ) # Wait for the setup form (data-cy confirmed in 2.2.1 bundle). page.wait_for_selector('[data-cy="username-input"]', timeout=_SETUP_TIMEOUT_MS) page.locator('[data-cy="username-input"]').fill(_admin_user) page.locator('[data-cy="password-input"]').fill(_pw) page.locator('[data-cy="password-repeat-input"]').fill(_pw) page.locator('[data-cy="submit-setup-form"]').click() # After Create: uptime-kuma auto-logs in and pushes router to "/" → Entry.vue # redirects to /dashboard. Wait for the dashboard URL to settle. page.wait_for_url(f"{base}/dashboard", timeout=_SETUP_TIMEOUT_MS) # ── 2. Create monitor: self-probe (target = app root → expect Up) ── page.goto(f"{base}/add", wait_until="domcontentloaded") _fill_monitor_form(page, "kuma-self-probe", f"{base}/") # After Save, router navigates to /dashboard/:id (monitor detail page). page.wait_for_url(f"{base}/dashboard/**", timeout=_DETAIL_URL_MS) # ── 3. Wait for Up status (first probe fires immediately on creation) ── _wait_for_status(page, "Up", _PROBE_UP_MS) # ── 4. Verify a real heartbeat record exists (proves a probe ran) ──── # The important-heartbeat table (Details.vue) shows all beats where # `important=true`; the server marks the FIRST beat as important # (isFirstBeat logic, server/model/monitor.js line 1420). So after the # first probe completes there will be exactly one row with a datetime stamp. heartbeat_rows = page.locator("table.table-hover tbody tr") heartbeat_rows.first.wait_for(timeout=_FORM_TIMEOUT_MS) first_row_text = heartbeat_rows.first.text_content(timeout=5_000) or "" assert "No important events" not in first_row_text, ( "Heartbeat table shows 'No important events' — the self-probe may not " "have recorded a heartbeat yet" ) # The row must contain a real datetime stamp (format: "YYYY-MM-DD HH:mm:ss"). assert _DATETIME_RE.search(first_row_text), ( f"Heartbeat row has no datetime matching YYYY-MM-DD HH:mm:ss; " f"row text: {first_row_text!r}" ) # ── 5. Negative teeth: dead-port monitor (→ Down) ─────────────────── # http://127.0.0.1:19999/dead — port 19999 is unused inside the container; # the OS returns connection-refused instantly (<1 s), so DOWN arrives fast. page.goto(f"{base}/add", wait_until="domcontentloaded") _fill_monitor_form(page, "kuma-dead-probe", "http://127.0.0.1:19999/dead") page.wait_for_url(f"{base}/dashboard/**", timeout=_DETAIL_URL_MS) _wait_for_status(page, "Down", _PROBE_DOWN_MS) # Confirm the dead monitor shows Down (belt-and-suspenders). status_text = ( page.locator('[data-testid="monitor-status"]').text_content(timeout=5_000) or "" ).strip() assert status_text == "Down", f"Dead-port monitor expected 'Down', got {status_text!r}" finally: browser.close()