Skip to content

Commit 4073099

Browse files
authored
ci: play every docs demo tour in a browser at phone and desktop width (#165)
1 parent 3d77468 commit 4073099

5 files changed

Lines changed: 164 additions & 2 deletions

File tree

‎.github/workflows/_checks.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ jobs:
2323
- uses: actions/checkout@v6
2424
- uses: extractions/setup-just@v4
2525
- uses: astral-sh/setup-uv@v8.2.0
26-
- run: just docs-build
26+
- run: just docs-demos-browser
27+
- run: just docs-demos
2728

2829
pytest:
2930
runs-on: ubuntu-latest

‎justfile‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,13 @@ publish:
4747
# Build the docs site, failing on broken links / nav warnings; CI runs this on every PR.
4848
docs-build:
4949
uvx --with-requirements docs/requirements.txt mkdocs build --strict
50+
51+
playwright := "playwright==1.64.0"
52+
53+
# Install the Chromium that docs-demos drives; --with-deps adds its system libraries on Linux.
54+
docs-demos-browser:
55+
uvx --from {{ playwright }} playwright install --with-deps chromium
56+
57+
# Play every demo tour at phone and desktop width and fail on layout breakage or page errors.
58+
docs-demos: docs-build
59+
uvx --with {{ playwright }} python -I scripts/check_demos.py site

‎pyproject.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ isort.no-lines-before = ["standard-library", "local-folder"]
113113
"src/httpware/client.py" = ["ASYNC109", "ANN401"]
114114
"tests/**" = ["S101"]
115115

116+
[[tool.ty.overrides]]
117+
include = ["scripts/**"]
118+
rules = { unresolved-import = "ignore" }
119+
116120
[tool.pytest.ini_options]
117121
testpaths = ["tests"]
118122
addopts = ""
@@ -125,6 +129,6 @@ markers = [
125129

126130
[tool.coverage]
127131
run.concurrency = ["thread"]
128-
run.omit = ["benchmarks/*"]
132+
run.omit = ["benchmarks/*", "scripts/*"]
129133
report.exclude_also = ["if typing.TYPE_CHECKING:"]
130134
report.fail_under = 100

‎scripts/__init__.py‎

Whitespace-only changes.

‎scripts/check_demos.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Play every guided tour in the built docs demos and check their layout in a real browser.
2+
3+
Usage: `just docs-demos`, which builds the site and runs this against it.
4+
5+
At a phone and a desktop viewport, every demo page is checked on load, at every tour stop and
6+
after each run: the page must not scroll sideways, the coach card must sit fully on screen, the
7+
tour must reach every stop it announces, and the page must raise no errors.
8+
"""
9+
10+
import asyncio
11+
import functools
12+
import http.server
13+
import sys
14+
import threading
15+
from pathlib import Path
16+
17+
from playwright.async_api import Browser, Locator, Page, async_playwright
18+
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
19+
20+
21+
VIEWPORTS = {"phone": {"width": 375, "height": 740}, "desktop": {"width": 1280, "height": 800}}
22+
STOP_TIMEOUT_MS = 20_000
23+
24+
MEASURE_JS = """(coach) => {
25+
const de = document.documentElement;
26+
const out = { scrollWidth: de.scrollWidth, clientWidth: de.clientWidth };
27+
if (coach) {
28+
const r = coach.getBoundingClientRect();
29+
out.coach = [r.left, r.top, r.right, r.bottom].map(Math.round);
30+
out.viewport = [innerWidth, innerHeight];
31+
}
32+
return out;
33+
}"""
34+
35+
SETTLE_JS = """(el) => new Promise((resolve) => {
36+
let last = '', same = 0, frames = 0;
37+
const tick = () => {
38+
const r = el.getBoundingClientRect();
39+
const key = [r.left, r.top, scrollY].map(Math.round).join();
40+
same = key === last ? same + 1 : 0;
41+
last = key;
42+
if (same >= 5 || ++frames > 180) resolve(); else requestAnimationFrame(tick);
43+
};
44+
tick();
45+
})"""
46+
47+
48+
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
49+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
50+
pass
51+
52+
53+
def _serve(site: Path) -> http.server.ThreadingHTTPServer:
54+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), functools.partial(_QuietHandler, directory=str(site)))
55+
threading.Thread(target=server.serve_forever, daemon=True).start()
56+
return server
57+
58+
59+
async def _measure(page: Page, coach: Locator | None, where: str) -> list[str]:
60+
handle = await coach.element_handle() if coach else None
61+
m = await page.evaluate(MEASURE_JS, handle)
62+
problems = []
63+
if m["scrollWidth"] > m["clientWidth"]:
64+
problems.append(f"{where}: page scrolls sideways (scrollWidth {m['scrollWidth']} > {m['clientWidth']})")
65+
if coach:
66+
left, top, right, bottom = m["coach"]
67+
width, height = m["viewport"]
68+
if left < 0 or top < 0 or right > width or bottom > height:
69+
problems.append(f"{where}: coach at {m['coach']} is not inside the {width}x{height} viewport")
70+
return problems
71+
72+
73+
async def _play_tour(page: Page, demo: str, play: str, where: str) -> list[str]:
74+
coach = page.locator(f"{demo} .coach")
75+
problems: list[str] = []
76+
seen, total = 0, None
77+
await page.click(play)
78+
while total is None or seen < total:
79+
try:
80+
await page.locator(f"{demo} .coach.show").wait_for(timeout=STOP_TIMEOUT_MS)
81+
except PlaywrightTimeoutError:
82+
break
83+
seen += 1
84+
step = await coach.locator(".step").inner_text()
85+
total = int(step.rsplit(" ", 1)[1])
86+
await coach.evaluate(SETTLE_JS)
87+
problems += await _measure(page, coach, f"{where} stop {seen}/{total}")
88+
await coach.locator(".go").dispatch_event("click")
89+
if total is None or seen < total:
90+
problems.append(f"{where}: tour stalled after {seen} of {total or '?'} stops")
91+
await page.wait_for_timeout(500)
92+
problems += await _measure(page, None, f"{where} after the run")
93+
return problems
94+
95+
96+
async def _check_page(browser: Browser, base: str, name: str, viewport: str) -> list[str]:
97+
context = await browser.new_context(viewport=VIEWPORTS[viewport])
98+
page = await context.new_page()
99+
errors: list[str] = []
100+
page.on("pageerror", lambda exc: errors.append(str(exc)))
101+
await page.goto(f"{base}/demos/{name}/")
102+
await page.wait_for_selector(".hw-demo .play, .hw-demo [data-el=hplay]")
103+
where = f"{name} @ {viewport}"
104+
problems = await _measure(page, None, f"{where} on load")
105+
for demo_id in await page.eval_on_selector_all(".hw-demo[id]", "els => els.map(e => e.id)"):
106+
demo = f"#{demo_id}"
107+
if await page.locator(f"{demo} [data-el=hplay]").count():
108+
problems += await _play_tour(page, demo, f"{demo} [data-el=hplay]", f"{where} {demo}")
109+
continue
110+
buttons = page.locator(f"{demo} .scenario-btn")
111+
for i in range(await buttons.count()):
112+
await buttons.nth(i).click()
113+
label = await buttons.nth(i).inner_text()
114+
problems += await _play_tour(page, demo, f"{demo} [data-el=play]", f"{where} {demo} '{label}'")
115+
problems += [f"{where}: page error: {e}" for e in errors]
116+
await context.close()
117+
return problems
118+
119+
120+
def _demo_pages(site: Path) -> list[str]:
121+
return sorted(p.parent.name for p in site.glob("demos/*/index.html") if 'class="hw-demo"' in p.read_text())
122+
123+
124+
async def _main(site: Path, pages: list[str]) -> int:
125+
server = _serve(site)
126+
base = f"http://127.0.0.1:{server.server_address[1]}"
127+
async with async_playwright() as p:
128+
browser = await p.chromium.launch()
129+
jobs = [(n, v) for n in pages for v in VIEWPORTS]
130+
results = await asyncio.gather(*(_check_page(browser, base, n, v) for n, v in jobs), return_exceptions=True)
131+
await browser.close()
132+
server.shutdown()
133+
problems = []
134+
for (name, viewport), result in zip(jobs, results, strict=True):
135+
if isinstance(result, BaseException):
136+
problems.append(f"{name} @ {viewport}: check crashed: {str(result).splitlines()[0]}")
137+
else:
138+
problems += result
139+
for problem in problems:
140+
sys.stdout.write(f"FAIL {problem}\n")
141+
sys.stdout.write(f"{len(pages)} pages x {len(VIEWPORTS)} viewports checked, {len(problems)} problems\n")
142+
return 1 if problems or not pages else 0
143+
144+
145+
if __name__ == "__main__":
146+
site = Path(sys.argv[1])
147+
sys.exit(asyncio.run(_main(site, _demo_pages(site))))

0 commit comments

Comments
 (0)