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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ── */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user