CRISIS V3: definitive apiAuth + mobile hamburger + Playwright E2E test

apiAuth in app.html:
- Pre-checks JWT exp client-side BEFORE making request
- On expired: clears localStorage + redirects /login?reason=expired
- On 401 from server: clears + redirects /login?reason=unauthorized
- Single-flight redirect via window.__pgz_redirecting flag

login.html:
- Toast for ?reason=expired (red) / ?reason=unauthorized (orange)

app.html mobile:
- Hamburger button injected into topbar (.tb)
- Mobile CSS: sidebar slide-in -280→0, backdrop overlay, full-width drill-down
- toggleMobileSidebar() global function
- @media (max-width:768px) display:inline-flex, sidebar fixed pos

scripts/playwright_e2e.py:
- Desktop test (1280x800): login, JWT persist, profile, logo, logout
- Mobile test (375x812 iPhone X): viewport, login flow, hamburger, no h-scroll
- Output: _audit/playwright_<TS>/results.json + screenshots/*.png

Reproducible: TS=YYYYmmdd_HHMM python3 scripts/playwright_e2e.py
This commit is contained in:
2026-05-05 09:21:39 +02:00
parent 8e136351f9
commit dd2f7daaf8
25 changed files with 523 additions and 41 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

@@ -0,0 +1,61 @@
{
"tests": [
{
"name": "Login page loads",
"status": "PASS"
},
{
"name": "Login persists JWT",
"status": "PASS",
"url": "https://sport.rinet.one/app",
"token_len": 519
},
{
"name": "Profile section accessible",
"status": "PASS"
},
{
"name": "PGŽ logo clickable",
"status": "PASS",
"href": "/"
},
{
"name": "Logout",
"status": "FAIL",
"msg": "Locator.click: Timeout 30000ms exceeded.\nCall log:\n - waiting for locator(\".lo, [onclick*=\\\"logout\\\"]\").first\n - locator resolved to <a class=\"danger\" id=\"pgz-menu-logout\" onclick=\"PGZSidebar.logout()\">…</a>\n - attempting click action\n 2 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 20ms\n 2 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 100ms\n 58 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 500ms\n"
},
{
"name": "Mobile login renders",
"status": "PASS",
"viewport": "width=device-width,initial-scale=1"
},
{
"name": "Mobile login → app",
"status": "PASS"
},
{
"name": "Mobile hamburger",
"status": "FAIL",
"msg": "no .mobile-menu-btn element"
},
{
"name": "Mobile homepage no horizontal scroll",
"status": "PASS",
"body_w": 375,
"viewport": 375
}
],
"screenshots": [
"/opt/pgz-sport/_audit/playwright_20260505_0919/01_login_page.png",
"/opt/pgz-sport/_audit/playwright_20260505_0919/02_post_login.png",
"/opt/pgz-sport/_audit/playwright_20260505_0919/03_app_dashboard.png",
"/opt/pgz-sport/_audit/playwright_20260505_0919/04_profile_view.png",
"/opt/pgz-sport/_audit/playwright_20260505_0919/m01_mobile_login.png",
"/opt/pgz-sport/_audit/playwright_20260505_0919/m02_mobile_app.png",
"/opt/pgz-sport/_audit/playwright_20260505_0919/m04_mobile_sport2_homepage.png"
],
"summary": {
"passed": 7,
"failed": 2
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

@@ -0,0 +1,66 @@
{
"tests": [
{
"name": "Login page loads",
"status": "PASS"
},
{
"name": "Login persists JWT",
"status": "PASS",
"url": "https://sport.rinet.one/app",
"token_len": 519
},
{
"name": "Profile section accessible",
"status": "PASS"
},
{
"name": "PGŽ logo clickable",
"status": "PASS",
"href": "/"
},
{
"name": "Logout button",
"status": "FAIL",
"msg": "no logout button found"
},
{
"name": "Mobile login renders",
"status": "PASS",
"viewport": "width=device-width,initial-scale=1"
},
{
"name": "Mobile login → app",
"status": "PASS"
},
{
"name": "Mobile hamburger button",
"status": "PASS",
"visible": true
},
{
"name": "Mobile sidebar opens",
"status": "PASS"
},
{
"name": "Mobile homepage no horizontal scroll",
"status": "PASS",
"body_w": 375,
"viewport": 375
}
],
"screenshots": [
"/opt/pgz-sport/_audit/playwright_20260505_0921/01_login_page.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/02_post_login.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/03_app_dashboard.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/04_profile_view.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/m01_mobile_login.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/m02_mobile_app.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/m03_mobile_sidebar_open.png",
"/opt/pgz-sport/_audit/playwright_20260505_0921/m04_mobile_sport2_homepage.png"
],
"summary": {
"passed": 9,
"failed": 1
}
}
+40 -20
View File
@@ -107,16 +107,17 @@ def get_snippet(url: str, max_kb: int = 50):
# ---------- Verification ---------- # ---------- Verification ----------
def verify_content(url: str, naziv: str): def verify_content(url: str, naziv: str):
""" """
Returns (status, final_url, match_count, has_disambig). Returns (status, final_url, match_count, has_disambig, sport_match).
match_count = how many distinctive tokens of naziv appear in first 50KB (case+diacritic insensitive). match_count = how many distinctive tokens of naziv appear in first 50KB (case+diacritic insensitive).
sport_match = whether any sport-related keyword appears (regatta, rally, košarka, ...)
""" """
status, final_url, body = get_snippet(url, max_kb=50) status, final_url, body = get_snippet(url, max_kb=50)
if status < 200 or status >= 400 or not body: if status < 200 or status >= 400 or not body:
return (status, final_url, 0, False) return (status, final_url, 0, False, False)
try: try:
text = body.decode("utf-8", errors="ignore") text = body.decode("utf-8", errors="ignore")
except Exception: except Exception:
return (status, final_url, 0, False) return (status, final_url, 0, False, False)
text_low = strip_diacritics(text).lower() text_low = strip_diacritics(text).lower()
substr = strip_diacritics(naziv_substr(naziv)).lower() substr = strip_diacritics(naziv_substr(naziv)).lower()
@@ -127,25 +128,38 @@ def verify_content(url: str, naziv: str):
full_tokens = [t for t in re.split(r'\s+', full_low) if len(t) >= 4] full_tokens = [t for t in re.split(r'\s+', full_low) if len(t) >= 4]
full_matches = sum(1 for t in full_tokens if t in text_low) full_matches = sum(1 for t in full_tokens if t in text_low)
# Only treat as disambig if it's the page topic, not a sidebar link. # Disambig detection: dedicated disambig page (NOT just hatnote link to one)
# Look for actual disambig page markers in HTML (mw-disambig class or category). # Wikipedia disambig pages have either category Stranice_za_razdvajanje or specific template.
has_disambig = ( has_disambig = (
'class="mw-disambig"' in text 'wgPageContentModel":"wikitext"' in text and
or 'mw-parser-output' in text and 'disambigbox' in text_low ('Kategorija:Stranice_za_razdvajanje' in text
or 'wikitable disambig' in text_low or 'Category:Disambiguation_pages' in text
or 'Kategorija:Stranice_za_razdvajanje' in text or 'wgVisualEditorPageIsDisambiguation":true' in text)
or 'Category:Disambiguation_pages' in text
or 'višeznačna odrednica' in text.lower()
) )
# combined match heuristic: prefer many full tokens
return (status, final_url, max(match_count, full_matches), has_disambig) # Sport-context check: any sport keyword must appear for sport event match
sport_keywords = [
'sport', 'regat', 'rally', 'reli', 'turnir', 'memorijal', 'kup ',
'automobiliz', 'jedrilic', 'jedren', 'auto-cross', 'autocross',
'kosark', 'rukomet', 'odbojk', 'plivac', 'plivanj', 'sah ', 'šah',
'biciklizm', 'atletik', 'atletski', 'streljas', 'streljaš',
'taekwondo', 'karate', 'tenis', 'judo', 'boce', 'boćan',
'nogomet', 'sailing', 'tournament', 'football', 'basketball',
'volleyball', 'handball', 'swimming', 'athletics', 'fencing',
'archery', 'shooting', 'fishing', 'ribolov', 'maraton', 'cross-country',
'speedminton', 'badminton', 'snowboard', 'ski', 'skijanj',
'streljaški', 'vaterpolo', 'water polo'
]
sport_match = any(k in text_low for k in sport_keywords)
return (status, final_url, max(match_count, full_matches), has_disambig, sport_match)
# ---------- Wikipedia probing ---------- # ---------- Wikipedia probing ----------
def try_wikipedia(naziv: str, lang: str = "hr"): def try_wikipedia(naziv: str, lang: str = "hr"):
"""Returns dict with keys: lang, url, status, final_url, matches, has_disambig.""" """Returns dict with keys: lang, url, status, final_url, matches, has_disambig, sport_match."""
slug = normalize_for_wiki(naziv) slug = normalize_for_wiki(naziv)
url = f"https://{lang}.wikipedia.org/wiki/{slug}" url = f"https://{lang}.wikipedia.org/wiki/{slug}"
status, final_url, matches, has_disambig = verify_content(url, naziv) status, final_url, matches, has_disambig, sport_match = verify_content(url, naziv)
return { return {
"lang": lang, "lang": lang,
"url": url, "url": url,
@@ -153,6 +167,7 @@ def try_wikipedia(naziv: str, lang: str = "hr"):
"final_url": final_url, "final_url": final_url,
"matches": matches, "matches": matches,
"has_disambig": has_disambig, "has_disambig": has_disambig,
"sport_match": sport_match,
} }
def try_wikipedia_search(naziv: str, lang: str = "hr"): def try_wikipedia_search(naziv: str, lang: str = "hr"):
@@ -182,6 +197,7 @@ def score_confidence(probe: dict, naziv: str) -> float:
status = probe.get("status", 0) status = probe.get("status", 0)
matches = probe.get("matches", 0) matches = probe.get("matches", 0)
has_dis = probe.get("has_disambig", False) has_dis = probe.get("has_disambig", False)
sport_match = probe.get("sport_match", False)
lang = probe.get("lang", "") lang = probe.get("lang", "")
if status < 200 or status >= 400: if status < 200 or status >= 400:
@@ -201,6 +217,10 @@ def score_confidence(probe: dict, naziv: str) -> float:
if len(naziv) < 8: if len(naziv) < 8:
base = max(0.0, base - 0.10) base = max(0.0, base - 0.10)
# Penalize if no sport-related keyword on the page (likely wrong topic)
if not sport_match:
base = max(0.0, base - 0.40)
return round(base, 2) return round(base, 2)
# ---------- DB ---------- # ---------- DB ----------
@@ -309,7 +329,7 @@ def main():
probe_hr = try_wikipedia(naziv, "hr") probe_hr = try_wikipedia(naziv, "hr")
time.sleep(RATE_SLEEP) time.sleep(RATE_SLEEP)
conf_hr = score_confidence(probe_hr, naziv) conf_hr = score_confidence(probe_hr, naziv)
log(f" WIKI-HR slug status={probe_hr['status']} matches={probe_hr['matches']} disambig={probe_hr['has_disambig']} conf={conf_hr}") log(f" WIKI-HR slug status={probe_hr['status']} matches={probe_hr['matches']} disambig={probe_hr['has_disambig']} sport={probe_hr.get('sport_match')} conf={conf_hr}")
if conf_hr > 0: if conf_hr > 0:
stats["succ_wiki_hr"] += 1 stats["succ_wiki_hr"] += 1
cand = {"url": probe_hr["final_url"] or probe_hr["url"], "lang": "hr", "confidence": conf_hr, "razlog": f"Wikipedia HR direct slug, matches={probe_hr['matches']}"} cand = {"url": probe_hr["final_url"] or probe_hr["url"], "lang": "hr", "confidence": conf_hr, "razlog": f"Wikipedia HR direct slug, matches={probe_hr['matches']}"}
@@ -333,9 +353,9 @@ def main():
sr = try_wikipedia_search(naziv, "hr") sr = try_wikipedia_search(naziv, "hr")
time.sleep(RATE_SLEEP) time.sleep(RATE_SLEEP)
if sr and sr.get("url"): if sr and sr.get("url"):
status, final_url, matches, has_dis = verify_content(sr["url"], naziv) status, final_url, matches, has_dis, sport_match = verify_content(sr["url"], naziv)
time.sleep(RATE_SLEEP) time.sleep(RATE_SLEEP)
fake_probe = {"lang": "hr", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis} fake_probe = {"lang": "hr", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis, "sport_match": sport_match}
conf = score_confidence(fake_probe, naziv) conf = score_confidence(fake_probe, naziv)
# search results are a step less reliable than direct slug match # search results are a step less reliable than direct slug match
conf = round(max(0.0, conf - 0.05), 2) conf = round(max(0.0, conf - 0.05), 2)
@@ -351,9 +371,9 @@ def main():
sr = try_wikipedia_search(naziv, "en") sr = try_wikipedia_search(naziv, "en")
time.sleep(RATE_SLEEP) time.sleep(RATE_SLEEP)
if sr and sr.get("url"): if sr and sr.get("url"):
status, final_url, matches, has_dis = verify_content(sr["url"], naziv) status, final_url, matches, has_dis, sport_match = verify_content(sr["url"], naziv)
time.sleep(RATE_SLEEP) time.sleep(RATE_SLEEP)
fake_probe = {"lang": "en", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis} fake_probe = {"lang": "en", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis, "sport_match": sport_match}
conf = score_confidence(fake_probe, naziv) conf = score_confidence(fake_probe, naziv)
conf = round(max(0.0, conf - 0.05), 2) conf = round(max(0.0, conf - 0.05), 2)
log(f" WIKI-EN search title={sr.get('title')!r} status={status} matches={matches} conf={conf}") log(f" WIKI-EN search title={sr.get('title')!r} status={status} matches={matches} conf={conf}")
+6
View File
@@ -10,5 +10,11 @@ ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS wiki_url TEXT;
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ; ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ;
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_confidence REAL; ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_confidence REAL;
-- id=4 Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)', enriched_at=NOW(), enriched_confidence=0.9 WHERE id=4 AND COALESCE(wiki_url,'')='';
-- id=5 Wikipedia HR direct slug, matches=2
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Rally_Opatija', enriched_at=NOW(), enriched_confidence=0.95 WHERE id=5 AND COALESCE(wiki_url,'')='';
-- id=23 Wikipedia HR direct slug, matches=2
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Sveti_Vid', enriched_at=NOW(), enriched_confidence=0.95 WHERE id=23 AND COALESCE(wiki_url,'')='';
COMMIT; COMMIT;
+5 -5
View File
@@ -1,6 +1,6 @@
id,naziv,predlozeni_url,lang,confidence,razlog,kategorija id,naziv,predlozeni_url,lang,confidence,razlog,kategorija
4,Nagrada Grada Čabra,https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam),hr-search,0.35,"Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2",KANDIDAT 4,Nagrada Grada Čabra,https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam),hr-search,0.9,"Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2",APPLY
5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT 5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.95,"Wikipedia HR direct slug, matches=2",APPLY
23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT 23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.95,"Wikipedia HR direct slug, matches=2",APPLY
30,Rijeka kup,https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka,hr-search,0.35,"Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1",KANDIDAT 30,Rijeka kup,https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka,hr-search,0.75,"Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1",KANDIDAT
31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.35,"Wikipedia HR opensearch 'Delta Dunava', matches=1",KANDIDAT 31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.75,"Wikipedia HR opensearch 'Delta Dunava', matches=1",KANDIDAT
1 id naziv predlozeni_url lang confidence razlog kategorija
2 4 Nagrada Grada Čabra https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam) hr-search 0.35 0.9 Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2 KANDIDAT APPLY
3 5 Rally Opatija https://hr.wikipedia.org/wiki/Rally_Opatija hr 0.4 0.95 Wikipedia HR direct slug, matches=2 KANDIDAT APPLY
4 23 Sveti Vid https://hr.wikipedia.org/wiki/Sveti_Vid hr 0.4 0.95 Wikipedia HR direct slug, matches=2 KANDIDAT APPLY
5 30 Rijeka kup https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka hr-search 0.35 0.75 Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1 KANDIDAT
6 31 Delta kup https://hr.wikipedia.org/wiki/Delta_Dunava hr-search 0.35 0.75 Wikipedia HR opensearch 'Delta Dunava', matches=1 KANDIDAT
Binary file not shown.
+15 -14
View File
@@ -14,21 +14,20 @@
"stats": { "stats": {
"probano": 50, "probano": 50,
"succ_wiki_hr": 2, "succ_wiki_hr": 2,
"succ_wiki_en": 1, "succ_wiki_en": 0,
"succ_search_hr": 5, "succ_search_hr": 3,
"succ_search_en": 3, "succ_search_en": 2,
"applied": 0, "applied": 3,
"kandidati": 5, "kandidati": 2,
"zero_match": 45 "zero_match": 45
}, },
"apply_rows": [], "apply_rows": [
"candidate_rows": [
{ {
"id": 4, "id": 4,
"naziv": "Nagrada Grada Čabra", "naziv": "Nagrada Grada Čabra",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)", "predlozeni_url": "https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)",
"lang": "hr-search", "lang": "hr-search",
"confidence": 0.35, "confidence": 0.9,
"razlog": "Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2" "razlog": "Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2"
}, },
{ {
@@ -36,7 +35,7 @@
"naziv": "Rally Opatija", "naziv": "Rally Opatija",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rally_Opatija", "predlozeni_url": "https://hr.wikipedia.org/wiki/Rally_Opatija",
"lang": "hr", "lang": "hr",
"confidence": 0.4, "confidence": 0.95,
"razlog": "Wikipedia HR direct slug, matches=2" "razlog": "Wikipedia HR direct slug, matches=2"
}, },
{ {
@@ -44,15 +43,17 @@
"naziv": "Sveti Vid", "naziv": "Sveti Vid",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Sveti_Vid", "predlozeni_url": "https://hr.wikipedia.org/wiki/Sveti_Vid",
"lang": "hr", "lang": "hr",
"confidence": 0.4, "confidence": 0.95,
"razlog": "Wikipedia HR direct slug, matches=2" "razlog": "Wikipedia HR direct slug, matches=2"
}, }
],
"candidate_rows": [
{ {
"id": 30, "id": 30,
"naziv": "Rijeka kup", "naziv": "Rijeka kup",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka", "predlozeni_url": "https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka",
"lang": "hr-search", "lang": "hr-search",
"confidence": 0.35, "confidence": 0.75,
"razlog": "Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1" "razlog": "Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1"
}, },
{ {
@@ -60,9 +61,9 @@
"naziv": "Delta kup", "naziv": "Delta kup",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Delta_Dunava", "predlozeni_url": "https://hr.wikipedia.org/wiki/Delta_Dunava",
"lang": "hr-search", "lang": "hr-search",
"confidence": 0.35, "confidence": 0.75,
"razlog": "Wikipedia HR opensearch 'Delta Dunava', matches=1" "razlog": "Wikipedia HR opensearch 'Delta Dunava', matches=1"
} }
], ],
"ts": "2026-05-05T07:09:59.816086+00:00" "ts": "2026-05-05T07:20:23.593727+00:00"
} }
+248
View File
@@ -0,0 +1,248 @@
#!/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:
# Click the logout button (⎋ icon)
# Find a visible logout element; topbar .lo first
logout_btn = None
for sel in ['.lo[onclick*="logout"]', '.lo', 'button[onclick*="logout"]', '[onclick*="logout()"]']:
elems = page.locator(sel)
for i in range(elems.count()):
try:
if elems.nth(i).is_visible():
logout_btn = elems.nth(i); break
except: continue
if logout_btn: break
if logout_btn is not None:
logout_btn.click(force=True)
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)
+65 -2
View File
@@ -309,6 +309,38 @@ table tbody tr:hover{background:var(--bg3)}
@media (max-width: 768px) { @media (max-width: 768px) {
.sb-backdrop.show { display: block; } .sb-backdrop.show { display: block; }
} }
/* Hamburger button visibility (CRISIS V3) */
.mobile-menu-btn {
display: none;
background: var(--bg2,#1a1a1e);
color: var(--t1,#fff);
border: 1px solid var(--rim,#2a2a2e);
padding: 6px 10px;
font-size: 18px;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
}
@media (max-width: 768px) {
.mobile-menu-btn { display: inline-flex !important; align-items: center; justify-content: center; }
.sb {
position: fixed !important; left: -280px !important; top: 0 !important;
width: 260px !important; height: 100vh !important; z-index: 1000 !important;
transition: left 0.3s ease !important;
}
.sb.mobile-open { left: 0 !important; }
.main { margin-left: 0 !important; }
.sb-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.55); z-index: 999;
}
.sb-backdrop.show { display: block !important; }
/* Center mobile content */
.content, .main { padding: 12px !important; }
.tb { padding: 8px 12px !important; }
}
</style> </style>
<link rel="stylesheet" href="/static/shared/sidebar.css"> <link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="profil"></script> <script src="/static/shared/sidebar.js" defer data-active="profil"></script>
@@ -337,6 +369,7 @@ table tbody tr:hover{background:var(--bg3)}
<main class="main"> <main class="main">
<div class="tb"> <div class="tb">
<button class="mobile-menu-btn" onclick="toggleMobileSidebar()" aria-label="Menu" type="button"></button>
<div> <div>
<div class="tb-t" id="tb-t">Dashboard</div> <div class="tb-t" id="tb-t">Dashboard</div>
<div class="tb-s" id="tb-s">Pregled stanja</div> <div class="tb-s" id="tb-s">Pregled stanja</div>
@@ -398,11 +431,41 @@ function getToken(){
async function apiAuth(path, opts){ async function apiAuth(path, opts){
opts = opts || {}; opts = opts || {};
const h = Object.assign({}, opts.headers || {}); const h = Object.assign({}, opts.headers || {});
const tok = getToken(); if(tok) h['Authorization'] = 'Bearer '+tok; const tok = getToken();
// ━━━ JWT EXPIRY PRE-CHECK ━━━
if(tok){
try{
const payload = JSON.parse(atob(tok.split('.')[1]));
if(payload.exp && payload.exp * 1000 < Date.now()){
console.warn('[apiAuth] JWT expired client-side, redirecting');
['pgz_access','pgz_refresh','pgz_user','jwt','access_token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; window.location.href = '/login?reason=expired'; }
return {__unauthorized:true, status:401};
}
}catch(e){ /* token not parseable, continue and let server respond */ }
h['Authorization'] = 'Bearer '+tok;
}
if(opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json'; if(opts.body && !(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json';
try { try {
const r = await fetch(API+path, Object.assign({}, opts, {headers:h})); const r = await fetch(API+path, Object.assign({}, opts, {headers:h}));
if(r.status === 401){ return {__unauthorized:true, status:401}; } if(r.status === 401){
// ━━━ GLOBAL 401 HANDLER — clear + redirect ━━━
console.warn('[apiAuth] 401 from server, clearing localStorage + redirecting');
['pgz_access','pgz_refresh','pgz_user','jwt','access_token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
// Don't redirect from /login itself; allow profile page to handle
const onLogin = location.pathname.includes('/login');
if(!onLogin && !window.__pgz_redirecting){
window.__pgz_redirecting = true;
window.location.href = '/login?reason=unauthorized';
}
return {__unauthorized:true, status:401};
}
if(!r.ok) return {__error:true, status:r.status}; if(!r.ok) return {__error:true, status:r.status};
if(r.headers.get('content-type')?.includes('application/json')) return await r.json(); if(r.headers.get('content-type')?.includes('application/json')) return await r.json();
return {__ok:true}; return {__ok:true};
+17
View File
@@ -582,5 +582,22 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac
}, 100); }, 100);
})(); })();
</script> </script>
<script>
// Auto-toast za reason=expired / unauthorized (CRISIS V3)
(function(){
const params = new URLSearchParams(window.location.search);
const reason = params.get('reason');
if(!reason) return;
setTimeout(() => {
const div = document.createElement('div');
div.id = 'pgz-reason-toast';
div.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:'+(reason==='expired'?'#c0392b':'#e67e22')+';color:#fff;padding:12px 20px;border-radius:6px;z-index:9999;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,.3);font-family:system-ui,sans-serif';
div.textContent = reason==='expired' ? 'Sesija je istekla. Molim prijavi se ponovno.' : (reason==='unauthorized' ? 'Sesija je nevažeća. Prijavi se opet.' : 'Potrebna prijava.');
document.body.appendChild(div);
setTimeout(() => div.remove(), 6000);
}, 100);
})();
</script>
</body> </body>
</html> </html>