pulse-dashboard
Version:
A Next.js Dashboard application for real-time monitoring and historical analysis of Playwright test executions, based on playwright-pulse-report. This component provides the UI for visualizing Playwright test results and can be run as a standalone CLI too
945 lines (928 loc) • 48.1 kB
JSX
"use client";
import { useRouter } from "next/navigation";
import { useTestData } from "@/hooks/useTestData";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import { History } from "lucide-react";
import { ArrowLeft, CheckCircle2, XCircle, AlertCircle, Clock, ImageIcon, FileText, LineChart, Info, Download, Film, Archive, Terminal, FileJson, FileSpreadsheet, FileCode, File as FileIcon, Sparkles, Lightbulb, Wrench, } from "lucide-react";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { TestStepItemRecursive } from "./TestStepItemRecursive";
import { getRawHistoricalReports } from "@/app/actions";
import { ResponsiveContainer, LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, } from "recharts";
import { cn, ansiToHtml, getAssetPath as getUtilAssetPath } from "@/lib/utils";
const StatusDot = (props) => {
const { cx, cy, payload } = props;
if (!cx || !cy || !payload)
return null;
let color = "hsl(var(--muted-foreground))"; // Default color
if (payload.status === "passed")
color = "hsl(var(--chart-3))";
else if (payload.status === "failed" || payload.status === "timedOut")
color = "hsl(var(--destructive))";
else if (payload.status === "skipped")
color = "hsl(var(--accent))";
return (<circle cx={cx} cy={cy} r={5} fill={color} stroke="hsl(var(--card))" strokeWidth={1}/>);
};
const HistoryTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (<div className="bg-card p-3 border border-border rounded-md shadow-lg">
<p className="label text-sm font-semibold text-foreground">{`Date: ${new Date(data.date).toLocaleString()}`}</p>
<p className="text-xs text-foreground">{`Duration: ${formatDuration(data.duration)}`}</p>
<p className="text-xs" style={{
color: data.status === "passed"
? "hsl(var(--chart-3))"
: data.status === "failed" || data.status === "timedOut"
? "hsl(var(--destructive))"
: "hsl(var(--accent))",
}}>
{`Status: ${data.status}`}
</p>
</div>);
}
return null;
};
function StatusIcon({ status }) {
switch (status) {
case "passed":
return <CheckCircle2 className="h-6 w-6 text-[hsl(var(--chart-3))]"/>;
case "failed":
return <XCircle className="h-6 w-6 text-destructive"/>;
case "skipped":
return <AlertCircle className="h-6 w-6 text-[hsl(var(--accent))]"/>;
case "timedOut":
return <Clock className="h-6 w-6 text-destructive"/>;
case "pending":
return <Clock className="h-6 w-6 text-primary animate-pulse"/>;
default:
return <Info className="h-6 w-6 text-muted-foreground"/>;
}
}
function formatDuration(ms) {
if (ms < 1000)
return `${ms}ms`;
const seconds = parseFloat((ms / 1000).toFixed(2));
if (seconds < 60)
return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = parseFloat((seconds % 60).toFixed(2));
return `${minutes}m ${remainingSeconds}s`;
}
function formatTestName(fullName) {
if (!fullName)
return "";
const parts = fullName.split(" > ");
return parts[parts.length - 1] || fullName;
}
function getStatusBadgeStyle(status) {
switch (status) {
case "passed":
return {
backgroundColor: "hsl(var(--chart-3))",
color: "hsl(var(--primary-foreground))",
};
case "failed":
case "timedOut":
return {
backgroundColor: "hsl(var(--destructive))",
color: "hsl(var(--destructive-foreground))",
};
case "skipped":
return {
backgroundColor: "hsl(var(--accent))",
color: "hsl(var(--accent-foreground))",
};
case "pending":
return {
backgroundColor: "hsl(var(--primary))",
color: "hsl(var(--primary-foreground))",
};
default:
return {
backgroundColor: "hsl(var(--muted))",
color: "hsl(var(--muted-foreground))",
};
}
}
function AttachmentIcon({ contentType }) {
const lowerContentType = contentType.toLowerCase();
if (lowerContentType.includes("html"))
return <FileCode className="h-6 w-6 text-blue-500"/>;
if (lowerContentType.includes("pdf"))
return <FileText className="h-6 w-6 text-red-500"/>;
if (lowerContentType.includes("json"))
return <FileJson className="h-6 w-6 text-yellow-500"/>;
if (lowerContentType.includes("csv") ||
lowerContentType.startsWith("text/plain"))
return <FileSpreadsheet className="h-6 w-6 text-green-500"/>;
if (lowerContentType.startsWith("text/"))
return <FileText className="h-6 w-6 text-gray-500"/>;
return <FileIcon className="h-6 w-6 text-gray-400"/>;
}
function getAttachmentNameFromPath(path, defaultName = "Attachment") {
if (!path || typeof path !== "string")
return defaultName;
const parts = path.split(/[/\\]/);
return parts.pop() || defaultName;
}
export function TestDetailsClientPage({ testId }) {
const router = useRouter();
const { currentRun, loadingCurrent, errorCurrent } = useTestData();
const [test, setTest] = useState(null);
const [historicalReports, setHistoricalReports] = useState([]);
const [testHistory, setTestHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [errorHistory, setErrorHistory] = useState(null);
const historyChartRef = useRef(null);
const [aiSuggestion, setAiSuggestion] = useState(null);
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
const [aiSuggestionError, setAiSuggestionError] = useState(null);
const [historyFetched, setHistoryFetched] = useState(false);
const [selectedRunTimestamp, setSelectedRunTimestamp] = useState("current");
const initialTest = useMemo(() => {
return (currentRun?.results?.find((t) => t.id === testId) ||
null);
}, [currentRun, testId]);
const handleGenerateSuggestion = async () => {
if (!test)
return;
setIsGeneratingSuggestion(true);
setAiSuggestion(null);
setAiSuggestionError(null);
try {
const response = await fetch("/api/analyze-test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
testName: test.name,
failureLogsAndErrors: test.errorMessage || "",
codeSnippet: test.snippet || "",
}),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to parse error response" }));
throw new Error(errorData.message ||
`API request failed with status ${response.status}`);
}
const result = await response.json();
setAiSuggestion(result);
}
catch (error) {
console.error("Error generating AI suggestion:", error);
setAiSuggestionError(error instanceof Error ? error.message : "An unknown error occurred.");
}
finally {
setIsGeneratingSuggestion(false);
}
};
useEffect(() => {
setTest(initialTest);
}, [initialTest]);
const fetchTestHistory = useCallback(async () => {
if (!testId ||
historyFetched ||
!initialTest?.suiteName ||
!currentRun?.run?.timestamp)
return;
setLoadingHistory(true);
setErrorHistory(null);
try {
const allHistoricalReports = await getRawHistoricalReports();
// Filter out the current run from the historical list to prevent duplicates
const filteredReports = allHistoricalReports.filter((report) => report.run.timestamp !== currentRun.run.timestamp);
setHistoricalReports(filteredReports);
const historyData = [];
filteredReports.forEach((report) => {
const historicalTest = report.results.find((r) => r.id === testId && r.suiteName === initialTest.suiteName);
if (historicalTest) {
historyData.push({
date: report.run.timestamp,
duration: historicalTest.duration,
status: historicalTest.status,
});
}
});
historyData.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setTestHistory(historyData);
setHistoryFetched(true);
}
catch (error) {
console.error("Error fetching test history:", error);
setErrorHistory(error instanceof Error ? error.message : "Failed to load test history");
}
finally {
setLoadingHistory(false);
}
}, [testId, historyFetched, initialTest, currentRun]);
useEffect(() => {
if (selectedRunTimestamp === "current") {
setTest(initialTest);
}
else {
const report = historicalReports.find((r) => r.run.timestamp === selectedRunTimestamp);
if (report?.results && initialTest) {
const foundTest = report.results.find((t) => t.id === testId && t.suiteName === initialTest.suiteName);
setTest(foundTest || null);
}
}
}, [selectedRunTimestamp, initialTest, historicalReports, testId]);
const screenshotAttachments = useMemo(() => {
if (!test || !Array.isArray(test.screenshots))
return [];
return test.screenshots.map((path, index) => ({
name: getAttachmentNameFromPath(path, `Screenshot ${index + 1}`),
path: path,
contentType: "image/png",
"data-ai-hint": `screenshot ${index + 1}`,
}));
}, [test]);
const videoAttachments = useMemo(() => {
if (!test || !Array.isArray(test.videoPath))
return [];
return test.videoPath.map((path, index) => ({
name: getAttachmentNameFromPath(path, `Video ${index + 1}`),
path: path,
contentType: "video/mp4",
"data-ai-hint": `video ${index + 1}`,
}));
}, [test]);
const traceAttachment = useMemo(() => {
if (!test || typeof test.tracePath !== "string" || !test.tracePath)
return null;
return {
name: getAttachmentNameFromPath(test.tracePath, "trace.zip"),
path: test.tracePath,
contentType: "application/zip",
};
}, [test]);
const allOtherAttachments = useMemo(() => {
if (!test || !Array.isArray(test.attachments))
return [];
return test.attachments.map((att, index) => ({
name: att.name ||
getAttachmentNameFromPath(att.path, `Attachment ${index + 1}`),
path: att.path,
contentType: att.contentType || "application/octet-stream",
"data-ai-hint": att["data-ai-hint"],
}));
}, [test]);
const htmlAttachments = useMemo(() => allOtherAttachments.filter((a) => a.contentType.toLowerCase().includes("html")), [allOtherAttachments]);
const pdfAttachments = useMemo(() => allOtherAttachments.filter((a) => a.contentType.toLowerCase().includes("pdf")), [allOtherAttachments]);
const jsonAttachments = useMemo(() => allOtherAttachments.filter((a) => a.contentType.toLowerCase().includes("json")), [allOtherAttachments]);
const textCsvAttachments = useMemo(() => allOtherAttachments.filter((a) => a.contentType.toLowerCase().startsWith("text/") ||
a.contentType.toLowerCase().includes("csv")), [allOtherAttachments]);
const otherGenericAttachments = useMemo(() => allOtherAttachments.filter((a) => !htmlAttachments.includes(a) &&
!pdfAttachments.includes(a) &&
!jsonAttachments.includes(a) &&
!textCsvAttachments.includes(a)), [
allOtherAttachments,
htmlAttachments,
pdfAttachments,
jsonAttachments,
textCsvAttachments,
]);
const totalAttachmentsCount = useMemo(() => {
return (screenshotAttachments.length +
videoAttachments.length +
(traceAttachment ? 1 : 0) +
allOtherAttachments.length);
}, [
screenshotAttachments,
videoAttachments,
traceAttachment,
allOtherAttachments,
]);
// Helper function to get color class based on test status
const getStatusColorClass = (status) => {
switch (status) {
case "passed":
return "bg-green-500";
case "failed":
case "timedOut":
return "bg-red-500";
case "skipped":
return "bg-yellow-500";
default:
return "bg-gray-400";
}
};
const isFailedTest = test?.status === "failed" || test?.status === "timedOut";
if (loadingCurrent && !test) {
return (<div className="container mx-auto py-8 space-y-6">
<Skeleton className="h-10 w-48 mb-4 rounded-md"/>
<Card className="shadow-xl rounded-lg">
<CardHeader>
<Skeleton className="h-8 w-3/4 mb-2 rounded-md"/>
<Skeleton className="h-4 w-1/2 rounded-md"/>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-1/3 mb-4 rounded-md"/>
<Skeleton className="h-40 w-full rounded-md"/>
</CardContent>
</Card>
</div>);
}
if (errorCurrent && !test) {
return (<div className="container mx-auto px-4 py-8">
<Alert variant="destructive" className="rounded-lg">
<AlertTitle>Error loading test data</AlertTitle>
<AlertDescription>{errorCurrent}</AlertDescription>
</Alert>
<Button onClick={() => router.push("/")} variant="outline" className="mt-4 rounded-lg">
<ArrowLeft className="mr-2 h-4 w-4"/> Back
</Button>
</div>);
}
if (!test) {
return (<div className="container mx-auto px-4 py-8 text-center">
<Alert className="rounded-lg">
<AlertTitle>Test Not Found</AlertTitle>
<AlertDescription>
The test with ID '{testId}' could not be found in the current
report.
</AlertDescription>
</Alert>
<Button onClick={() => router.push("/")}
// No variant needed, we are applying custom styles
className="mt-6 inline-flex items-center justify-center rounded-xl border bg-transparent px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-all duration-300 ease-in-out hover:-translate-y-0.5 hover:shadow-lg hover:bg-primary hover:text-primary-foreground hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">
<ArrowLeft className="mr-2 h-4 w-4"/> Back to Dashboard
</Button>
</div>);
}
const displayName = formatTestName(test.name);
return (<div className="container mx-auto px-4 py-8 space-y-6">
<Button onClick={() => router.push("/")}
// No variant needed, we are applying custom styles
className="mt-6 inline-flex items-center justify-center rounded-xl border bg-transparent px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-all duration-300 ease-in-out hover:-translate-y-0.5 hover:shadow-lg hover:bg-primary hover:text-primary-foreground hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">
<ArrowLeft className="mr-2 h-4 w-4"/> Back to Dashboard
</Button>
<Card className="shadow-xl rounded-lg">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-2xl font-headline text-primary flex items-center" title={test.name}>
<StatusIcon status={test.status}/>
<span className="ml-3">{displayName}</span>
</CardTitle>
{test.suiteName && (<CardDescription className="mt-1 text-md">
From suite: {test.suiteName}
</CardDescription>)}
<p className="text-sm text-muted-foreground mt-2">
Run Date:{" "}
{selectedRunTimestamp === "current" ? (loadingCurrent ? (<span className="text-muted-foreground">Loading...</span>) : currentRun?.run?.timestamp ? (<span className="font-medium">
{new Date(currentRun.run.timestamp).toLocaleString()}
</span>) : errorCurrent && !currentRun?.run?.timestamp ? (<span className="text-destructive font-medium">
Error loading date
</span>) : (<span className="font-medium">Not available</span>)) : (<span className="font-medium">
{new Date(selectedRunTimestamp).toLocaleString()}
</span>)}
</p>
<div className="mt-1 text-xs text-muted-foreground">
<p>ID: {test.id}</p>
{test.browser && <p>Browser: {test.browser}</p>}
</div>
</div>
<div className="text-right flex-shrink-0">
<Badge variant="outline" className="capitalize text-sm px-3 py-1 rounded-full border" style={getStatusBadgeStyle(test.status)}>
{test.status}
</Badge>
<p className="text-sm text-muted-foreground mt-1">
Duration: {formatDuration(test.duration)}
</p>
<p className="text-xs text-muted-foreground">
Retries: {test.retries}
</p>
{test.tags && test.tags.length > 0 && (<div className="mt-1 space-x-1">
{test.tags.map((tag) => (<Badge key={tag} variant="secondary" className="text-xs rounded-full">
{tag}
</Badge>))}
</div>)}
</div>
</div>
<div className="mt-4">
<Select value={selectedRunTimestamp} onValueChange={setSelectedRunTimestamp} onOpenChange={(isOpen) => {
if (isOpen && !historyFetched) {
fetchTestHistory();
}
}}>
<SelectTrigger className="w-full md:w-[350px] h-12 rounded-xl bg-card/50 border-2 border-transparent hover:border-primary/30 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors duration-200 group">
<div className="flex items-center gap-3">
<History className="h-5 w-5 text-muted-foreground transition-colors group-hover:text-primary"/>
<SelectValue placeholder="Switch to a past run"/>
</div>
</SelectTrigger>
<SelectContent sideOffset={8} className="bg-card/80 backdrop-blur-lg border border-border/50 rounded-xl shadow-2xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
<SelectItem value="current" className="rounded-lg">
Current Run
</SelectItem>
{loadingHistory && (<SelectItem value="loading" disabled className="rounded-lg">
Loading history...
</SelectItem>)}
{testHistory.map((run) => (<SelectItem key={run.date} value={run.date} className="rounded-lg">
<div className="flex items-center gap-3">
<span className={cn("h-2 w-2 rounded-full", getStatusColorClass(run.status))} title={`Status: ${run.status}`}/>
<span className="text-sm">
{new Date(run.date).toLocaleString()} -{" "}
<span className="font-medium capitalize">
{run.status}
</span>
</span>
</div>
</SelectItem>))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="steps" className="w-full" onValueChange={(value) => {
if (value === "history" && !historyFetched) {
fetchTestHistory();
}
}}>
<TabsList className={cn("grid w-full mb-4 rounded-lg", isFailedTest
? "grid-cols-2 md:grid-cols-5"
: "grid-cols-2 md:grid-cols-4")}>
<TabsTrigger value="steps">
Execution Steps ({test.steps?.length || 0})
</TabsTrigger>
<TabsTrigger value="attachments">
Attachments ({totalAttachmentsCount})
</TabsTrigger>
<TabsTrigger value="logs">
<FileText className="h-4 w-4 mr-2"/>
Logs
</TabsTrigger>
<TabsTrigger value="history">Test Run History</TabsTrigger>
{isFailedTest && (<TabsTrigger value="ai-suggestions">
<Sparkles className="h-4 w-4 mr-2"/>
AI Suggestions
</TabsTrigger>)}
</TabsList>
<TabsContent value="steps" className="mt-4 p-1 md:p-4 border rounded-lg bg-card shadow-inner">
<h3 className="text-lg font-semibold text-foreground mb-3 px-3 md:px-0">
Test Execution Steps
</h3>
{test.errorMessage && (<div className="mb-4 p-3 md:p-0">
<h4 className="font-semibold text-md text-destructive mb-1">
Overall Test Error:
</h4>
<pre className="bg-destructive/10 text-sm p-4 rounded-lg whitespace-pre-wrap break-all font-code overflow-x-auto">
<span dangerouslySetInnerHTML={{
__html: ansiToHtml(test.errorMessage),
}}/>
</pre>
</div>)}
{test.annotations && test.annotations.length > 0 && (<div className="mb-4 p-3 md:p-0">
<div style={{
margin: "12px 0",
padding: "12px",
backgroundColor: "rgba(139, 92, 246, 0.1)",
border: "1px solid rgba(139, 92, 246, 0.3)",
borderLeft: "4px solid #8b5cf6",
borderRadius: "4px",
}}>
<h4 style={{
marginTop: 0,
marginBottom: "10px",
color: "#8b5cf6",
fontSize: "1.1em",
}}>
📌 Annotations
</h4>
{test.annotations.map((annotation, index) => (<div key={index} style={{
marginBottom: index === test.annotations.length - 1 ? "0" : "10px",
}}>
<strong style={{ color: "#8b5cf6" }}>Type:</strong>{" "}
<span style={{
backgroundColor: "rgba(139, 92, 246, 0.2)",
padding: "2px 8px",
borderRadius: "4px",
fontSize: "0.9em",
}}>
{annotation.type}
</span>
{annotation.description && (<>
<br />
<strong style={{ color: "#8b5cf6" }}>
Description:
</strong>{" "}
{annotation.description}
</>)}
{annotation.location && (<div style={{
fontSize: "0.85em",
color: "#6b7280",
marginTop: "4px",
}}>
Location: {annotation.location.file}:
{annotation.location.line}:{annotation.location.column}
</div>)}
</div>))}
</div>
</div>)}
{test.steps && test.steps.length > 0 ? (<ScrollArea className="h-[600px] w-full">
<div className="pr-4">
{test.steps.map((step, index) => (<TestStepItemRecursive key={step.id || index} step={step}/>))}
</div>
</ScrollArea>) : (<p className="text-muted-foreground p-3 md:p-0">
No detailed execution steps available for this test.
</p>)}
</TabsContent>
<TabsContent value="attachments" className="mt-4 p-1 md:p-4 border rounded-lg bg-card shadow-inner">
<Tabs defaultValue="sub-screenshots" className="w-full">
<ScrollArea className="w-full whitespace-nowrap rounded-lg">
<TabsList className="inline-grid w-max grid-flow-col mb-4 rounded-lg">
<TabsTrigger value="sub-screenshots" disabled={screenshotAttachments.length === 0}>
<ImageIcon className="h-4 w-4 mr-2"/>
Screenshots ({screenshotAttachments.length})
</TabsTrigger>
<TabsTrigger value="sub-video" disabled={videoAttachments.length === 0}>
<Film className="h-4 w-4 mr-2"/>
Videos ({videoAttachments.length})
</TabsTrigger>
<TabsTrigger value="sub-trace" disabled={!traceAttachment}>
<Archive className="h-4 w-4 mr-2"/>
Trace {traceAttachment ? "(1)" : "(0)"}
</TabsTrigger>
<TabsTrigger value="sub-html" disabled={htmlAttachments.length === 0}>
<FileCode className="h-4 w-4 mr-2"/>
HTML ({htmlAttachments.length})
</TabsTrigger>
<TabsTrigger value="sub-pdf" disabled={pdfAttachments.length === 0}>
<FileText className="h-4 w-4 mr-2"/>
PDF ({pdfAttachments.length})
</TabsTrigger>
<TabsTrigger value="sub-json" disabled={jsonAttachments.length === 0}>
<FileJson className="h-4 w-4 mr-2"/>
JSON ({jsonAttachments.length})
</TabsTrigger>
<TabsTrigger value="sub-text" disabled={textCsvAttachments.length === 0}>
<FileText className="h-4 w-4 mr-2"/>
Text/CSV ({textCsvAttachments.length})
</TabsTrigger>
<TabsTrigger value="sub-other" disabled={otherGenericAttachments.length === 0}>
<FileIcon className="h-4 w-4 mr-2"/>
Others ({otherGenericAttachments.length})
</TabsTrigger>
</TabsList>
</ScrollArea>
<TabsContent value="sub-screenshots" className="mt-4">
<h3 className="text-lg font-semibold text-foreground mb-4">
Screenshots
</h3>
{screenshotAttachments.length > 0 ? (<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{screenshotAttachments.map((attachment, index) => {
const imageSrc = getUtilAssetPath(attachment.path);
if (imageSrc === "#")
return null;
return (<a key={`img-preview-${index}`} href={imageSrc} target="_blank" rel="noopener noreferrer" className="relative aspect-video rounded-lg overflow-hidden group border hover:border-primary transition-all shadow-md hover:shadow-lg">
<Image src={imageSrc} alt={attachment.name || `Screenshot ${index + 1}`} fill={true} style={{ objectFit: "cover" }} className="group-hover:scale-105 transition-transform duration-300" data-ai-hint={attachment["data-ai-hint"] || "test screenshot"}/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center p-2">
<p className="text-white text-xs text-center break-all">
{attachment.name || `Screenshot ${index + 1}`}
</p>
</div>
</a>);
})}
</div>) : (<p className="text-muted-foreground">
No screenshots available for this test.
</p>)}
</TabsContent>
<TabsContent value="sub-video" className="mt-4">
<h3 className="text-lg font-semibold text-foreground mb-4">
Video Recording(s)
</h3>
<div className="space-y-4">
{videoAttachments.length > 0 ? (videoAttachments.map((attachment, index) => (<div key={`video-${index}`} className="p-4 border rounded-lg bg-muted/30 shadow-sm flex items-center justify-between">
<p className="text-sm font-medium text-foreground truncate" title={attachment.name}>
{attachment.name}
</p>
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm">
<a href={getUtilAssetPath(attachment.path)} target="_blank" rel="noopener noreferrer">
View
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a href={getUtilAssetPath(attachment.path)} download={attachment.name}>
<Download className="h-4 w-4 mr-2"/>
Download
</a>
</Button>
</div>
</div>))) : (<Alert className="rounded-lg">
<Info className="h-4 w-4"/>
<AlertTitle>No Videos Available</AlertTitle>
<AlertDescription>
There is no video recording associated with this test
run.
</AlertDescription>
</Alert>)}
</div>
</TabsContent>
<TabsContent value="sub-trace" className="mt-4">
<h3 className="text-lg font-semibold text-foreground mb-4">
Trace File
</h3>
{traceAttachment ? (<div className="p-4 border rounded-lg bg-muted/30 space-y-3 shadow-sm">
<a href={getUtilAssetPath(traceAttachment.path)} target="_blank" rel="noopener noreferrer" className="inline-flex items-center text-primary hover:underline text-base" download={traceAttachment.name}>
<Download className="h-5 w-5 mr-2"/> Download Trace
File ({traceAttachment.name})
</a>
<p className="text-xs text-muted-foreground">
Path: {traceAttachment.path}
</p>
<Alert className="rounded-lg">
<Info className="h-4 w-4"/>
<AlertTitle>Using Trace Files</AlertTitle>
<AlertDescription>
Trace files (.zip) can be viewed using the Playwright
CLI:{" "}
<code className="bg-muted px-1 py-0.5 rounded-sm">
npx playwright show-trace /path/to/your/trace.zip
</code>
. Or by uploading them to{" "}
<a href="https://trace.playwright.dev/" target="_blank" rel="noopener noreferrer" className="underline">
trace.playwright.dev
</a>
.
</AlertDescription>
</Alert>
</div>) : (<Alert className="rounded-lg">
<Info className="h-4 w-4"/>
<AlertTitle>No Trace File Available</AlertTitle>
<AlertDescription>
There is no Playwright trace file associated with this
test run.
</AlertDescription>
</Alert>)}
</TabsContent>
{[
{
value: "sub-html",
title: "HTML Files",
attachments: htmlAttachments,
},
{
value: "sub-pdf",
title: "PDF Documents",
attachments: pdfAttachments,
},
{
value: "sub-json",
title: "JSON Files",
attachments: jsonAttachments,
},
{
value: "sub-text",
title: "Text & CSV Files",
attachments: textCsvAttachments,
},
{
value: "sub-other",
title: "Other Files",
attachments: otherGenericAttachments,
},
].map((tab) => (<TabsContent key={tab.value} value={tab.value} className="mt-4">
<h3 className="text-lg font-semibold text-foreground mb-4">
{tab.title}
</h3>
<div className="space-y-3">
{tab.attachments.length > 0 ? (tab.attachments.map((attachment, index) => (<div key={index} className="p-3 border rounded-lg bg-muted/30 shadow-sm flex items-center justify-between gap-4">
<div className="flex items-center gap-3 truncate">
<AttachmentIcon contentType={attachment.contentType}/>
<div className="truncate">
<p className="text-sm font-medium text-foreground truncate" title={attachment.name}>
{attachment.name}
</p>
<p className="text-xs text-muted-foreground">
{attachment.contentType}
</p>
</div>
</div>
<div className="flex items-center flex-shrink-0 gap-2">
<Button asChild variant="ghost" size="sm">
<a href={getUtilAssetPath(attachment.path)} target="_blank" rel="noopener noreferrer">
View
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a href={getUtilAssetPath(attachment.path)} download={attachment.name}>
<Download className="h-4 w-4 mr-2"/>
Download
</a>
</Button>
</div>
</div>))) : (<Alert className="rounded-lg">
<Info className="h-4 w-4"/>
<AlertTitle>No Files Available</AlertTitle>
<AlertDescription>
No attachments of this type were found for this
test.
</AlertDescription>
</Alert>)}
</div>
</TabsContent>))}
</Tabs>
</TabsContent>
<TabsContent value="logs" className="mt-4 p-4 border rounded-lg bg-card space-y-6 shadow-inner">
<div>
<h3 className="text-lg font-semibold text-foreground mb-3 flex items-center">
<Terminal className="h-5 w-5 mr-2 text-primary"/>
Console Logs / Standard Output
</h3>
<ScrollArea className="h-48 w-full rounded-lg border p-3 bg-muted/30 shadow-sm">
<pre className="text-sm whitespace-pre-wrap break-words font-code">
<span dangerouslySetInnerHTML={{
__html: ansiToHtml(test.stdout &&
Array.isArray(test.stdout) &&
test.stdout.length > 0
? test.stdout.join("\n")
: "No standard output logs captured for this test."),
}}/>
</pre>
</ScrollArea>
</div>
<div>
<h3 className="text-lg font-semibold text-foreground mb-3 flex items-center">
<AlertCircle className="h-5 w-5 mr-2 text-destructive"/>
Error Messages / Standard Error
</h3>
<ScrollArea className="h-48 w-full rounded-lg border bg-destructive/5 shadow-sm">
<pre className="text-sm p-3 whitespace-pre-wrap break-all font-code">
<span dangerouslySetInnerHTML={{
__html: ansiToHtml(test.errorMessage ||
"No errors captured for this test."),
}}/>
</pre>
</ScrollArea>
</div>
{test.snippet && (<div>
<h3 className="text-lg font-semibold text-foreground mb-3 flex items-center">
<FileCode className="h-5 w-5 mr-2 text-primary"/>
Test Case Snippet
</h3>
<ScrollArea className="h-48 w-full rounded-lg border p-3 bg-muted/30 shadow-sm">
<pre className="text-sm whitespace-pre-wrap break-words font-code">
<span dangerouslySetInnerHTML={{
__html: ansiToHtml(test.snippet),
}}/>
</pre>
</ScrollArea>
</div>)}
</TabsContent>
<TabsContent value="history" className="mt-4 p-4 border rounded-lg bg-card shadow-inner">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold text-foreground flex items-center">
<LineChart className="h-5 w-5 mr-2 text-primary"/>
Individual Test Run History
</h3>
</div>
{loadingHistory && (<div className="space-y-3">
<Skeleton className="h-6 w-3/4 rounded-md"/>
<Skeleton className="h-64 w-full rounded-lg"/>
</div>)}
{errorHistory && !loadingHistory && (<Alert variant="destructive" className="rounded-lg">
<AlertCircle className="h-4 w-4"/>
<AlertTitle>Error Loading Test History</AlertTitle>
<AlertDescription>{errorHistory}</AlertDescription>
</Alert>)}
{!loadingHistory &&
!errorHistory &&
historyFetched &&
testHistory.length === 0 && (<Alert className="rounded-lg">
<Info className="h-4 w-4"/>
<AlertTitle>No Historical Data</AlertTitle>
<AlertDescription>
No historical run data found for this specific test (ID:{" "}
{testId}) in this suite.
</AlertDescription>
</Alert>)}
{!loadingHistory && !errorHistory && testHistory.length > 0 && (<div ref={historyChartRef} className="w-full h-[300px] bg-card p-4 rounded-lg shadow-inner">
<ResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={[...testHistory].reverse()} // Show oldest to newest
margin={{ top: 5, right: 20, left: -20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))"/>
<XAxis dataKey="date" tickFormatter={(tick) => new Date(tick).toLocaleDateString("en-CA", {
month: "short",
day: "numeric",
})} stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 10 }} angle={-30} textAnchor="end" height={40}/>
<YAxis tickFormatter={(tick) => formatDuration(tick)} stroke="hsl(var(--muted-foreground))" tick={{ fontSize: 10 }} width={80}/>
<RechartsTooltip content={<HistoryTooltip />}/>
<Legend wrapperStyle={{ fontSize: "12px" }}/>
<Line type="monotone" dataKey="duration" name="Duration" stroke="hsl(var(--primary))" strokeWidth={2} dot={<StatusDot />} activeDot={{ r: 7 }}/>
</RechartsLineChart>
</ResponsiveContainer>
</div>)}
</TabsContent>
{isFailedTest && (<TabsContent value="ai-suggestions" className="mt-4 p-4 border rounded-lg bg-card shadow-inner">
<div className="flex flex-col items-center justify-center text-center p-2 md:p-6">
<h3 className="text-lg font-semibold text-foreground mb-3 flex items-center">
<Sparkles className="h-5 w-5 mr-2 text-primary"/>
AI-Powered Failure Analysis
</h3>
{!(isGeneratingSuggestion ||
aiSuggestion ||
aiSuggestionError) && (<>
<p className="text-muted-foreground text-sm mb-6 max-w-md">
Get suggestions from AI to help diagnose the root cause
of this test failure and find potential solutions.
</p>
<Button onClick={handleGenerateSuggestion} disabled={isGeneratingSuggestion}>
<Sparkles className="h-4 w-4 mr-2"/>
Generate Suggestion
</Button>
</>)}
{isGeneratingSuggestion && (<div className="flex flex-col items-center justify-center py-10">
<Sparkles className="h-8 w-8 text-primary animate-pulse mb-4"/>
<p className="text-muted-foreground">
Generating suggestion... Please wait.
</p>
</div>)}
{aiSuggestionError && (<div className="w-full text-left">
<Alert variant="destructive">
<AlertTitle>Error Generating Suggestion</AlertTitle>
<AlertDescription>{aiSuggestionError}</AlertDescription>
</Alert>
<Button onClick={handleGenerateSuggestion} variant="outline" size="sm" className="mt-4">
<Sparkles className="h-4 w-4 mr-2"/>
Try Again
</Button>
</div>)}
{aiSuggestion && (<div className="text-left w-full mt-6 space-y-6">
<Alert variant="default" className="border-primary/30 bg-primary/5">
<Lightbulb className="h-5 w-5 text-primary"/>
<AlertTitle className="text-primary font-semibold">
Root Cause Analysis
</AlertTitle>
<AlertDescription className="text-primary/90">
{aiSuggestion.rootCause ||
"No root cause analysis provided."}
</AlertDescription>
</Alert>
<div>
<h4 className="font-semibold text-foreground mb-2">
Affected Tests:
</h4>
<div className="flex flex-wrap gap-2">
{aiSuggestion.affectedTests?.map((testName) => (<Badge key={testName} variant="secondary">
{testName}
</Badge>)) || (<p className="text-sm text-muted-foreground">N/A</p>)}
</div>
</div>
<div>
<h4 className="font-semibold text-foreground mb-3 flex items-center">
<Wrench className="h-4 w-4 mr-2"/>
Suggested Fixes
</h4>
<div className="space-y-4">
{aiSuggestion.suggestedFixes?.map((fix, index) => (<Card key={index} className="bg-card/50 shadow-md">
<CardHeader>
<CardTitle className="text-base">
Suggestion #{index + 1}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
{fix.description}
</p>
{fix.codeSnippet && (<div>
<h5 className="text-xs font-semibold text-foreground mb-1 flex items-center">
<FileCode className="h-3 w-3 mr-1.5"/>
Code Snippet:
</h5>
<pre className="bg-muted text-sm p-3 rounded-md whitespace-pre-wrap font-code overflow-x-auto">
<code>{fix.codeSnippet}</code>
</pre>
</div>)}
</CardContent>
</Card>))}
{(!aiSuggestion.suggestedFixes ||
aiSuggestion.suggestedFixes.length === 0) && (<p className="text-sm text-muted-foreground">
No specific fixes were suggested.
</p>)}
</div>
</div>
<Button onClick={handleGenerateSuggestion} variant="outline" size="sm" className="mt-4">
<Sparkles className="h-4 w-4 mr-2"/>
Regenerate Suggestion
</Button>
</div>)}
</div>
</TabsContent>)}
</Tabs>
</CardContent>
</Card>
</div>);
}
//# sourceMappingURL=TestDetailsClientPage.jsx.map