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