@a5c-ai/babysitter-observer-dashboard 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- package/tsconfig.json +50 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { cn } from "@/lib/cn";
|
|
2
|
+
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import type { RunStatus, TaskStatus } from "@/types";
|
|
4
|
+
import { CheckCircle2, XCircle, Clock, Loader2, Circle, Hand } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
const iconSize = "h-3.5 w-3.5";
|
|
7
|
+
|
|
8
|
+
const statusConfig: Record<string, { variant: "success" | "error" | "warning" | "info" | "pending"; icon: React.ReactNode; label: string; extraClass?: string }> = {
|
|
9
|
+
completed: {
|
|
10
|
+
variant: "success",
|
|
11
|
+
icon: <CheckCircle2 className={cn(iconSize, "drop-shadow-[var(--drop-glow-success)]")} />,
|
|
12
|
+
label: "Completed",
|
|
13
|
+
extraClass: "ring-success/30 shadow-neon-glow-success-sm",
|
|
14
|
+
},
|
|
15
|
+
resolved: {
|
|
16
|
+
variant: "success",
|
|
17
|
+
icon: <CheckCircle2 className={cn(iconSize, "drop-shadow-[var(--drop-glow-success)]")} />,
|
|
18
|
+
label: "Done",
|
|
19
|
+
extraClass: "ring-success/30 shadow-neon-glow-success-sm",
|
|
20
|
+
},
|
|
21
|
+
ok: {
|
|
22
|
+
variant: "success",
|
|
23
|
+
icon: <CheckCircle2 className={cn(iconSize, "drop-shadow-[var(--drop-glow-success)]")} />,
|
|
24
|
+
label: "OK",
|
|
25
|
+
extraClass: "ring-success/30 shadow-neon-glow-success-sm",
|
|
26
|
+
},
|
|
27
|
+
failed: {
|
|
28
|
+
variant: "error",
|
|
29
|
+
icon: <XCircle className={cn(iconSize, "drop-shadow-[var(--drop-glow-error)]")} />,
|
|
30
|
+
label: "Failed",
|
|
31
|
+
extraClass: "ring-error/30 shadow-neon-glow-error-sm",
|
|
32
|
+
},
|
|
33
|
+
error: {
|
|
34
|
+
variant: "error",
|
|
35
|
+
icon: <XCircle className={cn(iconSize, "drop-shadow-[var(--drop-glow-error)]")} />,
|
|
36
|
+
label: "Error",
|
|
37
|
+
extraClass: "ring-error/30 shadow-neon-glow-error-sm",
|
|
38
|
+
},
|
|
39
|
+
waiting: {
|
|
40
|
+
variant: "warning",
|
|
41
|
+
icon: <Clock className={cn(iconSize, "drop-shadow-[var(--drop-glow-warning)]")} />,
|
|
42
|
+
label: "Waiting",
|
|
43
|
+
extraClass: "ring-warning/30 shadow-neon-glow-warning-sm",
|
|
44
|
+
},
|
|
45
|
+
waiting_breakpoint: {
|
|
46
|
+
variant: "warning",
|
|
47
|
+
icon: <Hand className={cn(iconSize, "animate-pulse drop-shadow-[var(--drop-glow-warning)]")} />,
|
|
48
|
+
label: "Approval Needed",
|
|
49
|
+
extraClass: "ring-warning/40 animate-breakpoint-glow",
|
|
50
|
+
},
|
|
51
|
+
waiting_task: {
|
|
52
|
+
variant: "info",
|
|
53
|
+
icon: <Loader2 className={cn(iconSize, "animate-spin drop-shadow-[var(--drop-glow-cyan)]")} />,
|
|
54
|
+
label: "Working",
|
|
55
|
+
extraClass: "ring-info/30 shadow-neon-glow-cyan-sm",
|
|
56
|
+
},
|
|
57
|
+
breakpoint_awaiting: {
|
|
58
|
+
variant: "warning",
|
|
59
|
+
icon: <Hand className={cn(iconSize, "animate-pulse drop-shadow-[var(--drop-glow-warning)]")} />,
|
|
60
|
+
label: "Needs Approval",
|
|
61
|
+
extraClass: "ring-warning/40 animate-breakpoint-glow",
|
|
62
|
+
},
|
|
63
|
+
requested: {
|
|
64
|
+
variant: "info",
|
|
65
|
+
icon: <Loader2 className={cn(iconSize, "animate-spin drop-shadow-[var(--drop-glow-cyan)]")} />,
|
|
66
|
+
label: "Running",
|
|
67
|
+
extraClass: "ring-info/30 shadow-neon-glow-cyan-sm",
|
|
68
|
+
},
|
|
69
|
+
pending: {
|
|
70
|
+
variant: "pending",
|
|
71
|
+
icon: <Circle className={iconSize} />,
|
|
72
|
+
label: "Pending",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
interface StatusBadgeProps {
|
|
77
|
+
status: RunStatus | TaskStatus | string;
|
|
78
|
+
className?: string;
|
|
79
|
+
waitingKind?: 'breakpoint' | 'task';
|
|
80
|
+
isStale?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function StatusBadge({ status, className, waitingKind, isStale }: StatusBadgeProps) {
|
|
84
|
+
// Resolve config: if status is "waiting" and a waitingKind is provided, use the sub-variant
|
|
85
|
+
let config = statusConfig[status] || statusConfig.pending;
|
|
86
|
+
if (status === "waiting" && waitingKind) {
|
|
87
|
+
const subKey = `waiting_${waitingKind}`;
|
|
88
|
+
config = statusConfig[subKey] || config;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Detect orphaned/interrupted runs: stale with no pending work remaining
|
|
92
|
+
const isInterrupted = isStale && (status === "pending" || (status === "waiting" && !waitingKind));
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Badge
|
|
96
|
+
data-testid={`status-badge-${status}`}
|
|
97
|
+
variant={isStale ? "default" : config.variant}
|
|
98
|
+
className={cn(
|
|
99
|
+
"gap-1",
|
|
100
|
+
isStale
|
|
101
|
+
? "opacity-60 text-zinc-500 ring-zinc-500/20 bg-zinc-500/10 shadow-none"
|
|
102
|
+
: config.extraClass,
|
|
103
|
+
className
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{config.icon}
|
|
107
|
+
{isInterrupted ? "Interrupted" : config.label}
|
|
108
|
+
</Badge>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
|
3
|
+
|
|
4
|
+
type Theme = "dark" | "light";
|
|
5
|
+
|
|
6
|
+
interface ThemeContextValue {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
toggle: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ThemeContext = createContext<ThemeContextValue>({
|
|
12
|
+
theme: "dark",
|
|
13
|
+
toggle: () => {},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function useTheme() {
|
|
17
|
+
return useContext(ThemeContext);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
21
|
+
const [theme, setTheme] = useState<Theme>("dark");
|
|
22
|
+
|
|
23
|
+
// Read initial theme from DOM (set by inline script in layout)
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const stored = document.documentElement.getAttribute("data-theme") as Theme;
|
|
26
|
+
if (stored === "light" || stored === "dark") {
|
|
27
|
+
setTheme(stored);
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const toggle = useCallback(() => {
|
|
32
|
+
setTheme((prev) => {
|
|
33
|
+
const next = prev === "dark" ? "light" : "dark";
|
|
34
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
35
|
+
document.documentElement.className = next;
|
|
36
|
+
localStorage.setItem("observer-theme", next);
|
|
37
|
+
return next;
|
|
38
|
+
});
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<ThemeContext.Provider value={{ theme, toggle }}>
|
|
43
|
+
{children}
|
|
44
|
+
</ThemeContext.Provider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { formatShortId } from '@/lib/utils';
|
|
4
|
+
import { cn } from '@/lib/cn';
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
6
|
+
|
|
7
|
+
interface TruncatedIdProps {
|
|
8
|
+
id: string;
|
|
9
|
+
chars?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TruncatedId({ id, chars = 4, className }: TruncatedIdProps) {
|
|
14
|
+
const [copied, setCopied] = useState(false);
|
|
15
|
+
|
|
16
|
+
const handleCopy = (e: React.MouseEvent) => {
|
|
17
|
+
e.stopPropagation();
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
navigator.clipboard.writeText(id);
|
|
20
|
+
setCopied(true);
|
|
21
|
+
setTimeout(() => setCopied(false), 1500);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<TooltipProvider>
|
|
26
|
+
<Tooltip>
|
|
27
|
+
<TooltipTrigger asChild>
|
|
28
|
+
<span
|
|
29
|
+
className={cn(
|
|
30
|
+
'inline-flex items-center justify-center rounded px-2 py-1 min-h-[44px] min-w-[44px] font-mono text-xs',
|
|
31
|
+
'bg-background-secondary text-info/80',
|
|
32
|
+
'hover:bg-background-tertiary hover:text-info',
|
|
33
|
+
'cursor-pointer transition-colors select-none',
|
|
34
|
+
copied && 'text-primary',
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
onClick={handleCopy}
|
|
38
|
+
role="button"
|
|
39
|
+
tabIndex={0}
|
|
40
|
+
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCopy(e as unknown as React.MouseEvent); } }}
|
|
41
|
+
>
|
|
42
|
+
{formatShortId(id, chars)}
|
|
43
|
+
</span>
|
|
44
|
+
</TooltipTrigger>
|
|
45
|
+
<TooltipContent>
|
|
46
|
+
<p className="font-mono text-xs">{copied ? <span className="text-primary font-semibold">Copied!</span> : id}</p>
|
|
47
|
+
</TooltipContent>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
</TooltipProvider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '../accordion';
|
|
6
|
+
|
|
7
|
+
describe('Accordion', () => {
|
|
8
|
+
function renderAccordion(defaultValue?: string) {
|
|
9
|
+
return render(
|
|
10
|
+
<Accordion type="single" collapsible defaultValue={defaultValue}>
|
|
11
|
+
<AccordionItem value="item-1">
|
|
12
|
+
<AccordionTrigger>Section 1</AccordionTrigger>
|
|
13
|
+
<AccordionContent>Content 1</AccordionContent>
|
|
14
|
+
</AccordionItem>
|
|
15
|
+
<AccordionItem value="item-2">
|
|
16
|
+
<AccordionTrigger>Section 2</AccordionTrigger>
|
|
17
|
+
<AccordionContent>Content 2</AccordionContent>
|
|
18
|
+
</AccordionItem>
|
|
19
|
+
</Accordion>,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('renders without crashing', () => {
|
|
24
|
+
renderAccordion();
|
|
25
|
+
expect(screen.getByText('Section 1')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Section 2')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders trigger buttons', () => {
|
|
30
|
+
renderAccordion();
|
|
31
|
+
const triggers = screen.getAllByRole('button');
|
|
32
|
+
expect(triggers.length).toBe(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows content of a pre-opened item', () => {
|
|
36
|
+
renderAccordion('item-1');
|
|
37
|
+
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('expands an item on click', async () => {
|
|
41
|
+
const user = userEvent.setup();
|
|
42
|
+
renderAccordion();
|
|
43
|
+
|
|
44
|
+
const trigger = screen.getByText('Section 1');
|
|
45
|
+
await user.click(trigger);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('collapses an expanded item on click (collapsible)', async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
renderAccordion('item-1');
|
|
53
|
+
|
|
54
|
+
const trigger = screen.getByText('Section 1');
|
|
55
|
+
await user.click(trigger);
|
|
56
|
+
|
|
57
|
+
// After collapsing, Content 1 should be hidden
|
|
58
|
+
const content = screen.queryByText('Content 1');
|
|
59
|
+
// The content might still be in DOM but hidden by data-state=closed
|
|
60
|
+
// Since Radix keeps it in the DOM with data-state, check the attribute
|
|
61
|
+
if (content) {
|
|
62
|
+
const parent = content.closest('[data-state]');
|
|
63
|
+
expect(parent?.getAttribute('data-state')).toBe('closed');
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders chevron icon in triggers', () => {
|
|
68
|
+
const { container } = renderAccordion();
|
|
69
|
+
const svgs = container.querySelectorAll('svg');
|
|
70
|
+
expect(svgs.length).toBeGreaterThanOrEqual(2);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('Accordion with multiple type', () => {
|
|
75
|
+
it('allows multiple items open simultaneously', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render(
|
|
78
|
+
<Accordion type="multiple">
|
|
79
|
+
<AccordionItem value="a">
|
|
80
|
+
<AccordionTrigger>Item A</AccordionTrigger>
|
|
81
|
+
<AccordionContent>Content A</AccordionContent>
|
|
82
|
+
</AccordionItem>
|
|
83
|
+
<AccordionItem value="b">
|
|
84
|
+
<AccordionTrigger>Item B</AccordionTrigger>
|
|
85
|
+
<AccordionContent>Content B</AccordionContent>
|
|
86
|
+
</AccordionItem>
|
|
87
|
+
</Accordion>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
await user.click(screen.getByText('Item A'));
|
|
91
|
+
await user.click(screen.getByText('Item B'));
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText('Content A')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText('Content B')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import { Badge } from '../badge';
|
|
5
|
+
|
|
6
|
+
describe('Badge', () => {
|
|
7
|
+
it('renders without crashing', () => {
|
|
8
|
+
render(<Badge>Label</Badge>);
|
|
9
|
+
expect(screen.getByText('Label')).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('renders children text', () => {
|
|
13
|
+
render(<Badge>Test Badge</Badge>);
|
|
14
|
+
expect(screen.getByText('Test Badge')).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders as a <span> element', () => {
|
|
18
|
+
const { container } = render(<Badge>Test</Badge>);
|
|
19
|
+
const span = container.querySelector('span');
|
|
20
|
+
expect(span).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('applies default variant classes', () => {
|
|
24
|
+
const { container } = render(<Badge>Default</Badge>);
|
|
25
|
+
const span = container.firstChild as HTMLElement;
|
|
26
|
+
expect(span.className).toContain('bg-muted');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('applies success variant classes', () => {
|
|
30
|
+
const { container } = render(<Badge variant="success">Success</Badge>);
|
|
31
|
+
const span = container.firstChild as HTMLElement;
|
|
32
|
+
expect(span.className).toContain('text-success');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('applies error variant classes', () => {
|
|
36
|
+
const { container } = render(<Badge variant="error">Error</Badge>);
|
|
37
|
+
const span = container.firstChild as HTMLElement;
|
|
38
|
+
expect(span.className).toContain('text-error');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('applies warning variant classes', () => {
|
|
42
|
+
const { container } = render(<Badge variant="warning">Warning</Badge>);
|
|
43
|
+
const span = container.firstChild as HTMLElement;
|
|
44
|
+
expect(span.className).toContain('text-warning');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('applies info variant classes', () => {
|
|
48
|
+
const { container } = render(<Badge variant="info">Info</Badge>);
|
|
49
|
+
const span = container.firstChild as HTMLElement;
|
|
50
|
+
expect(span.className).toContain('text-info');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('applies pending variant classes', () => {
|
|
54
|
+
const { container } = render(<Badge variant="pending">Pending</Badge>);
|
|
55
|
+
const span = container.firstChild as HTMLElement;
|
|
56
|
+
expect(span.className).toContain('text-pending');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('applies custom className', () => {
|
|
60
|
+
const { container } = render(<Badge className="custom-cls">C</Badge>);
|
|
61
|
+
const span = container.firstChild as HTMLElement;
|
|
62
|
+
expect(span.className).toContain('custom-cls');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes through additional HTML attributes', () => {
|
|
66
|
+
render(<Badge data-testid="my-badge">X</Badge>);
|
|
67
|
+
expect(screen.getByTestId('my-badge')).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { Button } from '../button';
|
|
6
|
+
|
|
7
|
+
describe('Button', () => {
|
|
8
|
+
it('renders without crashing', () => {
|
|
9
|
+
render(<Button>Click me</Button>);
|
|
10
|
+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders children text', () => {
|
|
14
|
+
render(<Button>Submit</Button>);
|
|
15
|
+
expect(screen.getByText('Submit')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders as a <button> element by default', () => {
|
|
19
|
+
render(<Button>Btn</Button>);
|
|
20
|
+
const btn = screen.getByRole('button');
|
|
21
|
+
expect(btn.tagName).toBe('BUTTON');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('handles click events', async () => {
|
|
25
|
+
const user = userEvent.setup();
|
|
26
|
+
const handleClick = vi.fn();
|
|
27
|
+
render(<Button onClick={handleClick}>Click</Button>);
|
|
28
|
+
await user.click(screen.getByRole('button'));
|
|
29
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not fire click when disabled', async () => {
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
const handleClick = vi.fn();
|
|
35
|
+
render(
|
|
36
|
+
<Button onClick={handleClick} disabled>
|
|
37
|
+
Disabled
|
|
38
|
+
</Button>,
|
|
39
|
+
);
|
|
40
|
+
await user.click(screen.getByRole('button'));
|
|
41
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('applies disabled attribute', () => {
|
|
45
|
+
render(<Button disabled>Disabled</Button>);
|
|
46
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('applies default variant classes', () => {
|
|
50
|
+
render(<Button>Default</Button>);
|
|
51
|
+
const btn = screen.getByRole('button');
|
|
52
|
+
expect(btn.className).toContain('bg-primary');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('applies neon variant classes', () => {
|
|
56
|
+
render(<Button variant="neon">Neon</Button>);
|
|
57
|
+
const btn = screen.getByRole('button');
|
|
58
|
+
expect(btn.className).toContain('border-primary');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('applies outline variant classes', () => {
|
|
62
|
+
render(<Button variant="outline">Outline</Button>);
|
|
63
|
+
const btn = screen.getByRole('button');
|
|
64
|
+
expect(btn.className).toContain('bg-transparent');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('applies ghost variant classes', () => {
|
|
68
|
+
render(<Button variant="ghost">Ghost</Button>);
|
|
69
|
+
const btn = screen.getByRole('button');
|
|
70
|
+
expect(btn.className).toContain('hover:bg-muted');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('applies destructive variant classes', () => {
|
|
74
|
+
render(<Button variant="destructive">Delete</Button>);
|
|
75
|
+
const btn = screen.getByRole('button');
|
|
76
|
+
expect(btn.className).toContain('bg-destructive');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('applies sm size classes', () => {
|
|
80
|
+
render(<Button size="sm">Small</Button>);
|
|
81
|
+
const btn = screen.getByRole('button');
|
|
82
|
+
expect(btn.className).toContain('h-11');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('applies lg size classes', () => {
|
|
86
|
+
render(<Button size="lg">Large</Button>);
|
|
87
|
+
const btn = screen.getByRole('button');
|
|
88
|
+
expect(btn.className).toContain('h-10');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('applies icon size classes', () => {
|
|
92
|
+
render(<Button size="icon">I</Button>);
|
|
93
|
+
const btn = screen.getByRole('button');
|
|
94
|
+
expect(btn.className).toContain('w-11');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('applies custom className', () => {
|
|
98
|
+
render(<Button className="extra">Cls</Button>);
|
|
99
|
+
const btn = screen.getByRole('button');
|
|
100
|
+
expect(btn.className).toContain('extra');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('renders as child component when asChild is true', () => {
|
|
104
|
+
render(
|
|
105
|
+
<Button asChild>
|
|
106
|
+
<a href="/test">Link Button</a>
|
|
107
|
+
</Button>,
|
|
108
|
+
);
|
|
109
|
+
const link = screen.getByRole('link', { name: 'Link Button' });
|
|
110
|
+
expect(link).toBeInTheDocument();
|
|
111
|
+
expect(link.tagName).toBe('A');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../tabs';
|
|
6
|
+
|
|
7
|
+
describe('Tabs', () => {
|
|
8
|
+
function renderTabs(defaultValue = 'tab1', onValueChange?: (v: string) => void) {
|
|
9
|
+
return render(
|
|
10
|
+
<Tabs defaultValue={defaultValue} onValueChange={onValueChange}>
|
|
11
|
+
<TabsList>
|
|
12
|
+
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
|
13
|
+
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
|
14
|
+
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
|
|
15
|
+
</TabsList>
|
|
16
|
+
<TabsContent value="tab1">Content 1</TabsContent>
|
|
17
|
+
<TabsContent value="tab2">Content 2</TabsContent>
|
|
18
|
+
<TabsContent value="tab3">Content 3</TabsContent>
|
|
19
|
+
</Tabs>,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('renders without crashing', () => {
|
|
24
|
+
renderTabs();
|
|
25
|
+
expect(screen.getByText('Tab 1')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Tab 2')).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText('Tab 3')).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders tab triggers as buttons', () => {
|
|
31
|
+
renderTabs();
|
|
32
|
+
const triggers = screen.getAllByRole('tab');
|
|
33
|
+
expect(triggers.length).toBe(3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('shows content of the default active tab', () => {
|
|
37
|
+
renderTabs('tab1');
|
|
38
|
+
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('switches content when clicking a different tab', async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
renderTabs('tab1');
|
|
44
|
+
|
|
45
|
+
await user.click(screen.getByText('Tab 2'));
|
|
46
|
+
expect(screen.getByText('Content 2')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('marks the active tab with data-state=active', () => {
|
|
50
|
+
renderTabs('tab1');
|
|
51
|
+
const tab1 = screen.getByText('Tab 1');
|
|
52
|
+
expect(tab1.getAttribute('data-state')).toBe('active');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks inactive tabs with data-state=inactive', () => {
|
|
56
|
+
renderTabs('tab1');
|
|
57
|
+
const tab2 = screen.getByText('Tab 2');
|
|
58
|
+
expect(tab2.getAttribute('data-state')).toBe('inactive');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('calls onValueChange when switching tabs', async () => {
|
|
62
|
+
const user = userEvent.setup();
|
|
63
|
+
const onChange = vi.fn();
|
|
64
|
+
renderTabs('tab1', onChange);
|
|
65
|
+
|
|
66
|
+
await user.click(screen.getByText('Tab 2'));
|
|
67
|
+
expect(onChange).toHaveBeenCalledWith('tab2');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('renders TabsList with styling classes', () => {
|
|
71
|
+
renderTabs();
|
|
72
|
+
const tablist = screen.getByRole('tablist');
|
|
73
|
+
expect(tablist.className).toContain('inline-flex');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '../tooltip';
|
|
5
|
+
|
|
6
|
+
describe('Tooltip', () => {
|
|
7
|
+
it('renders the trigger element', () => {
|
|
8
|
+
render(
|
|
9
|
+
<TooltipProvider>
|
|
10
|
+
<Tooltip>
|
|
11
|
+
<TooltipTrigger>
|
|
12
|
+
<button>Hover me</button>
|
|
13
|
+
</TooltipTrigger>
|
|
14
|
+
<TooltipContent>Tooltip text</TooltipContent>
|
|
15
|
+
</Tooltip>
|
|
16
|
+
</TooltipProvider>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Hover me')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders trigger as child when asChild is set', () => {
|
|
22
|
+
render(
|
|
23
|
+
<TooltipProvider>
|
|
24
|
+
<Tooltip>
|
|
25
|
+
<TooltipTrigger asChild>
|
|
26
|
+
<button>Trigger</button>
|
|
27
|
+
</TooltipTrigger>
|
|
28
|
+
<TooltipContent>Info</TooltipContent>
|
|
29
|
+
</Tooltip>
|
|
30
|
+
</TooltipProvider>,
|
|
31
|
+
);
|
|
32
|
+
expect(screen.getByRole('button', { name: 'Trigger' })).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows tooltip content when open is true', () => {
|
|
36
|
+
render(
|
|
37
|
+
<TooltipProvider>
|
|
38
|
+
<Tooltip open={true}>
|
|
39
|
+
<TooltipTrigger>
|
|
40
|
+
<button>Btn</button>
|
|
41
|
+
</TooltipTrigger>
|
|
42
|
+
<TooltipContent>Visible tooltip</TooltipContent>
|
|
43
|
+
</Tooltip>
|
|
44
|
+
</TooltipProvider>,
|
|
45
|
+
);
|
|
46
|
+
// Radix renders tooltip text twice (visible + accessible hidden span)
|
|
47
|
+
const matches = screen.getAllByText('Visible tooltip');
|
|
48
|
+
expect(matches.length).toBeGreaterThanOrEqual(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not show tooltip content when open is false', () => {
|
|
52
|
+
render(
|
|
53
|
+
<TooltipProvider>
|
|
54
|
+
<Tooltip open={false}>
|
|
55
|
+
<TooltipTrigger>
|
|
56
|
+
<button>Btn</button>
|
|
57
|
+
</TooltipTrigger>
|
|
58
|
+
<TooltipContent>Hidden tooltip</TooltipContent>
|
|
59
|
+
</Tooltip>
|
|
60
|
+
</TooltipProvider>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.queryByText('Hidden tooltip')).not.toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('applies custom className to tooltip content', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<TooltipProvider>
|
|
68
|
+
<Tooltip open={true}>
|
|
69
|
+
<TooltipTrigger>
|
|
70
|
+
<button>Btn</button>
|
|
71
|
+
</TooltipTrigger>
|
|
72
|
+
<TooltipContent className="my-tooltip">Tooltip content here</TooltipContent>
|
|
73
|
+
</Tooltip>
|
|
74
|
+
</TooltipProvider>,
|
|
75
|
+
);
|
|
76
|
+
// Find the element with the custom class directly
|
|
77
|
+
const contentEl = container.querySelector('.my-tooltip');
|
|
78
|
+
expect(contentEl).toBeInTheDocument();
|
|
79
|
+
expect(contentEl?.textContent).toContain('Tooltip content here');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('renders children inside TooltipProvider', () => {
|
|
83
|
+
render(
|
|
84
|
+
<TooltipProvider>
|
|
85
|
+
<span>Wrapped child</span>
|
|
86
|
+
</TooltipProvider>,
|
|
87
|
+
);
|
|
88
|
+
expect(screen.getByText('Wrapped child')).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
});
|