feat(screenshots): cropped shots, sizes for the site, a site check
- shots.py crops to a region (clip) or to what one or more elements cover (element, pad), per-shot viewport; capture.py writes the published sizes to src/data/screenshots.json so the page reserves the right space. - anonymize.py no longer empties secrets that netOrk compares with each other (Wi-Fi keys on an SSID against the key read from the access point). Emptying them invented passphrase "drift" that never existed; a keyed hash keeps equal equal, reverses nothing, and its key lives for one run. - scripts/check/site.py checks the built site in both languages at four widths: sideways overflow, one h1, images with alt and size, console errors, requests to other origins, links to unknown routes, old-URL redirects, language detection, and word counts against the budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d585f9272f
commit
369f66afdc
@@ -0,0 +1,156 @@
|
||||
#!/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 <main> (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())
|
||||
Reference in New Issue
Block a user