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 ----------
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).
sport_match = whether any sport-related keyword appears (regatta, rally, košarka, ...)
"""
status, final_url, body = get_snippet(url, max_kb=50)
if status < 200 or status >= 400 or not body:
return (status, final_url, 0, False)
return (status, final_url, 0, False, False)
try:
text = body.decode("utf-8", errors="ignore")
except Exception:
return (status, final_url, 0, False)
return (status, final_url, 0, False, False)
text_low = strip_diacritics(text).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_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.
# Look for actual disambig page markers in HTML (mw-disambig class or category).
# Disambig detection: dedicated disambig page (NOT just hatnote link to one)
# Wikipedia disambig pages have either category Stranice_za_razdvajanje or specific template.
has_disambig = (
'class="mw-disambig"' in text
or 'mw-parser-output' in text and 'disambigbox' in text_low
or 'wikitable disambig' in text_low
or 'Kategorija:Stranice_za_razdvajanje' in text
or 'Category:Disambiguation_pages' in text
or 'višeznačna odrednica' in text.lower()
'wgPageContentModel":"wikitext"' in text and
('Kategorija:Stranice_za_razdvajanje' in text
or 'Category:Disambiguation_pages' in text
or 'wgVisualEditorPageIsDisambiguation":true' in text)
)
# 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 ----------
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)
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 {
"lang": lang,
"url": url,
@@ -153,6 +167,7 @@ def try_wikipedia(naziv: str, lang: str = "hr"):
"final_url": final_url,
"matches": matches,
"has_disambig": has_disambig,
"sport_match": sport_match,
}
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)
matches = probe.get("matches", 0)
has_dis = probe.get("has_disambig", False)
sport_match = probe.get("sport_match", False)
lang = probe.get("lang", "")
if status < 200 or status >= 400:
@@ -201,6 +217,10 @@ def score_confidence(probe: dict, naziv: str) -> float:
if len(naziv) < 8:
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)
# ---------- DB ----------
@@ -309,7 +329,7 @@ def main():
probe_hr = try_wikipedia(naziv, "hr")
time.sleep(RATE_SLEEP)
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:
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']}"}
@@ -333,9 +353,9 @@ def main():
sr = try_wikipedia_search(naziv, "hr")
time.sleep(RATE_SLEEP)
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)
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)
# search results are a step less reliable than direct slug match
conf = round(max(0.0, conf - 0.05), 2)
@@ -351,9 +371,9 @@ def main():
sr = try_wikipedia_search(naziv, "en")
time.sleep(RATE_SLEEP)
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)
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 = round(max(0.0, conf - 0.05), 2)
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_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;
+5 -5
View File
@@ -1,6 +1,6 @@
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
5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT
23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT
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
31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.35,"Wikipedia HR opensearch 'Delta Dunava', matches=1",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.95,"Wikipedia HR direct slug, matches=2",APPLY
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.75,"Wikipedia HR opensearch 'Rijeka dubrovačka', 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": {
"probano": 50,
"succ_wiki_hr": 2,
"succ_wiki_en": 1,
"succ_search_hr": 5,
"succ_search_en": 3,
"applied": 0,
"kandidati": 5,
"succ_wiki_en": 0,
"succ_search_hr": 3,
"succ_search_en": 2,
"applied": 3,
"kandidati": 2,
"zero_match": 45
},
"apply_rows": [],
"candidate_rows": [
"apply_rows": [
{
"id": 4,
"naziv": "Nagrada Grada Čabra",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)",
"lang": "hr-search",
"confidence": 0.35,
"confidence": 0.9,
"razlog": "Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2"
},
{
@@ -36,7 +35,7 @@
"naziv": "Rally Opatija",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rally_Opatija",
"lang": "hr",
"confidence": 0.4,
"confidence": 0.95,
"razlog": "Wikipedia HR direct slug, matches=2"
},
{
@@ -44,15 +43,17 @@
"naziv": "Sveti Vid",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Sveti_Vid",
"lang": "hr",
"confidence": 0.4,
"confidence": 0.95,
"razlog": "Wikipedia HR direct slug, matches=2"
},
}
],
"candidate_rows": [
{
"id": 30,
"naziv": "Rijeka kup",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka",
"lang": "hr-search",
"confidence": 0.35,
"confidence": 0.75,
"razlog": "Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1"
},
{
@@ -60,9 +61,9 @@
"naziv": "Delta kup",
"predlozeni_url": "https://hr.wikipedia.org/wiki/Delta_Dunava",
"lang": "hr-search",
"confidence": 0.35,
"confidence": 0.75,
"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"
}