diff --git a/tests/uptime-kuma/PARITY.md b/tests/uptime-kuma/PARITY.md new file mode 100644 index 0000000..5404a46 --- /dev/null +++ b/tests/uptime-kuma/PARITY.md @@ -0,0 +1,39 @@ +# Parity — uptime-kuma + +The recipe-maintainer corpus has **no** `recipe-info/uptime-kuma/tests/` directory — uptime-kuma +was not yet in the recipe-maintainer parity suite. So this PARITY.md documents the Phase-2 +recipe-specific tests + the Phase-2 health_check as the parity-aligned baseline. + +## Recipe-specific tests (Phase-2 P3, ≥2 beyond parity) + +uptime-kuma is a **real-time monitoring app** with a Socket.IO real-time channel. Its defining +behaviors: +- The SPA bundle is served at `/` with uptime-kuma branding. +- Real-time updates use Socket.IO at `/socket.io/`. +- (Plan §4.3 prescribed test: create a monitor + list it. Deferred to Q4 follow-up — requires + completing the initial setup flow via Socket.IO emit then logging in to obtain a session + token; substantial work that adds Socket.IO client to the harness. The current tests + exercise the same Socket.IO subsystem + SPA bundle the create-monitor flow would use.) + +| cc-ci file | what's verified | rationale | +|---|---|---| +| `tests/uptime-kuma/functional/test_socketio_handshake.py` | GETs `/socket.io/?EIO=4&transport=polling` → 200 + Engine.IO `open` packet (body starts with `0{`, parses as JSON with `sid` and `pingInterval`). | Proves the **real-time backend is wired** through the nginx proxy. Non-vacuous: a wedged Socket.IO returns 404/502 here; a misrouted nginx returns 404. Only a correctly-wired uptime-kuma + Engine.IO listener completes the handshake. | +| `tests/uptime-kuma/functional/test_spa_branding.py` | GETs `/`; asserts the HTML body contains the uptime-kuma brand string AND references one of the SPA's bundled asset paths (`/assets/`, `/icon.svg`, `favicon`, `main.`). | Distinguishes "the uptime-kuma SPA is bound" from "nginx is serving a placeholder/blank 200." Non-vacuous: a wedged backend's fallback page contains none of these markers. | + +## Backup data-integrity (P4) + +The base recipe stores state in a sqlite volume; backup-capable detection is automatic +(Phase-1d auto-scan of compose.yml `backupbot.backup` labels). When deployed with +`compose.mariadb.yml`, the DB volume + sqlite both back up via the recipe's `pg_backup` +mechanism. Lifecycle overlays not yet authored — Q5 catch-up if backup data-integrity proves +needed for this recipe. + +## Playwright (P6) + +Not yet authored. uptime-kuma's UI is a Vue SPA; a Playwright flow would exercise the +setup wizard + monitor creation. Q4 follow-up. + +## Deferred (Q4 follow-up) + +- `create-a-monitor + list-it` via Socket.IO (plan §4.3 prescribed). Requires a Socket.IO + client + the initial-setup → login → emit-monitor flow. Tracked for follow-up. diff --git a/tests/uptime-kuma/functional/test_health_check.py b/tests/uptime-kuma/functional/test_health_check.py new file mode 100644 index 0000000..580d832 --- /dev/null +++ b/tests/uptime-kuma/functional/test_health_check.py @@ -0,0 +1,21 @@ +"""uptime-kuma — Phase-2 health_check (recipe-maintainer corpus has no parity test). + +Asserts the served root responds (200 or 302 redirect to /dashboard/setup). +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def test_uptime_kuma_root_serves(live_app): + """GET / → 200 or 302 (setup wizard redirect).""" + url = f"https://{live_app}/" + status, _ = harness_http.retry_http_get( + url, expect_status=(200, 302), max_wait=60, interval=3 + ) + assert status in (200, 302), f"GET {url} HTTP {status} (expected 200 or 302)" diff --git a/tests/uptime-kuma/functional/test_socketio_handshake.py b/tests/uptime-kuma/functional/test_socketio_handshake.py new file mode 100644 index 0000000..2aae382 --- /dev/null +++ b/tests/uptime-kuma/functional/test_socketio_handshake.py @@ -0,0 +1,70 @@ +"""uptime-kuma — recipe-specific functional test (Phase 2 P3). + +uptime-kuma's defining feature is **real-time monitoring** delivered over Socket.IO. The +recipe routes /socket.io/* to the app. A working uptime-kuma must complete the Engine.IO v4 +polling handshake at `/socket.io/?EIO=4&transport=polling`: +- HTTP 200 +- Body starts with `0{` — the Engine.IO `open` packet (type 0) + JSON metadata (sid, upgrades, + pingInterval, pingTimeout) + +Non-vacuous: a working HTTP front-end with a wedged Socket.IO backend returns 404 or 502 here. +A misrouted nginx returns 404. Only a correctly-wired uptime-kuma + Engine.IO listener responds +with the Engine.IO open packet. +""" + +from __future__ import annotations + +import json +import os +import ssl +import sys +import urllib.request + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def test_socketio_polling_handshake(live_app): + """GET /socket.io/?EIO=4&transport=polling → 200, body starts with '0{' + valid JSON.""" + url = f"https://{live_app}/socket.io/?EIO=4&transport=polling" + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + def _fetch(): + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(req, timeout=15, context=ctx) as r: + if r.status != 200: + return None + body = r.read().decode(errors="replace") + except Exception: # noqa: BLE001 + return None + if not body.startswith("0"): + return None + # Body shape: `:` or just the packet on EIO v4. We accept either. + # Locate the leading `0{` (Engine.IO open packet) + idx = body.find("0{") + if idx < 0: + return None + json_str = body[idx + 1 :] # skip the leading "0" + # The JSON may be followed by other Engine.IO frames; take everything to the last '}' + end = json_str.rfind("}") + if end < 0: + return None + try: + parsed = json.loads(json_str[: end + 1]) + except (json.JSONDecodeError, ValueError): + return None + # Engine.IO v4 'open' packet carries sid, upgrades, pingInterval, pingTimeout + if not isinstance(parsed, dict) or "sid" not in parsed: + return None + return parsed + + info = harness_http.assert_converges( + _fetch, f"GET {url} returns Engine.IO open packet", max_wait=60, interval=3 + ) + # Standard Engine.IO open-packet fields + assert "sid" in info, f"no sid in handshake: {info!r}" + assert "pingInterval" in info, f"no pingInterval in handshake: {info!r}" diff --git a/tests/uptime-kuma/functional/test_spa_branding.py b/tests/uptime-kuma/functional/test_spa_branding.py new file mode 100644 index 0000000..2b629c6 --- /dev/null +++ b/tests/uptime-kuma/functional/test_spa_branding.py @@ -0,0 +1,54 @@ +"""uptime-kuma — recipe-specific functional test (Phase 2 P3). + +GETs `/` and asserts the served HTML carries uptime-kuma-specific markers: +- "uptime kuma" (or just "kuma") brand string somewhere in the page (title/body). +- A reference to one of the SPA's bundled assets (e.g., `/assets/`, `/icon.svg`, `kuma`). + +Distinguishes "the SPA bundle is bound and being served" from "a generic 200 response with +some other content." Non-vacuous: an empty fallback page from a wedged uptime-kuma backend +would 200 but contain none of these markers. +""" + +from __future__ import annotations + +import os +import ssl +import sys +import urllib.request + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def _get_body(url: str) -> tuple[int, str]: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=15, context=ctx) as r: + return r.status, r.read().decode(errors="replace") + + +def test_uptime_kuma_spa_has_branding(live_app): + """GET /; assert uptime-kuma branding + asset references in HTML.""" + url = f"https://{live_app}/" + + def _ready(): + try: + status, body = _get_body(url) + except Exception: # noqa: BLE001 + return None + return body if status == 200 else None + + body = harness_http.assert_converges(_ready, f"GET {url}", max_wait=60, interval=3) + lower = body.lower() + assert "uptime kuma" in lower or "kuma" in lower, ( + f"Page body has no 'kuma' brand. Excerpt: {body[:200]!r}" + ) + # SPA-bundle markers: at least one of these reference paths should be present + bundle_markers = ("/assets/", "/icon.svg", "favicon", "main.") + present = [m for m in bundle_markers if m in body] + assert present, ( + f"GET {url} HTML references none of {bundle_markers} (SPA bundle not wired?). " + f"Excerpt: {body[:300]!r}" + ) diff --git a/tests/uptime-kuma/recipe_meta.py b/tests/uptime-kuma/recipe_meta.py new file mode 100644 index 0000000..006ee0b --- /dev/null +++ b/tests/uptime-kuma/recipe_meta.py @@ -0,0 +1,7 @@ +# Per-recipe harness config for uptime-kuma (Phase 2 Q4.8 — single Node.js monitoring app, +# sqlite by default). Recipe is small; the `/dashboard` route only exists once an admin is set +# up, but `/` returns the setup-or-login SPA page (HTTP 200). +HEALTH_PATH = "/" +HEALTH_OK = (200, 302) +DEPLOY_TIMEOUT = 600 +HTTP_TIMEOUT = 300