@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,217 @@
|
|
|
1
|
+
import { render, screen, setupUser } from '@/test/test-utils';
|
|
2
|
+
import { ToastStack } from '../toast-stack';
|
|
3
|
+
import type { AppNotification } from '@/hooks/use-notifications';
|
|
4
|
+
|
|
5
|
+
// Mock next/navigation
|
|
6
|
+
const mockPush = vi.fn();
|
|
7
|
+
vi.mock('next/navigation', () => ({
|
|
8
|
+
useRouter: () => ({
|
|
9
|
+
push: mockPush,
|
|
10
|
+
replace: vi.fn(),
|
|
11
|
+
back: vi.fn(),
|
|
12
|
+
forward: vi.fn(),
|
|
13
|
+
refresh: vi.fn(),
|
|
14
|
+
prefetch: vi.fn(),
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
function makeNotification(overrides: Partial<AppNotification> = {}): AppNotification {
|
|
19
|
+
return {
|
|
20
|
+
id: overrides.id ?? 'toast-1',
|
|
21
|
+
title: overrides.title ?? 'Toast Title',
|
|
22
|
+
body: overrides.body ?? 'Toast body text',
|
|
23
|
+
type: overrides.type ?? 'info',
|
|
24
|
+
timestamp: overrides.timestamp ?? Date.now(),
|
|
25
|
+
href: overrides.href,
|
|
26
|
+
persistent: overrides.persistent,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('ToastStack', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// -----------------------------------------------------------------------
|
|
36
|
+
// Empty state
|
|
37
|
+
// -----------------------------------------------------------------------
|
|
38
|
+
it('renders nothing when notifications array is empty', () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<ToastStack notifications={[]} onDismiss={vi.fn()} />,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// The container div is rendered but no toast items inside
|
|
44
|
+
const toastItems = container.querySelectorAll('[class*="animate-slide-in-right"]');
|
|
45
|
+
expect(toastItems).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// -----------------------------------------------------------------------
|
|
49
|
+
// Renders toasts
|
|
50
|
+
// -----------------------------------------------------------------------
|
|
51
|
+
it('renders toast for each notification', () => {
|
|
52
|
+
const notifications = [
|
|
53
|
+
makeNotification({ id: 't1', title: 'First Toast', body: 'Body 1' }),
|
|
54
|
+
makeNotification({ id: 't2', title: 'Second Toast', body: 'Body 2' }),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
render(<ToastStack notifications={notifications} onDismiss={vi.fn()} />);
|
|
58
|
+
|
|
59
|
+
expect(screen.getByText('First Toast')).toBeInTheDocument();
|
|
60
|
+
expect(screen.getByText('Body 1')).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText('Second Toast')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('Body 2')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// -----------------------------------------------------------------------
|
|
66
|
+
// Stacking -- multiple toasts rendered in order
|
|
67
|
+
// -----------------------------------------------------------------------
|
|
68
|
+
it('stacks multiple toasts', () => {
|
|
69
|
+
const notifications = [
|
|
70
|
+
makeNotification({ id: 't1', title: 'Alpha' }),
|
|
71
|
+
makeNotification({ id: 't2', title: 'Beta' }),
|
|
72
|
+
makeNotification({ id: 't3', title: 'Gamma' }),
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
render(<ToastStack notifications={notifications} onDismiss={vi.fn()} />);
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText('Gamma')).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// -----------------------------------------------------------------------
|
|
83
|
+
// Dismiss button
|
|
84
|
+
// -----------------------------------------------------------------------
|
|
85
|
+
it('calls onDismiss with the toast id when dismiss button is clicked', async () => {
|
|
86
|
+
const user = setupUser();
|
|
87
|
+
const onDismiss = vi.fn();
|
|
88
|
+
const notifications = [
|
|
89
|
+
makeNotification({ id: 'toast-42', title: 'Dismissable' }),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
render(<ToastStack notifications={notifications} onDismiss={onDismiss} />);
|
|
93
|
+
|
|
94
|
+
// The X button inside the toast
|
|
95
|
+
const xIcon = screen.getByTestId('icon-X');
|
|
96
|
+
const dismissButton = xIcon.closest('button')!;
|
|
97
|
+
await user.click(dismissButton);
|
|
98
|
+
|
|
99
|
+
expect(onDismiss).toHaveBeenCalledWith('toast-42');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('dismiss does not trigger navigation', async () => {
|
|
103
|
+
const user = setupUser();
|
|
104
|
+
const onDismiss = vi.fn();
|
|
105
|
+
const notifications = [
|
|
106
|
+
makeNotification({ id: 't1', title: 'Has Link', href: '/runs/abc' }),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
render(<ToastStack notifications={notifications} onDismiss={onDismiss} />);
|
|
110
|
+
|
|
111
|
+
// Click the dismiss button (not the toast body)
|
|
112
|
+
const xIcon = screen.getByTestId('icon-X');
|
|
113
|
+
const dismissButton = xIcon.closest('button')!;
|
|
114
|
+
await user.click(dismissButton);
|
|
115
|
+
|
|
116
|
+
// Dismiss was called but navigation should NOT happen (stopPropagation)
|
|
117
|
+
expect(onDismiss).toHaveBeenCalledWith('t1');
|
|
118
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// -----------------------------------------------------------------------
|
|
122
|
+
// Clicking toast with href navigates
|
|
123
|
+
// -----------------------------------------------------------------------
|
|
124
|
+
it('navigates and dismisses when a toast with href is clicked', async () => {
|
|
125
|
+
const user = setupUser();
|
|
126
|
+
const onDismiss = vi.fn();
|
|
127
|
+
const notifications = [
|
|
128
|
+
makeNotification({ id: 't1', title: 'Clickable', href: '/runs/xyz' }),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
render(<ToastStack notifications={notifications} onDismiss={onDismiss} />);
|
|
132
|
+
|
|
133
|
+
await user.click(screen.getByText('Clickable'));
|
|
134
|
+
|
|
135
|
+
expect(mockPush).toHaveBeenCalledWith('/runs/xyz');
|
|
136
|
+
expect(onDismiss).toHaveBeenCalledWith('t1');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not navigate when a toast without href is clicked', async () => {
|
|
140
|
+
const user = setupUser();
|
|
141
|
+
const onDismiss = vi.fn();
|
|
142
|
+
const notifications = [
|
|
143
|
+
makeNotification({ id: 't1', title: 'No Link' }),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
render(<ToastStack notifications={notifications} onDismiss={onDismiss} />);
|
|
147
|
+
|
|
148
|
+
await user.click(screen.getByText('No Link'));
|
|
149
|
+
|
|
150
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
151
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
// Notification type icons
|
|
156
|
+
// -----------------------------------------------------------------------
|
|
157
|
+
it('renders the correct icon for success type', () => {
|
|
158
|
+
const notifications = [makeNotification({ id: 't1', type: 'success' })];
|
|
159
|
+
|
|
160
|
+
render(<ToastStack notifications={notifications} onDismiss={vi.fn()} />);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByTestId('icon-CheckCircle2')).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders the correct icon for error type', () => {
|
|
166
|
+
const notifications = [makeNotification({ id: 't1', type: 'error' })];
|
|
167
|
+
|
|
168
|
+
render(<ToastStack notifications={notifications} onDismiss={vi.fn()} />);
|
|
169
|
+
|
|
170
|
+
expect(screen.getByTestId('icon-XCircle')).toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('renders the correct icon for warning type', () => {
|
|
174
|
+
const notifications = [makeNotification({ id: 't1', type: 'warning' })];
|
|
175
|
+
|
|
176
|
+
render(<ToastStack notifications={notifications} onDismiss={vi.fn()} />);
|
|
177
|
+
|
|
178
|
+
expect(screen.getByTestId('icon-AlertTriangle')).toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('renders the correct icon for info type', () => {
|
|
182
|
+
const notifications = [makeNotification({ id: 't1', type: 'info' })];
|
|
183
|
+
|
|
184
|
+
render(<ToastStack notifications={notifications} onDismiss={vi.fn()} />);
|
|
185
|
+
|
|
186
|
+
expect(screen.getByTestId('icon-Info')).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
190
|
+
// Persistent (pinned) notification
|
|
191
|
+
// -----------------------------------------------------------------------
|
|
192
|
+
it('shows pin icon for persistent notifications', () => {
|
|
193
|
+
const persistentNotif = makeNotification({ id: 't-pin', persistent: true });
|
|
194
|
+
render(<ToastStack notifications={[persistentNotif]} onDismiss={vi.fn()} />);
|
|
195
|
+
expect(screen.getByTitle('Pinned — won\'t auto-dismiss')).toBeInTheDocument();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('does not show pin icon for non-persistent notifications', () => {
|
|
199
|
+
const notif = makeNotification({ id: 't-nopin' });
|
|
200
|
+
render(<ToastStack notifications={[notif]} onDismiss={vi.fn()} />);
|
|
201
|
+
expect(screen.queryByTitle('Pinned — won\'t auto-dismiss')).not.toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// -----------------------------------------------------------------------
|
|
205
|
+
// Slide-in animation class
|
|
206
|
+
// -----------------------------------------------------------------------
|
|
207
|
+
it('applies the slide-in animation class to toasts', () => {
|
|
208
|
+
const notifications = [makeNotification({ id: 't1' })];
|
|
209
|
+
|
|
210
|
+
const { container } = render(
|
|
211
|
+
<ToastStack notifications={notifications} onDismiss={vi.fn()} />,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const toastEl = container.querySelector('[class*="animate-slide-in-right"]');
|
|
215
|
+
expect(toastEl).toBeInTheDocument();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRouter } from "next/navigation";
|
|
3
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
import { X, CheckCircle2, XCircle, AlertTriangle, Info, Bell, Pin } from "lucide-react";
|
|
6
|
+
import type { AppNotification } from "@/hooks/use-notifications";
|
|
7
|
+
|
|
8
|
+
const iconMap: Record<AppNotification["type"], React.ReactNode> = {
|
|
9
|
+
success: <CheckCircle2 className="h-4 w-4 text-success drop-shadow-[var(--drop-glow-success)]" />,
|
|
10
|
+
error: <XCircle className="h-4 w-4 text-error drop-shadow-[var(--drop-glow-error)]" />,
|
|
11
|
+
warning: <AlertTriangle className="h-4 w-4 text-warning drop-shadow-[var(--drop-glow-warning)]" />,
|
|
12
|
+
info: <Info className="h-4 w-4 text-info drop-shadow-[var(--drop-glow-cyan)]" />,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const borderMap: Record<AppNotification["type"], string> = {
|
|
16
|
+
success: "border-l-success",
|
|
17
|
+
error: "border-l-error",
|
|
18
|
+
warning: "border-l-warning",
|
|
19
|
+
info: "border-l-info",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
interface NotificationPanelProps {
|
|
23
|
+
open: boolean;
|
|
24
|
+
notifications: AppNotification[];
|
|
25
|
+
onDismiss: (id: string) => void;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function NotificationPanel({ open, notifications, onDismiss, onClose }: NotificationPanelProps) {
|
|
30
|
+
const router = useRouter();
|
|
31
|
+
|
|
32
|
+
const handleClick = (notif: AppNotification) => {
|
|
33
|
+
if (notif.href) {
|
|
34
|
+
router.push(notif.href);
|
|
35
|
+
onDismiss(notif.id);
|
|
36
|
+
onClose();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const formatTime = (timestamp: number) => {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const diff = now - timestamp;
|
|
43
|
+
const seconds = Math.floor(diff / 1000);
|
|
44
|
+
const minutes = Math.floor(seconds / 60);
|
|
45
|
+
const hours = Math.floor(minutes / 60);
|
|
46
|
+
|
|
47
|
+
if (seconds < 60) return "just now";
|
|
48
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
49
|
+
if (hours < 24) return `${hours}h ago`;
|
|
50
|
+
return new Date(timestamp).toLocaleString();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Dialog.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
|
|
55
|
+
<Dialog.Portal>
|
|
56
|
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
|
|
57
|
+
<Dialog.Content
|
|
58
|
+
data-testid="notification-panel"
|
|
59
|
+
className="fixed right-4 top-4 z-50 rounded-lg border border-[var(--glass-border)] bg-[var(--glass-bg)] backdrop-blur-xl shadow-glass w-full max-w-md max-h-[80vh] flex flex-col"
|
|
60
|
+
>
|
|
61
|
+
<div className="flex items-center justify-between p-4 border-b border-[var(--glass-border-subtle)]">
|
|
62
|
+
<div className="flex items-center gap-2">
|
|
63
|
+
<Bell className="h-4 w-4 text-primary/60" />
|
|
64
|
+
<Dialog.Title className="text-sm font-medium text-foreground">Notifications</Dialog.Title>
|
|
65
|
+
{notifications.length > 0 && (
|
|
66
|
+
<span className="text-xs text-primary/70 font-mono">({notifications.length})</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
<Dialog.Close asChild>
|
|
70
|
+
<button
|
|
71
|
+
className="rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-primary transition-colors"
|
|
72
|
+
>
|
|
73
|
+
<X className="h-4 w-4" />
|
|
74
|
+
</button>
|
|
75
|
+
</Dialog.Close>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
78
|
+
{notifications.length === 0 ? (
|
|
79
|
+
<div className="flex flex-col items-center justify-center py-12 text-foreground-muted">
|
|
80
|
+
<Bell className="h-8 w-8 mb-2 opacity-50" />
|
|
81
|
+
<p className="text-sm">No notifications</p>
|
|
82
|
+
</div>
|
|
83
|
+
) : (
|
|
84
|
+
<div className="space-y-2">
|
|
85
|
+
{notifications.map((notif) => (
|
|
86
|
+
<div
|
|
87
|
+
key={notif.id}
|
|
88
|
+
data-testid={`notification-item-${notif.id}`}
|
|
89
|
+
className={cn(
|
|
90
|
+
"rounded-lg border border-[var(--glass-border-faint)] bg-[var(--glass-card-bg)] p-3 border-l-2",
|
|
91
|
+
"transition-colors duration-150",
|
|
92
|
+
notif.href && "cursor-pointer hover:bg-[var(--glass-border-faint)]",
|
|
93
|
+
borderMap[notif.type]
|
|
94
|
+
)}
|
|
95
|
+
onClick={() => handleClick(notif)}
|
|
96
|
+
>
|
|
97
|
+
<div className="flex items-start gap-2">
|
|
98
|
+
<div className="shrink-0 mt-0.5">{iconMap[notif.type]}</div>
|
|
99
|
+
{notif.persistent && <span title="Pinned — won't auto-dismiss" className="shrink-0"><Pin className="h-3 w-3 text-primary/50" /></span>}
|
|
100
|
+
<div className="flex-1 min-w-0">
|
|
101
|
+
<p className="text-sm font-medium text-foreground">{notif.title}</p>
|
|
102
|
+
<p className="text-xs text-foreground-muted mt-0.5">{notif.body}</p>
|
|
103
|
+
<p className="text-xs text-foreground-muted mt-1 opacity-70">
|
|
104
|
+
{formatTime(notif.timestamp)}
|
|
105
|
+
{notif.persistent && <span className="text-primary/50 ml-1">· Pinned</span>}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
<button
|
|
109
|
+
onClick={(e) => { e.stopPropagation(); onDismiss(notif.id); }}
|
|
110
|
+
className="shrink-0 rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-primary transition-colors"
|
|
111
|
+
>
|
|
112
|
+
<X className="h-3.5 w-3.5" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</Dialog.Content>
|
|
121
|
+
</Dialog.Portal>
|
|
122
|
+
</Dialog.Root>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
useNotifications,
|
|
5
|
+
type AppNotification,
|
|
6
|
+
} from "@/hooks/use-notifications";
|
|
7
|
+
import { ToastStack } from "./toast-stack";
|
|
8
|
+
import { usePolling } from "@/hooks/use-polling";
|
|
9
|
+
import { formatShortId } from "@/lib/utils";
|
|
10
|
+
import type { DigestResponse } from "@/types";
|
|
11
|
+
|
|
12
|
+
interface NotificationContextValue {
|
|
13
|
+
notify: (
|
|
14
|
+
title: string,
|
|
15
|
+
body: string,
|
|
16
|
+
type?: AppNotification["type"],
|
|
17
|
+
options?: { href?: string; persistent?: boolean },
|
|
18
|
+
) => void;
|
|
19
|
+
requestPermission: () => void;
|
|
20
|
+
permission: NotificationPermission;
|
|
21
|
+
notifications: AppNotification[];
|
|
22
|
+
dismiss: (id: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NotificationContext = createContext<NotificationContextValue>({
|
|
26
|
+
notify: () => {},
|
|
27
|
+
requestPermission: () => {},
|
|
28
|
+
permission: "default",
|
|
29
|
+
notifications: [],
|
|
30
|
+
dismiss: () => {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const useNotificationContext = () => useContext(NotificationContext);
|
|
34
|
+
|
|
35
|
+
// Watermark per run: tracks the highest-seen state so notifications only fire once
|
|
36
|
+
interface RunWatermark {
|
|
37
|
+
status: string;
|
|
38
|
+
completedTasks: number;
|
|
39
|
+
pendingBreakpoints: number;
|
|
40
|
+
notifiedCompleted: boolean;
|
|
41
|
+
notifiedFailed: boolean;
|
|
42
|
+
notifiedWaiting: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Duration after mount during which watermarks are seeded silently (no notifications). */
|
|
46
|
+
export const STABILIZATION_WINDOW_MS = 10_000; // 10 seconds
|
|
47
|
+
|
|
48
|
+
export function NotificationProvider({ children }: { children: ReactNode }) {
|
|
49
|
+
const { notifications, notify, dismiss, requestPermission, permission } =
|
|
50
|
+
useNotifications();
|
|
51
|
+
const { data: digest } = usePolling<DigestResponse>("/api/digest", {
|
|
52
|
+
interval: 3000,
|
|
53
|
+
});
|
|
54
|
+
// Permanent watermark: tracks highest-seen state per run across all polls
|
|
55
|
+
const watermarkRef = useRef<Map<string, RunWatermark>>(new Map());
|
|
56
|
+
const mountedAtRef = useRef(Date.now());
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!digest) return;
|
|
60
|
+
|
|
61
|
+
const watermarks = watermarkRef.current;
|
|
62
|
+
const isStabilizing =
|
|
63
|
+
Date.now() - mountedAtRef.current < STABILIZATION_WINDOW_MS;
|
|
64
|
+
|
|
65
|
+
// During the stabilization window, seed watermarks for every run without
|
|
66
|
+
// firing any notifications. This replaces the old count-based INIT_SKIP
|
|
67
|
+
// approach which could miss runs when there are many active runs and the
|
|
68
|
+
// first N polls didn't cover them all.
|
|
69
|
+
if (isStabilizing) {
|
|
70
|
+
for (const run of digest.runs) {
|
|
71
|
+
watermarks.set(run.runId, {
|
|
72
|
+
status: run.status,
|
|
73
|
+
completedTasks: run.completedTasks,
|
|
74
|
+
pendingBreakpoints: run.pendingBreakpoints || 0,
|
|
75
|
+
notifiedCompleted: run.status === "completed",
|
|
76
|
+
notifiedFailed: run.status === "failed",
|
|
77
|
+
notifiedWaiting: run.status === "waiting",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const run of digest.runs) {
|
|
84
|
+
const wm = watermarks.get(run.runId);
|
|
85
|
+
|
|
86
|
+
if (!wm) {
|
|
87
|
+
// Genuinely new run — seed watermark and notify
|
|
88
|
+
watermarks.set(run.runId, {
|
|
89
|
+
status: run.status,
|
|
90
|
+
completedTasks: run.completedTasks,
|
|
91
|
+
pendingBreakpoints: run.pendingBreakpoints || 0,
|
|
92
|
+
notifiedCompleted: run.status === "completed",
|
|
93
|
+
notifiedFailed: run.status === "failed",
|
|
94
|
+
notifiedWaiting: run.status === "waiting",
|
|
95
|
+
});
|
|
96
|
+
notify(
|
|
97
|
+
"New Run Started",
|
|
98
|
+
`${formatShortId(run.runId, 4)} started`,
|
|
99
|
+
"info",
|
|
100
|
+
{ href: `/runs/${run.runId}` },
|
|
101
|
+
);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Run completed — only notify once ever
|
|
106
|
+
if (run.status === "completed" && !wm.notifiedCompleted) {
|
|
107
|
+
wm.notifiedCompleted = true;
|
|
108
|
+
notify(
|
|
109
|
+
"Run Completed",
|
|
110
|
+
`${formatShortId(run.runId, 4)} finished successfully`,
|
|
111
|
+
"success",
|
|
112
|
+
{ href: `/runs/${run.runId}` },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Run failed — only notify once ever
|
|
117
|
+
if (run.status === "failed" && !wm.notifiedFailed) {
|
|
118
|
+
wm.notifiedFailed = true;
|
|
119
|
+
notify(
|
|
120
|
+
"Run Failed",
|
|
121
|
+
`${formatShortId(run.runId, 4)} failed`,
|
|
122
|
+
"error",
|
|
123
|
+
{ href: `/runs/${run.runId}` },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Track completed-task watermark (no per-task notification — the
|
|
128
|
+
// terminal "Run Completed" notification already covers this, and
|
|
129
|
+
// per-task notifications flood the panel when many runs are active).
|
|
130
|
+
if (run.completedTasks > wm.completedTasks) {
|
|
131
|
+
wm.completedTasks = run.completedTasks;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Run transitioned to waiting (breakpoint) — only notify once per waiting episode
|
|
135
|
+
// These notifications are persistent (no auto-dismiss) so users cannot miss them
|
|
136
|
+
if (run.status === "waiting" && !wm.notifiedWaiting) {
|
|
137
|
+
wm.notifiedWaiting = true;
|
|
138
|
+
const breakpointTitle = run.breakpointQuestion || "Review required";
|
|
139
|
+
notify(
|
|
140
|
+
`Run ${formatShortId(run.runId, 4)} needs attention`,
|
|
141
|
+
breakpointTitle,
|
|
142
|
+
"warning",
|
|
143
|
+
{ href: `/runs/${run.runId}`, persistent: true },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Reset waiting flag when run leaves waiting state (allows re-notification on next breakpoint)
|
|
148
|
+
if (run.status !== "waiting" && wm.notifiedWaiting) {
|
|
149
|
+
wm.notifiedWaiting = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Breakpoint resolved — pending count dropped to zero
|
|
153
|
+
if (wm.pendingBreakpoints > 0 && (run.pendingBreakpoints === 0 || run.pendingBreakpoints === undefined)) {
|
|
154
|
+
notify(
|
|
155
|
+
"Breakpoint Resolved",
|
|
156
|
+
`Breakpoint in ${formatShortId(run.runId, 4)} was approved`,
|
|
157
|
+
"success",
|
|
158
|
+
{ href: `/runs/${run.runId}` },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
wm.pendingBreakpoints = run.pendingBreakpoints || 0;
|
|
162
|
+
|
|
163
|
+
wm.status = run.status;
|
|
164
|
+
}
|
|
165
|
+
}, [digest, notify]);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<NotificationContext.Provider
|
|
169
|
+
value={{ notify, requestPermission, permission, notifications, dismiss }}
|
|
170
|
+
>
|
|
171
|
+
{children}
|
|
172
|
+
<ToastStack notifications={notifications} onDismiss={dismiss} />
|
|
173
|
+
</NotificationContext.Provider>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRouter } from "next/navigation";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
import { X, CheckCircle2, XCircle, AlertTriangle, Info, Pin } from "lucide-react";
|
|
5
|
+
import type { AppNotification } from "@/hooks/use-notifications";
|
|
6
|
+
|
|
7
|
+
const iconMap: Record<AppNotification["type"], React.ReactNode> = {
|
|
8
|
+
success: <CheckCircle2 className="h-4 w-4 text-success drop-shadow-[var(--drop-glow-success)]" />,
|
|
9
|
+
error: <XCircle className="h-4 w-4 text-error drop-shadow-[var(--drop-glow-error)]" />,
|
|
10
|
+
warning: <AlertTriangle className="h-4 w-4 text-warning drop-shadow-[var(--drop-glow-warning)]" />,
|
|
11
|
+
info: <Info className="h-4 w-4 text-info drop-shadow-[var(--drop-glow-cyan)]" />,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const borderMap: Record<AppNotification["type"], string> = {
|
|
15
|
+
success: "border-l-success shadow-toast-glow-success",
|
|
16
|
+
error: "border-l-error shadow-toast-glow-error",
|
|
17
|
+
warning: "border-l-warning shadow-toast-glow-warning",
|
|
18
|
+
info: "border-l-info shadow-toast-glow-cyan",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
interface ToastStackProps {
|
|
22
|
+
notifications: AppNotification[];
|
|
23
|
+
onDismiss: (id: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ToastStack({ notifications, onDismiss }: ToastStackProps) {
|
|
27
|
+
const router = useRouter();
|
|
28
|
+
|
|
29
|
+
const handleClick = (notif: AppNotification) => {
|
|
30
|
+
if (notif.href) {
|
|
31
|
+
router.push(notif.href);
|
|
32
|
+
onDismiss(notif.id);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div role="log" aria-live="assertive" aria-label="Notifications" data-testid="toast-stack" className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
|
38
|
+
{notifications.map((notif) => (
|
|
39
|
+
<div
|
|
40
|
+
key={notif.id}
|
|
41
|
+
data-testid={`toast-item-${notif.id}`}
|
|
42
|
+
className={cn(
|
|
43
|
+
"rounded-lg border border-[var(--glass-border-subtle)] bg-[var(--glass-bg-heavy)] backdrop-blur-sm p-3 shadow-lg border-l-2",
|
|
44
|
+
"animate-slide-in-right",
|
|
45
|
+
notif.href && "cursor-pointer hover:bg-card-hover",
|
|
46
|
+
notif.persistent && "ring-1 ring-primary/20",
|
|
47
|
+
borderMap[notif.type]
|
|
48
|
+
)}
|
|
49
|
+
onClick={() => handleClick(notif)}
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-start gap-2">
|
|
52
|
+
<div className="shrink-0 mt-0.5">{iconMap[notif.type]}</div>
|
|
53
|
+
{notif.persistent && <span title="Pinned — won't auto-dismiss" className="shrink-0"><Pin className="h-3 w-3 text-primary/50" /></span>}
|
|
54
|
+
<div className="flex-1 min-w-0">
|
|
55
|
+
<div className="flex items-center gap-1">
|
|
56
|
+
<p className="text-sm font-medium text-foreground">{notif.title}</p>
|
|
57
|
+
{notif.persistent && (
|
|
58
|
+
<Pin className="h-3 w-3 text-warning shrink-0" aria-label="Persistent notification" />
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<p className="text-xs text-foreground-muted mt-0.5 truncate">{notif.body}</p>
|
|
62
|
+
</div>
|
|
63
|
+
<button
|
|
64
|
+
onClick={(e) => { e.stopPropagation(); onDismiss(notif.id); }}
|
|
65
|
+
className="shrink-0 rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-primary transition-colors"
|
|
66
|
+
aria-label={`Dismiss ${notif.title}`}
|
|
67
|
+
>
|
|
68
|
+
<X className="h-3.5 w-3.5" />
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen } from '@/test/test-utils';
|
|
4
|
+
import { ParallelGroup } from '../parallel-group';
|
|
5
|
+
|
|
6
|
+
describe('ParallelGroup', () => {
|
|
7
|
+
it('renders without crashing', () => {
|
|
8
|
+
render(
|
|
9
|
+
<ParallelGroup count={3}>
|
|
10
|
+
<div>child</div>
|
|
11
|
+
</ParallelGroup>,
|
|
12
|
+
);
|
|
13
|
+
expect(screen.getByText('parallel')).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('displays the count label with correct task count', () => {
|
|
17
|
+
render(
|
|
18
|
+
<ParallelGroup count={5}>
|
|
19
|
+
<div>child</div>
|
|
20
|
+
</ParallelGroup>,
|
|
21
|
+
);
|
|
22
|
+
// The label renders as "· 5 tasks" via · entity
|
|
23
|
+
expect(screen.getByText(/5 tasks/)).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders children inside the group', () => {
|
|
27
|
+
render(
|
|
28
|
+
<ParallelGroup count={2}>
|
|
29
|
+
<div data-testid="child-a">Alpha</div>
|
|
30
|
+
<div data-testid="child-b">Beta</div>
|
|
31
|
+
</ParallelGroup>,
|
|
32
|
+
);
|
|
33
|
+
expect(screen.getByTestId('child-a')).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByTestId('child-b')).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
36
|
+
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders the GitBranch icon', () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<ParallelGroup count={2}>
|
|
42
|
+
<div>child</div>
|
|
43
|
+
</ParallelGroup>,
|
|
44
|
+
);
|
|
45
|
+
const icon = container.querySelector('[data-lucide="GitBranch"]');
|
|
46
|
+
expect(icon).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('applies custom className', () => {
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<ParallelGroup count={1} className="my-custom-class">
|
|
52
|
+
<div>child</div>
|
|
53
|
+
</ParallelGroup>,
|
|
54
|
+
);
|
|
55
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
56
|
+
expect(wrapper.className).toContain('my-custom-class');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('always shows "parallel" label in uppercase styling container', () => {
|
|
60
|
+
render(
|
|
61
|
+
<ParallelGroup count={4}>
|
|
62
|
+
<div>child</div>
|
|
63
|
+
</ParallelGroup>,
|
|
64
|
+
);
|
|
65
|
+
const label = screen.getByText('parallel');
|
|
66
|
+
expect(label).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders with count of 1', () => {
|
|
70
|
+
render(
|
|
71
|
+
<ParallelGroup count={1}>
|
|
72
|
+
<div>single child</div>
|
|
73
|
+
</ParallelGroup>,
|
|
74
|
+
);
|
|
75
|
+
expect(screen.getByText(/1 tasks/)).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByText('single child')).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('renders dashed border container with expected structure', () => {
|
|
80
|
+
const { container } = render(
|
|
81
|
+
<ParallelGroup count={3}>
|
|
82
|
+
<div>child</div>
|
|
83
|
+
</ParallelGroup>,
|
|
84
|
+
);
|
|
85
|
+
const outer = container.firstChild as HTMLElement;
|
|
86
|
+
expect(outer.className).toContain('border-dashed');
|
|
87
|
+
});
|
|
88
|
+
});
|