From bec92659b1d84f5af7883a37b099bac5c14f05c6 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Thu, 28 May 2026 04:40:12 +0100 Subject: [PATCH] =?UTF-8?q?feat(2):=20Q0.3/Q1.1=20=E2=80=94=20custom-html?= =?UTF-8?q?=20PARITY=20+=20functional=20+=20playwright=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/custom-html/PARITY.md: parity mapping (health_check.py ported); recipe-specific tests recorded with rationale; backup data-integrity + playwright sections. - tests/custom-html/functional/test_health_check.py: parity port of recipe-info/custom-html/tests/health_check.py — SOURCE comment included. - tests/custom-html/functional/test_content_roundtrip.py: NEW recipe-specific — write a marker into the served volume, fetch over HTTPS, assert exact bytes. - tests/custom-html/functional/test_content_type_header.py: NEW recipe-specific — prove nginx returns text/html for .html and text/plain for .txt (MIME mapping). - tests/custom-html/playwright/test_browser_smoke.py: P6 browser smoke (renders HTML, no console errors). Standalone Phase-2 custom-stage version. Verified cold on cc-ci (STAGES=install,custom): 5 assertions all PASS in one run (install generic + install overlay + content roundtrip + content type + health check + browser smoke), deploy-count=1. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/custom-html/PARITY.md | 43 +++++++++++++ .../functional/test_content_roundtrip.py | 54 ++++++++++++++++ .../functional/test_content_type_header.py | 61 +++++++++++++++++++ .../functional/test_health_check.py | 28 +++++++++ .../playwright/test_browser_smoke.py | 37 +++++++++++ 5 files changed, 223 insertions(+) create mode 100644 tests/custom-html/PARITY.md create mode 100644 tests/custom-html/functional/test_content_roundtrip.py create mode 100644 tests/custom-html/functional/test_content_type_header.py create mode 100644 tests/custom-html/functional/test_health_check.py create mode 100644 tests/custom-html/playwright/test_browser_smoke.py diff --git a/tests/custom-html/PARITY.md b/tests/custom-html/PARITY.md new file mode 100644 index 0000000..870c877 --- /dev/null +++ b/tests/custom-html/PARITY.md @@ -0,0 +1,43 @@ +# Parity — custom-html + +Phase-2 P2 mapping table: every `references/recipe-maintainer/recipe-info/custom-html/tests/*.py` has +a comparable cc-ci test under `tests/custom-html/functional/`, asserting the **same thing** (not just +a renamed file). The Adversary cold-verifies parity by reading the source `recipe-info/` and the +cc-ci file side-by-side. + +| recipe-maintainer file | cc-ci file | what's verified | status | +|---|---|---|---| +| `recipe-info/custom-html/tests/health_check.py` | `tests/custom-html/functional/test_health_check.py` | The app is reachable over HTTPS and returns a successful response (the original asserted HTTP 200 against a persistent instance). The cc-ci port preserves the assertion shape — non-5xx status — and adapts to the ephemeral per-run domain via the `live_app` fixture. | **ported** | + +## Recipe-specific tests (Phase-2 P3, ≥2 beyond parity) + +custom-html is an nginx serving the `/usr/share/nginx/html` volume — its characteristic behavior is +**serving / persisting static content** (see plan §4.3 "custom-html — serve/persist content: write +content, fetch it back"). Two new functional tests beyond parity: + +| cc-ci file | what's verified | rationale | +|---|---|---| +| `tests/custom-html/functional/test_content_roundtrip.py` | Writes a uniquely-marked content file to the served volume via `lifecycle.exec_in_app` and asserts an HTTPS GET to the corresponding path returns that exact byte content — proves the app serves files written into its served volume, not a static synthetic page. | The recipe IS a content-server: a roundtrip is the canonical proof it works for what it's for. | +| `tests/custom-html/functional/test_content_type_header.py` | Writes both an `.html` and a `.txt` marker to the served volume, fetches each, and asserts `Content-Type` reflects the file type (`text/html`, `text/plain`) — proves nginx is properly serving with MIME-typed responses, not just returning bytes. | Distinctive nginx-served behavior — distinguishes a working nginx from a misconfigured one that emits everything as `application/octet-stream`. | + +Both tests run in the **custom** stage against the same `live_app` shared deployment as the +lifecycle overlays — no extra deploy, no extra teardown. + +## Backup data-integrity (P4) + +Already exercised by the lifecycle overlays from Phase 1d/1e: +`tests/custom-html/test_backup.py` + `test_restore.py` + `ops.py` (`pre_backup` seeds `original`, +`pre_restore` mutates to `mutated`; restore must return the volume to `original`). The marker is read +via `lifecycle.exec_in_app` (volume-direct, immune to the post-backup serving race). + +## Playwright (P6) + +`tests/custom-html/playwright/test_browser_smoke.py` covers the browser-rendered nginx HTML — already +exercised inline by `tests/custom-html/test_install.py::test_serving_and_content` (lifecycle install +overlay), which uses Playwright Chromium to confirm the page renders. The Phase-2 split file is the +canonical home for browser-flow coverage and is invoked by the **custom** stage. + +## Non-ports + +None — the recipe-maintainer custom-html `tests/` directory contains only `health_check.py`, which +is fully ported above. diff --git a/tests/custom-html/functional/test_content_roundtrip.py b/tests/custom-html/functional/test_content_roundtrip.py new file mode 100644 index 0000000..c3a8b5f --- /dev/null +++ b/tests/custom-html/functional/test_content_roundtrip.py @@ -0,0 +1,54 @@ +"""custom-html — recipe-specific functional test (Phase 2 P3, ≥2 beyond parity). + +The recipe IS a content server (nginx serving /usr/share/nginx/html). Its characteristic behavior is +**serve persisted content**, not "returns 200". So write a uniquely-marked file into the served +volume and fetch it back over HTTPS — proves the app serves what's in the volume, not a hardcoded +default page. + +Runs in the custom tier against the shared post-install deployment (live_app). No deploy/teardown. +""" + +from __future__ import annotations + +import os +import sys +import uuid + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http, lifecycle # noqa: E402 + + +def test_content_roundtrip(live_app): + """Write a uniquely-marked .txt file into nginx's served volume, fetch it over HTTPS, assert the + exact bytes round-trip. Non-vacuous: a stale page or misrouted backend would not return our + randomly-generated content.""" + marker = f"ccci-roundtrip-{uuid.uuid4().hex}" + # written into the served volume; nginx routes / to /usr/share/nginx/html/ + filename = f"ccci-roundtrip-{uuid.uuid4().hex[:12]}.txt" + lifecycle.exec_in_app( + live_app, + ["sh", "-c", f"printf %s {marker} > /usr/share/nginx/html/{filename}"], + ) + + url = f"https://{live_app}/{filename}" + # short retry: nginx serves the file off the live volume as soon as it lands; the retry is + # belt-and-suspenders against any FS-cache latency. + deadline_wait = 15 + last_status, last_body = 0, "" + for _ in range(deadline_wait): + last_status, last_body = lifecycle.http_fetch(live_app, f"/{filename}", timeout=10) + if last_status == 200 and last_body == marker: + break + import time + + time.sleep(1) + assert last_status == 200, f"GET {url} -> HTTP {last_status} (expected 200)" + assert last_body == marker, ( + f"GET {url} returned {last_body!r}, expected {marker!r} — " + "the served volume did not round-trip" + ) + # Also prove it via the harness_http helper (consistent with Phase-2 canonical API) + status, parsed = harness_http.http_get(url) + assert status == 200 + # plain text, not JSON — parsed should be None (http_get only returns json_or_None) + assert parsed is None or parsed == marker diff --git a/tests/custom-html/functional/test_content_type_header.py b/tests/custom-html/functional/test_content_type_header.py new file mode 100644 index 0000000..64c2fd9 --- /dev/null +++ b/tests/custom-html/functional/test_content_type_header.py @@ -0,0 +1,61 @@ +"""custom-html — recipe-specific functional test (Phase 2 P3, ≥2 beyond parity). + +nginx maps file extensions to MIME types in its default mime.types config. A working serve emits +`Content-Type: text/html` for `.html` and `text/plain` for `.txt`. A broken/misconfigured serve +(e.g. nginx falling back to `application/octet-stream`, or a wrong mime.types include) would not. +This distinguishes a real working nginx from a generic 200-returner. + +Runs in the custom tier against the shared post-install deployment. +""" + +from __future__ import annotations + +import os +import ssl +import sys +import urllib.request +import uuid + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + + +def _head(url: str) -> tuple[int, dict[str, str]]: + """HEAD the URL (no body), return (status, headers).""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + req = urllib.request.Request(url, method="GET") # nginx default config may not allow HEAD on /, + # but per-file GET always returns headers; we read 0 bytes anyway. + with urllib.request.urlopen(req, timeout=15, context=ctx) as resp: + return resp.status, {k.lower(): v for k, v in resp.getheaders()} + + +def test_content_type_html_and_txt(live_app): + """Write an .html file AND a .txt file into the served volume, fetch each, assert nginx returns + the right MIME type for each. Non-vacuous: both files exist with the same bytes — only the + extension/MIME mapping distinguishes them; a broken MIME config would fail this even if 200.""" + html_name = f"ccci-{uuid.uuid4().hex[:8]}.html" + txt_name = f"ccci-{uuid.uuid4().hex[:8]}.txt" + body = "hello" + for name in (html_name, txt_name): + lifecycle.exec_in_app( + live_app, ["sh", "-c", f"printf %s {body} > /usr/share/nginx/html/{name}"] + ) + + s_html, h_html = _head(f"https://{live_app}/{html_name}") + s_txt, h_txt = _head(f"https://{live_app}/{txt_name}") + + assert s_html == 200, f"html file status {s_html}" + assert s_txt == 200, f"txt file status {s_txt}" + + ct_html = h_html.get("content-type", "") + ct_txt = h_txt.get("content-type", "") + + # nginx default: "text/html" for .html and "text/plain" for .txt (may include "; charset=utf-8") + assert ct_html.startswith("text/html"), ( + f"{html_name} Content-Type={ct_html!r}, expected text/html (nginx MIME config broken?)" + ) + assert ct_txt.startswith("text/plain"), ( + f"{txt_name} Content-Type={ct_txt!r}, expected text/plain (nginx MIME config broken?)" + ) diff --git a/tests/custom-html/functional/test_health_check.py b/tests/custom-html/functional/test_health_check.py new file mode 100644 index 0000000..27c7623 --- /dev/null +++ b/tests/custom-html/functional/test_health_check.py @@ -0,0 +1,28 @@ +"""custom-html — parity port of recipe-maintainer's health_check.py (Phase 2 P2). + +SOURCE: references/recipe-maintainer/recipe-info/custom-html/tests/health_check.py + +The recipe-maintainer original asserted `HTTP 200` from `https://custom-html.`. The +cc-ci port preserves that assertion shape (the served response is a real, non-error HTTP) but adapts +to the ephemeral per-run domain via the `live_app` fixture from `tests/conftest.py`. It runs in the +**custom** tier against the shared post-install live deployment — no deploy/teardown here. +""" + +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_custom_html_returns_200(live_app): + """Parity with recipe-info/custom-html/tests/health_check.py: HTTP 200 from the app root. + + Uses harness.http.retry_http_get so the cc-ci test is tolerant of any brief reconverge after the + deploy (the orchestrator already waited for services_converged + ok status, so this should be + immediate; the short retry is a safety net only).""" + url = f"https://{live_app}/" + status, _ = harness_http.retry_http_get(url, expect_status=200, max_wait=30, interval=2) + assert status == 200, f"custom-html at {url} returned HTTP {status} (expected 200)" diff --git a/tests/custom-html/playwright/test_browser_smoke.py b/tests/custom-html/playwright/test_browser_smoke.py new file mode 100644 index 0000000..5248c06 --- /dev/null +++ b/tests/custom-html/playwright/test_browser_smoke.py @@ -0,0 +1,37 @@ +"""custom-html — Playwright UI flow (Phase 2 P6). + +The recipe-maintainer corpus did not ship a Playwright test for custom-html — but plan §4.1 names +`playwright/` as the canonical home for browser flows where a recipe's core UX is a UI. custom-html +serves HTML; a browser-rendered fetch (vs raw HTTP) proves the page actually renders and any client- +side resources resolve. Distinct from `tests/custom-html/test_install.py` which runs Playwright as +part of the lifecycle INSTALL overlay; this file is the standalone Phase-2 custom-stage version, so a +later non-lifecycle browser flow (e.g. a content-management UI) has its home already. +""" + +from __future__ import annotations + + +def test_browser_renders_html(live_app): + """Browser-render the served root page and assert the HTML loads with no console errors.""" + from playwright.sync_api import sync_playwright + + url = f"https://{live_app}/" + with sync_playwright() as p: + browser = p.chromium.launch(args=["--no-sandbox"]) + try: + context = browser.new_context(ignore_https_errors=True) + page = context.new_page() + console_errors: list[str] = [] + page.on( + "console", + lambda msg: console_errors.append(msg.text) if msg.type == "error" else None, + ) + resp = page.goto(url, wait_until="load", timeout=30_000) + assert resp is not None and resp.status == 200, f"page status {resp and resp.status}" + html = page.content() + assert "