diff --git a/src/app/prayer/page.tsx b/src/app/prayer/page.tsx index 1011ba1..a68e5fa 100644 --- a/src/app/prayer/page.tsx +++ b/src/app/prayer/page.tsx @@ -81,7 +81,9 @@ const DEFAULT_LOCATION: LocationSettings = { // ── Helpers ── function parseTimeToMs(timeStr: string, dayOffset = 0): number { - const [h, m] = timeStr.split(":").map(Number); + const clean = timeStr.replace(/\s*\(.*\)\s*$/, "").trim(); + const [h, m] = clean.split(":").map(Number); + if (isNaN(h) || isNaN(m)) return 0; const d = new Date(); d.setDate(d.getDate() + dayOffset); d.setHours(h, m, 0, 0); diff --git a/tests/qa-visual.py b/tests/qa-visual.py index 07e991a..30b27fa 100644 --- a/tests/qa-visual.py +++ b/tests/qa-visual.py @@ -43,136 +43,156 @@ def section(title): def sub(title): print(f"\n\033[0;36m── {title} ──\033[0m") -CHECK_SCRIPT = """ -// ── Wait for React hydration & API calls ── -async function waitForPageStable() { - // Wait for network to idle - await new Promise(r => setTimeout(r, 2000)); - // Check for common loading indicators - const loadingEls = document.querySelectorAll('[class*="animate-spin"], [class*="loading"], [class*="Loader"]'); - return loadingEls.length; -} -// ── Collect all visible text ── -function getVisibleText() { - return document.body?.innerText || ''; -} -// ── Collect all API fetch URLs ── -function getFetchUrls() { - return performance.getEntriesByType('resource') - .filter(e => e.initiatorType === 'fetch' || e.initiatorType === 'xmlhttprequest') - .map(e => ({ url: e.name, status: e.responseStatus, duration: e.duration })); -} -// ── Check if a text is in the visible DOM ── -function hasText(t) { - return document.body?.innerText?.includes(t) || false; -} -// ── Check for error patterns in DOM ── -function findErrors() { - const patterns = [%s]; - const body = document.body?.innerHTML || ''; - return patterns.filter(p => body.toLowerCase().includes(p.toLowerCase())); -} -""" - -# The actual error patterns to search for — passed as JS array ERROR_PATTERNS_JS = json.dumps([ "Invalid response", "Internal Server Error", "Application error", "Cannot read properties of", "undefined is not", "TypeError", "Failed to fetch", "not found", "500 Internal", ]) -def vischeck(page, name: str, check_after_load: bool = True, expected_texts: list = None): - """Run a visual check on a page using Playwright.""" + +def authenticate(browser): + """Login with demo credentials and return authenticated context + page.""" + context = browser.new_context(viewport=VIEWPORTS[viewport]) + page = context.new_page() + page.on("pageerror", lambda err: None) + try: - from playwright.sync_api import sync_playwright - except ImportError: - fail("Playwright not installed. Run: pip install playwright && playwright install chromium") + # Step 1: Go to homepage → login page + page.goto(f"{BASE_URL.rstrip('/')}/", timeout=TIMEOUT_MS, wait_until="networkidle") + page.wait_for_timeout(1000) + + # Step 2: Click "Continue with Email" + email_btn = page.locator("button:has-text('Continue with Email')") + if email_btn.count() > 0: + email_btn.click() + page.wait_for_timeout(1500) + + # Step 3: Click "Sign in" to get to the login form + sign_in = page.locator("button:has-text('Sign in')") + if sign_in.count() > 0: + sign_in.click() + page.wait_for_timeout(1500) + + # Step 4: Fill email + email_input = page.locator("input[type='email']") + if email_input.count() > 0: + email_input.fill("demo@falahos.my") + page.wait_for_timeout(200) + + # Step 5: Fill password + pw_input = page.locator("input[type='password']") + if pw_input.count() > 0: + pw_input.fill("password123") + page.wait_for_timeout(200) + + # Step 6: Submit + submit = page.locator("button[type='submit'], button:has-text('Sign In')") + if submit.count() > 0: + submit.first.click() + page.wait_for_timeout(3000) + + # Wait for network idle after login + try: + page.wait_for_load_state("networkidle", timeout=10000) + except: + pass + page.wait_for_timeout(1000) + + # Verify auth + body_text = page.inner_text("body") or "" + if "Assalamualaikum" in body_text: + ok("Authenticated successfully") + else: + warn(f"Auth may not have worked — body starts: {body_text[:80]}...") + + return context, page + except Exception as e: + fail(f"Authentication failed: {e}") + context.close() + return None, None + + +def vischeck(context, page_obj, name: str, path: str = None, check_after_load: bool = True, expected_texts: list = None): + """Run a visual check on a page using Playwright.""" + console_errors = [] + page_obj.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) + page_obj.on("pageerror", lambda err: console_errors.append(f"PAGE ERROR: {err}")) + + try: + url_path = (path or name).lstrip('/') + url = f"{BASE_URL.rstrip('/')}/{url_path}" + t0 = time.time() + # halal-monitor has a Leaflet map that can hang in headless; + # use domcontentloaded instead of networkidle to avoid tile timeout + wait_state = "domcontentloaded" if "halal" in url_path else "networkidle" + page_obj.goto(url, timeout=TIMEOUT_MS, wait_until=wait_state) + load_ms = (time.time() - t0) * 1000 + + # Wait for React to settle + page_obj.wait_for_timeout(3000) + + # More checks + visible_text = page_obj.evaluate("document.body?.innerText || ''") + has_errors = page_obj.evaluate(f"document.body?.innerHTML?.toLowerCase() || ''") + + except Exception as e: + fail(f"{name} — browser error: {e}") return - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - context = browser.new_context(viewport=VIEWPORTS[viewport]) - page_obj = context.new_page() + # ── Result analysis ── + ok(f"{name} — loaded in {load_ms:.0f}ms") - # Collect console errors - console_errors = [] - page_obj.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) - page_obj.on("pageerror", lambda err: console_errors.append(f"PAGE ERROR: {err}")) + # 1. Console errors + js_errors = [e for e in console_errors if "favicon" not in e.lower() and "404" not in e] + if js_errors: + for err in js_errors[:3]: + fail(f"{name} — console error: {err[:120]}") + else: + ok(f"{name} — no JS console errors") - try: - url = f"{BASE_URL.rstrip('/')}/{name.lstrip('/')}" - t0 = time.time() - page_obj.goto(url, timeout=TIMEOUT_MS, wait_until="networkidle") - load_ms = (time.time() - t0) * 1000 - - # Wait for React to settle - page_obj.wait_for_timeout(2000) - - # More checks - visible_text = page_obj.evaluate("document.body?.innerText || ''") - has_errors = page_obj.evaluate(f"document.body?.innerHTML?.toLowerCase() || ''") - - except Exception as e: - fail(f"{name} — browser error: {e}") - browser.close() - return - - # ── Result analysis ── - ok(f"{name} — loaded in {load_ms:.0f}ms") - - # 1. Console errors - js_errors = [e for e in console_errors if "favicon" not in e.lower() and "404" not in e] - if js_errors: - for err in js_errors[:3]: - fail(f"{name} — console error: {err[:120]}") - else: - ok(f"{name} — no JS console errors") - - # 2. Expected content (post-hydration) - if expected_texts: - for et in expected_texts: - if et.lower() in visible_text.lower(): - ok(f"{name} — visible text contains \"{et}\"") - else: - # Check if it's in the raw HTML (might be inside a shadow DOM or canvas) - if et.lower() in (page_obj.content() or "").lower(): - warn(f"{name} — \"{et}\" in HTML but not visible text (maybe offscreen)") - else: - fail(f"{name} — \"{et}\" not found in page at all") - - # 3. Error patterns in rendered DOM - for pat in json.loads(ERROR_PATTERNS_JS): - if pat.lower() in has_errors: - idx = has_errors.find(pat.lower()) - ctx = has_errors[max(0,idx-30):idx+len(pat)+30] - fail(f"{name} — ERROR \"{pat}\" in rendered DOM: ...{ctx}...") + # 2. Expected content (post-hydration) + if expected_texts: + for et in expected_texts: + if et.lower() in visible_text.lower(): + ok(f"{name} — visible text contains \"{et}\"") else: - ok(f"{name} — clean of \"{pat}\"") + # Check if it's in the raw HTML (might be inside a shadow DOM or canvas) + if et.lower() in (page_obj.content() or "").lower(): + warn(f"{name} — \"{et}\" in HTML but not visible text (maybe offscreen)") + else: + fail(f"{name} — \"{et}\" not found in page at all") - # 4. Network fetch analysis - fetch_urls = page_obj.evaluate(""" - performance.getEntriesByType('resource') - .filter(e => e.initiatorType === 'fetch') - .map(e => ({ url: e.name, status: e.responseStatus, duration: e.duration })) - """) - failed_fetches = [f for f in fetch_urls if f["status"] and f["status"] >= 400] - if failed_fetches: - for ff in failed_fetches: - short_url = ff["url"].split("/")[-2] + "/" + ff["url"].split("/")[-1] if "/" in ff["url"] else ff["url"] - fail(f"{name} — API fetch failed: {short_url} → {ff['status']} ({ff['duration']:.0f}ms)") + # 3. Error patterns in rendered DOM + for pat in json.loads(ERROR_PATTERNS_JS): + if pat.lower() in has_errors: + idx = has_errors.find(pat.lower()) + ctx = has_errors[max(0,idx-30):idx+len(pat)+30] + fail(f"{name} — ERROR \"{pat}\" in rendered DOM: ...{ctx}...") else: - ok(f"{name} — all API fetches succeeded") + ok(f"{name} — clean of \"{pat}\"") - # 5. Loading spinners cleared - spinners = page_obj.evaluate(""" - document.querySelectorAll('[class*="animate-spin"], [class*="loading"], [class*="Loader"]').length - """) - if spinners > 3: - warn(f"{name} — {spinners} loading indicators still visible (may be stuck)") - else: - ok(f"{name} — loading states resolved ({spinners} remaining)") + # 4. Network fetch analysis + fetch_urls = page_obj.evaluate(""" + performance.getEntriesByType('resource') + .filter(e => e.initiatorType === 'fetch') + .map(e => ({ url: e.name, status: e.responseStatus, duration: e.duration })) + """) + failed_fetches = [f for f in fetch_urls if f["status"] and f["status"] >= 400] + if failed_fetches: + for ff in failed_fetches: + short_url = ff["url"].split("/")[-2] + "/" + ff["url"].split("/")[-1] if "/" in ff["url"] else ff["url"] + fail(f"{name} — API fetch failed: {short_url} → {ff['status']} ({ff['duration']:.0f}ms)") + else: + ok(f"{name} — all API fetches succeeded") - browser.close() + # 5. Loading spinners cleared + spinners = page_obj.evaluate(""" + document.querySelectorAll('[class*="animate-spin"], [class*="loading"], [class*="Loader"]').length + """) + if spinners > 3: + warn(f"{name} — {spinners} loading indicators still visible (may be stuck)") + else: + ok(f"{name} — loading states resolved ({spinners} remaining)") def main(): @@ -193,24 +213,46 @@ def main(): print(f" Viewport: {args.viewport} {VIEWPORTS[args.viewport]}") print(f"{'='*60}") - pages = { + # — Authenticate-first pages (auth-gated) + auth_pages = { "home": ("/", ["Assalamualaikum", "Falah", "Daily Verse"]), "prayer": ("/prayer", ["Prayer Times", "Fajr", "Dhuhr", "Asr", "Maghrib", "Isha", "Kuala Lumpur"]), "dhikr": ("/dhikr", ["Dhikr", "SubhanAllah", "Alhamdulillah", "Allahu Akbar"]), - "qibla": ("/qibla", ["Qibla", "Kaaba"]), - "halal-monitor": ("/halal-monitor", ["Halal", "Restaurant"]), + "qibla": ("/qibla", ["Qibla", "Kaaba", "Location not available"]), + "halal-monitor": ("/halal-monitor", ["Halal", "Places"]), } - if args.page == "all": - for pname, (path, texts) in pages.items(): - sub(f"Visual check: {pname} ({path})") - try: - vischeck({}, pname, path, check_after_load=True, expected_texts=texts) - except Exception as e: - fail(f"{pname} — crashed: {e}") - else: - path, texts = pages[args.page] - vischeck({}, args.page, path, check_after_load=True, expected_texts=texts) + try: + from playwright.sync_api import sync_playwright + except ImportError: + fail("Playwright not installed. Run: pip install playwright && playwright install chromium") + return + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + + # Step 1: Authenticate + section("AUTHENTICATION") + context, auth_page = authenticate(browser) + if not context or not auth_page: + fail("Cannot continue without authentication") + browser.close() + return + + # Step 2: Test auth-gated pages + if args.page == "all": + for pname, (path, texts) in auth_pages.items(): + sub(f"Visual check (authenticated): {pname} ({path})") + try: + vischeck(context, auth_page, pname, path=path, check_after_load=True, expected_texts=texts) + except Exception as e: + fail(f"{pname} — crashed: {e}") + else: + path, texts = auth_pages[args.page] + vischeck(context, auth_page, args.page, path=path, check_after_load=True, expected_texts=texts) + + context.close() + browser.close() total = PASS + FAIL + WARN grade = "A"