diff --git a/_audit/playwright_20260505_0919/01_login_page.png b/_audit/playwright_20260505_0919/01_login_page.png new file mode 100644 index 0000000..f1cfde1 Binary files /dev/null and b/_audit/playwright_20260505_0919/01_login_page.png differ diff --git a/_audit/playwright_20260505_0919/02_post_login.png b/_audit/playwright_20260505_0919/02_post_login.png new file mode 100644 index 0000000..faf9165 Binary files /dev/null and b/_audit/playwright_20260505_0919/02_post_login.png differ diff --git a/_audit/playwright_20260505_0919/03_app_dashboard.png b/_audit/playwright_20260505_0919/03_app_dashboard.png new file mode 100644 index 0000000..faf9165 Binary files /dev/null and b/_audit/playwright_20260505_0919/03_app_dashboard.png differ diff --git a/_audit/playwright_20260505_0919/04_profile_view.png b/_audit/playwright_20260505_0919/04_profile_view.png new file mode 100644 index 0000000..cbc229b Binary files /dev/null and b/_audit/playwright_20260505_0919/04_profile_view.png differ diff --git a/_audit/playwright_20260505_0919/m01_mobile_login.png b/_audit/playwright_20260505_0919/m01_mobile_login.png new file mode 100644 index 0000000..ce2c57c Binary files /dev/null and b/_audit/playwright_20260505_0919/m01_mobile_login.png differ diff --git a/_audit/playwright_20260505_0919/m02_mobile_app.png b/_audit/playwright_20260505_0919/m02_mobile_app.png new file mode 100644 index 0000000..587d520 Binary files /dev/null and b/_audit/playwright_20260505_0919/m02_mobile_app.png differ diff --git a/_audit/playwright_20260505_0919/m04_mobile_sport2_homepage.png b/_audit/playwright_20260505_0919/m04_mobile_sport2_homepage.png new file mode 100644 index 0000000..8dd170a Binary files /dev/null and b/_audit/playwright_20260505_0919/m04_mobile_sport2_homepage.png differ diff --git a/_audit/playwright_20260505_0919/results.json b/_audit/playwright_20260505_0919/results.json new file mode 100644 index 0000000..c6c1266 --- /dev/null +++ b/_audit/playwright_20260505_0919/results.json @@ -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 \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 + } +} \ No newline at end of file diff --git a/_audit/playwright_20260505_0921/01_login_page.png b/_audit/playwright_20260505_0921/01_login_page.png new file mode 100644 index 0000000..f1cfde1 Binary files /dev/null and b/_audit/playwright_20260505_0921/01_login_page.png differ diff --git a/_audit/playwright_20260505_0921/02_post_login.png b/_audit/playwright_20260505_0921/02_post_login.png new file mode 100644 index 0000000..c6fffc2 Binary files /dev/null and b/_audit/playwright_20260505_0921/02_post_login.png differ diff --git a/_audit/playwright_20260505_0921/03_app_dashboard.png b/_audit/playwright_20260505_0921/03_app_dashboard.png new file mode 100644 index 0000000..c6fffc2 Binary files /dev/null and b/_audit/playwright_20260505_0921/03_app_dashboard.png differ diff --git a/_audit/playwright_20260505_0921/04_profile_view.png b/_audit/playwright_20260505_0921/04_profile_view.png new file mode 100644 index 0000000..cadd961 Binary files /dev/null and b/_audit/playwright_20260505_0921/04_profile_view.png differ diff --git a/_audit/playwright_20260505_0921/m01_mobile_login.png b/_audit/playwright_20260505_0921/m01_mobile_login.png new file mode 100644 index 0000000..61edefe Binary files /dev/null and b/_audit/playwright_20260505_0921/m01_mobile_login.png differ diff --git a/_audit/playwright_20260505_0921/m02_mobile_app.png b/_audit/playwright_20260505_0921/m02_mobile_app.png new file mode 100644 index 0000000..dbdf790 Binary files /dev/null and b/_audit/playwright_20260505_0921/m02_mobile_app.png differ diff --git a/_audit/playwright_20260505_0921/m03_mobile_sidebar_open.png b/_audit/playwright_20260505_0921/m03_mobile_sidebar_open.png new file mode 100644 index 0000000..9bec9c4 Binary files /dev/null and b/_audit/playwright_20260505_0921/m03_mobile_sidebar_open.png differ diff --git a/_audit/playwright_20260505_0921/m04_mobile_sport2_homepage.png b/_audit/playwright_20260505_0921/m04_mobile_sport2_homepage.png new file mode 100644 index 0000000..8dd170a Binary files /dev/null and b/_audit/playwright_20260505_0921/m04_mobile_sport2_homepage.png differ diff --git a/_audit/playwright_20260505_0921/results.json b/_audit/playwright_20260505_0921/results.json new file mode 100644 index 0000000..f3a96cd --- /dev/null +++ b/_audit/playwright_20260505_0921/results.json @@ -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 + } +} \ No newline at end of file diff --git a/_audit/sub4_enrich.py b/_audit/sub4_enrich.py index ebfe2b0..8435802 100644 --- a/_audit/sub4_enrich.py +++ b/_audit/sub4_enrich.py @@ -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}") diff --git a/_audit/sub4_manifestacije_apply.sql b/_audit/sub4_manifestacije_apply.sql index 9606fba..c670635 100644 --- a/_audit/sub4_manifestacije_apply.sql +++ b/_audit/sub4_manifestacije_apply.sql @@ -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; diff --git a/_audit/sub4_manifestacije_kandidati.csv b/_audit/sub4_manifestacije_kandidati.csv index 1388289..363536c 100644 --- a/_audit/sub4_manifestacije_kandidati.csv +++ b/_audit/sub4_manifestacije_kandidati.csv @@ -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 diff --git a/_audit/sub4_manifestacije_kandidati.xlsx b/_audit/sub4_manifestacije_kandidati.xlsx index 557311a..9986456 100644 Binary files a/_audit/sub4_manifestacije_kandidati.xlsx and b/_audit/sub4_manifestacije_kandidati.xlsx differ diff --git a/_audit/sub4_manifestacije_stats.json b/_audit/sub4_manifestacije_stats.json index 96678dd..a4067e7 100644 --- a/_audit/sub4_manifestacije_stats.json +++ b/_audit/sub4_manifestacije_stats.json @@ -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" } \ No newline at end of file diff --git a/scripts/playwright_e2e.py b/scripts/playwright_e2e.py new file mode 100755 index 0000000..cae71bb --- /dev/null +++ b/scripts/playwright_e2e.py @@ -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) diff --git a/static/app.html b/static/app.html index bc9716d..a4a1d83 100644 --- a/static/app.html +++ b/static/app.html @@ -309,6 +309,38 @@ table tbody tr:hover{background:var(--bg3)} @media (max-width: 768px) { .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; } +} @@ -337,6 +369,7 @@ table tbody tr:hover{background:var(--bg3)}
+
Dashboard
Pregled stanja
@@ -398,11 +431,41 @@ function getToken(){ async function apiAuth(path, opts){ opts = opts || {}; 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'; try { 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.headers.get('content-type')?.includes('application/json')) return await r.json(); return {__ok:true}; diff --git a/static/login.html b/static/login.html index afa4f0f..dd19ba2 100644 --- a/static/login.html +++ b/static/login.html @@ -582,5 +582,22 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac }, 100); })(); + +