381 lines
16 KiB
Python
381 lines
16 KiB
Python
"""
|
||
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())
|