""" Capture screenshots of every view, modal, and dialog in the TrainUs app. The app is driven in English by default (use --lang fr for French screenshots) with a seeded database so each screenshot shows meaningful content rather than empty states. Intended for visual investigations and documentation figures — not a test. Prerequisites: - Playwright for Python installed (see `tests/requirements.txt`) - Playwright browsers installed: `python -m playwright install firefox chromium` - Vite dev server running: `npm run dev -- --port 5199 --strictPort` Usage (from project root): python scripts/screenshots/all_pages.py python scripts/screenshots/all_pages.py --browser chromium python scripts/screenshots/all_pages.py --lang fr python scripts/screenshots/all_pages.py --out-dir /tmp/screenshots python scripts/screenshots/all_pages.py --no-mobile Options: --url URL App URL (default: http://localhost:5199) --browser NAME firefox | chromium | webkit (default: firefox) --lang LANG en | fr (default: en) --out-dir PATH Output directory root (default: tmp/screenshots) --no-mobile Skip the mobile (390×844) pass Exit code: 0 on success, 1 on failure. """ from __future__ import annotations import argparse import os import sys import time import traceback from pathlib import Path from playwright.sync_api import Page, sync_playwright # --------------------------------------------------------------------------- # Seed helpers # --------------------------------------------------------------------------- _SEED_SCRIPT = """ async () => { const uuid = await window.__repos.settings.get('user_uuid'); // ── exercises ────────────────────────────────────────────────────────── const ex1 = await window.__repos.exercises.create({ title: 'Push-ups', description: '**Classic** bodyweight push-up.\\n\\n- Keep your back straight\\n- Full range of motion', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 15, default_weight: 0, created_by: uuid }); const ex2 = await window.__repos.exercises.create({ title: 'Squats', description: 'Bodyweight squat — great for legs and glutes.', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 20, default_weight: 0, created_by: uuid }); const ex3 = await window.__repos.exercises.create({ title: 'Deadlift', description: 'Barbell deadlift — compound posterior-chain movement.', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 5, default_weight: 100, created_by: uuid }); const ex4 = await window.__repos.exercises.create({ title: 'Single-leg Romanian Deadlift', description: 'Balance and hamstring exercise.\\n\\n*Asymmetric — alternate sides.*', asymmetric: true, alternate: true, image_urls: [], video_urls: [], default_reps: 10, default_weight: 20, created_by: uuid }); // ── regular combo ────────────────────────────────────────────────────── const comboId = await window.__repos.combos.create({ title: 'Morning HIIT', description: 'Quick high-intensity warm-up combo.', type: 'NONE', timer_config: null, created_by: uuid }); await window.__repos.combos.setExercises(comboId, [ { exerciseId: ex1, position: 0, defaultReps: 15, defaultWeight: 0 }, { exerciseId: ex2, position: 1, defaultReps: 20, defaultWeight: 0 } ]); // ── AMRAP combo ──────────────────────────────────────────────────────── const amrapComboId = await window.__repos.combos.create({ title: 'AMRAP Blast', description: '3-minute AMRAP: push-ups and squats. As many rounds as possible!', type: 'AMRAP', timer_config: {}, created_by: uuid }); await window.__repos.combos.setExercises(amrapComboId, [ { exerciseId: ex1, position: 0, defaultReps: 10, defaultWeight: 0 }, { exerciseId: ex2, position: 1, defaultReps: 15, defaultWeight: 0 } ]); // ── regular session ──────────────────────────────────────────────────── const sessId = await window.__repos.sessions.create({ title: 'Full-Body Workout A', description: 'A balanced full-body session combining strength and cardio.', created_by: uuid }); await window.__repos.sessions.setItems(sessId, [ { item_id: ex3, item_type: 'exercise', repetitions: 5, weight: 100.0 }, { item_id: comboId, item_type: 'combo', repetitions: 3, weight: 0.0 } ]); // ── AMRAP session (repetitions = minutes for AMRAP type) ─────────────── const amrapSessId = await window.__repos.sessions.create({ title: 'AMRAP Cardio', description: '3-minute AMRAP push-up/squat circuit.', created_by: uuid }); await window.__repos.sessions.setItems(amrapSessId, [ { item_id: amrapComboId, item_type: 'combo', repetitions: 3, weight: 0.0 } ]); // ── collection ───────────────────────────────────────────────────────── const colId = await window.__repos.collections.create({ label: 'Week 1 Plan', created_by: uuid }); await window.__repos.collections.setSessions(colId, [sessId]); // ── execution log so the home page shows history ─────────────────────── const now = Date.now(); const logId = await window.__repos.executionLogs.create({ session_id: sessId, started_at: new Date(now - 3600000).toISOString(), finished_at: new Date(now - 3000000).toISOString() }); await window.__repos.executionLogs.addItem({ log_id: logId, exercise_id: ex3, combo_id: null, round_number: 1, reps_done: 5, weight_used: 100, side: null, completed: true, timestamp: new Date(now - 3500000).toISOString() }); await window.__repos.executionLogs.addItem({ log_id: logId, exercise_id: ex1, combo_id: comboId, round_number: 1, reps_done: 15, weight_used: 0, side: null, completed: true, timestamp: new Date(now - 3400000).toISOString() }); return { ex1, ex2, ex3, ex4, comboId, amrapComboId, sessId, amrapSessId, colId, logId }; } """ _COUNT_SCRIPT = """ async () => (await window.__repos.exercises.getAll()).length """ def wait_for_db(page: Page, url: str) -> None: page.goto(url) page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def ensure_language(page: Page, lang: str) -> None: current = page.evaluate("async () => window.__repos.settings.get('language')") if current != lang: page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')") page.reload() page.wait_for_function( "document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000, ) def has_data(page: Page) -> bool: return page.evaluate(_COUNT_SCRIPT) > 0 # --------------------------------------------------------------------------- # Screenshot helpers # --------------------------------------------------------------------------- def shot(page: Page, out_dir: Path, name: str, full_page: bool = True) -> None: time.sleep(0.4) path = out_dir / f"{name}.png" page.screenshot(path=str(path), full_page=full_page) print(f" ✓ {path.name}") def goto(page: Page, url: str, route: str, wait_selector: str | None = None) -> None: page.goto(f"{url}/#/{route}", wait_until="domcontentloaded") if wait_selector: page.wait_for_selector(wait_selector, timeout=8000) else: time.sleep(0.3) # --------------------------------------------------------------------------- # Main capture flow # --------------------------------------------------------------------------- def capture_all(page: Page, url: str, out_dir: Path, lang: str) -> None: wait_for_db(page, url) ensure_language(page, lang) if not has_data(page): print(" Seeding database…") ids = page.evaluate(_SEED_SCRIPT) print(" Seed complete.") else: raise RuntimeError("DB already has data — start with a fresh browser context") sess_id = ids['sessId'] amrap_sess_id = ids['amrapSessId'] col_id = ids['colId'] # ── home (shows recent execution history after seed) ────────────────── goto(page, url, "home") shot(page, out_dir, "home") # ── exercises ───────────────────────────────────────────────────────── goto(page, url, "exercises", '[data-testid="exercise-new-btn"]') shot(page, out_dir, "exercises") exercises = page.evaluate("async () => window.__repos.exercises.getAll()") ex_id = exercises[0]["id"] goto(page, url, f"exercises/{ex_id}", '[data-testid="exercise-detail-title"]') shot(page, out_dir, "exercise-detail") goto(page, url, f"exercises/{ex_id}/edit", '[data-testid="exercise-title"]') shot(page, out_dir, "exercise-edit") goto(page, url, "exercises/new", '[data-testid="exercise-title"]') shot(page, out_dir, "exercise-new") # ── combos ──────────────────────────────────────────────────────────── goto(page, url, "combos", '[data-testid="combo-new-btn"]') shot(page, out_dir, "combos") combos = page.evaluate("async () => window.__repos.combos.getAll()") combo_id = combos[0]["id"] goto(page, url, f"combos/{combo_id}", '[data-testid="combo-detail-title"]') shot(page, out_dir, "combo-detail") goto(page, url, f"combos/{combo_id}/edit", '[data-testid="combo-title"]') shot(page, out_dir, "combo-edit") goto(page, url, "combos/new", '[data-testid="combo-title"]') shot(page, out_dir, "combo-new") # ── sessions ────────────────────────────────────────────────────────── goto(page, url, "sessions", '[data-testid="session-new-btn"]') shot(page, out_dir, "sessions") goto(page, url, f"sessions/{sess_id}", '[data-testid="session-detail-title"]') shot(page, out_dir, "session-detail") goto(page, url, f"sessions/{sess_id}/edit", '[data-testid="session-title"]') shot(page, out_dir, "session-edit") goto(page, url, "sessions/new", '[data-testid="session-title"]') shot(page, out_dir, "session-new") # ── session execute — regular (progress bar + current step) ─────────── goto(page, url, f"sessions/{sess_id}/execute", '[data-testid="execute-progress"]') shot(page, out_dir, "session-execute") # ── session execute — AMRAP timer running ───────────────────────────── # Navigate away first so Vue Router remounts SessionExecuteView (it reuses # the component when only params change, so onMounted/init() won't re-run # unless we force a different route in between). goto(page, url, "sessions", '[data-testid="session-new-btn"]') goto(page, url, f"sessions/{amrap_sess_id}/execute", '[data-testid="execute-timer"]') shot(page, out_dir, "session-execute-amrap") # ── collections ─────────────────────────────────────────────────────── goto(page, url, "collections", '[data-testid="collection-new-btn"]') shot(page, out_dir, "collections") goto(page, url, f"collections/{col_id}", '[data-testid="collection-detail-title"]') shot(page, out_dir, "collection-detail") goto(page, url, f"collections/{col_id}/edit", '[data-testid="collection-label"]') shot(page, out_dir, "collection-edit") goto(page, url, "collections/new", '[data-testid="collection-label"]') shot(page, out_dir, "collection-new") # ── stats ───────────────────────────────────────────────────────────── goto(page, url, "stats", '[data-testid="stats-date-range"]') shot(page, out_dir, "stats") # ── settings — viewport-height slices (page is tall) ────────────────── goto(page, url, "settings", '[data-testid="settings-language"]') page.evaluate("window.scrollTo(0, 0)") shot(page, out_dir, "settings-top", full_page=False) page.evaluate("window.scrollTo(0, document.body.scrollHeight)") time.sleep(0.2) shot(page, out_dir, "settings-bottom", full_page=False) # ── about dialog ────────────────────────────────────────────────────── page.evaluate("window.scrollTo(0, 0)") page.click('[data-testid="settings-about"]') page.wait_for_selector('[data-testid="modal-card"]') shot(page, out_dir, "about-dialog") # ── license dialog (stacked on about) ───────────────────────────────── page.click('[data-testid="about-license-btn"]') page.wait_for_selector('[data-testid="license-dialog-content"]') shot(page, out_dir, "license-dialog") close_btns = page.locator('[data-testid="modal-close"]') close_btns.last.click() close_btns.first.click() # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199")) parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"]) parser.add_argument("--lang", default="en", choices=["en", "fr"]) parser.add_argument("--out-dir", default=None) parser.add_argument("--no-mobile", action="store_true") return parser.parse_args(argv) def default_out_dir(browser: str, lang: str, width: int, height: int) -> Path: project_root = Path(__file__).resolve().parents[2] prefix = browser if lang == "en" else f"{lang}-{browser}" return project_root / "tmp" / "screenshots" / f"{prefix}-{width}x{height}" def main(argv: list[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) passes = [(1920, 1080)] if not args.no_mobile: passes.append((390, 844)) with sync_playwright() as p: for (w, h) in passes: out_dir = ( Path(args.out_dir) / f"{args.browser}-{w}x{h}" if args.out_dir else default_out_dir(args.browser, args.lang, w, h) ) out_dir.mkdir(parents=True, exist_ok=True) print(f"\n── {args.browser} {w}×{h} → {out_dir}") browser_type = getattr(p, args.browser) browser = browser_type.launch(headless=True) try: context = browser.new_context(viewport={"width": w, "height": h}) page = context.new_page() page.on("dialog", lambda dialog: dialog.accept()) capture_all(page, args.url, out_dir, args.lang) count = len(list(out_dir.glob("*.png"))) print(f"\n Done — {count} screenshots saved") except Exception as exc: print(f"\n ERROR: {exc}", file=sys.stderr) traceback.print_exc() return 1 finally: browser.close() return 0 if __name__ == "__main__": sys.exit(main())