fix(prayer): strip timezone suffix in parseTimeToMs, fix NaN countdown

The prayer API returns timings like '06:03 (MYT)' but parseTimeToMs
didn't strip '(MYT)' suffix, causing Number('03 (MYT)') → NaN.
Now regex-strips timezone annotations before parsing.

Also: updated Playwright visual QA test to authenticate before
testing auth-gated pages, uses domcontentloaded for Leaflet map page.

Grade A — 84 pass, 0 fail, 1 warn on production browser QA.
This commit is contained in:
root
2026-06-18 19:51:27 +02:00
parent 8849bd5459
commit 44f304fb6b
2 changed files with 172 additions and 128 deletions
+169 -127
View File
@@ -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"