feat(2): Q0.3/Q1.1 — custom-html PARITY + functional + playwright (Phase 2)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 04:40:12 +01:00
parent 0d0fc6c4bc
commit bec92659b1
5 changed files with 223 additions and 0 deletions

View File

@ -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/<file>` 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.

View File

@ -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 /<filename> to /usr/share/nginx/html/<filename>
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

View File

@ -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?)"
)

View File

@ -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.<DOMAIN_SUFFIX>`. 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)"

View File

@ -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 "<html" in html.lower(), "page did not render an HTML document"
# nginx default page contains "nginx" in markup; either custom HTML or default works,
# but BOTH should be served as actual HTML — caught above.
assert not console_errors, f"browser logged console errors: {console_errors}"
finally:
browser.close()