trainUs/scripts/screenshots/all_pages.py
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

381 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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