Skip to content

Commit d5091df

Browse files
authored
Merge pull request #225 from rdhyee/smoke-gate-option-c
ci: pre-deploy smoke gate (Option C) so a JS-dead render can't reach isamples.org
2 parents 24dc057 + 91e0af9 commit d5091df

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

.github/workflows/quarto-pages.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ jobs:
5959
# individual page content in the web site is defined by various xxx.qmd files.
6060
run: |
6161
scripts/generate_vocab_docs.sh; quarto render
62+
63+
# Pre-deploy smoke gate (Option C). Loads the freshly-rendered
64+
# docs/ in a headless browser and asserts the explorer is
65+
# fundamentally alive (DuckDB-WASM inits, Cesium draws, a search
66+
# returns results, no uncaught JS error). Fail-closed: if this
67+
# step fails the job fails and the Deploy step below is skipped,
68+
# so a JS-dead render never reaches isamples.org.
69+
- name: Smoke test rendered site
70+
run: |
71+
pip install pytest playwright
72+
playwright install --with-deps chromium
73+
python -m http.server 8080 --directory docs &
74+
SERVER_PID=$!
75+
# Always reap the static server, even when pytest fails and
76+
# `bash -e` aborts the script (GitHub's default shell). The
77+
# non-zero exit still propagates -> step fails -> Deploy is
78+
# skipped (fail-closed).
79+
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
80+
# Wait for the static server to accept connections.
81+
for i in $(seq 1 30); do
82+
curl -sf http://localhost:8080/explorer.html >/dev/null && break
83+
sleep 1
84+
done
85+
ISAMPLES_BASE_URL=http://localhost:8080 \
86+
pytest tests/test_smoke.py -s -q
87+
6288
- name: Deploy 🚀
6389
# only deploy when push to main
6490
if: github.event_name != 'pull_request'

tests/test_smoke.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Pre-deploy smoke test (Option C) — the gate that catches a JS-dead render.
3+
4+
WHY THIS EXISTS
5+
---------------
6+
The deploy workflow runs `quarto render` and ships whatever `docs/` it
7+
produces. Neither Codex review nor `pytest --collect-only` ever *loads*
8+
the rendered page in a browser, so a render that "succeeds" but yields a
9+
page where DuckDB-WASM never inits, Cesium never draws, or search returns
10+
nothing has historically deployed to isamples.org anyway. This test closes
11+
exactly that gap: it is run in CI against the freshly-rendered `docs/`
12+
(served locally) *before* the Deploy step. If it fails, the job fails and
13+
the deploy never happens (fail-closed).
14+
15+
DESIGN CONSTRAINTS (learned the hard way)
16+
-----------------------------------------
17+
- ONE fresh context, ONE navigation, poll-for-readiness. Hammering the
18+
page with rapid reloads exhausts the DuckDB-WASM worker and produces
19+
*false* failures — a test-harness artifact, not a real break. Never
20+
add a reload loop here.
21+
- Assert only on unambiguous "fundamentally alive" signals so a benign
22+
console warning can't block a deploy: DuckDB-WASM inits, Cesium draws,
23+
a search returns results, and no *uncaught* JS exception fired.
24+
- Self-contained: does NOT import the slow CANONICAL_QUERIES benchmark
25+
from test_search_perf.py. This must stay fast (well under a minute).
26+
27+
Run locally against the rendered output:
28+
29+
cd docs && python -m http.server 8080 &
30+
ISAMPLES_BASE_URL=http://localhost:8080 pytest tests/test_smoke.py -s
31+
"""
32+
import re
33+
34+
import pytest
35+
from conftest import SITE_URL
36+
37+
EXPLORER_URL = f"{SITE_URL}/explorer.html?perf=1"
38+
39+
# DuckDB-WASM is "alive" once it has run the facet query and written a
40+
# numeric count into the SESAR source facet. Same proxy the perf test
41+
# uses for "ready to search".
42+
_READY_JS = """() => {
43+
const el = document.querySelector(
44+
".facet-count[data-facet='source'][data-value='SESAR']"
45+
);
46+
return el && /\\(\\d/.test(el.textContent || '');
47+
}"""
48+
49+
# High-signal regression fingerprints. We do NOT fail on every console
50+
# error (benign third-party noise would block deploys); we DO fail on
51+
# uncaught exceptions (pageerror) and on these specific "the JS broke"
52+
# strings, which are what an OJS/scope/undefined-symbol regression emits.
53+
_FATAL_CONSOLE = re.compile(
54+
r"is not defined|is not a function|Cannot read propert|"
55+
r"Uncaught|SyntaxError|ReferenceError",
56+
re.IGNORECASE,
57+
)
58+
59+
60+
def test_explorer_smoke(browser):
61+
"""Fundamental-liveness gate for explorer.html. Fail-closed in CI."""
62+
context = browser.new_context(viewport={"width": 1280, "height": 900})
63+
page = context.new_page()
64+
65+
page_errors: list[str] = []
66+
fatal_console: list[str] = []
67+
page.on("pageerror", lambda e: page_errors.append(str(e)))
68+
69+
def _on_console(msg):
70+
# Only treat *same-origin* console errors as fatal. A third-party
71+
# script (Cesium CDN, an injected extension) emitting a string that
72+
# matches the regex must not block a deploy — pageerror remains the
73+
# unambiguous hard signal for uncaught app exceptions.
74+
if msg.type != "error" or not _FATAL_CONSOLE.search(msg.text or ""):
75+
return
76+
src = (msg.location or {}).get("url", "") or ""
77+
if src.startswith(SITE_URL):
78+
fatal_console.append(f"{msg.text} @{src}")
79+
80+
page.on("console", _on_console)
81+
82+
try:
83+
# Single navigation. ?perf=1 matches what the perf test / users hit.
84+
page.goto(EXPLORER_URL, wait_until="domcontentloaded", timeout=60_000)
85+
86+
# 1. DuckDB-WASM initialized (facet query ran). Poll, do not reload.
87+
page.wait_for_function(_READY_JS, timeout=90_000)
88+
89+
# 2. Cesium actually drew a globe: canvas attached AND laid out
90+
# with non-zero dimensions. A 0x0 canvas means the widget
91+
# mounted but the globe never sized/rendered (the "container
92+
# but no globe" failure). A full pixel-readback assertion is
93+
# deliberately avoided — flaky timing, not worth the false-fail
94+
# risk for a liveness gate.
95+
page.wait_for_selector(
96+
".cesium-viewer .cesium-widget canvas",
97+
state="attached",
98+
timeout=30_000,
99+
)
100+
canvas_box = page.evaluate(
101+
"""() => {
102+
const c = document.querySelector(
103+
'.cesium-viewer .cesium-widget canvas');
104+
return c ? {w: c.clientWidth, h: c.clientHeight} : null;
105+
}"""
106+
)
107+
assert canvas_box and canvas_box["w"] > 0 and canvas_box["h"] > 0, (
108+
f"Cesium canvas has no dimensions: {canvas_box}"
109+
)
110+
111+
# 3. A world search via the *visible* slim-overlay submit button
112+
# returns results. "pottery" is a high-frequency term, so a
113+
# healthy build always returns >=1; zero/blank means broken
114+
# search wiring or a dead query path.
115+
search = page.locator("#sampleSearch")
116+
search.click()
117+
search.fill("pottery")
118+
page.locator("#searchSubmitBtn").click()
119+
page.wait_for_function(
120+
"""() => {
121+
const el = document.getElementById('searchResults');
122+
const t = (el && el.textContent || '').trim();
123+
return t && !/Searching/i.test(t) && /result/i.test(t);
124+
}""",
125+
# Aligned with the perf test's 90s search budget — a cold
126+
# DuckDB-WASM query + remote parquet fetch on a slow CI
127+
# runner can exceed 60s without the build being broken.
128+
timeout=90_000,
129+
)
130+
results_text = page.locator("#searchResults").inner_text().strip()
131+
132+
# 4. No uncaught JS exception and no regression-fingerprint
133+
# console error fired during the whole flow.
134+
assert not page_errors, f"Uncaught JS exception(s): {page_errors}"
135+
assert not fatal_console, f"Fatal console error(s): {fatal_console}"
136+
137+
# Sanity: the result line must actually carry a count.
138+
assert re.search(r"\d", results_text), (
139+
f"Search returned no countable results: {results_text!r}"
140+
)
141+
print(f"SMOKE OK — search result: {results_text!r}")
142+
finally:
143+
context.close()

0 commit comments

Comments
 (0)