UNPKG

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

501 lines (499 loc) 25.9 kB
'use client'; import { useState, useMemo, useEffect } from 'react'; import { TestItem } from './TestItem'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Terminal, Info, ChevronDown, XCircle, FilterX, Repeat1, ListChecks, CheckCircle2, SkipForward, Clock, FileSpreadsheet, AlertTriangle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Checkbox } from '@/components/ui/checkbox'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { useToast } from '@/hooks/use-toast'; import { getEffectiveTestStatus } from '@/lib/testUtils'; const testStatuses = ['all', 'passed', 'failed', 'skipped', 'flaky']; const suiteColorsCssVars = [ '--suite-color-1-hsl', '--suite-color-2-hsl', '--suite-color-3-hsl', '--suite-color-4-hsl', '--suite-color-5-hsl', ]; export function LiveTestResults({ report, loading, error, initialFilter }) { const [statusFilter, setStatusFilter] = useState(initialFilter || 'all'); const [searchTerm, setSearchTerm] = useState(''); const [selectedTags, setSelectedTags] = useState([]); const [allTags, setAllTags] = useState([]); const [tagPopoverOpen, setTagPopoverOpen] = useState(false); const [selectedBrowser, setSelectedBrowser] = useState('all'); const [allBrowsers, setAllBrowsers] = useState(['all']); const [selectedSuite, setSelectedSuite] = useState('all'); const [allSuites, setAllSuites] = useState(['all']); const [showRetriesOnly, setShowRetriesOnly] = useState(false); const { toast } = useToast(); useEffect(() => { if (initialFilter) { setStatusFilter(initialFilter); } }, [initialFilter]); useEffect(() => { if (report?.results) { const uniqueTags = new Set(); const uniqueBrowsers = new Set(); const uniqueSuites = new Set(); report.results.forEach((test) => { test.tags?.forEach((tag) => uniqueTags.add(tag)); if (test.browser) uniqueBrowsers.add(test.browser); if (test.suiteName) uniqueSuites.add(test.suiteName); else uniqueSuites.add("Untitled Suite"); }); setAllTags(Array.from(uniqueTags).sort()); setAllBrowsers(['all', ...Array.from(uniqueBrowsers).sort()]); setAllSuites(['all', ...Array.from(uniqueSuites).sort()]); } }, [report]); const isAnyFilterActive = useMemo(() => { return statusFilter !== 'all' || searchTerm !== '' || selectedTags.length > 0 || selectedBrowser !== 'all' || selectedSuite !== 'all' || showRetriesOnly; }, [statusFilter, searchTerm, selectedTags, selectedBrowser, selectedSuite, showRetriesOnly]); const handleClearAllFilters = () => { setStatusFilter('all'); setSearchTerm(''); setSelectedTags([]); setSelectedBrowser('all'); setSelectedSuite('all'); setShowRetriesOnly(false); }; const groupedAndFilteredSuites = useMemo(() => { if (!report?.results) return []; const filteredTests = report.results.filter((test) => { const effectiveStatus = getEffectiveTestStatus(test); let statusMatch = false; if (statusFilter === 'all') statusMatch = true; else if (statusFilter === 'flaky') statusMatch = effectiveStatus === 'flaky'; else if (statusFilter === 'passed') statusMatch = effectiveStatus === 'passed'; else if (statusFilter === 'failed') statusMatch = (effectiveStatus === 'failed' || effectiveStatus === 'timedOut'); else if (statusFilter === 'skipped') statusMatch = effectiveStatus === 'skipped'; else statusMatch = effectiveStatus === statusFilter; const searchTermMatch = (test.name || '').toLowerCase().includes(searchTerm.toLowerCase()) || (test.suiteName || '').toLowerCase().includes(searchTerm.toLowerCase()); const tagMatch = selectedTags.length === 0 || (test.tags && test.tags.some((tag) => selectedTags.includes(tag))); const browserMatch = selectedBrowser === 'all' || test.browser === selectedBrowser; const currentSuiteName = test.suiteName || "Untitled Suite"; const suiteMatch = selectedSuite === 'all' || currentSuiteName === selectedSuite; const retries = test.retryHistory ? test.retryHistory.filter((r) => r.status !== "passed" && r.status !== "skipped").length : 0; const retriesMatch = !showRetriesOnly || (showRetriesOnly && retries > 0); return statusMatch && searchTermMatch && tagMatch && browserMatch && suiteMatch && retriesMatch; }); const suitesMap = new Map(); filteredTests.forEach((test) => { const suiteName = test.suiteName || 'Untitled Suite'; if (!suitesMap.has(suiteName)) { suitesMap.set(suiteName, { tests: [], stats: { total: 0, passed: 0, failed: 0, skipped: 0, pending: 0, flaky: 0 }, }); } const currentSuiteData = suitesMap.get(suiteName); currentSuiteData.tests.push(test); currentSuiteData.stats.total++; const effectiveStatus = getEffectiveTestStatus(test); if (effectiveStatus === 'flaky') currentSuiteData.stats.flaky++; else if (effectiveStatus === 'passed') currentSuiteData.stats.passed++; else if (effectiveStatus === 'failed' || effectiveStatus === 'timedOut') currentSuiteData.stats.failed++; else if (effectiveStatus === 'skipped') currentSuiteData.stats.skipped++; else if (effectiveStatus === 'pending') currentSuiteData.stats.pending++; }); return Array.from(suitesMap.entries()).map(([title, data]) => ({ title, tests: data.tests, stats: data.stats, })).sort((a, b) => b.stats.failed - a.stats.failed || b.stats.total - a.stats.total || a.title.localeCompare(b.title)); }, [report, statusFilter, searchTerm, selectedTags, selectedBrowser, selectedSuite, showRetriesOnly]); const handleExportCsv = () => { if (!report || !report.results || report.results.length === 0) { toast({ title: "Export Failed", description: "No data available to export.", variant: "destructive", }); return; } const headers = ["id", "name", "suiteName", "status", "duration", "startTime", "endTime", "browser", "retries", "tags", "stdout", "stderr", "workerId"]; const escapeCsvCell = (cellData) => { if (cellData === null || cellData === undefined) { return ''; } let cellString = String(cellData); if (cellString.includes(',') || cellString.includes('"') || cellString.includes('\n')) { cellString = `"${cellString.replace(/"/g, '""')}"`; } return cellString; }; let csvContent = headers.map(escapeCsvCell).join(',') + '\n'; report.results.forEach((item) => { const row = headers.map(header => { let value; switch (header) { case 'tags': value = item.tags ? item.tags.join('; ') : ''; break; case 'stdout': value = item.stdout ? item.stdout.join('\\n') : ''; break; case 'stderr': value = item.errorMessage || ''; break; case 'workerId': value = item.workerId !== undefined && item.workerId !== null ? String(item.workerId) : ''; break; default: value = item[header]; } return escapeCsvCell(value); }); csvContent += row.join(',') + '\n'; }); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.setAttribute("href", url); let fileRunIdPart = 'data'; if (report.run && report.run.id && typeof report.run.id === 'string' && report.run.id.trim() !== '') { const idStr = report.run.id.trim(); const parts = idStr.split('-'); if (parts.length > 1 && parts[0].toLowerCase() === 'run' && parts[1] && parts[1].trim() !== '') { fileRunIdPart = parts[1]; } else if (parts.length > 0 && parts[0].trim() !== '') { fileRunIdPart = parts[0]; } } else if (report.run && report.run.timestamp) { try { fileRunIdPart = String(new Date(report.run.timestamp).getTime()); } catch (e) { // fallback to 'data' already set } } const fileName = `run-${fileRunIdPart}.csv`; link.setAttribute("download", fileName); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); toast({ title: "Export Successful", description: `${fileName} has been downloaded.`, }); }; if (loading) { return (<div className="space-y-6 p-6 lg:p-8 bg-muted/20 backdrop-blur-md border border-border/40 rounded-[2.5rem] shadow-inner"> <Card className="shadow-xl"> <CardHeader> <Skeleton className="h-6 w-48"/> <Skeleton className="h-4 w-64 mt-1"/> </CardHeader> <CardContent className="space-y-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6 p-6 border rounded-xl bg-muted/50 shadow-lg"> <Skeleton className="h-10 w-full"/> <Skeleton className="h-10 w-full"/> <Skeleton className="h-10 w-full"/> <Skeleton className="h-10 w-full"/> <Skeleton className="h-10 w-full"/> <Skeleton className="h-10 w-full"/> <Skeleton className="h-10 w-full"/> </div> {[...Array(3)].map((_, i) => (<div key={i} className="space-y-2 p-2 border rounded-2xl shadow-md"> <Skeleton className="h-5 w-1/3"/> <Skeleton className="h-8 w-full"/> <Skeleton className="h-8 w-full"/> </div>))} </CardContent> </Card> </div>); } if (error) { return (<div className="space-y-6 p-6 lg:p-8 bg-muted/20 backdrop-blur-md border border-border/40 rounded-[2.5rem] shadow-inner"> <Alert variant="destructive" className="mt-4 shadow-md"> <Terminal className="h-4 w-4"/> <AlertTitle>Error Fetching Test Results</AlertTitle> <AlertDescription>{error}</AlertDescription> </Alert> </div>); } if (!report || !report.results || report.results.length === 0) { return (<div className="space-y-6 p-6 lg:p-8 bg-muted/20 backdrop-blur-md border border-border/40 rounded-[2.5rem] shadow-inner"> <Card className="shadow-xl"> <CardHeader className="flex flex-row items-center justify-between"> <div> <CardTitle className="text-2xl font-headline text-primary"> Test Results </CardTitle> {(report?.metadata?.generatedAt || report?.run?.timestamp) && (<CardDescription> Last updated:{" "} {new Date(report.metadata?.generatedAt || report.run?.timestamp || Date.now()).toLocaleString()} </CardDescription>)} </div> </CardHeader> <CardContent> <Alert className="shadow-sm rounded-2xl"> <Info className="h-4 w-4"/> <AlertTitle>No Test Data</AlertTitle> <AlertDescription> No test results available. Ensure 'playwright-pulse-report.json' exists in 'pulse-report/' and is correctly formatted, or that the data source is providing results. </AlertDescription> </Alert> </CardContent> </Card> </div>); } return (<div className="space-y-6 p-6 lg:p-8 bg-muted/20 backdrop-blur-md border border-border/40 rounded-[2.5rem] shadow-inner"> <Card className="shadow-xl"> <CardHeader className="flex flex-row items-center justify-between"> <div> <CardTitle className="text-2xl font-headline text-primary"> Test Results </CardTitle> {(report.metadata?.generatedAt || report.run?.timestamp) && (<CardDescription> Last updated:{" "} {new Date(report.metadata.generatedAt || report.run.timestamp).toLocaleString()} </CardDescription>)} </div> <Button onClick={handleExportCsv} variant="outline" size="sm" className="ml-auto rounded-xl shadow-md"> <FileSpreadsheet className="mr-2 h-4 w-4"/> Export as CSV </Button> </CardHeader> <CardContent className="space-y-6"> <div className="p-6 border rounded-xl bg-muted/50 shadow-lg space-y-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-4"> <div className="space-y-1.5"> <Label htmlFor="status-filter" className="text-sm font-medium text-muted-foreground"> Filter by Status </Label> <Select value={statusFilter} onValueChange={(value) => setStatusFilter(value)}> <SelectTrigger id="status-filter" className="w-full bg-background shadow-inner rounded-xl"> <SelectValue placeholder="Select status"/> </SelectTrigger> <SelectContent className="rounded-xl"> {testStatuses.map((status) => (<SelectItem key={status} value={status} className="capitalize"> {status.charAt(0).toUpperCase() + status.slice(1)} </SelectItem>))} </SelectContent> </Select> </div> <div className="space-y-1.5"> <Label htmlFor="search-filter" className="text-sm font-medium text-muted-foreground"> Search by Name/Suite </Label> <Input id="search-filter" type="text" placeholder="Enter test or suite name..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full bg-background shadow-inner rounded-xl"/> </div> <div className="space-y-1.5"> <Label className="text-sm font-medium text-muted-foreground"> Filter by Tags </Label> <Popover open={tagPopoverOpen} onOpenChange={setTagPopoverOpen}> <PopoverTrigger asChild> <Button variant="outline" className="w-full bg-background shadow-inner justify-between rounded-xl"> {selectedTags.length > 0 ? `Tags (${selectedTags.length})` : "Select Tags"} <ChevronDown className="ml-2 h-4 w-4 opacity-50"/> </Button> </PopoverTrigger> <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0 rounded-xl" align="start"> <div className="p-2 border-b"> <p className="text-sm font-medium">Filter by Tags</p> </div> <ScrollArea className="h-48"> <div className="p-2 space-y-1"> {allTags.length > 0 ? (allTags.map((tag) => (<Label key={tag} htmlFor={`tag-${tag}`} className="flex items-center space-x-2 p-1.5 rounded-xl hover:bg-accent/10 cursor-pointer"> <Checkbox id={`tag-${tag}`} checked={selectedTags.includes(tag)} onCheckedChange={(checked) => { setSelectedTags((prev) => checked === true ? [...prev, tag] : prev.filter((t) => t !== tag)); }} className="rounded-xl"/> <span>{tag}</span> </Label>))) : (<p className="text-xs text-muted-foreground p-2"> No tags available in this report. </p>)} </div> </ScrollArea> {selectedTags.length > 0 && (<div className="p-2 border-t flex justify-end"> <Button variant="ghost" size="sm" onClick={() => setSelectedTags([])}> Clear selected </Button> </div>)} </PopoverContent> </Popover> </div> <div className="space-y-1.5"> <Label htmlFor="browser-filter" className="text-sm font-medium text-muted-foreground"> Filter by Browser </Label> <Select value={selectedBrowser} onValueChange={setSelectedBrowser}> <SelectTrigger id="browser-filter" className="w-full bg-background shadow-inner rounded-xl"> <SelectValue placeholder="Select browser"/> </SelectTrigger> <SelectContent className="rounded-xl"> {allBrowsers.map((browser) => (<SelectItem key={browser} value={browser} className="capitalize"> {browser === "all" ? "All Browsers" : browser} </SelectItem>))} </SelectContent> </Select> </div> <div className="space-y-1.5"> <Label htmlFor="suite-filter" className="text-sm font-medium text-muted-foreground"> Filter by Test Suite </Label> <Select value={selectedSuite} onValueChange={setSelectedSuite}> <SelectTrigger id="suite-filter" className="w-full bg-background shadow-inner rounded-xl"> <SelectValue placeholder="Select test suite"/> </SelectTrigger> <SelectContent className="rounded-xl"> {allSuites.map((suite) => (<SelectItem key={suite} value={suite} className="capitalize truncate"> {suite === "all" ? "All Suites" : suite.length > 40 ? suite.substring(0, 37) + "..." : suite} </SelectItem>))} </SelectContent> </Select> </div> <div className="flex items-center space-x-2 pt-5"> <Checkbox id="retries-filter" checked={showRetriesOnly} onCheckedChange={(checked) => setShowRetriesOnly(Boolean(checked))} className="rounded-xl"/> <Label htmlFor="retries-filter" className="text-sm font-medium text-muted-foreground cursor-pointer flex items-center"> <Repeat1 className="h-4 w-4 mr-1.5 text-muted-foreground"/> Retries Only </Label> </div> </div> {isAnyFilterActive && (<div className="mt-4 flex justify-end"> <Button variant="ghost" onClick={handleClearAllFilters} className="text-sm rounded-xl"> <FilterX className="mr-2 h-4 w-4"/> Clear All Filters </Button> </div>)} </div> {selectedTags.length > 0 && (<div className="mb-4 flex flex-wrap gap-2 items-center"> <span className="text-sm text-muted-foreground">Active tags:</span> {selectedTags.map((tag) => (<Badge key={tag} variant="secondary" className="flex items-center rounded-full"> {tag} <button type="button" aria-label={`Remove ${tag} filter`} onClick={() => { setSelectedTags((prev) => prev.filter((t) => t !== tag)); }} className="ml-1.5 p-0.5 rounded-full hover:bg-muted-foreground/20 focus:outline-none focus:ring-1 focus:ring-ring"> <XCircle className="h-3.5 w-3.5"/> </button> </Badge>))} </div>)} {groupedAndFilteredSuites.length > 0 ? (<Accordion type="multiple" className="w-full space-y-3"> {groupedAndFilteredSuites.map((suite, index) => (<AccordionItem value={`suite-${suite.title.replace(/\s+/g, "-")}-${index}`} key={`suite-${suite.title.replace(/\s+/g, "-")}-${index}`} className="border rounded-xl shadow-lg bg-card hover:shadow-xl transition-shadow duration-300"> <AccordionTrigger className="p-4 hover:no-underline text-left w-full group"> <div className="flex justify-between items-center w-full"> <div className="flex-grow min-w-0"> <h3 className="text-lg font-semibold group-hover:opacity-80 transition-opacity" title={suite.title} style={{ color: `hsl(var(${suiteColorsCssVars[index % suiteColorsCssVars.length]}))`, }}> {suite.title} </h3> <div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2"> <Badge variant="outline" className="text-xs border-muted-foreground/50"> <ListChecks className="mr-1.5 h-3 w-3 text-muted-foreground"/> Total: {suite.stats.total} </Badge> <Badge variant="outline" className="text-xs" style={{ color: "hsl(var(--chart-3))", borderColor: "hsl(var(--chart-3) / 0.5)", }}> <CheckCircle2 className="mr-1.5 h-3 w-3"/> Passed: {suite.stats.passed} </Badge> {suite.stats.flaky > 0 && (<Badge variant="outline" className="text-xs" style={{ color: "hsl(var(--flaky))", borderColor: "hsl(var(--flaky) / 0.5)", }}> <AlertTriangle className="mr-1.5 h-3 w-3 text-[hsl(var(--flaky))]"/> <span className="text-[hsl(var(--flaky))]">Flaky: {suite.stats.flaky}</span> </Badge>)} <Badge variant="outline" className="text-xs" style={{ color: "hsl(var(--destructive))", borderColor: "hsl(var(--destructive) / 0.5)", }}> <XCircle className="mr-1.5 h-3 w-3"/> Failed: {suite.stats.failed} </Badge> <Badge variant="outline" className="text-xs" style={{ color: "hsl(var(--accent))", borderColor: "hsl(var(--accent) / 0.5)", }}> <SkipForward className="mr-1.5 h-3 w-3"/> Skipped: {suite.stats.skipped} </Badge> {suite.stats.pending > 0 && (<Badge variant="outline" className="text-xs" style={{ color: "hsl(var(--primary))", borderColor: "hsl(var(--primary) / 0.5)", }}> <Clock className="mr-1.5 h-3 w-3"/> Pending: {suite.stats.pending} </Badge>)} </div> </div> </div> </AccordionTrigger> <AccordionContent className="p-4 pt-0"> {suite.tests.length > 0 ? (<ScrollArea className="h-[600px] pr-4"> <div className="space-y-1 mt-2"> {suite.tests.map((test) => (<TestItem key={test.id} test={test}/>))} </div> </ScrollArea>) : (<p className="text-sm text-muted-foreground mt-2"> No tests in this suite match the current filters. </p>)} </AccordionContent> </AccordionItem>))} </Accordion>) : (<div className="text-center py-8"> <p className="text-muted-foreground text-lg"> No test results match your current filters. </p> <p className="text-sm text-muted-foreground mt-1"> Try adjusting the status, search term, or tag filters. </p> </div>)} </CardContent> </Card> </div>); } //# sourceMappingURL=LiveTestResults.jsx.map