Files
falah-mobile/tests/qa-visual.py
T
root cfff74e2db feat: Souq native integration + auth simplification + CE-wide enhancements
Souq Marketplace:
- Native souq pages: browse+filters, create listing, detail+seller profile, orders+chat
- New API: reviews (POST), sellers/[id], upload (multipart), polar-checkout webhook
- Listings API enhanced: minPrice, maxPrice, sortBy, deliveryDays filters
- Seller workflow: start order, mark delivered, upload files, confirm delivery
- Wallet: 5 Polar.sh FLH tiers, production checkout, mock fallback, success banner
- Fix: order redirect /souq/history → /souq/orders

Auth System:
- Unified auth page, removed OAuth provider routing (2 files deleted)
- Deleted oauth.ts (319 lines) — simplified auth chain
- Streamlined login/register API routes

Prisma Schema (+179 lines):
- New models: Listing, Purchase, Review, Group/GroupMember
- Gamification: DailyStreak, XpTransaction, Achievement, UserLevel
- Learning: LearnCourse/Module/Enrollment/Certificate
- Notifications, Referrals, Dhikr, PremiumBenefits

Deleted legacy code:
- src/app/api/marketplace/* (3 files) — replaced by /api/souq/*
- src/app/api/files/[listingId]/route.ts — replaced by /api/souq/upload
- src/app/api/seller/[sellerId]/route.ts — replaced by /api/souq/sellers/[id]

Infrastructure:
- Dockerfile: standalone output, Prisma runtime migration
- docker-compose.yml: Polar env vars, healthcheck fix
- docker-compose.staging.yml: staging on port 4014
- .gitea/workflows/ci.yml: CI pipeline
2026-06-24 07:02:03 +02:00

265 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Falah Mobile — Browser-Based Visual QA (Playwright Layer 9)
===========================================================
Catches client-side rendering errors that curl-based tests miss.
Requires: playwright install chromium
Usage:
python3 tests/qa-visual.py # Full visual suite
python3 tests/qa-visual.py --url https://staging...
python3 tests/qa-visual.py --viewport iphone14
python3 tests/qa-visual.py --ci-exit
Scenarios:
1. Page loads without console errors (JS exceptions, 404 fetches)
2. Prayer page renders actual prayer times (not error state)
3. Dhikr page shows counter (not login wall or error)
4. Qibla page handles geolocation denied gracefully
5. All async API calls complete within timeout
6. No "Invalid response" in rendered DOM post-hydration
"""
import argparse, json, os, sys, time, re
BASE_URL = os.environ.get("BASE_URL", "https://falahos.my/mobile")
TIMEOUT_MS = 30000
VIEWPORTS = {
"iphone14": {"width": 390, "height": 844},
"pixel7": {"width": 412, "height": 915},
"ipad": {"width": 820, "height": 1180},
"desktop": {"width": 1440, "height": 900},
}
PASS, FAIL, WARN = 0, 0, 0
REPORT_LINES = []
def ok(msg): global PASS; PASS += 1; print(f" \033[0;32m✓\033[0m {msg}"); REPORT_LINES.append(("PASS", msg))
def fail(msg): global FAIL; FAIL += 1; print(f" \033[0;31m✗\033[0m {msg}"); REPORT_LINES.append(("FAIL", msg))
def warn(msg): global WARN; WARN += 1; print(f" \033[1;33m⚠\033[0m {msg}"); REPORT_LINES.append(("WARN", msg))
def section(title):
print(f"\n\033[0;36m════════════════════════════════════════════════════════════\033[0m")
print(f"\033[0;36m {title}\033[0m")
print(f"\033[0;36m════════════════════════════════════════════════════════════\033[0m")
def sub(title):
print(f"\n\033[0;36m── {title} ──\033[0m")
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 authenticate(browser):
"""Login via API and set token in localStorage, then return authenticated page."""
context = browser.new_context(viewport=VIEWPORTS[viewport])
page = context.new_page()
page.on("pageerror", lambda err: None)
try:
# Step 1: Call login API to get a JWT token
import urllib.request, json as jsonlib, ssl
login_url = f"{BASE_URL.rstrip('/')}/api/auth/login"
req = urllib.request.Request(
login_url,
data=jsonlib.dumps({"email": "demo@falahos.my", "password": "password123"}).encode(),
headers={
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36",
"Accept": "application/json",
},
method="POST",
)
ctx = ssl.create_default_context()
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
auth_data = jsonlib.loads(resp.read().decode())
token = auth_data.get("token", "")
if not token:
fail("Login API returned no token")
return None, None
ok(f"Got API token: {token[:20]}...{token[-4:]}")
# Step 2: Navigate to any page and inject the token into localStorage
page.goto(f"{BASE_URL.rstrip('/')}/", timeout=TIMEOUT_MS, wait_until="domcontentloaded")
page.evaluate(f"localStorage.setItem('flh_token', '{token}')")
page.wait_for_timeout(500)
# Step 3: Reload to let React AuthContext pick up the token
page.goto(f"{BASE_URL.rstrip('/')}/", timeout=TIMEOUT_MS, wait_until="networkidle")
page.wait_for_timeout(2000)
# 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}")
import traceback
traceback.print_exc()
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
# ── 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:
ok(f"{name} — clean of \"{pat}\"")
# 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")
# 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():
global BASE_URL, viewport
parser = argparse.ArgumentParser(description="Falah Mobile Visual QA")
parser.add_argument("--url", default=BASE_URL)
parser.add_argument("--viewport", choices=list(VIEWPORTS.keys()), default="iphone14")
parser.add_argument("--ci-exit", action="store_true")
parser.add_argument("--page", choices=["prayer", "dhikr", "qibla", "halal-monitor", "home", "all"], default="all")
args = parser.parse_args()
BASE_URL = args.url.rstrip("/")
viewport = args.viewport
CI_EXIT = args.ci_exit
print(f"{'='*60}")
print(f" Falah Mobile — Browser-Based Visual QA")
print(f" URL: {BASE_URL}")
print(f" Viewport: {args.viewport} {VIEWPORTS[args.viewport]}")
print(f"{'='*60}")
# — 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", "Location not available"]),
"halal-monitor": ("/halal-monitor", ["Halal", "Places"]),
}
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"
if FAIL > 0: grade = "B"
if FAIL > 3: grade = "C"
if FAIL > 5: grade = "D"
print(f"\n{'='*60}")
print(f" VISUAL QA RESULTS — Grade: {grade}")
print(f"{'='*60}")
print(f" Pass: {PASS} Fail: {FAIL} Warn: {WARN} Total: {total}")
if CI_EXIT and FAIL > 0:
sys.exit(1)
if __name__ == "__main__":
main()