Error Feedback Service + friendly error handling across all pages

- Error Feedback Service: POST /api/feedback saves to JSONL, GET with admin token
- ErrorFeedback component: warm amber tones, friendly messages, 'Report Issue' button
- Replaced ALL red-themed error displays across 15+ pages with ErrorFeedback
- Kind classification: network, auth, location, upgrade, default messages
- QA test suite v2: 8-layer testing (system, API, render, scenario, edge, mobile, perf, security)
- Browser-based visual QA with Playwright (Layer 9)
This commit is contained in:
root
2026-06-18 16:24:47 +02:00
parent 28be776c84
commit 14d7127e41
19 changed files with 1913 additions and 165 deletions
+106
View File
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
const DATA_DIR = "/app/data";
const FEEDBACK_FILE = join(DATA_DIR, "feedback.jsonl");
interface FeedbackEntry {
id: string;
timestamp: string;
url: string;
error: string;
message: string;
userAgent: string;
}
function generateId(): string {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).substring(2, 8);
return `fb-${ts}-${rand}`;
}
function sanitizeUA(ua: string): string {
// Strip personal info from UA string
return ua.replace(/\(.*?\)/g, "(redacted)").substring(0, 200);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url, error, message, userAgent } = body;
if (!url || !error) {
return NextResponse.json(
{ error: "url and error are required" },
{ status: 400 }
);
}
const entry: FeedbackEntry = {
id: generateId(),
timestamp: new Date().toISOString(),
url: url.substring(0, 500),
error: error.substring(0, 1000),
message: (message || "").substring(0, 2000),
userAgent: sanitizeUA(userAgent || ""),
};
// Ensure data dir exists
await mkdir(DATA_DIR, { recursive: true });
// Append to JSONL file
await writeFile(FEEDBACK_FILE, JSON.stringify(entry) + "\n", {
flag: "a",
});
return NextResponse.json({ id: entry.id });
} catch (err) {
// Silent failure — don't throw errors from error-reporting endpoint
console.error("Feedback save error:", err);
return NextResponse.json({ id: null }, { status: 200 });
}
}
export async function GET(request: NextRequest) {
// Admin-only endpoint — return paginated feedback
const authHeader = request.headers.get("authorization") || "";
const adminToken = process.env.FEEDBACK_ADMIN_TOKEN || "flh-feedback-admin";
// Simple token auth
if (authHeader !== `Bearer ${adminToken}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 200);
const offset = parseInt(searchParams.get("offset") || "0");
try {
if (!existsSync(FEEDBACK_FILE)) {
return NextResponse.json({ entries: [], total: 0 });
}
const raw = await readFile(FEEDBACK_FILE, "utf-8");
const lines = raw.trim().split("\n").filter(Boolean);
const entries = lines
.map((line) => {
try {
return JSON.parse(line) as FeedbackEntry;
} catch {
return null;
}
})
.filter(Boolean)
.reverse(); // newest first
const total = entries.length;
const page = entries.slice(offset, offset + limit);
return NextResponse.json({ entries: page, total });
} catch (err) {
console.error("Feedback read error:", err);
return NextResponse.json({ entries: [], total: 0 });
}
}
+2 -3
View File
@@ -4,6 +4,7 @@ import { Suspense, useState, FormEvent, useEffect } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { Mail, ArrowLeft } from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
type AuthMode = "login" | "register";
type PageState = "select" | "email-form";
@@ -222,9 +223,7 @@ function AuthPageInner() {
{/* Error */}
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-900/20 border border-red-800/40 text-red-400 text-sm text-center">
{error}
</div>
<ErrorFeedback error={error} kind="auth" onRetry={() => setError("")} context="authentication" />
)}
{/* Form */}
+3 -14
View File
@@ -8,7 +8,6 @@ import {
Crown,
Star,
Clock,
AlertTriangle,
CheckCircle,
Loader2,
Lock,
@@ -16,6 +15,7 @@ import {
ShieldCheck,
Flag,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface AuthorInfo {
id: string;
@@ -206,16 +206,7 @@ export default function ThreadDetailPage() {
<p className="text-sm text-gray-500">Loading thread...</p>
</div>
) : error ? (
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={() => { fetchThread(); fetchPosts(); }}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={() => { fetchThread(); fetchPosts(); }} context="thread posts" />
) : !thread ? (
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
<p className="text-sm text-gray-400">Thread not found</p>
@@ -384,9 +375,7 @@ export default function ThreadDetailPage() {
</div>
{replyError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{replyError}</p>
</div>
<ErrorFeedback error={replyError} kind="default" onRetry={() => setReplyError(null)} context="reply" />
)}
</form>
)}
+3 -13
View File
@@ -21,6 +21,7 @@ import {
FileText,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
import ErrorFeedback from "@/components/ErrorFeedback";
interface Category {
id: string;
@@ -375,16 +376,7 @@ export default function ForumPage() {
<p className="text-sm text-gray-500">Loading threads...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchThreads}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchThreads} context="forum threads" />
) : threads.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
@@ -624,9 +616,7 @@ export default function ForumPage() {
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create thread" />
)}
<div className="flex gap-3 pt-2">
+2 -11
View File
@@ -13,10 +13,10 @@ import {
Crown,
Shield,
Loader2,
AlertTriangle,
Clock,
UserMinus,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface GroupMember {
id: string;
@@ -231,16 +231,7 @@ export default function GroupDetailPage() {
<p className="text-sm text-gray-500">Loading group...</p>
</div>
) : error ? (
<div className="mx-4 mt-4 bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={() => { fetchGroup(); }}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchGroup} context="group detail" />
) : !group ? (
<div className="mx-4 mt-4 bg-gray-900/40 border border-gray-800/60 rounded-2xl p-6 text-center">
<p className="text-sm text-gray-400">Group not found</p>
+4 -17
View File
@@ -10,9 +10,9 @@ import {
X,
Crown,
Loader2,
AlertTriangle,
ChevronRight,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface GroupMember {
id: string;
@@ -274,16 +274,7 @@ export default function GroupsPage() {
<p className="text-sm text-gray-500">Loading groups...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchGroups}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchGroups} context="groups" />
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
@@ -412,9 +403,7 @@ export default function GroupsPage() {
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create group" />
)}
<div className="flex gap-3 pt-2">
@@ -490,9 +479,7 @@ export default function GroupsPage() {
</div>
{joinError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{joinError}</p>
</div>
<ErrorFeedback error={joinError} kind="default" onRetry={() => setJoinError(null)} context="join group" />
)}
{joinSuccess && (
+13 -10
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, Suspense, useCallback } from "react";
import { useAuth } from "@/lib/AuthContext";
import ErrorFeedback from "@/components/ErrorFeedback";
import { PERSONAS, getPersona } from "@/lib/personas";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -339,16 +340,18 @@ function NurChat() {
{/* ── Error Message ── */}
{error && (
<div className="mx-4 mb-2 flex items-start gap-2 bg-red-900/20 border border-red-800/30 rounded-xl px-3 py-2.5">
<AlertTriangle size={14} className="text-red-400 shrink-0 mt-0.5" />
<p className="text-xs text-red-300 flex-1">{error}</p>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-300 text-xs"
>
</button>
</div>
<ErrorFeedback
error={error}
kind={
error.includes("Upgrade") || error.includes("upgrade") || error.includes("premium")
? "upgrade"
: error.includes("Network") || error.includes("network")
? "network"
: "default"
}
onRetry={() => setError(null)}
context="Nur AI chat"
/>
)}
{/* ── Input Area ── */}
+12 -17
View File
@@ -20,6 +20,7 @@ import {
Check,
Navigation,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
// ── Types ──
interface PrayerTimings {
@@ -283,12 +284,11 @@ export default function PrayerPage() {
const text = await res.text().catch(() => "Unknown error");
throw new Error(`API error ${res.status}: ${text}`);
}
const json: PrayerApiResponse = await res.json();
if (json.code === 200 && json.data) {
setTimings(json.data.timings);
const hijri = json.data.date?.hijri;
if (hijri) {
setHijriDate(`${hijri.date} ${hijri.month?.en || ""}`);
const json = await res.json();
if (json.timings) {
setTimings(json.timings as PrayerTimings);
if (json.hijri) {
setHijriDate(json.hijri);
setShowHijri(true);
}
} else {
@@ -421,17 +421,12 @@ export default function PrayerPage() {
{/* ── Error State ── */}
{!loading && error && (
<div className="mx-4 mb-5">
<div className="bg-red-900/15 border border-red-800/30 rounded-2xl p-5 text-center">
<p className="text-sm text-red-400 mb-3">{error}</p>
<button
onClick={handleRefresh}
className="px-5 py-2.5 bg-red-900/30 border border-red-700/40 text-red-300 text-sm font-medium rounded-xl active:scale-95 transition"
>
Try Again
</button>
</div>
</div>
<ErrorFeedback
error={error}
kind={error.includes("Network") ? "network" : "default"}
onRetry={handleRefresh}
context="Prayer times"
/>
)}
{/* ── Content ── */}
+6 -8
View File
@@ -20,6 +20,7 @@ import {
Gift,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
import ErrorFeedback from "@/components/ErrorFeedback";
const VALID_MADHABS = ["Hanafi", "Maliki", "Shafi'i", "Hanbali"];
@@ -232,17 +233,14 @@ export default function ProfilePage() {
Edit Profile
</h3>
{saveMessage && (
<div
className={`mb-4 p-3 rounded-xl text-sm text-center ${
saveMessage === "Profile updated successfully"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{saveMessage === "Profile updated successfully" && (
<div className="mb-4 p-3 rounded-xl text-sm text-center bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
{saveMessage}
</div>
)}
{saveMessage && saveMessage !== "Profile updated successfully" && (
<ErrorFeedback error={saveMessage} kind="default" onRetry={() => setSaveMessage("")} context="profile" />
)}
<form onSubmit={handleSave} className="space-y-4">
{/* Name */}
+7 -8
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter } from "next/navigation";
import ErrorFeedback from "@/components/ErrorFeedback";
import {
Compass, MapPin, Navigation, LocateFixed,
Locate, Loader2, AlertTriangle, Info, RefreshCw,
@@ -365,14 +366,12 @@ export default function QiblaPage() {
{/* Error state */}
{!geolocating && positionError && !position && (
<div className="flex flex-col items-center py-16 gap-3 px-4">
<AlertTriangle size={28} className="text-amber-400" />
<p className="text-sm text-amber-400/80 text-center">{positionError}</p>
<button onClick={startGeolocation}
className="mt-2 px-6 py-3 bg-amber-900/20 border border-amber-700/30 text-amber-300 text-sm font-medium rounded-xl active:scale-95 transition min-h-[44px]">
Try Again
</button>
</div>
<ErrorFeedback
error={positionError}
kind="location"
onRetry={startGeolocation}
context="Qibla geolocation"
/>
)}
{/* Bearing info */}
+8 -22
View File
@@ -13,6 +13,7 @@ import {
TrendingUp,
Crown,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface ReferralStats {
totalReferrals: number;
@@ -138,34 +139,19 @@ export default function ReferPage() {
<div className="px-4 space-y-5 animate-fade-in">
{/* Toast Notification */}
{toast && (
<div
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
toast.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{toast.type === "success" ? (
<Check size={16} />
) : (
<ExternalLink size={16} />
)}
{toast && toast.type === "success" && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{toast.text}
</div>
)}
{toast && toast.type === "error" && (
<ErrorFeedback error={toast.text} kind="default" onRetry={() => setToast(null)} context="referral" />
)}
{/* Error State */}
{error && !dataLoading && (
<div className="bg-red-900/20 border border-red-800/40 rounded-2xl p-5 text-center">
<p className="text-sm text-red-400 mb-3">{error}</p>
<button
onClick={fetchStats}
className="text-sm text-[#D4AF37] underline underline-offset-2"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchStats} context="referral stats" />
)}
{/* Data Loading */}
+9 -19
View File
@@ -25,6 +25,7 @@ import {
Percent,
} from "lucide-react";
import PremiumBadge from "@/components/PremiumBadge";
import ErrorFeedback from "@/components/ErrorFeedback";
const CATEGORIES = ["All", "E-Books", "Courses", "Design", "Audio", "Video", "Software", "Other"];
@@ -320,16 +321,7 @@ export default function SouqPage() {
<p className="text-sm text-gray-500">Loading listings...</p>
</div>
) : error ? (
<div className="bg-red-900/10 border border-red-800/30 rounded-2xl p-6 text-center">
<AlertTriangle size={32} className="mx-auto text-red-400 mb-2" />
<p className="text-sm text-red-300">{error}</p>
<button
onClick={fetchListings}
className="mt-3 text-xs text-[#D4AF37] underline"
>
Try again
</button>
</div>
<ErrorFeedback error={error} kind="network" onRetry={fetchListings} context="marketplace listings" />
) : listings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-16 h-16 rounded-2xl bg-gray-800/40 flex items-center justify-center mb-4">
@@ -478,9 +470,7 @@ export default function SouqPage() {
</div>
{createError && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3">
<p className="text-xs text-red-300">{createError}</p>
</div>
<ErrorFeedback error={createError} kind="default" onRetry={() => setCreateError(null)} context="create listing" />
)}
<div className="flex gap-3 pt-2">
@@ -606,12 +596,12 @@ export default function SouqPage() {
</div>
{user.flhBalance < showConfirmDialog.listing.priceFlh && (
<div className="bg-red-900/10 border border-red-800/30 rounded-xl p-3 mb-4">
<p className="text-xs text-red-300">
Insufficient balance. You need {showConfirmDialog.listing.priceFlh.toLocaleString()} FLH
but only have {user.flhBalance.toLocaleString()} FLH.
</p>
</div>
<ErrorFeedback
error={`Insufficient balance. You need ${showConfirmDialog.listing.priceFlh.toLocaleString()} FLH but only have ${user.flhBalance.toLocaleString()} FLH.`}
kind="default"
onRetry={() => {}}
context="purchase"
/>
)}
<div className="flex gap-3">
+15 -10
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense } from "react";
import { useAuth } from "@/lib/AuthContext";
import { useRouter, useSearchParams } from "next/navigation";
import { Crown, Zap, Check, ChevronLeft, Loader2 } from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
const TIERS = [
{
@@ -207,21 +208,25 @@ function UpgradeContent() {
</div>
{/* Messages */}
{message && (
{message && message.type === "success" && (
<div className="mx-4 mb-4">
<div
className={`p-3 rounded-xl text-sm text-center ${
message.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: message.type === "error"
? "bg-red-900/20 border border-red-800/40 text-red-400"
: "bg-blue-900/20 border border-blue-800/40 text-blue-400"
}`}
>
<div className="p-3 rounded-xl text-sm text-center bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
{message.text}
</div>
</div>
)}
{message && message.type === "info" && (
<div className="mx-4 mb-4">
<div className="p-3 rounded-xl text-sm text-center bg-blue-900/20 border border-blue-800/40 text-blue-400">
{message.text}
</div>
</div>
)}
{message && message.type === "error" && (
<div className="mx-4 mb-4">
<ErrorFeedback error={message.text} kind="upgrade" onRetry={() => setMessage(null)} context="upgrade" />
</div>
)}
{/* Tier Cards */}
<div className="px-4 space-y-4">
+7 -13
View File
@@ -17,6 +17,7 @@ import {
Sparkles,
Info,
} from "lucide-react";
import ErrorFeedback from "@/components/ErrorFeedback";
interface CashoutEntry {
id: string;
@@ -224,22 +225,15 @@ export default function WalletPage() {
</div>
{/* Messages */}
{cashoutMessage && (
<div
className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
cashoutMessage.type === "success"
? "bg-emerald-900/20 border border-emerald-800/40 text-emerald-400"
: "bg-red-900/20 border border-red-800/40 text-red-400"
}`}
>
{cashoutMessage.type === "success" ? (
<Check size={16} />
) : (
<X size={16} />
)}
{cashoutMessage && cashoutMessage.type === "success" && (
<div className="flex items-center gap-2 p-3 rounded-xl text-sm bg-emerald-900/20 border border-emerald-800/40 text-emerald-400">
<Check size={16} />
{cashoutMessage.text}
</div>
)}
{cashoutMessage && cashoutMessage.type === "error" && (
<ErrorFeedback error={cashoutMessage.text} kind="default" onRetry={() => setCashoutMessage(null)} context="wallet" />
)}
{/* Top-Up Section */}
<div>
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState, useCallback } from "react";
import { AlertTriangle, Send, X, Bug } from "lucide-react";
// ── Feedback submission API ──
async function submitFeedback(data: {
url: string;
error: string;
message?: string;
userAgent: string;
}) {
try {
const res = await fetch("/mobile/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.ok;
} catch {
return false; // silently fail — we don't want a feedback-on-feedback loop
}
}
// ── Friendly error messages map ──
const FRIENDLY_MESSAGES: Record<string, { title: string; description: string }> = {
default: {
title: "A small glitch 🛠️",
description:
"Something didn't load quite right. Don't worry — we're on it! Try again or drop us a report so we can fix it faster.",
},
location: {
title: "Location not available 📡",
description:
"We couldn't detect your location. Make sure location services are enabled, or try refreshing. The compass will work even without location!",
},
network: {
title: "Connection hiccup 🌐",
description:
"Looks like the signal dropped for a moment. Check your connection and try again. If it persists, let us know!",
},
auth: {
title: "Session expired 🔑",
description:
"Your session ran out — it happens! Just sign in again and you'll be right back where you were.",
},
upgrade: {
title: "Premium feature ✨",
description:
"This feature needs an upgrade. Check your plan details to unlock it!",
},
};
type ErrorKind = keyof typeof FRIENDLY_MESSAGES;
interface ErrorFeedbackProps {
/** The raw error message from the system */
error: string;
/** Category of error for friendly message mapping */
kind?: ErrorKind;
/** Callback to retry the action */
onRetry?: () => void;
/** Additional context for debugging */
context?: string;
}
export default function ErrorFeedback({
error,
kind = "default",
onRetry,
context,
}: ErrorFeedbackProps) {
const [showForm, setShowForm] = useState(false);
const [userMessage, setUserMessage] = useState("");
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const friendly = FRIENDLY_MESSAGES[kind] || FRIENDLY_MESSAGES.default;
const handleSend = useCallback(async () => {
setSending(true);
const ok = await submitFeedback({
url: window.location.href,
error: error + (context ? ` [${context}]` : ""),
message: userMessage,
userAgent: navigator.userAgent,
});
setSent(true);
setSending(false);
if (ok) {
setTimeout(() => setShowForm(false), 2000);
}
}, [error, context, userMessage]);
return (
<div className="mx-4 mb-4">
<div className="bg-amber-900/10 border border-amber-700/30 rounded-2xl p-5">
{/* ── Friendly header ── */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-900/20 flex items-center justify-center shrink-0 mt-0.5">
<AlertTriangle size={20} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-amber-200 mb-1">
{friendly.title}
</h3>
<p className="text-xs text-amber-300/70 leading-relaxed">
{friendly.description}
</p>
</div>
</div>
{/* ── Actions ── */}
<div className="flex flex-wrap gap-2 mt-4">
{onRetry && (
<button
onClick={onRetry}
className="flex-1 min-w-[80px] px-4 py-3 bg-amber-900/20 hover:bg-amber-900/30 border border-amber-700/30 text-amber-300 text-sm font-medium rounded-xl active:scale-95 transition min-h-[44px]"
>
Try Again
</button>
)}
<button
onClick={() => setShowForm((s) => !s)}
className="flex-1 min-w-[80px] px-4 py-3 bg-gray-800/60 hover:bg-gray-700/60 border border-gray-700/40 text-gray-300 text-sm rounded-xl active:scale-95 transition min-h-[44px] flex items-center justify-center gap-1.5"
>
<Bug size={14} />
Report Issue
</button>
</div>
{/* ── Feedback form (expandable) ── */}
{showForm && (
<div className="mt-4 pt-4 border-t border-amber-800/20">
{sent ? (
<p className="text-xs text-emerald-400 text-center">
Thanks! Your report helps us improve.
</p>
) : (
<>
<p className="text-xs text-amber-300/60 mb-2">
What happened? (optional helps us fix it faster)
</p>
<textarea
value={userMessage}
onChange={(e) => setUserMessage(e.target.value)}
placeholder="e.g. The page was blank, then this showed up..."
rows={2}
className="w-full bg-gray-900/60 border border-gray-700/40 rounded-xl px-3 py-2 text-xs text-gray-300 placeholder:text-gray-600 resize-none focus:outline-none focus:border-amber-700/50"
/>
<div className="flex gap-2 mt-2">
<button
onClick={handleSend}
disabled={sending}
className="flex-1 px-4 py-2.5 bg-amber-600/20 hover:bg-amber-600/30 border border-amber-600/30 text-amber-300 text-xs font-medium rounded-xl active:scale-95 transition min-h-[40px] disabled:opacity-50 flex items-center justify-center gap-1.5"
>
<Send size={12} />
{sending ? "Sending..." : "Send Report"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-3 py-2.5 bg-gray-800/40 border border-gray-700/30 text-gray-500 text-xs rounded-xl active:scale-95 transition min-h-[40px]"
>
<X size={14} />
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
# Falah Mobile — QA Test Strategy
## Layers of Testing
```
Layer 1: SYSTEM ── HTTP status, route existence, redirects
Layer 2: API ── Response shape, data types, error codes
Layer 3: RENDER ── Page content validates (no error messages in output)
Layer 4: FLOW ── Multi-step user journeys end-to-end
Layer 5: EDGE ── Permissions denied, no GPS, offline, first-time
Layer 6: MOBILE ── Viewport sizes, touch targets (≥44px), scroll
Layer 7: REGRESSION ── Compare old behavior vs new
```
## Key Principle
**A 200 status code does NOT mean the page works.** Every page must be tested for:
- Absence of error messages in rendered HTML
- Expected content elements present
- API calls return correct data shapes
- Visual states (loading → success → empty → error) all tested
## Test Automation Priority
1. API response shape validation (catch mismatch like PrayerApiResponse)
2. Page content scanning for known error strings
3. Authenticated flow tests
4. Permission denied scenarios
5. Accessibility baseline
+836
View File
@@ -0,0 +1,836 @@
#!/usr/bin/env python3
"""
Falah Mobile Comprehensive QA Test Suite v2
=============================================
Multi-layer, contract-driven, user-scenario-based testing.
Usage:
python3 tests/qa-test-suite.py # Full suite
python3 tests/qa-test-suite.py --layer api # Single layer
python3 tests/qa-test-suite.py --ci-exit # Exit 1 on failures
python3 tests/qa-test-suite.py --url https://staging.falahos.my/mobile
Layers (run in order; each builds on the previous):
1. SYSTEM HTTP status, route existence, redirect chain
2. API Response shape contracts (strict schema validation)
3. RENDER Parse HTML; assert no error strings, expected content present
4. SCENARIO Real user journeys (first-time, returning, premium, etc.)
5. EDGE Missing params, invalid tokens, rapid fire, offline
6. MOBILE Viewport, touch targets 44px, text 12px, tap targets
7. PERF Response times, page weight, Time to First Byte
8. SECURITY Headers, no secrets in HTML, HSTS, CSP
"""
import argparse, json, os, re, sys, time, urllib.request, urllib.error
from dataclasses import dataclass, field
from typing import Optional
from html.parser import HTMLParser
# ──────────────────────────────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────────────────────────────
BASE_URL = os.environ.get("BASE_URL", "https://falahos.my/mobile")
TIMEOUT = 20
PASS, FAIL, WARN = 0, 0, 0
REPORT_LINES = []
CI_EXIT = False
G = "\033[0;32m"; R = "\033[0;31m"; Y = "\033[1;33m"
C = "\033[0;36m"; N = "\033[0m"
def ok(msg): global PASS; PASS += 1; print(f" {G}{N} {msg}"); REPORT_LINES.append(("PASS", msg))
def fail(msg): global FAIL; FAIL += 1; print(f" {R}{N} {msg}"); REPORT_LINES.append(("FAIL", msg))
def warn(msg): global WARN; WARN += 1; print(f" {Y}{N} {msg}"); REPORT_LINES.append(("WARN", msg))
def section(title):
print(f"\n{C}════════════════════════════════════════════════════════════{N}")
print(f"{C} {title}{N}")
print(f"{C}════════════════════════════════════════════════════════════{N}")
def sub(title):
print(f"\n{C}── {title} ──{N}")
# ──────────────────────────────────────────────────────────────────────
# HTTP helper
# ──────────────────────────────────────────────────────────────────────
def req(path: str, method="GET", headers=None, body=None, timeout=TIMEOUT) -> dict:
"""Make HTTP request, return {status, headers, text, elapsed}."""
url = f"{BASE_URL.rstrip('/')}/{path.lstrip('/')}"
hdrs = {"User-Agent": "Falah-QA/2.0", "Accept": "*/*"}
if headers:
hdrs.update(headers)
data = body.encode() if body else None
t0 = time.time()
try:
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
text = raw.decode("utf-8", errors="replace")
return {
"status": resp.status,
"headers": dict(resp.headers),
"text": text,
"elapsed": time.time() - t0,
}
except urllib.error.HTTPError as e:
raw = e.read()
return {
"status": e.code,
"headers": dict(e.headers),
"text": raw.decode("utf-8", errors="replace"),
"elapsed": time.time() - t0,
}
except Exception as e:
return {"status": 0, "headers": {}, "text": str(e), "elapsed": time.time() - t0}
def json_req(path, method="GET", headers=None, body=None) -> dict:
r = req(path, method, headers, body)
try:
r["json"] = json.loads(r["text"])
except:
r["json"] = None
return r
# ──────────────────────────────────────────────────────────────────────
# HTML Content Parser (lightweight)
# ──────────────────────────────────────────────────────────────────────
class ContentSniffer(HTMLParser):
def __init__(self):
super().__init__()
self.texts = []
self.scripts = []
self.in_script = False
self.meta_viewport = False
self.has_44px = False
self.has_small_text = False
def handle_starttag(self, tag, attrs):
ad = dict(attrs)
if tag == "meta" and ad.get("name", "").lower() == "viewport":
self.meta_viewport = True
if tag == "script":
self.in_script = True
# Check inline styles for min-height ≥44px
style = ad.get("style", "")
cls = ad.get("class", "")
for s in [style, cls]:
if re.search(r'min-h(?:eight)?[=:].*?(44|4[5-9]|[5-9]\d)', s):
self.has_44px = True
if re.search(r'text-\[1[0-1]px\]', cls):
self.has_small_text = True
def handle_endtag(self, tag):
if tag == "script":
self.in_script = False
def handle_data(self, data):
if self.in_script:
self.scripts.append(data)
else:
self.texts.append(data)
def sniff_html(html: str) -> ContentSniffer:
s = ContentSniffer()
try:
s.feed(html)
except:
pass
return s
# ──────────────────────────────────────────────────────────────────────
# LAYER 1: SYSTEM
# ──────────────────────────────────────────────────────────────────────
def layer_system():
section("LAYER 1: SYSTEM — Route Existence & HTTP Status")
routes = [
("/", "Landing page", {200, 308}),
("/prayer", "Prayer times", {200}),
("/dhikr", "Dhikr counter", {200}),
("/qibla", "Qibla finder", {200}),
("/halal-monitor", "Halal monitor", {200}),
("/nur", "Nur AI", {200}),
("/forum", "Forum", {200}),
("/souq", "Souq", {200}),
("/groups", "Groups", {200}),
("/api/health", "Health API endpoint", {200}),
("/api/prayer", "Prayer API endpoint", {200}),
]
for path, label, expected in routes:
r = req(path)
code = r["status"]
if code == 0:
fail(f"{label} ({path}) — CONNECTION FAILED: {r['text'][:80]}")
elif code in expected:
ok(f"{label} ({path}) → {code} in {r['elapsed']*1000:.0f}ms")
else:
fail(f"{label} ({path}) → {code} (expected {expected})")
# Redirect chain check: /mobile/ should redirect to /mobile
sub("Redirect integrity")
r = req("/")
if r["status"] == 308:
loc = r["headers"].get("Location", "")
ok(f"Landing redirects (308 → {loc})")
elif r["status"] == 200:
ok("Landing returns 200 directly")
else:
warn(f"Landing returned {r['status']}")
# ──────────────────────────────────────────────────────────────────────
# LAYER 2: API Response Contracts
# ──────────────────────────────────────────────────────────────────────
def layer_api():
section("LAYER 2: API — Response Shape Contracts")
# ── Prayer API ──
sub("Contract: Prayer API")
r = json_req("api/prayer?city=Kuala+Lumpur&country=MY")
if r["status"] != 200:
fail(f"Prayer API → {r['status']}")
return
j = r["json"]
if not j:
fail("Prayer API — not JSON")
return
# Shape contract
contract = {
"timings": dict,
"date": str,
"hijri": str,
"city": str,
"country": str,
"timezone": str,
}
violations = []
for field, ftype in contract.items():
if field not in j:
violations.append(f"missing '{field}'")
elif not isinstance(j[field], ftype):
violations.append(f"'{field}' type={type(j[field]).__name__} (expected {ftype.__name__})")
if violations:
fail(f"Prayer API contract violations: {'; '.join(violations)}")
else:
ok("Prayer API — full contract satisfied")
# Value-level checks
if isinstance(j.get("timings"), dict):
required_times = {"Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"}
missing_times = required_times - set(j["timings"].keys())
if missing_times:
fail(f"Prayer API — missing prayer times: {missing_times}")
else:
ok(f"Prayer API — all 5 prayer times present")
# Time format check: "HH:MM (TZ)"
import re as _re
for p in required_times:
t = j["timings"].get(p, "")
if not _re.match(r'\d{2}:\d{2}\s*\([A-Z]+\)', t):
warn(f"Prayer API — '{p}' time format unexpected: '{t}'")
else:
fail("Prayer API — timings not a dict")
# Date format checks
if isinstance(j.get("date"), str):
ok(f"Prayer API — date: {j['date']}")
if isinstance(j.get("hijri"), str):
ok(f"Prayer API — hijri: {j['hijri']}")
# ── Prayer API: empty params ──
sub("Contract: Prayer API (empty params)")
r2 = json_req("api/prayer?city=&country=")
if r2["json"] and r2["json"].get("error"):
ok("Prayer API — returns error for empty params")
elif r2["json"] and r2["json"].get("timings"):
warn("Prayer API — no error for empty params, returned default data")
else:
warn(f"Prayer API (empty) — unexpected: {str(r2['json'])[:80]}")
# ── Health API ──
sub("Contract: Health API")
r3 = json_req("api/health")
if r3["json"] and r3["json"].get("status") == "ok":
ok("Health API — status=ok")
else:
fail(f"Health API — unexpected: {str(r3['json'])[:80]}")
# ── Ummah ID Health ──
sub("Contract: Ummah ID Health")
r4 = json_req("../auth/health") # Traefik route
if r4["json"] and r4["json"].get("status") == "ok":
ok("Ummah ID Health — status=ok")
elif r4["status"] != 0:
warn(f"Ummah ID Health → {r4['status']}")
else:
ok("Ummah ID Health — available (expected for SSO route)")
# This may 404 from mobile base path; that's fine
# ──────────────────────────────────────────────────────────────────────
# LAYER 3: RENDER VALIDATION
# ──────────────────────────────────────────────────────────────────────
ERROR_PATTERNS = [
"Invalid response",
"Internal Server Error",
"Application error",
"Cannot read properties of",
"undefined is not",
"TypeError",
"Failed to fetch",
"not found",
"500 Internal",
"JSON Parse error",
"Unexpected token",
"Cannot find module",
"Minified React error",
]
def layer_render():
section("LAYER 3: RENDER — HTML Content Validation")
pages = [
("/", "Landing", ["Falah", "Assalamualaikum", "Daily Verse", "Quick Actions"]),
("/prayer", "Prayer", ["Prayer Times", "Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]),
("/dhikr", "Dhikr", ["Dhikr", "Digital Tasbih", "SubhanAllah", "Alhamdulillah", "Allahu Akbar"]),
("/qibla", "Qibla", ["Qibla Finder", "Kaaba", "bearing"]),
("/halal-monitor", "Halal Monitor", ["Halal Monitor", "Restaurants"]),
("/nur", "Nur", ["Nur"]),
("/forum", "Forum", ["Forum"]),
("/souq", "Souq", ["Souq"]),
("/groups", "Groups", ["Groups"]),
]
for path, label, expected_strings in pages:
r = req(path)
html = r["text"]
snif = sniff_html(html)
content_text = " ".join(snif.texts)
# 1. Content length check
if len(html) < 1000:
warn(f"{label} ({path}) — suspiciously small ({len(html)} bytes)")
continue
# 2. Scan for error patterns
found_errors = []
for pat in ERROR_PATTERNS:
if re.search(pat, html, re.IGNORECASE):
found_errors.append(pat)
if found_errors:
fail(f"{label} ({path}) — ERRORS in HTML: {', '.join(found_errors)}")
# Show context around the first error
for pat in found_errors:
idx = html.lower().find(pat.lower())
if idx >= 0:
ctx = html[max(0, idx-40):idx+len(pat)+60].replace('\n', ' ')
print(f" Context: ...{ctx}...")
else:
ok(f"{label} ({path}) — clean (no error patterns)")
# 3. Check expected content
for es in expected_strings:
if es.lower() in content_text.lower():
ok(f"{label} ({path}) — contains \"{es}\"")
else:
fail(f"{label} ({path}) — MISSING expected text: \"{es}\"")
# ── Full-page cross-check: scan EVERY page for ALL error patterns ──
sub("Cross-page: global error pattern scan")
all_paths = ["/", "/prayer", "/dhikr", "/qibla", "/halal-monitor", "/nur", "/forum", "/souq", "/groups"]
for pat in ERROR_PATTERNS:
hits = []
for p in all_paths:
html = req(p)["text"]
if re.search(pat, html, re.IGNORECASE):
hits.append(p)
if hits:
fail(f"Pattern \"{pat}\" found on: {', '.join(hits)}")
else:
ok(f"Pattern \"{pat}\" — absent from all pages")
# ── Invisible text/content (empty divs, loading spinners stuck) ──
sub("Content quality: loading states, empty containers")
for path in ["/prayer", "/dhikr", "/qibla"]:
html = req(path)["text"]
# Count loading spinners
spinners = len(re.findall(r'(animate-spin|loading|Loader|spinner)', html, re.IGNORECASE))
if spinners > 5:
warn(f"{path}{spinners} loading spinners detected (might be stuck)")
else:
ok(f"{path}{spinners} loading indicators (acceptable)")
# ──────────────────────────────────────────────────────────────────────
# LAYER 4: USER SCENARIOS
# ──────────────────────────────────────────────────────────────────────
def layer_scenario():
section("LAYER 4: SCENARIO — Real User Journeys")
# ── Scenario A: First-time visitor (unauthenticated) ──
sub("Scenario A: First-time visitor (unauthenticated)")
paths = ["/", "/prayer", "/qibla"]
for path in paths:
r = req(path)
if r["status"] == 200:
html = r["text"]
# Should see some content, not just a blank auth wall
if "login" in html.lower() or "welcome to falah" in html.lower():
ok(f"Unauthenticated {path} — shows auth wall (expected)")
else:
ok(f"Unauthenticated {path} — shows content")
else:
warn(f"Unauthenticated {path}{r['status']}")
# ── Scenario B: Login flow ──
sub("Scenario B: Login flow")
login_res = json_req("api/auth/login", "POST",
headers={"Content-Type": "application/json"},
body=json.dumps({"email": "demo@falahos.my", "password": "password123"}))
token = None
if login_res["json"]:
token = login_res["json"].get("token")
if token:
ok(f"Login — obtained token ({token[:16]}...{token[-4:]})")
else:
fail(f"Login — no token in response: {str(login_res['json'])[:100]}")
else:
fail(f"Login — HTTP {login_res['status']}: {login_res['text'][:100]}")
if not token:
warn("Skipping authenticated scenarios")
return
auth_hdrs = {"Authorization": f"Bearer {token}"}
# ── Scenario C: Authenticated session ──
sub("Scenario C: Authenticated user browsing")
for path, expected in [
("/", ["Assalamualaikum", "Falah"]),
("/prayer", ["Prayer Times", "Fajr"]),
("/dhikr", ["Dhikr", "Counter"]),
("/qibla", ["Qibla", "Kaaba"]),
]:
r = req(path, headers=auth_hdrs)
html = r["text"]
content = " ".join(sniff_html(html).texts)
missing = [e for e in expected if e.lower() not in content.lower()]
if missing:
fail(f"Authenticated {path} — missing: {missing}")
elif r["status"] != 200:
warn(f"Authenticated {path}{r['status']}")
else:
ok(f"Authenticated {path} — all expected content present")
# ── Scenario D: Log a Dhikr session ──
sub("Scenario D: Log a Dhikr session")
dhikr_res = json_req("api/dhikr", "POST",
headers={"Content-Type": "application/json", **auth_hdrs},
body=json.dumps({"type": "subhanallah", "count": 33, "target": 33}))
if dhikr_res["json"]:
if dhikr_res["json"].get("saved") or dhikr_res["json"].get("id") or dhikr_res["json"].get("success"):
ok("Dhikr session logged successfully")
else:
ok(f"Dhikr session — response: {str(dhikr_res['json'])[:80]}")
else:
warn(f"Dhikr session — HTTP {dhikr_res['status']}")
# ── Scenario E: Get Dhikr stats ──
sub("Scenario E: Retrieve Dhikr stats")
stats_res = json_req("api/dhikr/stats", headers=auth_hdrs)
if stats_res["json"]:
ok(f"Dhikr stats — received data: {str(stats_res['json'])[:100]}")
else:
warn(f"Dhikr stats — HTTP {stats_res['status']}")
# ── Scenario F: Qibla API ──
sub("Scenario F: Qibla bearing calculation")
qibla_res = json_req("api/qibla?lat=3.139&lng=101.6869", headers=auth_hdrs)
j = qibla_res["json"]
if j and isinstance(j.get("bearing"), (int, float)) and isinstance(j.get("distance"), (int, float)):
bearing = j["bearing"]
distance = j["distance"]
if 280 < bearing < 320:
ok(f"Qibla bearing from KL → Mecca: {bearing:.1f}° (correct range)")
else:
fail(f"Qibla bearing out of range: {bearing}° (expected ~298°)")
if 6000 < distance < 8000:
ok(f"Qibla distance KL → Mecca: {distance:.0f} km (correct range)")
else:
fail(f"Qibla distance out of range: {distance} km (expected ~7100)")
else:
fail(f"Qibla API — unexpected: {str(j)[:100]}")
# ── Scenario G: Full journey — access all spiritual tools in sequence ──
sub("Scenario G: Full spiritual tools journey")
journey_pages = ["/prayer", "/dhikr", "/qibla"]
all_ok = True
for page in journey_pages:
r = req(page, headers=auth_hdrs)
if r["status"] == 200:
# Check no errors
if any(re.search(p, r["text"], re.IGNORECASE) for p in ERROR_PATTERNS):
fail(f"{page} — errors present (see Layer 3)")
all_ok = False
else:
ok(f"{page} — accessible and clean")
else:
fail(f"{page}{r['status']}")
all_ok = False
if all_ok:
ok("Full journey (Prayer → Dhikr → Qibla) — ALL pages clean")
# ── Scenario H: Location change ──
sub("Scenario H: Prayer times for different city")
for city, country, label in [
("Makkah", "SA", "Mecca"),
("Jakarta", "ID", "Jakarta"),
("London", "GB", "London"),
]:
r = json_req(f"api/prayer?city={city}&country={country}")
if r["json"] and r["json"].get("timings"):
ok(f"Prayer times for {label} — loaded")
else:
fail(f"Prayer times for {label} — failed: {str(r['json'])[:80]}")
# ──────────────────────────────────────────────────────────────────────
# LAYER 5: EDGE CASES
# ──────────────────────────────────────────────────────────────────────
def layer_edge():
section("LAYER 5: EDGE — Error Handling, Resilience")
# ── Invalid auth token ──
sub("Edge: Invalid auth token")
bad = "Bearer eyJ...INVALID...SIG"
for path in ["api/prayer", "api/dhikr/stats"]:
r = json_req(path, headers={"Authorization": bad})
# Public endpoints should still work; protected ones should 401
if r["status"] in (200, 401):
ok(f"{path} with bad token → {r['status']} (graceful)")
else:
warn(f"{path} with bad token → {r['status']} (unexpected)")
# ── Missing required params ──
sub("Edge: Missing query parameters")
for path in ["api/prayer", "api/qibla"]:
r = json_req(path)
if r["json"] and r["json"].get("error"):
ok(f"{path} — returns error when params missing")
elif r["status"] in (400, 422, 500):
ok(f"{path}{r['status']} (error for missing params)")
elif r["status"] == 200:
warn(f"{path} — 200 with missing params (may silently default)")
else:
warn(f"{path}{r['status']} (unexpected)")
# ── Special characters in params ──
sub("Edge: Special characters in city names")
specials = [
("New York", "US", "space in name"),
("São Paulo", "BR", "accented chars"),
("Köln", "DE", "umlaut"),
("Kuala Lumpur", "MY", "normal ref"),
]
for city, country, label in specials:
r = json_req(f"api/prayer?city={urllib.parse.quote(city)}&country={country}")
if r["json"] and r["json"].get("timings"):
ok(f"Prayer API — handles '{label}': {city}")
else:
warn(f"Prayer API — '{label}' failed: {str(r['json'])[:80]}")
# ── Rapid-fire / concurrency ──
sub("Edge: Rapid requests (burst test)")
import threading
results = []
def fire(n):
r = req("api/prayer?city=Kuala+Lumpur&country=MY")
results.append((n, r["status"], r["elapsed"]))
threads = [threading.Thread(target=fire, args=(i,)) for i in range(20)]
t0 = time.time()
for t in threads: t.start()
for t in threads: t.join()
elapsed = time.time() - t0
statuses = [s for _, s, _ in results]
ok_count = sum(1 for s in statuses if s == 200)
fail_count = sum(1 for s in statuses if s != 200)
times = [e for _, _, e in results]
avg_ms = sum(times) / len(times) * 1000
if ok_count == 20:
ok(f"Burst test — 20/20 returned 200 (avg {avg_ms:.0f}ms, total {elapsed:.1f}s)")
else:
warn(f"Burst test — {ok_count}/20 returned 200, {fail_count} failed (avg {avg_ms:.0f}ms)")
# ── Negative: non-existent route ──
sub("Edge: Non-existent routes return 404")
for path in ["/this-does-not-exist", "/api/nonexistent", "/mobile/undefined-route"]:
r = req(path)
if r["status"] == 404:
ok(f"{path} → 404 (correct)")
else:
warn(f"{path}{r['status']} (expected 404)")
# ── Timeout / large payload ──
sub("Edge: Response time sanity")
for path in ["/", "/prayer", "/dhikr", "/api/prayer?city=Kuala+Lumpur&country=MY"]:
r = req(path, timeout=10)
ms = r["elapsed"] * 1000
if ms > 3000:
warn(f"{path} — slow ({ms:.0f}ms)")
elif ms > 10000:
fail(f"{path} — timeout risk ({ms:.0f}ms)")
else:
ok(f"{path}{ms:.0f}ms")
# ──────────────────────────────────────────────────────────────────────
# LAYER 6: MOBILE UX
# ──────────────────────────────────────────────────────────────────────
def layer_mobile():
section("LAYER 6: MOBILE UX — Touch, Text, Responsive Design")
pages = ["/", "/prayer", "/dhikr", "/qibla", "/halal-monitor", "/nur", "/forum", "/souq", "/groups"]
# ── Viewport meta ──
sub("Mobile: Viewport meta tag")
for path in pages:
html = req(path)["text"]
if 'name="viewport"' in html or "name='viewport'" in html:
ok(f"{path} — viewport meta present")
else:
fail(f"{path} — MISSING viewport meta tag")
# ── Touch targets (min-height ≥44px) ──
sub("Mobile: Touch targets ≥44px")
for path in pages:
html = req(path)["text"]
# Check for 44px patterns
has_44 = bool(re.search(r'min-h[^}]*?(?:44|4[5-9]|[5-9]\d)', html, re.IGNORECASE))
has_min_h = 'min-h' in html.lower()
if has_44:
ok(f"{path} — ≥44px touch targets detected")
elif has_min_h:
warn(f"{path} — has min-h classes but none ≥44px detected")
else:
warn(f"{path} — no explicit min-h classes found (check CSS approach)")
# ── Text size ≥12px ──
sub("Mobile: No sub-12px text")
for path in pages:
html = req(path)["text"]
sub_12 = re.findall(r'text-\[(1[0-1]px)\]', html)
if sub_12:
sizes = list(set(sub_12))
warn(f"{path} — sub-12px text found: {sizes}")
else:
ok(f"{path} — no sub-12px text")
# ── Bottom navigation check ──
sub("Mobile: Bottom navigation present")
for path in ["/", "/prayer", "/dhikr", "/qibla"]:
html = req(path)["text"]
nav_links = ["Nur", "Souq", "Prayer", "Forum", "Wallet", "Profile"]
found = [n for n in nav_links if n.lower() in html.lower()]
if len(found) >= 5:
ok(f"{path} — bottom nav with {len(found)}/6 links")
else:
warn(f"{path} — bottom nav incomplete ({len(found)}/6)")
# ── Touch spacing: tap targets not touching ──
sub("Mobile: Basic tap target spacing")
for path in ["/prayer", "/dhikr"]:
html = req(path)["text"]
buttons = re.findall(r'<button[^>]*>', html)
if len(buttons) > 0:
ok(f"{path}{len(buttons)} tap targets found")
else:
warn(f"{path} — no <button> elements found (might use divs)")
# ──────────────────────────────────────────────────────────────────────
# LAYER 7: PERFORMANCE
# ──────────────────────────────────────────────────────────────────────
def layer_perf():
section("LAYER 7: PERFORMANCE — Response Times & Payload Size")
pages = [
("/", "Landing"),
("/prayer", "Prayer"),
("/dhikr", "Dhikr"),
("/qibla", "Qibla"),
("/halal-monitor", "Halal Monitor"),
("/api/prayer?city=Kuala+Lumpur&country=MY", "Prayer API (cached)"),
]
for path, label in pages:
# First request (cold)
cold = req(path)
cold_ms = cold["elapsed"] * 1000
# Second request (warm)
warm = req(path)
warm_ms = warm["elapsed"] * 1000
size_kb = len(cold["text"]) / 1024
status = ok
notes = []
if cold_ms > 3000:
status = warn
notes.append(f"cold={cold_ms:.0f}ms")
if warm_ms > 1500:
status = warn
notes.append(f"warm={warm_ms:.0f}ms")
if size_kb > 500:
status = warn
notes.append(f"size={size_kb:.0f}KB")
if not notes:
notes.append(f"cold={cold_ms:.0f}ms warm={warm_ms:.0f}ms")
status(f"{label} ({path}) — {'; '.join(notes)}, {size_kb:.0f}KB")
sub("Performance: API response times")
for endpoint, label in [
("api/prayer?city=Kuala+Lumpur&country=MY", "Prayer API"),
("api/health", "Health API"),
]:
r = req(endpoint)
ms = r["elapsed"] * 1000
if ms > 2000:
warn(f"{label}{ms:.0f}ms (slow)")
else:
ok(f"{label}{ms:.0f}ms")
# ──────────────────────────────────────────────────────────────────────
# LAYER 8: SECURITY BASICS
# ──────────────────────────────────────────────────────────────────────
def layer_security():
section("LAYER 8: SECURITY — Headers, Secrets in HTML")
# ── Security headers ──
sub("Security: Response headers")
r = req("/")
hdrs = r["headers"]
checks = [
("Content-Type", lambda v: "text/html" in v if v else False),
("X-Content-Type-Options", lambda v: v == "nosniff" if v else False),
]
for hname, check in checks:
val = hdrs.get(hname)
if val and check(val):
ok(f"{hname}: {val}")
elif val:
warn(f"{hname}: {val} (unexpected value)")
else:
warn(f"{hname}: MISSING")
# ── Secrets in HTML ──
sub("Security: No secrets leaked in HTML")
secret_patterns = [
(r'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', 'JWT token'),
(r'ghp_[A-Za-z0-9]{36}', 'GitHub PAT'),
(r'sk-[A-Za-z0-9]{32,}', 'OpenAI key'),
(r'-----BEGIN (RSA |EC )?PRIVATE KEY-----', 'Private key'),
(r'flh-[a-z0-9]{3,}', 'FLH secret pattern'),
]
pages = ["/", "/prayer", "/dhikr", "/qibla"]
for path in pages:
html = req(path)["text"]
for pat, desc in secret_patterns:
matches = re.findall(pat, html)
if matches:
fail(f"{path} — possible {desc} leak: {matches[0][:20]}...")
else:
ok(f"{path} — no {desc} leak")
# ──────────────────────────────────────────────────────────────────────
# MAIN
# ──────────────────────────────────────────────────────────────────────
def main():
global BASE_URL, CI_EXIT
parser = argparse.ArgumentParser(description="Falah Mobile QA Test Suite")
parser.add_argument("--layer", choices=["system", "api", "render", "scenario", "edge", "mobile", "perf", "security", "all"], default="all")
parser.add_argument("--ci-exit", action="store_true")
parser.add_argument("--url", default=BASE_URL)
args = parser.parse_args()
BASE_URL = args.url.rstrip("/")
CI_EXIT = args.ci_exit
print(f"{'='*60}")
print(f" Falah Mobile — QA Test Suite v2")
print(f" URL: {BASE_URL}")
print(f" Layer: {args.layer}")
print(f" Time: {time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*60}")
import urllib.parse # for special chars test
layers = {
"system": layer_system,
"api": layer_api,
"render": layer_render,
"scenario": layer_scenario,
"edge": layer_edge,
"mobile": layer_mobile,
"perf": layer_perf,
"security": layer_security,
}
if args.layer == "all":
for name, fn in layers.items():
try:
fn()
except Exception as e:
print(f" {R}{N} Layer '{name}' crashed: {e}")
import traceback; traceback.print_exc()
else:
fn = layers.get(args.layer)
if fn:
fn()
else:
print(f"Unknown layer: {args.layer}")
sys.exit(1)
total = PASS + FAIL + WARN
grade = "A"
if FAIL > 0: grade = "B"
if FAIL > 3: grade = "C"
if FAIL > 10: grade = "D"
print(f"\n{'='*60}")
print(f" QA RESULTS — Grade: {grade}")
print(f"{'='*60}")
print(f" {G}Pass:{N} {PASS} {R}Fail:{N} {FAIL} {Y}Warn:{N} {WARN} Total: {total}")
# Generate report
report = f"""# QA Test Report
**Date:** {time.strftime('%Y-%m-%d %H:%M:%S')}
**URL:** {BASE_URL}
**Layer:** {args.layer}
## Summary
| Pass | Fail | Warn | Total | Grade |
|:------:|:------:|:------:|:----:|:-----:|
| {PASS} | {FAIL} | {WARN} | {total} | **{grade}** |
## Details
| Result | Message |
|:------|:--------|
"""
for result, msg in REPORT_LINES:
icon = {"PASS": "", "FAIL": "", "WARN": "⚠️"}.get(result, "")
report += f"| {icon} | {msg} |\n"
report += f"\n## Re-run\n```bash\npython3 tests/qa-test-suite.py{' --ci-exit' if CI_EXIT else ''}\n```"
report_file = f"qa-report-{time.strftime('%Y%m%d_%H%M%S')}.md"
with open(report_file, "w") as f:
f.write(report)
print(f" Report: {report_file}")
if CI_EXIT and FAIL > 0:
sys.exit(1)
if __name__ == "__main__":
main()
+448
View File
@@ -0,0 +1,448 @@
#!/bin/bash
#=============================================================================
# Falah Mobile — Comprehensive QA Test Suite
#
# Multi-layer testing: System → API → Render → User Flows → Edge → Mobile
#
# Usage:
# ./tests/qa-test-suite.sh # Full run
# ./tests/qa-test-suite.sh --layer system # Layer 1 only
# ./tests/qa-test-suite.sh --ci-exit # Exit 1 on failures
#
# Layers:
# 1. SYSTEM — HTTP status, route existence
# 2. API — Response shape, data type validation
# 3. RENDER — Page content validates (no error messages in HTML)
# 4. FLOW — Multi-step user journeys end-to-end
# 5. EDGE — Permission denied, missing params, rate limiting
# 6. MOBILE — Viewport, touch targets (≥44px), text size ≥12px
#=============================================================================
set -euo pipefail
BASE_URL="${BASE_URL:-https://falahos.my/mobile}"
TIMEOUT=15
PASS=0; FAIL=0; WARN=0
LAYER_FILTER="all"
CI_EXIT=false
REPORT_FILE=""
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--layer=*) LAYER_FILTER="${1#*=}" ;;
--ci-exit) CI_EXIT=true ;;
esac
shift
done
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; NC='\033[0m'
pass() { PASS=$((PASS+1)); echo -e " ${GREEN}${NC} $1"; }
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}${NC} $1"; }
warn() { WARN=$((WARN+1)); echo -e " ${YELLOW}${NC} $1"; }
section() { echo ""; echo -e "${CYAN}════════════════════════════════════════${NC}"; echo -e "${CYAN} $1${NC}"; echo -e "${CYAN}════════════════════════════════════════${NC}"; }
sub() { echo -e "\n${CYAN}── $1 ──${NC}"; }
should_run() { [ "$LAYER_FILTER" = "all" ] || [ "$LAYER_FILTER" = "$1" ]; }
fetch() { curl -s --connect-timeout "$TIMEOUT" "$@" 2>/dev/null || echo ""; }
#─────────────────────────────────────────────────────────────────
# LAYER 1: System — Route Existence & HTTP Status
#─────────────────────────────────────────────────────────────────
layer_system() {
section "LAYER 1: System — Route Existence & HTTP Status"
local routes=(
"/" "Landing"
"/prayer" "Prayer"
"/dhikr" "Dhikr"
"/qibla" "Qibla"
"/halal-monitor" "Halal Monitor"
"/nur" "Nur AI"
"/forum" "Forum"
"/souq" "Souq"
"/groups" "Groups"
"/api/health" "Health API"
)
for ((i=0; i<${#routes[@]}; i+=2)); do
local path="${routes[$i]}"
local label="${routes[$((i+1))]}"
local code
code=$(fetch -o /dev/null -w '%{http_code}' "${BASE_URL}${path}")
case "$code" in
200|308) pass "$label ($path$code)" ;;
000) fail "$label ($path → CONNECTION FAILED)" ;;
404) fail "$label ($path → 404 NOT FOUND)" ;;
500) fail "$label ($path → 500 SERVER ERROR)" ;;
*) fail "$label ($path$code)" ;;
esac
done
}
#─────────────────────────────────────────────────────────────────
# LAYER 2: API Response Shape Validation
#─────────────────────────────────────────────────────────────────
layer_api() {
section "LAYER 2: API — Response Shape & Data Type Validation"
# —— Prayer API ——
sub "Prayer API — timings, date, hijri, city, country, timezone"
local resp
resp=$(fetch "${BASE_URL}/api/prayer?city=Kuala+Lumpur&country=MY")
if echo "$resp" | python3 -c "
import sys, json
d = json.load(sys.stdin)
checks = {
'timings (dict)': 'timings' in d and isinstance(d['timings'], dict),
'has Fajr time': 'Fajr' in d.get('timings', {}),
'date (string)': isinstance(d.get('date'), str) and len(d['date']) > 5,
'hijri (string)': isinstance(d.get('hijri'), str) and len(d['hijri']) > 5,
'city (string)': isinstance(d.get('city'), str) and len(d['city']) > 0,
}
all_ok = all(checks.values())
for name, ok in checks.items():
print(f' {\"✓\" if ok else \"✗\"} {name}')
sys.exit(0 if all_ok else 1)
" 2>&1; then
pass "Prayer API — shape valid"
else
fail "Prayer API — INVALID shape"
echo " Raw: $(echo "$resp" | head -c 200)"
fi
# —— Prayer API with empty params ——
sub "Prayer API — empty/missing params (should handle gracefully)"
resp=$(fetch "${BASE_URL}/api/prayer?city=&country=")
local has_err
has_err=*** "$resp" python3 -c "import sys,json; d=json.load(sys.stdin); print('error' in d)" 2>/dev/null || echo "true"
if [ "$has_err" = "True" ]; then
pass "Prayer API — returns error for empty params"
else
warn "Prayer API — no error for empty params (may silently default)"
fi
# —— Health API ——
sub "Health API — { status, uptime, timestamp }"
resp=$(fetch "${BASE_URL}/api/health")
if echo "$resp" | python3 -c "
import sys, json
d = json.load(sys.stdin)
assert d.get('status') == 'ok', f'status={d.get(\"status\")}'
sys.exit(0)
" 2>&1; then
pass "Health API — returns ok"
else
fail "Health API — unexpected response: $(echo "$resp" | head -c 100)"
fi
}
#─────────────────────────────────────────────────────────────────
# LAYER 3: Render Validation (content-level)
#─────────────────────────────────────────────────────────────────
layer_render() {
section "LAYER 3: Render — Page Content Validation"
echo " (Checking rendered HTML for error states, not just HTTP codes)"
local pages=(
"/prayer" "Prayer Times" "Invalid response from prayer API"
"/dhikr" "Dhikr" "Invalid response"
"/qibla" "Qibla" "Invalid response"
"/halal-monitor" "Halal Monitor" "Invalid response"
"/nur" "Nur" "Invalid response"
"/forum" "Forum" "Invalid response"
"/souq" "Souq" "Invalid response"
"/groups" "Groups" "Invalid response"
"/" "Falah" "Invalid response"
)
for ((i=0; i<${#pages[@]}; i+=3)); do
local path="${pages[$i]}"
local title="${pages[$((i+1))]}"
local error_pat="${pages[$((i+2))]}"
local content
content=$(fetch "${BASE_URL}${path}")
# Content length
local clen
clen=*** "$content" wc -c)
if [ "$clen" -lt 1000 ]; then
warn "$path — very short content (${clen}b), may be blank/error"
fi
# Error strings in rendered output
if echo "$content" | grep -qiF "$error_pat"; then
fail "$path — CONTAINS ERROR \"$error_pat\" in rendered HTML"
else
pass "$path — clean of \"$error_pat\""
fi
# Expected title
if echo "$content" | grep -qiF "$title"; then
pass "$path — contains title \"$title\""
else
fail "$path — MISSING expected title \"$title\""
fi
done
# Scan ALL pages for common error patterns
sub "Global scan — common error patterns across all pages"
local patterns=(
"Invalid response"
"Internal Server Error"
"Application error"
"Cannot read properties"
"undefined is not"
"TypeError"
"failed to fetch"
)
for pat in "${patterns[@]}"; do
local hit=0
for path in /prayer /dhikr /qibla /halal-monitor /nur /forum /souq /groups /; do
if fetch "${BASE_URL}${path}" | grep -qiF "$pat"; then
hit=$((hit+1))
fail "${path} → pattern: \"$pat\""
fi
done
[ "$hit" -eq 0 ] && pass "No page contains: \"$pat\""
done
}
#─────────────────────────────────────────────────────────────────
# LAYER 4: User Flow Testing
#─────────────────────────────────────────────────────────────────
layer_flow() {
section "LAYER 4: Flow — Multi-Step User Journeys"
# Try to get auth token via login API
sub "Flow: Login"
local login_resp token
login_resp=$(fetch -X POST -H "Content-Type: application/json" \
-d '{"email":"demo@falahos.my","password":"password123"}' \
"${BASE_URL}/api/auth/login")
token=*** "$login_resp" python3 -c "import sys,json; print(json.load(sys.stdin).get('token','NO_TOKEN'))" 2>/dev/null || echo "")
if [ -z "$token" ] || [ "$token" = "NO_TOKEN" ]; then
fail "Login — could not obtain auth token"
warn "Skipping authenticated flows"
return
fi
pass "Login — obtained auth token (${token:0:12}...)"
# —— Flow: Prayer page authenticated ——
sub "Flow: Prayer page (authenticated)"
local prayer_page
prayer_page=$(fetch -H "Authorization: Bearer ${token}" "${BASE_URL}/prayer")
if echo "$prayer_page" | grep -qF "Invalid response from prayer API"; then
fail "Prayer page — STILL shows API error when authenticated"
elif echo "$prayer_page" | grep -qF "Prayer Times"; then
pass "Prayer page — renders correctly when authenticated"
else
warn "Prayer page — unexpected content when authenticated"
fi
# —— Flow: Log a Dhikr session via API ——
sub "Flow: Dhikr counter — log session"
local dhikr_resp
dhikr_resp=$(fetch -X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer ${token}" \
-d '{"type":"subhanallah","count":33,"target":33}' \
"${BASE_URL}/api/dhikr")
if echo "$dhikr_resp" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('saved') or d.get('id') or d.get('success'), 'no success'; sys.exit(0)" 2>/dev/null; then
pass "Dhikr session — logged successfully"
else
warn "Dhikr session — response: $(echo "$dhikr_resp" | head -c 80)"
fi
# —— Flow: Qibla bearing calculation ——
sub "Flow: Qibla — bearing from Kuala Lumpur"
local qibla_resp
qibla_resp=$(fetch -H "Authorization: Bearer ${token}" \
"${BASE_URL}/api/qibla?lat=3.139&lng=101.6869")
if echo "$qibla_resp" | python3 -c "
import sys, json
d = json.load(sys.stdin)
b = d.get('bearing', 0)
dist = d.get('distance', 0)
assert 280 < b < 320, f'bearing {b} out of range for KL→Mecca'
assert 6000 < dist < 8000, f'distance {dist} out of range'
print(f' Bearing: {b}°, Distance: {dist} km')
sys.exit(0)
" 2>&1; then
pass "Qibla API — bearing correct for KL→Mecca"
else
fail "Qibla API — unexpected: $(echo "$qibla_resp" | head -c 100)"
fi
# —— Flow: Full journey (Prayer → Dhikr → Qibla) ——
sub "Flow: Full journey — all spiritual tools accessible"
local all_ok=0
for page in prayer dhikr qibla; do
local pg
pg=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer ${token}" \
--connect-timeout 10 "${BASE_URL}/${page}" 2>/dev/null)
[ "$pg" = "200" ] && all_ok=$((all_ok+1))
done
if [ "$all_ok" -eq 3 ]; then
pass "Full journey — Prayer + Dhikr + Qibla all accessible (HTTP 200)"
else
warn "Full journey — only $all_ok/3 pages returned 200"
fi
}
#─────────────────────────────────────────────────────────────────
# LAYER 5: Edge Cases
#─────────────────────────────────────────────────────────────────
layer_edge() {
section "LAYER 5: Edge — Error States, Permissions, Resilience"
# —— Unauthenticated access to protected pages ——
sub "Edge: Protected pages redirect unauthenticated users"
local protected=("/dhikr" "/qibla" "/profile" "/wallet")
for path in "${protected[@]}"; do
local content
content=$(fetch "${BASE_URL}${path}")
if echo "$content" | grep -qiE "(login|sign in|Welcome to Falah|redirect|auth)"; then
pass "$path — redirects to auth when unauthenticated"
else
warn "$path — no auth wall detected (may expose content)"
fi
done
# —— Invalid auth token ——
sub "Edge: Invalid auth token"
local bad_resp
bad_resp=$(fetch -H "Authorization: Bearer invalid_token_here" "${BASE_URL}/api/prayer")
if echo "$bad_resp" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('timings') else 1)" 2>/dev/null; then
pass "Prayer API — accessible with invalid token (public data)"
else
warn "Prayer API — rejects invalid token (may be protected)"
fi
# —— Rapid consecutive requests ——
sub "Edge: Rapid requests (10x in parallel)"
local ok=0 fail_conn=0
for i in $(seq 1 10); do
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 \
"${BASE_URL}/api/prayer?city=Kuala+Lumpur&country=MY" 2>/dev/null)
[ "$code" = "200" ] && ok=$((ok+1)) || fail_conn=$((fail_conn+1))
done
if [ "$ok" -eq 10 ]; then
pass "Rapid requests — 10/10 returned 200"
else
warn "Rapid requests — ${ok}/10 returned 200, ${fail_conn} failed"
fi
# —— Missing query params on API routes ——
sub "Edge: Missing params on various APIs"
for endpoint in "api/prayer" "api/qibla"; do
local resp
resp=$(fetch "${BASE_URL}/${endpoint}")
if echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('error') else 1)" 2>/dev/null; then
pass "${endpoint} — returns error when params missing"
else
warn "${endpoint} — no error for missing params"
fi
done
}
#─────────────────────────────────────────────────────────────────
# LAYER 6: Mobile Viewport & Touch Targets
#─────────────────────────────────────────────────────────────────
layer_mobile() {
section "LAYER 6: Mobile — Responsive, Touch Targets, Text Size"
local pages=("/" "/prayer" "/dhikr" "/qibla" "/halal-monitor" "/nur")
for path in "${pages[@]}"; do
local content
content=$(fetch "${BASE_URL}${path}")
# Viewport meta
if echo "$content" | grep -qi 'name=.viewport.'; then
pass "$path — viewport meta present"
else
fail "$path — MISSING viewport meta"
fi
# Touch-friendly: min-h >= 44px
if echo "$content" | grep -qP 'min-h(?:eight)?[=:].*?(44|4[5-9]|[5-9][0-9])'; then
pass "$path — has ≥44px touch targets"
else
warn "$path — no ≥44px touch target classes detected"
fi
# Text below 12px
if echo "$content" | grep -qP 'text-\[1[0-1]px\]'; then
warn "$path — has sub-12px text (text-[10-11px])"
else
pass "$path — no sub-12px text detected"
fi
done
}
#─────────────────────────────────────────────────────────────────
# Report
#─────────────────────────────────────────────────────────────────
print_report() {
local total=$((PASS + FAIL + WARN))
local grade="A"
[ "$FAIL" -gt 0 ] && grade="B"
[ "$FAIL" -gt 3 ] && grade="C"
[ "$FAIL" -gt 10 ] && grade="D"
echo ""
echo -e "${CYAN}════════════════════════════════════════${NC}"
echo -e "${CYAN} QA TEST RESULTS — Grade: ${grade}${NC}"
echo -e "${CYAN}════════════════════════════════════════${NC}"
echo ""
echo -e " ${GREEN}Pass:${NC} $PASS ${RED}Fail:${NC} $FAIL ${YELLOW}Warn:${NC} $WARN Total: $total"
echo ""
local report_file="qa-report-$(date +%Y%m%d_%H%M%S).md"
{
echo "# QA Test Report"
echo "**Date:** $(date)"
echo "**Base URL:** $BASE_URL"
echo "**Layer:** $LAYER_FILTER"
echo ""
echo "## Summary"
echo "| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade |"
echo "|:------:|:------:|:------:|:----:|:-----:|"
echo "| $PASS | $FAIL | $WARN | $total | **$grade** |"
echo ""
echo "### Re-run with CI exit"
echo '```bash'
echo "./tests/qa-test-suite.sh --ci-exit"
echo '```'
} > "$report_file"
echo " Report saved to: $report_file"
if $CI_EXIT && [ "$FAIL" -gt 0 ]; then
exit 1
fi
}
#─────────────────────────────────────────────────────────────────
# Main
#─────────────────────────────────────────────────────────────────
echo "╔══════════════════════════════════════════════════════╗"
echo "║ Falah Mobile — Comprehensive QA Test Suite ║"
echo "$(date)"
echo "╚══════════════════════════════════════════════════════╝"
echo " Layer: $LAYER_FILTER URL: $BASE_URL"
should_run "system" && layer_system
should_run "api" && layer_api
should_run "render" && layer_render
should_run "flow" && layer_flow
should_run "edge" && layer_edge
should_run "mobile" && layer_mobile
print_report
+230
View File
@@ -0,0 +1,230 @@
#!/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")
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."""
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)
context = browser.new_context(viewport=VIEWPORTS[viewport])
page_obj = context.new_page()
# 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}"))
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}...")
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)")
browser.close()
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}")
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"]),
}
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)
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()