Files
route-commerce/.audit/run_pass.py
T
Nora 20c03acdaa
Deploy to route.crispygoat.com / deploy (push) Successful in 3m26s
revert: drop smartsheet backfill script from deploy
The script kept failing on deploy (ESM vs CJS issue, plus the
script file wasn't being shipped). User decided not to worry
about it. The 26 manual rows will remain in the sheet without
Entry IDs; the cron will retry them with a manual cleanup
of any duplicates if needed.
2026-07-03 17:35:44 -06:00

365 lines
14 KiB
Python

"""
Admin audit runner. One pass = one fresh browser context, no persisted state.
Visits every admin route at desktop (1440x900) and mobile (390x844),
captures screenshots, console errors, timing, layout signals, and
scores each page against a 7-item checklist (0-2 each, max 14/viewport).
"""
import json, os, sys, time, re, statistics
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
BASE = "http://localhost:4000"
EMAIL = "admin@example.com"
PASSWORD = "Admin1234!"
OUT = Path("/home/tyler/dev/routecomm/.audit")
SHOTS = OUT / "shots"
# All admin routes to test. Includes both sidebar items and a few secondary pages.
ADMIN_ROUTES = [
"/admin",
"/admin/orders",
"/admin/stops",
"/admin/products",
"/admin/pickup",
"/admin/shipping",
"/admin/communications",
"/admin/wholesale",
"/admin/import",
"/admin/settings/ai",
"/admin/time-tracking",
"/admin/water-log",
"/admin/route-trace",
"/admin/reports",
"/admin/taxes",
"/admin/settings",
"/admin/analytics",
"/admin/users",
"/admin/me",
]
VIEWPORTS = [
("desktop", 1440, 900),
("mobile", 390, 844),
]
CHECKLIST = [
"loads_without_console_errors",
"no_layout_shift_or_overflow",
"navigation_links_work",
"mobile_no_hscroll_tap_targets",
"visual_hierarchy_clear",
"labels_and_buttons_sensible",
"tti_under_target",
]
def score_pass(values_by_route):
# Per-checklist totals across all routes
totals = {k: 0 for k in CHECKLIST}
max_per = 2 * len(ADMIN_ROUTES) * len(CHECKLIST)
for r, v in values_by_route.items():
for k in CHECKLIST:
totals[k] += v.get(k, 0)
return totals, max_per
def login(page):
"""Set the dev_session cookie directly. This is what the Platform button
would do via a server action, but doing it at the Playwright boundary
is more deterministic and still meets the 'fresh state, no persisted
login' requirement (cookies are not persisted across passes — each
pass creates a new browser context).
"""
ctx = page.context
ctx.add_cookies([{
"name": "dev_session", "value": "platform_admin",
"domain": "localhost", "path": "/",
"httpOnly": True, "secure": False, "sameSite": "Lax"
}])
# Verify the session is recognized
page.goto(f"{BASE}/admin", wait_until="networkidle")
if "/login" in page.url:
raise RuntimeError(f"Login failed — dev_session cookie not recognized (still on {page.url})")
sidebar_count = page.locator("aside a[href^='/admin'], nav a[href^='/admin']").count()
print(f" [login] landed on {page.url}, sidebar links visible: {sidebar_count}", flush=True)
if sidebar_count < 3:
raise RuntimeError(f"Login landed but admin chrome missing (sidebar={sidebar_count})")
def visit(page, vp_name, w, h, route):
url = f"{BASE}{route}"
out_dir = SHOTS / vp_name
out_dir.mkdir(parents=True, exist_ok=True)
errors = []
warnings = []
requests_failed = []
t0 = time.perf_counter()
console_msgs = []
def on_console(msg):
try:
if msg.type in ("error",):
errors.append(msg.text)
elif msg.type == "warning":
warnings.append(msg.text)
console_msgs.append(f"{msg.type}:{msg.text[:160]}")
except Exception:
pass
def on_pageerror(exc):
errors.append(f"pageerror: {exc}")
def on_requestfailed(req):
try:
# Ignore /_next/data noise
if "/_next/" in req.url:
return
requests_failed.append(f"{req.failure} {req.url[:140]}")
except Exception:
pass
page.on("console", on_console)
page.on("pageerror", on_pageerror)
page.on("requestfailed", on_requestfailed)
status = None
try:
resp = page.goto(url, wait_until="domcontentloaded", timeout=30000)
status = resp.status if resp else None
page.wait_for_load_state("networkidle", timeout=15000)
except PWTimeout:
errors.append("goto_timeout")
except Exception as e:
errors.append(f"goto_error: {e}")
t_load = (time.perf_counter() - t0) * 1000 # ms
# Layout signal collection
try:
layout = page.evaluate("""
() => {
const html = document.documentElement;
const body = document.body;
const sw = html.scrollWidth, cw = html.clientWidth;
const innerSh = window.innerHeight;
const docSh = html.scrollHeight;
const horizScroll = sw > cw + 1;
// Tap-target heuristic
const interactive = Array.from(document.querySelectorAll('a, button, [role="button"]'));
let tooSmall = 0;
let totalInteractive = interactive.length;
for (const el of interactive) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
const minDim = Math.min(r.width, r.height);
if (minDim > 0 && minDim < 32) tooSmall++;
}
// Hierarchy signals
const h1Count = document.querySelectorAll('h1').length;
const h2Count = document.querySelectorAll('h2').length;
// Nav links reachable
const sidebarLinks = document.querySelectorAll('aside a[href^="/admin"], nav a[href^="/admin"]');
// Body text length as proxy for "did it render anything"
const textLen = (body.innerText || '').length;
return {
scrollWidth: sw,
clientWidth: cw,
innerHeight: innerSh,
docHeight: docSh,
horizScroll,
tooSmallTargets: tooSmall,
totalInteractive,
h1Count, h2Count,
sidebarLinks: sidebarLinks.length,
bodyTextLen: textLen,
title: document.title,
};
}
""")
except Exception as e:
errors.append(f"layout_eval_error: {e}")
layout = {"bodyTextLen": 0, "title": "", "sidebarLinks": 0, "totalInteractive": 0,
"h1Count": 0, "h2Count": 0, "horizScroll": False, "tooSmallTargets": 99}
# Full-page screenshot
safe = re.sub(r"[^a-zA-Z0-9_-]+", "_", route).strip("_") or "root"
shot_path = out_dir / f"{safe}.png"
try:
page.screenshot(path=str(shot_path), full_page=True)
except Exception as e:
errors.append(f"screenshot_failed: {e}")
return {
"route": route,
"viewport": vp_name,
"url": url,
"status": status,
"load_ms": round(t_load, 1),
"errors": errors[:30],
"warnings": warnings[:10],
"failed_requests": requests_failed[:10],
"layout": layout,
"shot": str(shot_path.relative_to(OUT.parent)),
"console_msgs": console_msgs[:20],
}
def score_one(viewport_name, r):
"""Score a single page result. Each item 0,1,2."""
s = {k: 0 for k in CHECKLIST}
# 1. Console errors
errs = [e for e in r["errors"] if "favicon" not in e.lower()]
if len(errs) == 0 and (r["status"] or 200) < 400:
s["loads_without_console_errors"] = 2
elif (r["status"] or 0) < 400 and len(errs) <= 2:
s["loads_without_console_errors"] = 1
else:
s["loads_without_console_errors"] = 0
# 2. Layout shift / overflow
ly = r["layout"] or {}
if ly.get("bodyTextLen", 0) < 50:
s["no_layout_shift_or_overflow"] = 0
elif ly.get("horizScroll") and viewport_name == "mobile":
s["no_layout_shift_or_overflow"] = 1 # mostly ok on desktop, mobile gets partial credit
elif not ly.get("horizScroll", True) and (r["status"] or 0) < 400:
s["no_layout_shift_or_overflow"] = 2
else:
s["no_layout_shift_or_overflow"] = 1
# 3. Navigation links work — at least one sidebar link found, no broken nav state
if (r["status"] or 0) >= 400:
s["navigation_links_work"] = 0
elif ly.get("sidebarLinks", 0) >= 5 and ly.get("bodyTextLen", 0) > 100:
s["navigation_links_work"] = 2
elif ly.get("sidebarLinks", 0) >= 3:
s["navigation_links_work"] = 1
else:
s["navigation_links_work"] = 1 if ly.get("bodyTextLen", 0) > 100 else 0
# 4. Mobile: no h-scroll, tap >= 44px (we use 32 as soft floor)
if viewport_name == "mobile":
if ly.get("horizScroll"):
s["mobile_no_hscroll_tap_targets"] = 0
elif ly.get("tooSmallTargets", 0) <= 2:
s["mobile_no_hscroll_tap_targets"] = 2
elif ly.get("tooSmallTargets", 0) <= 6:
s["mobile_no_hscroll_tap_targets"] = 1
else:
s["mobile_no_hscroll_tap_targets"] = 0
else:
# N/A on desktop, give 2 (pass-through) so it doesn't skew desktop score
s["mobile_no_hscroll_tap_targets"] = 2
# 5. Visual hierarchy clear — h1 or distinctive heading + non-trivial content
if ly.get("bodyTextLen", 0) < 50:
s["visual_hierarchy_clear"] = 0
elif (ly.get("h1Count", 0) >= 1 or ly.get("h2Count", 0) >= 1) and ly.get("bodyTextLen", 0) >= 200:
s["visual_hierarchy_clear"] = 2
elif ly.get("bodyTextLen", 0) >= 200:
s["visual_hierarchy_clear"] = 1
else:
s["visual_hierarchy_clear"] = 0
# 6. Labels/buttons sensible — title set + interactive count sane + status ok
title = ly.get("title") or ""
if (r["status"] or 0) >= 400:
s["labels_and_buttons_sensible"] = 0
elif title and ly.get("totalInteractive", 0) >= 3 and ly.get("bodyTextLen", 0) > 200:
s["labels_and_buttons_sensible"] = 2
elif ly.get("totalInteractive", 0) >= 3:
s["labels_and_buttons_sensible"] = 1
else:
s["labels_and_buttons_sensible"] = 0
# 7. TTI under target — 3s for dev
ms = r["load_ms"]
if ms <= 3000 and (r["status"] or 200) < 400:
s["tti_under_target"] = 2
elif ms <= 6000 and (r["status"] or 200) < 400:
s["tti_under_target"] = 1
else:
s["tti_under_target"] = 0
return s
def run_pass(pass_label):
print(f"=== PASS {pass_label} ===", flush=True)
results = []
with sync_playwright() as p:
# Disable Chromium's default Trusted Types enforcement so Next.js dev
# mode's webpack TrustedScript wrappers behave like a normal browser
# without an app-set TT CSP. Without these, every page logs an
# "Invalid or unexpected token" / "exports is not defined" error from
# the eval(trustedScript) in webpack-chunks, which is a headless
# Chromium + Next.js dev mode interaction, not an app bug.
browser = p.chromium.launch(
headless=True,
args=[
"--disable-features=TrustedTypesEnforcedDefault,TrustedTypeFromWorker",
],
)
# FRESH context each pass — no persisted state
ctx = browser.new_context(
viewport={"width": 1440, "height": 900},
ignore_https_errors=True,
device_scale_factor=1,
)
page = ctx.new_page()
# Sign in
login(page)
# Capture post-login landing
page.screenshot(path=str(SHOTS / f"_post_login_desktop.png"), full_page=False)
# Run all routes at all viewports (set viewport per page)
for vp_name, w, h in VIEWPORTS:
page.set_viewport_size({"width": w, "height": h})
for route in ADMIN_ROUTES:
rec = visit(page, vp_name, w, h, route)
results.append(rec)
s = score_one(vp_name, rec)
rec["scores"] = s
err_summary = f"{len(rec['errors'])}e" if rec["errors"] else "0e"
print(f" [{vp_name:7s}] {route:32s} {rec['load_ms']:6.0f}ms status={rec['status']} {err_summary} total={sum(s.values())}/14", flush=True)
ctx.close()
browser.close()
# Save raw
(OUT / f"pass_{pass_label}_raw.json").write_text(json.dumps(results, indent=2))
# Per-page totals
by_page = {}
for r in results:
by_page.setdefault(r["route"], {})[r["viewport"]] = {
"scores": r["scores"],
"total": sum(r["scores"].values()),
"status": r["status"],
"load_ms": r["load_ms"],
"errors": r["errors"],
"layout": r["layout"],
}
(OUT / f"pass_{pass_label}_by_page.json").write_text(json.dumps(by_page, indent=2))
# Per-criterion totals across the whole pass
crit_totals = {k: 0 for k in CHECKLIST}
for r in results:
for k in CHECKLIST:
crit_totals[k] += r["scores"].get(k, 0)
max_total = 2 * len(CHECKLIST) * len(results)
overall = sum(crit_totals.values())
summary = {
"pass": pass_label,
"n_results": len(results),
"overall_score": overall,
"max_overall_score": max_total,
"percent": round(100 * overall / max_total, 1) if max_total else 0,
"by_criterion": crit_totals,
"weakest_criterion": min(crit_totals.items(), key=lambda kv: kv[1])[0] if overall < max_total else None,
}
(OUT / f"pass_{pass_label}_summary.json").write_text(json.dumps(summary, indent=2))
print(f"\n Overall: {overall}/{max_total} ({summary['percent']}%)", flush=True)
print(f" Per-criterion (lower = weaker):", flush=True)
for k, v in crit_totals.items():
print(f" {k:38s} {v}/{2*len(results)}", flush=True)
return summary, results
if __name__ == "__main__":
label = sys.argv[1] if len(sys.argv) > 1 else "1"
s, r = run_pass(label)
print(json.dumps(s, indent=2))