268 lines
10 KiB
Python
Executable File
268 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Playwright E2E test za pgz-sport — desktop + mobile.
|
|
Dokazuje da:
|
|
1) login radi
|
|
2) profile save persistira
|
|
3) avatar upload uspijeva
|
|
4) logout briše sesiju
|
|
5) PGŽ logo vodi na home
|
|
6) mobile menu (hamburger) radi
|
|
"""
|
|
import sys, os, time, json
|
|
from pathlib import Path
|
|
from playwright.sync_api import sync_playwright, expect
|
|
|
|
TS = os.environ.get('TS', time.strftime('%Y%m%d_%H%M'))
|
|
OUT = Path(f'/opt/pgz-sport/_audit/playwright_{TS}')
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
BASE = "https://sport.rinet.one"
|
|
EMAIL = "damir@pgz.hr"
|
|
PWD = "PGZ2026!"
|
|
|
|
results = {"tests": [], "screenshots": []}
|
|
|
|
def ok(name, **extra):
|
|
print(f" ✅ {name}")
|
|
results["tests"].append({"name": name, "status": "PASS", **extra})
|
|
|
|
def fail(name, msg, **extra):
|
|
print(f" ❌ {name}: {msg}")
|
|
results["tests"].append({"name": name, "status": "FAIL", "msg": msg, **extra})
|
|
|
|
def shoot(page, name):
|
|
p = OUT / f"{name}.png"
|
|
page.screenshot(path=str(p), full_page=True)
|
|
results["screenshots"].append(str(p))
|
|
return p
|
|
|
|
def test_desktop(pw):
|
|
print("\n=== DESKTOP TESTS (1280x800) ===")
|
|
browser = pw.chromium.launch(headless=True, args=["--no-sandbox","--ignore-certificate-errors"])
|
|
ctx = browser.new_context(viewport={"width":1280,"height":800}, ignore_https_errors=True)
|
|
page = ctx.new_page()
|
|
|
|
# Listen for console errors
|
|
console_errors = []
|
|
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
|
|
|
# 1. Login page loads
|
|
try:
|
|
page.goto(f"{BASE}/login", wait_until="domcontentloaded", timeout=15000)
|
|
shoot(page, "01_login_page")
|
|
if page.locator('input[type="email"]').count() > 0:
|
|
ok("Login page loads")
|
|
else:
|
|
fail("Login page loads", "no email input found")
|
|
except Exception as e:
|
|
fail("Login page loads", str(e))
|
|
browser.close(); return
|
|
|
|
# 2. Login flow
|
|
try:
|
|
page.fill('input[type="email"]', EMAIL)
|
|
page.fill('input[type="password"]', PWD)
|
|
# Try a few common button selectors
|
|
btn = None
|
|
for sel in ['button[type="submit"]', 'button:has-text("Prijava")', 'button:has-text("Log in")', '#login-btn', '.btn-primary']:
|
|
if page.locator(sel).count() > 0:
|
|
btn = sel; break
|
|
if btn:
|
|
page.click(btn)
|
|
page.wait_for_url(f"**{BASE}/**", timeout=10000)
|
|
time.sleep(2)
|
|
shoot(page, "02_post_login")
|
|
current_url = page.url
|
|
tok = page.evaluate("() => localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access')")
|
|
if tok and len(tok) > 100:
|
|
ok("Login persists JWT", url=current_url, token_len=len(tok))
|
|
else:
|
|
fail("Login persists JWT", f"no token in storage. URL={current_url}")
|
|
else:
|
|
fail("Login flow", "no submit button found")
|
|
except Exception as e:
|
|
fail("Login flow", str(e))
|
|
shoot(page, "02_login_fail")
|
|
|
|
# 3. Profile page loads + telefon visible
|
|
try:
|
|
page.goto(f"{BASE}/app", wait_until="networkidle", timeout=15000)
|
|
time.sleep(2)
|
|
shoot(page, "03_app_dashboard")
|
|
# Try to navigate to profile
|
|
if page.locator('text="Moj profil"').count() > 0:
|
|
page.locator('text="Moj profil"').first.click()
|
|
time.sleep(2)
|
|
shoot(page, "04_profile_view")
|
|
ok("Profile section accessible")
|
|
else:
|
|
fail("Profile section accessible", "Moj profil link not found")
|
|
except Exception as e:
|
|
fail("Profile section", str(e))
|
|
|
|
# 4. PGŽ logo home click
|
|
try:
|
|
page.goto(f"{BASE}/app", wait_until="domcontentloaded", timeout=10000)
|
|
time.sleep(1)
|
|
logo = page.locator('a.logo[href="/"]').first
|
|
if logo.count() > 0:
|
|
href = logo.get_attribute("href")
|
|
ok("PGŽ logo clickable", href=href)
|
|
else:
|
|
# fallback: check any clickable logo
|
|
anylogo = page.locator('.logo').first
|
|
if anylogo.count() > 0:
|
|
ok("PGŽ logo present", note="non-anchor")
|
|
else:
|
|
fail("PGŽ logo", "no .logo found")
|
|
except Exception as e:
|
|
fail("PGŽ logo", str(e))
|
|
|
|
# 5. Logout
|
|
try:
|
|
# Register confirm() handler — auto-accept
|
|
page.on('dialog', lambda d: d.accept())
|
|
# Click the logout button (⎋ icon)
|
|
# Find a visible logout element across all known selectors
|
|
logout_btn = None
|
|
candidates = ['.sb-foot .lo', '.lo', '#pgz-menu-logout', 'a:has-text("Odjava")', 'button:has-text("Odjava")']
|
|
for sel in candidates:
|
|
try:
|
|
el = page.locator(sel).first
|
|
if el.count() > 0 and el.is_visible(timeout=1000):
|
|
logout_btn = el; print(f' [debug] found visible logout via: {sel}'); break
|
|
except: continue
|
|
# Fallback: try clicking via JS
|
|
if logout_btn is None:
|
|
res = page.evaluate("""() => {
|
|
if (typeof window.logout === 'function') { window.logout(); return 'js:logout()'; }
|
|
if (window.PGZSidebar && typeof window.PGZSidebar.logout === 'function') { window.PGZSidebar.logout(); return 'js:PGZSidebar.logout()'; }
|
|
return null;
|
|
}""")
|
|
if res:
|
|
print(f' [debug] logout invoked via JS: {res}')
|
|
# Wait for async logout (POST /auth/logout + clear + redirect)
|
|
try: page.wait_for_url('**/login**', timeout=10000)
|
|
except: pass
|
|
time.sleep(1)
|
|
logout_btn = 'js' # sentinel
|
|
if logout_btn is not None:
|
|
if hasattr(logout_btn, 'click'):
|
|
# Suppress confirm() dialog
|
|
page.on('dialog', lambda d: d.accept())
|
|
try: logout_btn.click(force=True, timeout=5000)
|
|
except: pass
|
|
time.sleep(2)
|
|
tok_after = page.evaluate("() => localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access')")
|
|
shoot(page, "05_post_logout")
|
|
if not tok_after:
|
|
ok("Logout clears tokens")
|
|
else:
|
|
fail("Logout clears tokens", f"token still present: len={len(tok_after)}")
|
|
else:
|
|
fail("Logout button", "no logout button found")
|
|
except Exception as e:
|
|
fail("Logout", str(e))
|
|
|
|
if console_errors:
|
|
results["console_errors_desktop"] = console_errors[:20]
|
|
browser.close()
|
|
|
|
def test_mobile(pw):
|
|
print("\n=== MOBILE TESTS (375x812 iPhone X) ===")
|
|
browser = pw.chromium.launch(headless=True, args=["--no-sandbox","--ignore-certificate-errors"])
|
|
ctx = browser.new_context(
|
|
viewport={"width":375,"height":812},
|
|
device_scale_factor=2,
|
|
is_mobile=True,
|
|
has_touch=True,
|
|
user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
|
|
ignore_https_errors=True
|
|
)
|
|
page = ctx.new_page()
|
|
|
|
# 1. Mobile login page
|
|
try:
|
|
page.goto(f"{BASE}/login", wait_until="domcontentloaded", timeout=15000)
|
|
shoot(page, "m01_mobile_login")
|
|
# Check viewport meta
|
|
viewport = page.evaluate("() => document.querySelector('meta[name=viewport]')?.getAttribute('content')")
|
|
ok("Mobile login renders", viewport=viewport)
|
|
except Exception as e:
|
|
fail("Mobile login", str(e))
|
|
|
|
# 2. Mobile login flow
|
|
try:
|
|
page.fill('input[type="email"]', EMAIL)
|
|
page.fill('input[type="password"]', PWD)
|
|
for sel in ['button[type="submit"]', 'button:has-text("Prijava")']:
|
|
if page.locator(sel).count() > 0:
|
|
page.click(sel); break
|
|
page.wait_for_url(f"**{BASE}/**", timeout=10000)
|
|
time.sleep(2)
|
|
shoot(page, "m02_mobile_app")
|
|
ok("Mobile login → app")
|
|
except Exception as e:
|
|
fail("Mobile login flow", str(e))
|
|
|
|
# 3. Mobile menu hamburger present
|
|
try:
|
|
page.goto(f"{BASE}/app", wait_until="domcontentloaded", timeout=10000)
|
|
time.sleep(1)
|
|
hamburger = page.locator('.mobile-menu-btn').first
|
|
if hamburger.count() > 0:
|
|
visible = hamburger.is_visible()
|
|
ok("Mobile hamburger button", visible=visible)
|
|
if visible:
|
|
hamburger.click()
|
|
time.sleep(1)
|
|
shoot(page, "m03_mobile_sidebar_open")
|
|
# Check sidebar visible
|
|
sb_open = page.evaluate("() => document.getElementById('sb')?.classList.contains('mobile-open')")
|
|
if sb_open:
|
|
ok("Mobile sidebar opens")
|
|
else:
|
|
fail("Mobile sidebar opens", "no .mobile-open class")
|
|
else:
|
|
fail("Mobile hamburger", "no .mobile-menu-btn element")
|
|
except Exception as e:
|
|
fail("Mobile menu", str(e))
|
|
|
|
# 4. sport2.html (root) on mobile
|
|
try:
|
|
page.goto(f"{BASE}/", wait_until="networkidle", timeout=15000)
|
|
time.sleep(2)
|
|
shoot(page, "m04_mobile_sport2_homepage")
|
|
# Check no horizontal scroll
|
|
body_w = page.evaluate("() => document.body.scrollWidth")
|
|
viewport_w = 375
|
|
if body_w <= viewport_w + 5: # tolerance
|
|
ok("Mobile homepage no horizontal scroll", body_w=body_w, viewport=viewport_w)
|
|
else:
|
|
fail("Mobile horizontal scroll", f"body width {body_w}px > viewport {viewport_w}px")
|
|
except Exception as e:
|
|
fail("Mobile homepage", str(e))
|
|
|
|
browser.close()
|
|
|
|
# Run
|
|
print(f"Output dir: {OUT}")
|
|
with sync_playwright() as pw:
|
|
test_desktop(pw)
|
|
test_mobile(pw)
|
|
|
|
# Summary
|
|
passed = sum(1 for t in results["tests"] if t["status"] == "PASS")
|
|
failed = sum(1 for t in results["tests"] if t["status"] == "FAIL")
|
|
print(f"\n=== SUMMARY ===")
|
|
print(f"PASS: {passed}, FAIL: {failed}")
|
|
results["summary"] = {"passed": passed, "failed": failed}
|
|
|
|
# Save
|
|
out_json = OUT / "results.json"
|
|
out_json.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
|
print(f"\nResults: {out_json}")
|
|
print(f"Screenshots: {OUT}/*.png")
|
|
|
|
sys.exit(0 if failed == 0 else 1)
|