|
| 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