@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,211 @@
|
|
|
1
|
+
import { render, screen, setupUser } from '@/test/test-utils';
|
|
2
|
+
import { EventItem } from '../event-item';
|
|
3
|
+
import { createMockJournalEvent } from '@/test/fixtures';
|
|
4
|
+
|
|
5
|
+
describe('EventItem', () => {
|
|
6
|
+
// -----------------------------------------------------------------------
|
|
7
|
+
// RUN_CREATED
|
|
8
|
+
// -----------------------------------------------------------------------
|
|
9
|
+
describe('RUN_CREATED event', () => {
|
|
10
|
+
it('renders the "Created" badge', () => {
|
|
11
|
+
const event = createMockJournalEvent({
|
|
12
|
+
type: 'RUN_CREATED',
|
|
13
|
+
payload: { processId: 'data-pipeline/ingest' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
render(<EventItem event={event} />);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText('Created')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('displays the processId from the payload', () => {
|
|
22
|
+
const event = createMockJournalEvent({
|
|
23
|
+
type: 'RUN_CREATED',
|
|
24
|
+
payload: { processId: 'data-pipeline/ingest' },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
render(<EventItem event={event} />);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByText('data-pipeline/ingest')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// -----------------------------------------------------------------------
|
|
34
|
+
// EFFECT_REQUESTED
|
|
35
|
+
// -----------------------------------------------------------------------
|
|
36
|
+
describe('EFFECT_REQUESTED event', () => {
|
|
37
|
+
it('renders the "Requested" badge', () => {
|
|
38
|
+
const event = createMockJournalEvent({
|
|
39
|
+
type: 'EFFECT_REQUESTED',
|
|
40
|
+
payload: { label: 'run-task-abc', kind: 'agent', effectId: 'eff-1', stepId: 'step-1', taskId: 'task-1' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
render(<EventItem event={event} />);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByText('Requested')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('displays the label from the payload', () => {
|
|
49
|
+
const event = createMockJournalEvent({
|
|
50
|
+
type: 'EFFECT_REQUESTED',
|
|
51
|
+
payload: { label: 'my-task-label', kind: 'shell', effectId: 'eff-1' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
render(<EventItem event={event} />);
|
|
55
|
+
|
|
56
|
+
expect(screen.getByText('my-task-label')).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('shows the kind badge when present', () => {
|
|
60
|
+
const event = createMockJournalEvent({
|
|
61
|
+
type: 'EFFECT_REQUESTED',
|
|
62
|
+
payload: { label: 'my-task', kind: 'agent', effectId: 'eff-1' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
render(<EventItem event={event} />);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('agent')).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('falls back to "Task" when label is empty', () => {
|
|
71
|
+
const event = createMockJournalEvent({
|
|
72
|
+
type: 'EFFECT_REQUESTED',
|
|
73
|
+
payload: { label: '', kind: 'node', effectId: 'eff-1' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
render(<EventItem event={event} />);
|
|
77
|
+
|
|
78
|
+
expect(screen.getByText('Task')).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders step and task metadata when present', () => {
|
|
82
|
+
const event = createMockJournalEvent({
|
|
83
|
+
type: 'EFFECT_REQUESTED',
|
|
84
|
+
payload: { label: 'x', kind: 'node', effectId: 'eff-1', stepId: 'step-42', taskId: 'task-99' },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
render(<EventItem event={event} />);
|
|
88
|
+
|
|
89
|
+
expect(screen.getByText(/step: step-42/)).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByText(/task: task-99/)).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// -----------------------------------------------------------------------
|
|
95
|
+
// EFFECT_RESOLVED
|
|
96
|
+
// -----------------------------------------------------------------------
|
|
97
|
+
describe('EFFECT_RESOLVED event', () => {
|
|
98
|
+
it('renders the "Resolved" badge', () => {
|
|
99
|
+
const event = createMockJournalEvent({
|
|
100
|
+
type: 'EFFECT_RESOLVED',
|
|
101
|
+
payload: { effectId: 'eff-1', status: 'ok' },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
render(<EventItem event={event} />);
|
|
105
|
+
|
|
106
|
+
expect(screen.getByText('Resolved')).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('displays label when available', () => {
|
|
110
|
+
const event = createMockJournalEvent({
|
|
111
|
+
type: 'EFFECT_RESOLVED',
|
|
112
|
+
payload: { label: 'completed-task', effectId: 'eff-1', status: 'ok' },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
render(<EventItem event={event} />);
|
|
116
|
+
|
|
117
|
+
expect(screen.getByText('completed-task')).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('displays duration when startedAt and finishedAt are present', () => {
|
|
121
|
+
const start = '2025-01-01T00:00:00.000Z';
|
|
122
|
+
const finish = '2025-01-01T00:00:03.000Z'; // 3 seconds later
|
|
123
|
+
const event = createMockJournalEvent({
|
|
124
|
+
type: 'EFFECT_RESOLVED',
|
|
125
|
+
payload: { effectId: 'eff-1', status: 'ok', startedAt: start, finishedAt: finish },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
render(<EventItem event={event} />);
|
|
129
|
+
|
|
130
|
+
expect(screen.getByText('3s')).toBeInTheDocument();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// -----------------------------------------------------------------------
|
|
135
|
+
// RUN_COMPLETED
|
|
136
|
+
// -----------------------------------------------------------------------
|
|
137
|
+
describe('RUN_COMPLETED event', () => {
|
|
138
|
+
it('renders the "Completed" badge and success message', () => {
|
|
139
|
+
const event = createMockJournalEvent({
|
|
140
|
+
type: 'RUN_COMPLETED',
|
|
141
|
+
payload: {},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
render(<EventItem event={event} />);
|
|
145
|
+
|
|
146
|
+
expect(screen.getByText('Completed')).toBeInTheDocument();
|
|
147
|
+
expect(screen.getByText('Run finished successfully')).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// -----------------------------------------------------------------------
|
|
152
|
+
// RUN_FAILED
|
|
153
|
+
// -----------------------------------------------------------------------
|
|
154
|
+
describe('RUN_FAILED event', () => {
|
|
155
|
+
it('renders the "Failed" badge and failure message', () => {
|
|
156
|
+
const event = createMockJournalEvent({
|
|
157
|
+
type: 'RUN_FAILED',
|
|
158
|
+
payload: {},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
render(<EventItem event={event} />);
|
|
162
|
+
|
|
163
|
+
expect(screen.getByText('Failed')).toBeInTheDocument();
|
|
164
|
+
expect(screen.getByText('Run failed')).toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// -----------------------------------------------------------------------
|
|
169
|
+
// Timestamp display
|
|
170
|
+
// -----------------------------------------------------------------------
|
|
171
|
+
it('displays a relative timestamp', () => {
|
|
172
|
+
// Create an event with a recent timestamp
|
|
173
|
+
const event = createMockJournalEvent({
|
|
174
|
+
ts: new Date(Date.now() - 30000).toISOString(), // 30 seconds ago
|
|
175
|
+
type: 'RUN_CREATED',
|
|
176
|
+
payload: { processId: 'p1' },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
render(<EventItem event={event} />);
|
|
180
|
+
|
|
181
|
+
// formatRelativeTime for 30s ago returns "30s ago"
|
|
182
|
+
expect(screen.getByText('30s ago')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
// Click handler
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
it('calls onClick when the item is clicked', async () => {
|
|
189
|
+
const user = setupUser();
|
|
190
|
+
const handleClick = vi.fn();
|
|
191
|
+
const event = createMockJournalEvent({
|
|
192
|
+
type: 'RUN_CREATED',
|
|
193
|
+
payload: { processId: 'p1' },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
render(<EventItem event={event} onClick={handleClick} />);
|
|
197
|
+
|
|
198
|
+
await user.click(screen.getByRole('button'));
|
|
199
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('renders without crashing when onClick is not provided', () => {
|
|
203
|
+
const event = createMockJournalEvent({
|
|
204
|
+
type: 'RUN_CREATED',
|
|
205
|
+
payload: { processId: 'p1' },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const { container } = render(<EventItem event={event} />);
|
|
209
|
+
expect(container.querySelector('button')).toBeInTheDocument();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { render, screen, setupUser } from '@/test/test-utils';
|
|
2
|
+
import { EventStream } from '../event-stream';
|
|
3
|
+
import { createMockJournalEvent } from '@/test/fixtures';
|
|
4
|
+
import type { JournalEvent } from '@/types';
|
|
5
|
+
|
|
6
|
+
describe('EventStream', () => {
|
|
7
|
+
function makeEvents(count: number, type: JournalEvent['type'] = 'EFFECT_REQUESTED'): JournalEvent[] {
|
|
8
|
+
return Array.from({ length: count }, (_, i) =>
|
|
9
|
+
createMockJournalEvent({
|
|
10
|
+
seq: i,
|
|
11
|
+
id: `evt-${i}`,
|
|
12
|
+
ts: new Date(Date.now() - (count - i) * 1000).toISOString(),
|
|
13
|
+
type,
|
|
14
|
+
payload: { effectId: `eff-${i}`, label: `task-${i}`, kind: 'node' },
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// -----------------------------------------------------------------------
|
|
20
|
+
// Empty state
|
|
21
|
+
// -----------------------------------------------------------------------
|
|
22
|
+
it('shows "No events yet" when the events array is empty', () => {
|
|
23
|
+
render(<EventStream events={[]} />);
|
|
24
|
+
|
|
25
|
+
expect(screen.getByText('No events yet')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// -----------------------------------------------------------------------
|
|
29
|
+
// Renders event list
|
|
30
|
+
// -----------------------------------------------------------------------
|
|
31
|
+
it('renders a list of events', () => {
|
|
32
|
+
const events = [
|
|
33
|
+
createMockJournalEvent({
|
|
34
|
+
seq: 0,
|
|
35
|
+
type: 'RUN_CREATED',
|
|
36
|
+
payload: { processId: 'pipeline/ingest' },
|
|
37
|
+
}),
|
|
38
|
+
createMockJournalEvent({
|
|
39
|
+
seq: 1,
|
|
40
|
+
type: 'EFFECT_REQUESTED',
|
|
41
|
+
payload: { label: 'task-alpha', kind: 'agent', effectId: 'e1' },
|
|
42
|
+
}),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
render(<EventStream events={events} />);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByText('task-alpha')).toBeInTheDocument();
|
|
48
|
+
expect(screen.getByText('pipeline/ingest')).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// -----------------------------------------------------------------------
|
|
52
|
+
// Event count display
|
|
53
|
+
// -----------------------------------------------------------------------
|
|
54
|
+
it('shows the total event count', () => {
|
|
55
|
+
const events = makeEvents(5);
|
|
56
|
+
|
|
57
|
+
render(<EventStream events={events} />);
|
|
58
|
+
|
|
59
|
+
expect(screen.getByText('5 events')).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
// Filter buttons
|
|
64
|
+
// -----------------------------------------------------------------------
|
|
65
|
+
it('renders filter buttons: All, Tasks, Results, Errors', () => {
|
|
66
|
+
render(<EventStream events={[]} />);
|
|
67
|
+
|
|
68
|
+
expect(screen.getByText('All')).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByText('Tasks')).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText('Results')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('Errors')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('filters events by type when a filter button is clicked', async () => {
|
|
75
|
+
const user = setupUser();
|
|
76
|
+
const events = [
|
|
77
|
+
createMockJournalEvent({
|
|
78
|
+
seq: 0,
|
|
79
|
+
type: 'EFFECT_REQUESTED',
|
|
80
|
+
payload: { label: 'requested-task', kind: 'node', effectId: 'e1' },
|
|
81
|
+
}),
|
|
82
|
+
createMockJournalEvent({
|
|
83
|
+
seq: 1,
|
|
84
|
+
type: 'EFFECT_RESOLVED',
|
|
85
|
+
payload: { label: 'resolved-task', effectId: 'e2', status: 'ok' },
|
|
86
|
+
}),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
render(<EventStream events={events} />);
|
|
90
|
+
|
|
91
|
+
// Both visible initially
|
|
92
|
+
expect(screen.getByText('requested-task')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText('resolved-task')).toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
// Click "Tasks" filter (EFFECT_REQUESTED only)
|
|
96
|
+
await user.click(screen.getByText('Tasks'));
|
|
97
|
+
|
|
98
|
+
expect(screen.getByText('requested-task')).toBeInTheDocument();
|
|
99
|
+
expect(screen.queryByText('resolved-task')).not.toBeInTheDocument();
|
|
100
|
+
|
|
101
|
+
// Click "Results" filter (EFFECT_RESOLVED only)
|
|
102
|
+
await user.click(screen.getByText('Results'));
|
|
103
|
+
|
|
104
|
+
expect(screen.queryByText('requested-task')).not.toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText('resolved-task')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// -----------------------------------------------------------------------
|
|
109
|
+
// Stats bar
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
it('displays summary stats when events are present', () => {
|
|
112
|
+
const events = [
|
|
113
|
+
createMockJournalEvent({ seq: 0, type: 'EFFECT_REQUESTED', payload: { effectId: 'e1', label: 'l1', kind: 'node' } }),
|
|
114
|
+
createMockJournalEvent({ seq: 1, type: 'EFFECT_RESOLVED', payload: { effectId: 'e2', status: 'ok' } }),
|
|
115
|
+
createMockJournalEvent({ seq: 2, type: 'RUN_FAILED', payload: {} }),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
render(<EventStream events={events} />);
|
|
119
|
+
|
|
120
|
+
// Check stats are rendered
|
|
121
|
+
expect(screen.getByText('Tasks:')).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByText('Completed:')).toBeInTheDocument();
|
|
123
|
+
expect(screen.getByText('Errors:')).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('does not display summary stats bar when no events', () => {
|
|
127
|
+
render(<EventStream events={[]} />);
|
|
128
|
+
|
|
129
|
+
expect(screen.queryByText('Tasks:')).not.toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// -----------------------------------------------------------------------
|
|
133
|
+
// Event click handler
|
|
134
|
+
// -----------------------------------------------------------------------
|
|
135
|
+
it('calls onEventClick when an event is clicked', async () => {
|
|
136
|
+
const user = setupUser();
|
|
137
|
+
const handleClick = vi.fn();
|
|
138
|
+
const events = [
|
|
139
|
+
createMockJournalEvent({
|
|
140
|
+
seq: 0,
|
|
141
|
+
type: 'RUN_COMPLETED',
|
|
142
|
+
payload: {},
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
render(<EventStream events={events} onEventClick={handleClick} />);
|
|
147
|
+
|
|
148
|
+
await user.click(screen.getByText('Run finished successfully'));
|
|
149
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
150
|
+
expect(handleClick).toHaveBeenCalledWith(events[0]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// -----------------------------------------------------------------------
|
|
154
|
+
// "Show more" pagination
|
|
155
|
+
// -----------------------------------------------------------------------
|
|
156
|
+
it('shows a "Show more" button when there are more than 20 events', () => {
|
|
157
|
+
// Create 25 events (page size is 20)
|
|
158
|
+
const events = makeEvents(25);
|
|
159
|
+
|
|
160
|
+
render(<EventStream events={events} />);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByText(/Show .* more/)).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('does not show "Show more" when there are 20 or fewer events', () => {
|
|
166
|
+
const events = makeEvents(10);
|
|
167
|
+
|
|
168
|
+
render(<EventStream events={events} />);
|
|
169
|
+
|
|
170
|
+
expect(screen.queryByText(/Show .* more/)).not.toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('loads more events when "Show more" is clicked', async () => {
|
|
174
|
+
const user = setupUser();
|
|
175
|
+
const events = makeEvents(25);
|
|
176
|
+
|
|
177
|
+
render(<EventStream events={events} />);
|
|
178
|
+
|
|
179
|
+
const showMoreBtn = screen.getByText(/Show .* more/);
|
|
180
|
+
await user.click(showMoreBtn);
|
|
181
|
+
|
|
182
|
+
// After loading more, the "Show more" button should be gone (all 25 visible)
|
|
183
|
+
expect(screen.queryByText(/Show .* more/)).not.toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// -----------------------------------------------------------------------
|
|
187
|
+
// Event grouping (3+ consecutive same-type events are collapsed)
|
|
188
|
+
// -----------------------------------------------------------------------
|
|
189
|
+
it('groups 3+ consecutive same-type events into a collapsed row', () => {
|
|
190
|
+
// 4 consecutive EFFECT_REQUESTED events
|
|
191
|
+
const events = makeEvents(4, 'EFFECT_REQUESTED');
|
|
192
|
+
|
|
193
|
+
render(<EventStream events={events} />);
|
|
194
|
+
|
|
195
|
+
// The group summary should show "4x" and "Requested"
|
|
196
|
+
expect(screen.getByText('4x')).toBeInTheDocument();
|
|
197
|
+
expect(screen.getByText('Requested')).toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('expands a collapsed group when clicked', async () => {
|
|
201
|
+
const user = setupUser();
|
|
202
|
+
const events = makeEvents(4, 'EFFECT_REQUESTED');
|
|
203
|
+
|
|
204
|
+
render(<EventStream events={events} />);
|
|
205
|
+
|
|
206
|
+
// Initially, individual labels should not be visible (collapsed)
|
|
207
|
+
expect(screen.queryByText('task-0')).not.toBeInTheDocument();
|
|
208
|
+
|
|
209
|
+
// Click the group header to expand
|
|
210
|
+
await user.click(screen.getByText('4x'));
|
|
211
|
+
|
|
212
|
+
// After expansion, individual items should be visible
|
|
213
|
+
expect(screen.getByText('task-0')).toBeInTheDocument();
|
|
214
|
+
expect(screen.getByText('task-1')).toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// "Event Stream" heading
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
it('renders the "Event Stream" heading', () => {
|
|
221
|
+
render(<EventStream events={[]} />);
|
|
222
|
+
|
|
223
|
+
expect(screen.getByText('Event Stream')).toBeInTheDocument();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { cn } from "@/lib/cn";
|
|
3
|
+
import { Badge } from "@/components/ui/badge";
|
|
4
|
+
import { formatRelativeTime, formatDuration } from "@/lib/utils";
|
|
5
|
+
import { TruncatedId } from "@/components/shared/truncated-id";
|
|
6
|
+
import type { JournalEvent } from "@/types";
|
|
7
|
+
|
|
8
|
+
const typeConfig: Record<string, { variant: "success" | "error" | "info" | "warning" | "default"; label: string }> = {
|
|
9
|
+
RUN_CREATED: { variant: "info", label: "Created" },
|
|
10
|
+
EFFECT_REQUESTED: { variant: "default", label: "Requested" },
|
|
11
|
+
EFFECT_RESOLVED: { variant: "success", label: "Resolved" },
|
|
12
|
+
RUN_COMPLETED: { variant: "success", label: "Completed" },
|
|
13
|
+
RUN_FAILED: { variant: "error", label: "Failed" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const kindColors: Record<string, string> = {
|
|
17
|
+
agent: "bg-primary/15 text-primary",
|
|
18
|
+
shell: "bg-secondary/15 text-secondary",
|
|
19
|
+
breakpoint: "bg-warning/15 text-warning",
|
|
20
|
+
node: "bg-info/15 text-info",
|
|
21
|
+
skill: "bg-success/15 text-success",
|
|
22
|
+
sleep: "bg-foreground-muted/15 text-foreground-muted",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface EventItemProps {
|
|
26
|
+
event: JournalEvent;
|
|
27
|
+
onClick?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EventItem = memo(function EventItem({ event, onClick }: EventItemProps) {
|
|
31
|
+
const config = typeConfig[event.type] || typeConfig.EFFECT_REQUESTED;
|
|
32
|
+
const payload = event.payload as Record<string, unknown>;
|
|
33
|
+
const label = (payload.label as string) || "";
|
|
34
|
+
const effectId = (payload.effectId as string) || "";
|
|
35
|
+
const kind = (payload.kind as string) || "";
|
|
36
|
+
const status = (payload.status as string) || "";
|
|
37
|
+
const processId = (payload.processId as string) || "";
|
|
38
|
+
const stepId = (payload.stepId as string) || "";
|
|
39
|
+
const taskId = (payload.taskId as string) || "";
|
|
40
|
+
|
|
41
|
+
// Compute duration from startedAt/finishedAt in resolved payloads
|
|
42
|
+
const startedAt = payload.startedAt as string | undefined;
|
|
43
|
+
const finishedAt = payload.finishedAt as string | undefined;
|
|
44
|
+
const resolvedDuration =
|
|
45
|
+
startedAt && finishedAt
|
|
46
|
+
? new Date(finishedAt).getTime() - new Date(startedAt).getTime()
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
data-testid={`event-item-${event.seq}`}
|
|
52
|
+
data-event-type={event.type}
|
|
53
|
+
onClick={onClick}
|
|
54
|
+
className="w-full text-left px-3 py-2 hover:bg-primary-muted/40 rounded transition-all duration-150"
|
|
55
|
+
>
|
|
56
|
+
{/* Primary line */}
|
|
57
|
+
<div className="flex items-center gap-2">
|
|
58
|
+
<span className="font-mono text-xs leading-tight text-secondary shrink-0 tabular-nums w-12 text-right">
|
|
59
|
+
{formatRelativeTime(event.ts)}
|
|
60
|
+
</span>
|
|
61
|
+
<Badge variant={config.variant} className="text-xs leading-tight shrink-0">
|
|
62
|
+
{config.label}
|
|
63
|
+
</Badge>
|
|
64
|
+
|
|
65
|
+
{event.type === "EFFECT_REQUESTED" && (
|
|
66
|
+
<>
|
|
67
|
+
<span className="text-xs text-foreground truncate font-medium">{label || "Task"}</span>
|
|
68
|
+
{kind && (
|
|
69
|
+
<span className={cn("rounded px-1.5 py-0.5 text-xs leading-tight font-medium shrink-0", kindColors[kind] || "bg-muted text-foreground-muted")}>
|
|
70
|
+
{kind}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{event.type === "EFFECT_RESOLVED" && (
|
|
77
|
+
<>
|
|
78
|
+
{label && <span className="text-xs text-foreground truncate font-medium">{label}</span>}
|
|
79
|
+
{!label && effectId && <TruncatedId id={effectId} chars={4} className="text-foreground-secondary" />}
|
|
80
|
+
<span className={cn(
|
|
81
|
+
"inline-block h-2 w-2 rounded-full shrink-0",
|
|
82
|
+
status === "ok" ? "bg-success shadow-[0_0_4px_var(--success)]" : status === "error" ? "bg-error shadow-[0_0_4px_var(--error)]" : "bg-foreground-muted"
|
|
83
|
+
)} />
|
|
84
|
+
{resolvedDuration != null && resolvedDuration > 0 && (
|
|
85
|
+
<span className="text-xs leading-tight font-mono text-foreground-muted shrink-0">{formatDuration(resolvedDuration)}</span>
|
|
86
|
+
)}
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{event.type === "RUN_CREATED" && processId && (
|
|
91
|
+
<span className="text-xs text-foreground-secondary truncate">{processId}</span>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{event.type === "RUN_COMPLETED" && (
|
|
95
|
+
<span className="text-xs text-success truncate">Run finished successfully</span>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{event.type === "RUN_FAILED" && (
|
|
99
|
+
<span className="text-xs text-error truncate">Run failed</span>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{/* Fallback for unknown types */}
|
|
103
|
+
{!["EFFECT_REQUESTED", "EFFECT_RESOLVED", "RUN_CREATED", "RUN_COMPLETED", "RUN_FAILED"].includes(event.type) && (
|
|
104
|
+
label ? (
|
|
105
|
+
<span className="text-xs text-foreground-secondary truncate">{label}</span>
|
|
106
|
+
) : effectId ? (
|
|
107
|
+
<TruncatedId id={effectId} chars={4} className="text-foreground-secondary" />
|
|
108
|
+
) : null
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Secondary metadata line */}
|
|
113
|
+
{(stepId || taskId) && (
|
|
114
|
+
<div className="flex items-center gap-2 mt-0.5 ml-14 text-xs leading-tight text-foreground-muted">
|
|
115
|
+
{stepId && <span className="font-mono truncate">step: {stepId}</span>}
|
|
116
|
+
{taskId && <span className="font-mono truncate">task: {taskId}</span>}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</button>
|
|
120
|
+
);
|
|
121
|
+
});
|