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:
43
tests/custom-html/PARITY.md
Normal file
43
tests/custom-html/PARITY.md
Normal 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.
|
||||
54
tests/custom-html/functional/test_content_roundtrip.py
Normal file
54
tests/custom-html/functional/test_content_roundtrip.py
Normal 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
|
||||
61
tests/custom-html/functional/test_content_type_header.py
Normal file
61
tests/custom-html/functional/test_content_type_header.py
Normal 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?)"
|
||||
)
|
||||
28
tests/custom-html/functional/test_health_check.py
Normal file
28
tests/custom-html/functional/test_health_check.py
Normal 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)"
|
||||
37
tests/custom-html/playwright/test_browser_smoke.py
Normal file
37
tests/custom-html/playwright/test_browser_smoke.py
Normal 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()
|
||||
Reference in New Issue
Block a user