""" Capture screenshots of EMOM and TABATA combo timers in Execution Mode, including the TABATA rest screen — not covered by all_pages.py (which only seeds an AMRAP combo). 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/combo_timers.py python scripts/screenshots/combo_timers.py --browser chromium python scripts/screenshots/combo_timers.py --lang fr python scripts/screenshots/combo_timers.py --out-dir /tmp/screenshots Options: --url URL App URL (default: http://localhost:5199) --browser NAME firefox | chromium | webkit (default: firefox) --lang LANG en | fr (default: en) --width N Viewport width in px (default: 1920) --height N Viewport height in px (default: 1080) --out-dir PATH Output directory (default: tmp/screenshots/combo-timers---x) 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 # --------------------------------------------------------------------------- # TABATA work/rest kept short (6s / 5s) so the script doesn't have to wait # long to reach the rest screen. _SEED_SCRIPT = """ async () => { const uuid = await window.__repos.settings.get('user_uuid'); const ex1 = await window.__repos.exercises.create({ title: 'Jumping Jacks', description: 'Cardio warm-up movement.', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 20, default_weight: 0, created_by: uuid }); const ex2 = await window.__repos.exercises.create({ title: 'Mountain Climbers', description: 'Core and cardio movement.', asymmetric: false, alternate: false, image_urls: [], video_urls: [], default_reps: 20, default_weight: 0, created_by: uuid }); // ── EMOM combo — work phase is always a 60s slot per exercise ───────── const emomComboId = await window.__repos.combos.create({ title: 'EMOM Cardio', description: '3-minute EMOM.', type: 'EMOM', timer_config: {}, created_by: uuid }); await window.__repos.combos.setExercises(emomComboId, [ { exerciseId: ex1, position: 0, defaultReps: 20, defaultWeight: 0 } ]); const emomSessId = await window.__repos.sessions.create({ title: 'EMOM Session', description: '', created_by: uuid }); await window.__repos.sessions.setItems(emomSessId, [ { item_id: emomComboId, item_type: 'combo', repetitions: 3, weight: 0.0 } ]); // ── TABATA combo ──────────────────────────────────────────────────── const tabataComboId = await window.__repos.combos.create({ title: 'TABATA Blast', description: 'Work/rest intervals.', type: 'TABATA', timer_config: { workTime: 6, restTime: 5 }, created_by: uuid }); await window.__repos.combos.setExercises(tabataComboId, [ { exerciseId: ex1, position: 0, defaultReps: 0, defaultWeight: 0 }, { exerciseId: ex2, position: 1, defaultReps: 0, defaultWeight: 0 } ]); const tabataSessId = await window.__repos.sessions.create({ title: 'TABATA Session', description: '', created_by: uuid }); await window.__repos.sessions.setItems(tabataSessId, [ { item_id: tabataComboId, item_type: 'combo', repetitions: 4, weight: 0.0 } ]); return { emomSessId, tabataSessId }; } """ _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.3) 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(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") # ── EMOM — work phase starts immediately ─────────────────────────────── goto(page, url, f"sessions/{ids['emomSessId']}/execute", '[data-testid="execute-timer"]') shot(page, out_dir, "combo-emom") # ── TABATA — work phase ───────────────────────────────────────────── # Navigate away first so Vue Router remounts SessionExecuteView (it reuses # the component when only params change, see all_pages.py for the same # workaround). goto(page, url, "sessions", '[data-testid="session-new-btn"]') goto(page, url, f"sessions/{ids['tabataSessId']}/execute", '[data-testid="execute-timer"]') shot(page, out_dir, "combo-tabata-work") # ── TABATA — rest phase (work duration is 6s in the seed above) ──────── page.wait_for_selector('[data-testid="execute-tabata-rest"]', timeout=12000) shot(page, out_dir, "combo-tabata-rest") # --------------------------------------------------------------------------- # 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("--width", type=int, default=1920) parser.add_argument("--height", type=int, default=1080) parser.add_argument("--out-dir", default=None) 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 = f"combo-timers-{browser}" if lang == "en" else f"combo-timers-{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:]) out_dir = ( Path(args.out_dir) if args.out_dir else default_out_dir(args.browser, args.lang, args.width, args.height) ) out_dir.mkdir(parents=True, exist_ok=True) print(f"\n── {args.browser} {args.width}×{args.height} → {out_dir}") with sync_playwright() as p: browser_type = getattr(p, args.browser) browser = browser_type.launch(headless=True) try: context = browser.new_context(viewport={"width": args.width, "height": args.height}) page = context.new_page() page.on("dialog", lambda dialog: dialog.accept()) capture(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())