@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,585 @@
|
|
|
1
|
+
import { render, screen, act } from '@/test/test-utils';
|
|
2
|
+
import { NotificationProvider, useNotificationContext, STABILIZATION_WINDOW_MS } from '../notification-provider';
|
|
3
|
+
import type { DigestResponse, RunDigest } from '@/types';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
// Mock hooks used by NotificationProvider
|
|
7
|
+
const mockNotify = vi.fn();
|
|
8
|
+
const mockDismiss = vi.fn();
|
|
9
|
+
const mockRequestPermission = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock('@/hooks/use-notifications', () => ({
|
|
12
|
+
useNotifications: () => ({
|
|
13
|
+
notifications: [],
|
|
14
|
+
notify: mockNotify,
|
|
15
|
+
dismiss: mockDismiss,
|
|
16
|
+
requestPermission: mockRequestPermission,
|
|
17
|
+
permission: 'default' as NotificationPermission,
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Mutable digest data that the usePolling mock reads from.
|
|
22
|
+
// Tests update this variable and rerender to simulate new poll responses.
|
|
23
|
+
let mockDigestData: DigestResponse | null = null;
|
|
24
|
+
|
|
25
|
+
vi.mock('@/hooks/use-polling', () => ({
|
|
26
|
+
usePolling: () => ({
|
|
27
|
+
data: mockDigestData,
|
|
28
|
+
loading: false,
|
|
29
|
+
error: null,
|
|
30
|
+
refresh: vi.fn(),
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Mock ToastStack to avoid next/navigation dependency
|
|
35
|
+
vi.mock('../toast-stack', () => ({
|
|
36
|
+
ToastStack: ({ notifications, onDismiss: _onDismiss }: { notifications: unknown[]; onDismiss: (id: string) => void }) =>
|
|
37
|
+
React.createElement('div', { 'data-testid': 'toast-stack' }, `toasts: ${(notifications as unknown[]).length}`),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
describe('NotificationProvider', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
mockDigestData = null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// -----------------------------------------------------------------------
|
|
47
|
+
// Renders children
|
|
48
|
+
// -----------------------------------------------------------------------
|
|
49
|
+
it('renders its children', () => {
|
|
50
|
+
render(
|
|
51
|
+
<NotificationProvider>
|
|
52
|
+
<div data-testid="child">Hello</div>
|
|
53
|
+
</NotificationProvider>,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(screen.getByTestId('child')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('Hello')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// Renders ToastStack
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
it('renders the ToastStack component', () => {
|
|
64
|
+
render(
|
|
65
|
+
<NotificationProvider>
|
|
66
|
+
<span>child</span>
|
|
67
|
+
</NotificationProvider>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(screen.getByTestId('toast-stack')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// -----------------------------------------------------------------------
|
|
74
|
+
// Context provides notify function
|
|
75
|
+
// -----------------------------------------------------------------------
|
|
76
|
+
it('provides notify function through context', () => {
|
|
77
|
+
function Consumer() {
|
|
78
|
+
const { notify } = useNotificationContext();
|
|
79
|
+
return (
|
|
80
|
+
<button onClick={() => notify('Test', 'Body', 'info')}>
|
|
81
|
+
Notify
|
|
82
|
+
</button>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<NotificationProvider>
|
|
88
|
+
<Consumer />
|
|
89
|
+
</NotificationProvider>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
screen.getByText('Notify').click();
|
|
93
|
+
|
|
94
|
+
expect(mockNotify).toHaveBeenCalledWith('Test', 'Body', 'info');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// -----------------------------------------------------------------------
|
|
98
|
+
// Context provides dismiss function
|
|
99
|
+
// -----------------------------------------------------------------------
|
|
100
|
+
it('provides dismiss function through context', () => {
|
|
101
|
+
function Consumer() {
|
|
102
|
+
const { dismiss } = useNotificationContext();
|
|
103
|
+
return (
|
|
104
|
+
<button onClick={() => dismiss('notif-1')}>
|
|
105
|
+
Dismiss
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
render(
|
|
111
|
+
<NotificationProvider>
|
|
112
|
+
<Consumer />
|
|
113
|
+
</NotificationProvider>,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
screen.getByText('Dismiss').click();
|
|
117
|
+
|
|
118
|
+
expect(mockDismiss).toHaveBeenCalledWith('notif-1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// -----------------------------------------------------------------------
|
|
122
|
+
// Context provides requestPermission
|
|
123
|
+
// -----------------------------------------------------------------------
|
|
124
|
+
it('provides requestPermission through context', () => {
|
|
125
|
+
function Consumer() {
|
|
126
|
+
const { requestPermission } = useNotificationContext();
|
|
127
|
+
return (
|
|
128
|
+
<button onClick={() => requestPermission()}>
|
|
129
|
+
Request
|
|
130
|
+
</button>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(
|
|
135
|
+
<NotificationProvider>
|
|
136
|
+
<Consumer />
|
|
137
|
+
</NotificationProvider>,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
screen.getByText('Request').click();
|
|
141
|
+
|
|
142
|
+
expect(mockRequestPermission).toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
146
|
+
// Context provides permission value
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
it('provides permission value through context', () => {
|
|
149
|
+
function Consumer() {
|
|
150
|
+
const { permission } = useNotificationContext();
|
|
151
|
+
return <span data-testid="perm">{permission}</span>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
render(
|
|
155
|
+
<NotificationProvider>
|
|
156
|
+
<Consumer />
|
|
157
|
+
</NotificationProvider>,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(screen.getByTestId('perm').textContent).toBe('default');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// -----------------------------------------------------------------------
|
|
164
|
+
// Context provides notifications array
|
|
165
|
+
// -----------------------------------------------------------------------
|
|
166
|
+
it('provides notifications array through context', () => {
|
|
167
|
+
function Consumer() {
|
|
168
|
+
const { notifications } = useNotificationContext();
|
|
169
|
+
return <span data-testid="count">{notifications.length}</span>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
render(
|
|
173
|
+
<NotificationProvider>
|
|
174
|
+
<Consumer />
|
|
175
|
+
</NotificationProvider>,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(screen.getByTestId('count').textContent).toBe('0');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
// Default context values (used without provider)
|
|
183
|
+
// -----------------------------------------------------------------------
|
|
184
|
+
it('provides safe default context values when used without a provider', () => {
|
|
185
|
+
function Consumer() {
|
|
186
|
+
const ctx = useNotificationContext();
|
|
187
|
+
return (
|
|
188
|
+
<div>
|
|
189
|
+
<span data-testid="perm">{ctx.permission}</span>
|
|
190
|
+
<span data-testid="count">{ctx.notifications.length}</span>
|
|
191
|
+
<button onClick={() => ctx.notify('a', 'b')}>n</button>
|
|
192
|
+
<button onClick={() => ctx.dismiss('x')}>d</button>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Render without provider -- uses the default context
|
|
198
|
+
render(<Consumer />);
|
|
199
|
+
|
|
200
|
+
expect(screen.getByTestId('perm').textContent).toBe('default');
|
|
201
|
+
expect(screen.getByTestId('count').textContent).toBe('0');
|
|
202
|
+
// These should not throw
|
|
203
|
+
screen.getByText('n').click();
|
|
204
|
+
screen.getByText('d').click();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// =======================================================================
|
|
208
|
+
// Stabilization window tests
|
|
209
|
+
// =======================================================================
|
|
210
|
+
describe('stabilization window', () => {
|
|
211
|
+
/** Helper to create a RunDigest with sensible defaults. */
|
|
212
|
+
function makeRun(overrides: Partial<RunDigest> = {}): RunDigest {
|
|
213
|
+
return {
|
|
214
|
+
runId: 'run-001',
|
|
215
|
+
latestSeq: 1,
|
|
216
|
+
status: 'pending',
|
|
217
|
+
taskCount: 5,
|
|
218
|
+
completedTasks: 0,
|
|
219
|
+
updatedAt: new Date().toISOString(),
|
|
220
|
+
pendingBreakpoints: 0,
|
|
221
|
+
...overrides,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
afterEach(() => {
|
|
230
|
+
vi.useRealTimers();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// -------------------------------------------------------------------
|
|
234
|
+
// 1. No notifications during stabilization window
|
|
235
|
+
// -------------------------------------------------------------------
|
|
236
|
+
it('does not fire notifications during the stabilization window', async () => {
|
|
237
|
+
mockDigestData = {
|
|
238
|
+
runs: [
|
|
239
|
+
makeRun({ runId: 'run-001' }),
|
|
240
|
+
makeRun({ runId: 'run-002' }),
|
|
241
|
+
makeRun({ runId: 'run-003' }),
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
render(
|
|
247
|
+
<NotificationProvider>
|
|
248
|
+
<span>child</span>
|
|
249
|
+
</NotificationProvider>,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// We are within the stabilization window — no notifications should fire
|
|
254
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// -------------------------------------------------------------------
|
|
258
|
+
// 2. Watermarks seeded during stabilization (no duplicate notifications)
|
|
259
|
+
// -------------------------------------------------------------------
|
|
260
|
+
it('seeds watermarks during stabilization so existing runs do not trigger notifications after window', async () => {
|
|
261
|
+
const runs = [
|
|
262
|
+
makeRun({ runId: 'run-001' }),
|
|
263
|
+
makeRun({ runId: 'run-002' }),
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
mockDigestData = { runs };
|
|
267
|
+
|
|
268
|
+
const { rerender } = await act(async () =>
|
|
269
|
+
render(
|
|
270
|
+
<NotificationProvider>
|
|
271
|
+
<span>child</span>
|
|
272
|
+
</NotificationProvider>,
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
277
|
+
|
|
278
|
+
// Advance past the stabilization window
|
|
279
|
+
await act(async () => {
|
|
280
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Provide the SAME runs again (simulating a new poll)
|
|
284
|
+
mockDigestData = { runs: [...runs] };
|
|
285
|
+
await act(async () => {
|
|
286
|
+
rerender(
|
|
287
|
+
<NotificationProvider>
|
|
288
|
+
<span>child</span>
|
|
289
|
+
</NotificationProvider>,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// No "New Run Started" notifications because watermarks were already seeded
|
|
294
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// -------------------------------------------------------------------
|
|
298
|
+
// 3. New run after stabilization fires notification
|
|
299
|
+
// -------------------------------------------------------------------
|
|
300
|
+
it('fires "New Run Started" for a genuinely new run after stabilization', async () => {
|
|
301
|
+
const existingRuns = [makeRun({ runId: 'run-001' })];
|
|
302
|
+
mockDigestData = { runs: existingRuns };
|
|
303
|
+
|
|
304
|
+
const { rerender } = await act(async () =>
|
|
305
|
+
render(
|
|
306
|
+
<NotificationProvider>
|
|
307
|
+
<span>child</span>
|
|
308
|
+
</NotificationProvider>,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
313
|
+
|
|
314
|
+
// Advance past stabilization window
|
|
315
|
+
await act(async () => {
|
|
316
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Add a new run to the digest
|
|
320
|
+
mockDigestData = {
|
|
321
|
+
runs: [...existingRuns, makeRun({ runId: 'run-new' })],
|
|
322
|
+
};
|
|
323
|
+
await act(async () => {
|
|
324
|
+
rerender(
|
|
325
|
+
<NotificationProvider>
|
|
326
|
+
<span>child</span>
|
|
327
|
+
</NotificationProvider>,
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(mockNotify).toHaveBeenCalledTimes(1);
|
|
332
|
+
expect(mockNotify).toHaveBeenCalledWith(
|
|
333
|
+
'New Run Started',
|
|
334
|
+
expect.stringContaining('started'),
|
|
335
|
+
'info',
|
|
336
|
+
expect.objectContaining({ href: '/runs/run-new' }),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// -------------------------------------------------------------------
|
|
341
|
+
// 4. Run completed during stabilization doesn't fire notification
|
|
342
|
+
// -------------------------------------------------------------------
|
|
343
|
+
it('does not fire "Run Completed" for a run that was already completed during stabilization', async () => {
|
|
344
|
+
mockDigestData = {
|
|
345
|
+
runs: [makeRun({ runId: 'run-001', status: 'completed' })],
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const { rerender } = await act(async () =>
|
|
349
|
+
render(
|
|
350
|
+
<NotificationProvider>
|
|
351
|
+
<span>child</span>
|
|
352
|
+
</NotificationProvider>,
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
357
|
+
|
|
358
|
+
// Advance past stabilization window
|
|
359
|
+
await act(async () => {
|
|
360
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Same run, still completed
|
|
364
|
+
mockDigestData = {
|
|
365
|
+
runs: [makeRun({ runId: 'run-001', status: 'completed' })],
|
|
366
|
+
};
|
|
367
|
+
await act(async () => {
|
|
368
|
+
rerender(
|
|
369
|
+
<NotificationProvider>
|
|
370
|
+
<span>child</span>
|
|
371
|
+
</NotificationProvider>,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// No notification since it was completed before stabilization ended
|
|
376
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// -------------------------------------------------------------------
|
|
380
|
+
// 5. Status transition after stabilization fires once
|
|
381
|
+
// -------------------------------------------------------------------
|
|
382
|
+
it('fires "Run Completed" exactly once when a run transitions to completed after stabilization', async () => {
|
|
383
|
+
mockDigestData = {
|
|
384
|
+
runs: [makeRun({ runId: 'run-001', status: 'pending' })],
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const { rerender } = await act(async () =>
|
|
388
|
+
render(
|
|
389
|
+
<NotificationProvider>
|
|
390
|
+
<span>child</span>
|
|
391
|
+
</NotificationProvider>,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
396
|
+
|
|
397
|
+
// Advance past stabilization window
|
|
398
|
+
await act(async () => {
|
|
399
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Run transitions to completed
|
|
403
|
+
mockDigestData = {
|
|
404
|
+
runs: [makeRun({ runId: 'run-001', status: 'completed' })],
|
|
405
|
+
};
|
|
406
|
+
await act(async () => {
|
|
407
|
+
rerender(
|
|
408
|
+
<NotificationProvider>
|
|
409
|
+
<span>child</span>
|
|
410
|
+
</NotificationProvider>,
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(mockNotify).toHaveBeenCalledTimes(1);
|
|
415
|
+
expect(mockNotify).toHaveBeenCalledWith(
|
|
416
|
+
'Run Completed',
|
|
417
|
+
expect.stringContaining('finished successfully'),
|
|
418
|
+
'success',
|
|
419
|
+
expect.objectContaining({ href: '/runs/run-001' }),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
mockNotify.mockClear();
|
|
423
|
+
|
|
424
|
+
// Same completed state on next poll — should NOT fire again
|
|
425
|
+
mockDigestData = {
|
|
426
|
+
runs: [makeRun({ runId: 'run-001', status: 'completed' })],
|
|
427
|
+
};
|
|
428
|
+
await act(async () => {
|
|
429
|
+
rerender(
|
|
430
|
+
<NotificationProvider>
|
|
431
|
+
<span>child</span>
|
|
432
|
+
</NotificationProvider>,
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// -------------------------------------------------------------------
|
|
440
|
+
// 6. Task completion does NOT fire per-task notification (flood fix)
|
|
441
|
+
// -------------------------------------------------------------------
|
|
442
|
+
it('does not fire per-task notifications when completedTasks increases after stabilization', async () => {
|
|
443
|
+
mockDigestData = {
|
|
444
|
+
runs: [makeRun({ runId: 'run-001', completedTasks: 3, taskCount: 10 })],
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const { rerender } = await act(async () =>
|
|
448
|
+
render(
|
|
449
|
+
<NotificationProvider>
|
|
450
|
+
<span>child</span>
|
|
451
|
+
</NotificationProvider>,
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
456
|
+
|
|
457
|
+
// Advance past stabilization window
|
|
458
|
+
await act(async () => {
|
|
459
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// completedTasks goes from 3 to 5 — watermark updates silently
|
|
463
|
+
mockDigestData = {
|
|
464
|
+
runs: [makeRun({ runId: 'run-001', completedTasks: 5, taskCount: 10 })],
|
|
465
|
+
};
|
|
466
|
+
await act(async () => {
|
|
467
|
+
rerender(
|
|
468
|
+
<NotificationProvider>
|
|
469
|
+
<span>child</span>
|
|
470
|
+
</NotificationProvider>,
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// No per-task "Tasks Completed" notification should fire (flood fix).
|
|
475
|
+
// The terminal "Run Completed" notification covers this use case.
|
|
476
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// -------------------------------------------------------------------
|
|
480
|
+
// 7. Waiting state notification after stabilization
|
|
481
|
+
// -------------------------------------------------------------------
|
|
482
|
+
it('fires a persistent breakpoint notification when a run transitions to waiting after stabilization', async () => {
|
|
483
|
+
mockDigestData = {
|
|
484
|
+
runs: [makeRun({ runId: 'run-001', status: 'pending' })],
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const { rerender } = await act(async () =>
|
|
488
|
+
render(
|
|
489
|
+
<NotificationProvider>
|
|
490
|
+
<span>child</span>
|
|
491
|
+
</NotificationProvider>,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
496
|
+
|
|
497
|
+
// Advance past stabilization window
|
|
498
|
+
await act(async () => {
|
|
499
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Run transitions to waiting
|
|
503
|
+
mockDigestData = {
|
|
504
|
+
runs: [
|
|
505
|
+
makeRun({
|
|
506
|
+
runId: 'run-001',
|
|
507
|
+
status: 'waiting',
|
|
508
|
+
breakpointQuestion: 'Approve deployment?',
|
|
509
|
+
}),
|
|
510
|
+
],
|
|
511
|
+
};
|
|
512
|
+
await act(async () => {
|
|
513
|
+
rerender(
|
|
514
|
+
<NotificationProvider>
|
|
515
|
+
<span>child</span>
|
|
516
|
+
</NotificationProvider>,
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(mockNotify).toHaveBeenCalledTimes(1);
|
|
521
|
+
expect(mockNotify).toHaveBeenCalledWith(
|
|
522
|
+
expect.stringContaining('needs attention'),
|
|
523
|
+
'Approve deployment?',
|
|
524
|
+
'warning',
|
|
525
|
+
expect.objectContaining({ href: '/runs/run-001', persistent: true }),
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// -------------------------------------------------------------------
|
|
530
|
+
// 8. Breakpoint resolved notification
|
|
531
|
+
// -------------------------------------------------------------------
|
|
532
|
+
it('fires "Breakpoint Resolved" when pendingBreakpoints drops to 0 after stabilization', async () => {
|
|
533
|
+
// Seed with a run that already has a pending breakpoint
|
|
534
|
+
mockDigestData = {
|
|
535
|
+
runs: [
|
|
536
|
+
makeRun({
|
|
537
|
+
runId: 'run-001',
|
|
538
|
+
status: 'waiting',
|
|
539
|
+
pendingBreakpoints: 1,
|
|
540
|
+
}),
|
|
541
|
+
],
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const { rerender } = await act(async () =>
|
|
545
|
+
render(
|
|
546
|
+
<NotificationProvider>
|
|
547
|
+
<span>child</span>
|
|
548
|
+
</NotificationProvider>,
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
expect(mockNotify).not.toHaveBeenCalled();
|
|
553
|
+
|
|
554
|
+
// Advance past stabilization window
|
|
555
|
+
await act(async () => {
|
|
556
|
+
vi.advanceTimersByTime(STABILIZATION_WINDOW_MS + 100);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Breakpoint resolved: pendingBreakpoints drops from 1 to 0
|
|
560
|
+
mockDigestData = {
|
|
561
|
+
runs: [
|
|
562
|
+
makeRun({
|
|
563
|
+
runId: 'run-001',
|
|
564
|
+
status: 'pending',
|
|
565
|
+
pendingBreakpoints: 0,
|
|
566
|
+
}),
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
await act(async () => {
|
|
570
|
+
rerender(
|
|
571
|
+
<NotificationProvider>
|
|
572
|
+
<span>child</span>
|
|
573
|
+
</NotificationProvider>,
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(mockNotify).toHaveBeenCalledWith(
|
|
578
|
+
'Breakpoint Resolved',
|
|
579
|
+
expect.stringContaining('approved'),
|
|
580
|
+
'success',
|
|
581
|
+
expect.objectContaining({ href: '/runs/run-001' }),
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
});
|