#!/usr/bin/env python3 """Check the built site the way a visitor meets it. npm run build && npx vite preview --port 4173 & scripts/check/site.py [--base http://127.0.0.1:4173] [--out .check] Every route in both languages at 360, 390, 768 and 1440 px: - no sideways scrolling, exactly one h1, every image loaded with alt and size - no console errors, no request to any other origin - internal links only to routes that exist Plus: the old URLs redirect, the language follows the browser until someone chooses, and the word count of each page (Home EN fails above its budget). Full-page PNGs land in --out for looking at. """ import argparse import sys from pathlib import Path from urllib.parse import urlparse from playwright.sync_api import sync_playwright ROUTES = ["/", "/pricing", "/nis2", "/plugins", "/glossary", "/docs/getting-started", "/for/it-department", "/for/it-support", "/for/msp", "/impressum", "/datenschutz"] REDIRECTS = {"/features": "/#included", "/drivers": "/#hardware", "/roadmap": "/", "/nope": "/"} WIDTHS = [360, 390, 768, 1440] # EN word budgets from docs/PAGES.md; only Home is a hard failure. BUDGETS = {"/": 400, "/pricing": 300, "/nis2": 550, "/plugins": 450, "/docs/getting-started": 80, "/for/it-department": 320, "/for/it-support": 320, "/for/msp": 320} EXTERNAL_OK = ("https://license.netork.io/", "mailto:") def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--base", default="http://127.0.0.1:4173") ap.add_argument("--out", type=Path, default=Path(".check")) args = ap.parse_args() base = args.base.rstrip("/") origin = urlparse(base).netloc args.out.mkdir(parents=True, exist_ok=True) problems: list[str] = [] words: dict[tuple[str, str], int] = {} with sync_playwright() as p: browser = p.chromium.launch() for lang in ("en", "de"): for width in WIDTHS: ctx = browser.new_context(viewport={"width": width, "height": 900}) ctx.add_init_script(f"localStorage.setItem('lang', '{lang}')") page = ctx.new_page() errors: list[str] = [] foreign: set[str] = set() page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) page.on("pageerror", lambda e: errors.append(str(e))) page.on("request", lambda r: foreign.add(r.url) if urlparse(r.url).netloc not in (origin, "") and not r.url.startswith("data:") else None) for route in ROUTES: where = f"{route} [{lang} {width}px]" errors.clear() page.goto(base + route) page.wait_for_load_state("networkidle") # Scroll through so lazy images load. height = page.evaluate("document.documentElement.scrollHeight") for y in range(0, height, 600): page.evaluate(f"window.scrollTo(0, {y})") page.wait_for_timeout(40) page.wait_for_timeout(300) page.evaluate("window.scrollTo(0, 0)") overflow = page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth") if overflow > 0: problems.append(f"{where}: {overflow}px sideways overflow") h1 = page.locator("h1").count() if h1 != 1: problems.append(f"{where}: {h1} h1 elements") bad_imgs = page.evaluate("""[...document.images].filter(i => !i.alt || !i.getAttribute('width') || !i.getAttribute('height') || !i.complete || i.naturalWidth === 0 ).map(i => i.currentSrc || i.src)""") for src in bad_imgs: problems.append(f"{where}: image missing alt/size or not loaded: {src}") for e in errors: problems.append(f"{where}: console: {e[:160]}") hrefs = page.evaluate("[...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href'))") for href in hrefs: if href.startswith(EXTERNAL_OK): continue if href.startswith(("http:", "https:")): problems.append(f"{where}: unexpected external link {href}") continue path = href.split("#")[0] or "/" if path.startswith("/") and path not in ROUTES: problems.append(f"{where}: link to unknown route {href}") if width == 1440: words[(route, lang)] = page.evaluate("document.querySelector('main').innerText.split(/\\s+/).filter(Boolean).length") name = route.strip("/").replace("/", "-") or "home" page.screenshot(path=str(args.out / f"{name}-{lang}.png"), full_page=True) if width == 390: name = route.strip("/").replace("/", "-") or "home" page.screenshot(path=str(args.out / f"{name}-{lang}-390.png"), full_page=True) for url in sorted(foreign): problems.append(f"[{lang} {width}px]: request to another origin: {url}") ctx.close() # Old URLs. ctx = browser.new_context() page = ctx.new_page() for old, target in REDIRECTS.items(): page.goto(base + old) page.wait_for_load_state("networkidle") got = urlparse(page.url) landed = got.path + (f"#{got.fragment}" if got.fragment else "") if landed != target: problems.append(f"redirect {old}: landed on {landed}, expected {target}") ctx.close() # Language: the browser decides until someone chooses. for locale, stored, expected in [("de-DE", None, "de"), ("en-US", None, "en"), ("fr-FR", None, "en"), ("de-DE", "en", "en")]: ctx = browser.new_context(locale=locale) if stored: ctx.add_init_script(f"localStorage.setItem('lang', '{stored}')") page = ctx.new_page() page.goto(base + "/") page.wait_for_load_state("networkidle") got = page.evaluate("document.documentElement.lang") stored_after = page.evaluate("localStorage.getItem('lang')") if got != expected: problems.append(f"language: locale {locale}, stored {stored}: got {got}, expected {expected}") if not stored and stored_after is not None: problems.append(f"language: locale {locale}: a choice was stored without anyone choosing") ctx.close() browser.close() print("words in
(EN / DE, budget):") for route in ROUTES: budget = BUDGETS.get(route) en, de = words.get((route, "en"), 0), words.get((route, "de"), 0) flag = " OVER" if budget and en > budget else "" print(f" {route:24} {en:5} / {de:5} {budget or '-'}{flag}") if words.get(("/", "en"), 0) > BUDGETS["/"]: problems.append(f"Home EN has {words[('/', 'en')]} words, budget {BUDGETS['/']}") if problems: print(f"\n{len(problems)} problems:") for pr in problems: print(f" {pr}") return 1 print("\nno problems") return 0 if __name__ == "__main__": sys.exit(main())