166 lines
8.5 KiB
Python
166 lines
8.5 KiB
Python
"""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()
|