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
+3 -1
View File
@@ -81,7 +81,9 @@ const DEFAULT_LOCATION: LocationSettings = {
// ── Helpers ── // ── Helpers ──
function parseTimeToMs(timeStr: string, dayOffset = 0): number { 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(); const d = new Date();
d.setDate(d.getDate() + dayOffset); d.setDate(d.getDate() + dayOffset);
d.setHours(h, m, 0, 0); d.setHours(h, m, 0, 0);
+169 -127
View File
@@ -43,136 +43,156 @@ def section(title):
def sub(title): def sub(title):
print(f"\n\033[0;36m── {title} ──\033[0m") 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([ ERROR_PATTERNS_JS = json.dumps([
"Invalid response", "Internal Server Error", "Application error", "Invalid response", "Internal Server Error", "Application error",
"Cannot read properties of", "undefined is not", "TypeError", "Cannot read properties of", "undefined is not", "TypeError",
"Failed to fetch", "not found", "500 Internal", "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: try:
from playwright.sync_api import sync_playwright # Step 1: Go to homepage → login page
except ImportError: page.goto(f"{BASE_URL.rstrip('/')}/", timeout=TIMEOUT_MS, wait_until="networkidle")
fail("Playwright not installed. Run: pip install playwright && playwright install chromium") 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 return
with sync_playwright() as p: # ── Result analysis ──
browser = p.chromium.launch(headless=True) ok(f"{name} — loaded in {load_ms:.0f}ms")
context = browser.new_context(viewport=VIEWPORTS[viewport])
page_obj = context.new_page()
# Collect console errors # 1. Console errors
console_errors = [] js_errors = [e for e in console_errors if "favicon" not in e.lower() and "404" not in e]
page_obj.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None) if js_errors:
page_obj.on("pageerror", lambda err: console_errors.append(f"PAGE ERROR: {err}")) for err in js_errors[:3]:
fail(f"{name} — console error: {err[:120]}")
else:
ok(f"{name} — no JS console errors")
try: # 2. Expected content (post-hydration)
url = f"{BASE_URL.rstrip('/')}/{name.lstrip('/')}" if expected_texts:
t0 = time.time() for et in expected_texts:
page_obj.goto(url, timeout=TIMEOUT_MS, wait_until="networkidle") if et.lower() in visible_text.lower():
load_ms = (time.time() - t0) * 1000 ok(f"{name} — visible text contains \"{et}\"")
# 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}...")
else: 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 # 3. Error patterns in rendered DOM
fetch_urls = page_obj.evaluate(""" for pat in json.loads(ERROR_PATTERNS_JS):
performance.getEntriesByType('resource') if pat.lower() in has_errors:
.filter(e => e.initiatorType === 'fetch') idx = has_errors.find(pat.lower())
.map(e => ({ url: e.name, status: e.responseStatus, duration: e.duration })) ctx = has_errors[max(0,idx-30):idx+len(pat)+30]
""") fail(f"{name} — ERROR \"{pat}\" in rendered DOM: ...{ctx}...")
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: else:
ok(f"{name}all API fetches succeeded") ok(f"{name}clean of \"{pat}\"")
# 5. Loading spinners cleared # 4. Network fetch analysis
spinners = page_obj.evaluate(""" fetch_urls = page_obj.evaluate("""
document.querySelectorAll('[class*="animate-spin"], [class*="loading"], [class*="Loader"]').length performance.getEntriesByType('resource')
""") .filter(e => e.initiatorType === 'fetch')
if spinners > 3: .map(e => ({ url: e.name, status: e.responseStatus, duration: e.duration }))
warn(f"{name}{spinners} loading indicators still visible (may be stuck)") """)
else: failed_fetches = [f for f in fetch_urls if f["status"] and f["status"] >= 400]
ok(f"{name} — loading states resolved ({spinners} remaining)") 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(): def main():
@@ -193,24 +213,46 @@ def main():
print(f" Viewport: {args.viewport} {VIEWPORTS[args.viewport]}") print(f" Viewport: {args.viewport} {VIEWPORTS[args.viewport]}")
print(f"{'='*60}") print(f"{'='*60}")
pages = { # — Authenticate-first pages (auth-gated)
auth_pages = {
"home": ("/", ["Assalamualaikum", "Falah", "Daily Verse"]), "home": ("/", ["Assalamualaikum", "Falah", "Daily Verse"]),
"prayer": ("/prayer", ["Prayer Times", "Fajr", "Dhuhr", "Asr", "Maghrib", "Isha", "Kuala Lumpur"]), "prayer": ("/prayer", ["Prayer Times", "Fajr", "Dhuhr", "Asr", "Maghrib", "Isha", "Kuala Lumpur"]),
"dhikr": ("/dhikr", ["Dhikr", "SubhanAllah", "Alhamdulillah", "Allahu Akbar"]), "dhikr": ("/dhikr", ["Dhikr", "SubhanAllah", "Alhamdulillah", "Allahu Akbar"]),
"qibla": ("/qibla", ["Qibla", "Kaaba"]), "qibla": ("/qibla", ["Qibla", "Kaaba", "Location not available"]),
"halal-monitor": ("/halal-monitor", ["Halal", "Restaurant"]), "halal-monitor": ("/halal-monitor", ["Halal", "Places"]),
} }
if args.page == "all": try:
for pname, (path, texts) in pages.items(): from playwright.sync_api import sync_playwright
sub(f"Visual check: {pname} ({path})") except ImportError:
try: fail("Playwright not installed. Run: pip install playwright && playwright install chromium")
vischeck({}, pname, path, check_after_load=True, expected_texts=texts) return
except Exception as e:
fail(f"{pname} — crashed: {e}") with sync_playwright() as p:
else: browser = p.chromium.launch(headless=True)
path, texts = pages[args.page]
vischeck({}, args.page, path, check_after_load=True, expected_texts=texts) # 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 total = PASS + FAIL + WARN
grade = "A" grade = "A"