497 lines
11 KiB
TypeScript
497 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
CheckCircle2Icon,
|
|
ChevronRightIcon,
|
|
CircleDotIcon,
|
|
CircleIcon,
|
|
XCircleIcon,
|
|
} from "lucide-react";
|
|
import type { ComponentProps, HTMLAttributes } from "react";
|
|
import { createContext, useContext, useMemo } from "react";
|
|
|
|
type TestStatus = "passed" | "failed" | "skipped" | "running";
|
|
|
|
interface TestResultsSummary {
|
|
passed: number;
|
|
failed: number;
|
|
skipped: number;
|
|
total: number;
|
|
duration?: number;
|
|
}
|
|
|
|
interface TestResultsContextType {
|
|
summary?: TestResultsSummary;
|
|
}
|
|
|
|
const TestResultsContext = createContext<TestResultsContextType>({});
|
|
|
|
const formatDuration = (ms: number) => {
|
|
if (ms < 1000) {
|
|
return `${ms}ms`;
|
|
}
|
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
};
|
|
|
|
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const TestResultsHeader = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestResultsHeaderProps) => (
|
|
<div
|
|
className={cn(
|
|
"flex items-center justify-between border-b px-4 py-3",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
|
export const TestResultsDuration = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestResultsDurationProps) => {
|
|
const { summary } = useContext(TestResultsContext);
|
|
|
|
if (!summary?.duration) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
|
|
{children ?? formatDuration(summary.duration)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const TestResultsSummary = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestResultsSummaryProps) => {
|
|
const { summary } = useContext(TestResultsContext);
|
|
|
|
if (!summary) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex items-center gap-3", className)} {...props}>
|
|
{children ?? (
|
|
<>
|
|
<Badge
|
|
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
variant="secondary"
|
|
>
|
|
<CheckCircle2Icon className="size-3" />
|
|
{summary.passed} passed
|
|
</Badge>
|
|
{summary.failed > 0 && (
|
|
<Badge
|
|
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
variant="secondary"
|
|
>
|
|
<XCircleIcon className="size-3" />
|
|
{summary.failed} failed
|
|
</Badge>
|
|
)}
|
|
{summary.skipped > 0 && (
|
|
<Badge
|
|
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
variant="secondary"
|
|
>
|
|
<CircleIcon className="size-3" />
|
|
{summary.skipped} skipped
|
|
</Badge>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
|
|
summary?: TestResultsSummary;
|
|
};
|
|
|
|
export const TestResults = ({
|
|
summary,
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestResultsProps) => {
|
|
const contextValue = useMemo(() => ({ summary }), [summary]);
|
|
|
|
return (
|
|
<TestResultsContext.Provider value={contextValue}>
|
|
<div
|
|
className={cn("rounded-lg border bg-background", className)}
|
|
{...props}
|
|
>
|
|
{children ??
|
|
(summary && (
|
|
<TestResultsHeader>
|
|
<TestResultsSummary />
|
|
<TestResultsDuration />
|
|
</TestResultsHeader>
|
|
))}
|
|
</div>
|
|
</TestResultsContext.Provider>
|
|
);
|
|
};
|
|
|
|
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const TestResultsProgress = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestResultsProgressProps) => {
|
|
const { summary } = useContext(TestResultsContext);
|
|
|
|
if (!summary) {
|
|
return null;
|
|
}
|
|
|
|
const passedPercent = (summary.passed / summary.total) * 100;
|
|
const failedPercent = (summary.failed / summary.total) * 100;
|
|
|
|
return (
|
|
<div className={cn("space-y-2", className)} {...props}>
|
|
{children ?? (
|
|
<>
|
|
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="bg-green-500 transition-all"
|
|
style={{ width: `${passedPercent}%` }}
|
|
/>
|
|
<div
|
|
className="bg-red-500 transition-all"
|
|
style={{ width: `${failedPercent}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-muted-foreground text-xs">
|
|
<span>
|
|
{summary.passed}/{summary.total} tests passed
|
|
</span>
|
|
<span>{passedPercent.toFixed(0)}%</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const TestResultsContent = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestResultsContentProps) => (
|
|
<div className={cn("space-y-2 p-4", className)} {...props}>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
interface TestSuiteContextType {
|
|
name: string;
|
|
status: TestStatus;
|
|
}
|
|
|
|
const TestSuiteContext = createContext<TestSuiteContextType>({
|
|
name: "",
|
|
status: "passed",
|
|
});
|
|
|
|
const statusStyles: Record<TestStatus, string> = {
|
|
failed: "text-red-600 dark:text-red-400",
|
|
passed: "text-green-600 dark:text-green-400",
|
|
running: "text-blue-600 dark:text-blue-400",
|
|
skipped: "text-yellow-600 dark:text-yellow-400",
|
|
};
|
|
|
|
const statusIcons: Record<TestStatus, React.ReactNode> = {
|
|
failed: <XCircleIcon className="size-4" />,
|
|
passed: <CheckCircle2Icon className="size-4" />,
|
|
running: <CircleDotIcon className="size-4 animate-pulse" />,
|
|
skipped: <CircleIcon className="size-4" />,
|
|
};
|
|
|
|
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
|
|
<span className={cn("shrink-0", statusStyles[status])}>
|
|
{statusIcons[status]}
|
|
</span>
|
|
);
|
|
|
|
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
|
|
name: string;
|
|
status: TestStatus;
|
|
};
|
|
|
|
export const TestSuite = ({
|
|
name,
|
|
status,
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestSuiteProps) => {
|
|
const contextValue = useMemo(() => ({ name, status }), [name, status]);
|
|
|
|
return (
|
|
<TestSuiteContext.Provider value={contextValue}>
|
|
<Collapsible className={cn("rounded-lg border", className)} {...props}>
|
|
{children}
|
|
</Collapsible>
|
|
</TestSuiteContext.Provider>
|
|
);
|
|
};
|
|
|
|
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
|
|
|
|
export const TestSuiteName = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestSuiteNameProps) => {
|
|
const { name, status } = useContext(TestSuiteContext);
|
|
|
|
return (
|
|
<CollapsibleTrigger
|
|
className={cn(
|
|
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
<TestStatusIcon status={status} />
|
|
<span className="font-medium text-sm">{children ?? name}</span>
|
|
</CollapsibleTrigger>
|
|
);
|
|
};
|
|
|
|
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
|
|
passed?: number;
|
|
failed?: number;
|
|
skipped?: number;
|
|
};
|
|
|
|
export const TestSuiteStats = ({
|
|
passed = 0,
|
|
failed = 0,
|
|
skipped = 0,
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestSuiteStatsProps) => (
|
|
<div
|
|
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
|
|
{...props}
|
|
>
|
|
{children ?? (
|
|
<>
|
|
{passed > 0 && (
|
|
<span className="text-green-600 dark:text-green-400">
|
|
{passed} passed
|
|
</span>
|
|
)}
|
|
{failed > 0 && (
|
|
<span className="text-red-600 dark:text-red-400">
|
|
{failed} failed
|
|
</span>
|
|
)}
|
|
{skipped > 0 && (
|
|
<span className="text-yellow-600 dark:text-yellow-400">
|
|
{skipped} skipped
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
|
|
|
export const TestSuiteContent = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestSuiteContentProps) => (
|
|
<CollapsibleContent className={cn("border-t", className)} {...props}>
|
|
<div className="divide-y">{children}</div>
|
|
</CollapsibleContent>
|
|
);
|
|
|
|
interface TestContextType {
|
|
name: string;
|
|
status: TestStatus;
|
|
duration?: number;
|
|
}
|
|
|
|
const TestContext = createContext<TestContextType>({
|
|
name: "",
|
|
status: "passed",
|
|
});
|
|
|
|
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
|
export const TestName = ({ className, children, ...props }: TestNameProps) => {
|
|
const { name } = useContext(TestContext);
|
|
|
|
return (
|
|
<span className={cn("flex-1", className)} {...props}>
|
|
{children ?? name}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
|
export const TestDuration = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestDurationProps) => {
|
|
const { duration } = useContext(TestContext);
|
|
|
|
if (duration === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<span
|
|
className={cn("ml-auto text-muted-foreground text-xs", className)}
|
|
{...props}
|
|
>
|
|
{children ?? `${duration}ms`}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
|
|
|
|
export const TestStatus = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestStatusProps) => {
|
|
const { status } = useContext(TestContext);
|
|
|
|
return (
|
|
<span
|
|
className={cn("shrink-0", statusStyles[status], className)}
|
|
{...props}
|
|
>
|
|
{children ?? statusIcons[status]}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
export type TestProps = HTMLAttributes<HTMLDivElement> & {
|
|
name: string;
|
|
status: TestStatus;
|
|
duration?: number;
|
|
};
|
|
|
|
export const Test = ({
|
|
name,
|
|
status,
|
|
duration,
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestProps) => {
|
|
const contextValue = useMemo(
|
|
() => ({ duration, name, status }),
|
|
[duration, name, status]
|
|
);
|
|
|
|
return (
|
|
<TestContext.Provider value={contextValue}>
|
|
<div
|
|
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
|
|
{...props}
|
|
>
|
|
{children ?? (
|
|
<>
|
|
<TestStatus />
|
|
<TestName />
|
|
{duration !== undefined && <TestDuration />}
|
|
</>
|
|
)}
|
|
</div>
|
|
</TestContext.Provider>
|
|
);
|
|
};
|
|
|
|
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
|
|
|
|
export const TestError = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestErrorProps) => (
|
|
<div
|
|
className={cn(
|
|
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
|
|
|
export const TestErrorMessage = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestErrorMessageProps) => (
|
|
<p
|
|
className={cn(
|
|
"font-medium text-red-700 text-sm dark:text-red-400",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</p>
|
|
);
|
|
|
|
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
|
|
|
export const TestErrorStack = ({
|
|
className,
|
|
children,
|
|
...props
|
|
}: TestErrorStackProps) => (
|
|
<pre
|
|
className={cn(
|
|
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</pre>
|
|
);
|