@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.
Files changed (205) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +490 -0
  3. package/next.config.mjs +25 -0
  4. package/package.json +104 -0
  5. package/postcss.config.mjs +8 -0
  6. package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
  7. package/src/app/actions/approve-breakpoint.ts +145 -0
  8. package/src/app/api/config/route.ts +137 -0
  9. package/src/app/api/digest/route.ts +45 -0
  10. package/src/app/api/runs/[runId]/events/route.ts +56 -0
  11. package/src/app/api/runs/[runId]/route.ts +84 -0
  12. package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
  13. package/src/app/api/runs/route.ts +48 -0
  14. package/src/app/api/stream/route.ts +136 -0
  15. package/src/app/api/test/route.ts +1 -0
  16. package/src/app/api/version/route.ts +57 -0
  17. package/src/app/globals.css +555 -0
  18. package/src/app/icon.svg +20 -0
  19. package/src/app/layout.tsx +39 -0
  20. package/src/app/not-found.tsx +16 -0
  21. package/src/app/page.tsx +120 -0
  22. package/src/app/runs/[runId]/page.tsx +279 -0
  23. package/src/cli.ts +271 -0
  24. package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
  25. package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
  26. package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
  27. package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
  28. package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
  29. package/src/components/breakpoint/file-preview.tsx +215 -0
  30. package/src/components/dashboard/.gitkeep +0 -0
  31. package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
  32. package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
  33. package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
  34. package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
  35. package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
  36. package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
  37. package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
  38. package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
  39. package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
  40. package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
  41. package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
  42. package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
  43. package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
  44. package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
  45. package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
  46. package/src/components/dashboard/breakpoint-banner.tsx +301 -0
  47. package/src/components/dashboard/catch-up-banner.tsx +88 -0
  48. package/src/components/dashboard/executive-summary-banner.tsx +174 -0
  49. package/src/components/dashboard/global-search.tsx +323 -0
  50. package/src/components/dashboard/kpi-grid.tsx +140 -0
  51. package/src/components/dashboard/pagination-controls.tsx +100 -0
  52. package/src/components/dashboard/project-accordion.tsx +72 -0
  53. package/src/components/dashboard/project-health-card.tsx +536 -0
  54. package/src/components/dashboard/project-list-view.tsx +246 -0
  55. package/src/components/dashboard/project-search-input.tsx +41 -0
  56. package/src/components/dashboard/project-section-header.tsx +73 -0
  57. package/src/components/dashboard/project-section.tsx +89 -0
  58. package/src/components/dashboard/run-card.tsx +218 -0
  59. package/src/components/dashboard/run-filter-bar.tsx +100 -0
  60. package/src/components/dashboard/run-list.tsx +77 -0
  61. package/src/components/dashboard/search-filter.tsx +69 -0
  62. package/src/components/dashboard/virtualized-run-list.tsx +130 -0
  63. package/src/components/details/.gitkeep +0 -0
  64. package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
  65. package/src/components/details/__tests__/json-tree.test.tsx +347 -0
  66. package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
  67. package/src/components/details/__tests__/task-detail.test.tsx +212 -0
  68. package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
  69. package/src/components/details/agent-panel.tsx +234 -0
  70. package/src/components/details/json-tree/categorize.ts +131 -0
  71. package/src/components/details/json-tree/index.tsx +120 -0
  72. package/src/components/details/json-tree/json-node.tsx +223 -0
  73. package/src/components/details/json-tree/smart-summary.tsx +596 -0
  74. package/src/components/details/json-tree/tree-controls.tsx +47 -0
  75. package/src/components/details/json-tree.tsx +9 -0
  76. package/src/components/details/log-viewer.tsx +140 -0
  77. package/src/components/details/task-detail.tsx +114 -0
  78. package/src/components/details/timing-panel.tsx +247 -0
  79. package/src/components/events/.gitkeep +0 -0
  80. package/src/components/events/__tests__/event-item.test.tsx +211 -0
  81. package/src/components/events/__tests__/event-stream.test.tsx +225 -0
  82. package/src/components/events/event-item.tsx +121 -0
  83. package/src/components/events/event-stream.tsx +260 -0
  84. package/src/components/notifications/.gitkeep +0 -0
  85. package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
  86. package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
  87. package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
  88. package/src/components/notifications/notification-panel.tsx +124 -0
  89. package/src/components/notifications/notification-provider.tsx +175 -0
  90. package/src/components/notifications/toast-stack.tsx +75 -0
  91. package/src/components/pipeline/.gitkeep +0 -0
  92. package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
  93. package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
  94. package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
  95. package/src/components/pipeline/parallel-group.tsx +39 -0
  96. package/src/components/pipeline/pipeline-view.tsx +197 -0
  97. package/src/components/pipeline/step-card.tsx +166 -0
  98. package/src/components/providers/event-stream-provider.tsx +29 -0
  99. package/src/components/providers.tsx +24 -0
  100. package/src/components/shared/.gitkeep +0 -0
  101. package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
  102. package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
  103. package/src/components/shared/__tests__/kbd.test.tsx +45 -0
  104. package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
  105. package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
  106. package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
  107. package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
  108. package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
  109. package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
  110. package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
  111. package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
  112. package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
  113. package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
  114. package/src/components/shared/app-footer.tsx +80 -0
  115. package/src/components/shared/app-header.tsx +160 -0
  116. package/src/components/shared/empty-state.tsx +18 -0
  117. package/src/components/shared/error-boundary.tsx +81 -0
  118. package/src/components/shared/friendly-id.tsx +48 -0
  119. package/src/components/shared/kbd.tsx +15 -0
  120. package/src/components/shared/kind-badge.tsx +51 -0
  121. package/src/components/shared/metrics-row.tsx +106 -0
  122. package/src/components/shared/outcome-banner.tsx +56 -0
  123. package/src/components/shared/progress-bar.tsx +42 -0
  124. package/src/components/shared/session-pill.tsx +69 -0
  125. package/src/components/shared/settings-modal.tsx +509 -0
  126. package/src/components/shared/shortcuts-help.tsx +113 -0
  127. package/src/components/shared/status-badge.tsx +110 -0
  128. package/src/components/shared/theme-provider.tsx +46 -0
  129. package/src/components/shared/truncated-id.tsx +51 -0
  130. package/src/components/ui/.gitkeep +0 -0
  131. package/src/components/ui/__tests__/accordion.test.tsx +96 -0
  132. package/src/components/ui/__tests__/badge.test.tsx +69 -0
  133. package/src/components/ui/__tests__/button.test.tsx +113 -0
  134. package/src/components/ui/__tests__/tabs.test.tsx +75 -0
  135. package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
  136. package/src/components/ui/accordion.tsx +61 -0
  137. package/src/components/ui/badge.tsx +25 -0
  138. package/src/components/ui/button.tsx +40 -0
  139. package/src/components/ui/card.tsx +21 -0
  140. package/src/components/ui/scroll-area.tsx +35 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/tabs.tsx +64 -0
  143. package/src/components/ui/tooltip.tsx +37 -0
  144. package/src/hooks/.gitkeep +0 -0
  145. package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
  146. package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
  147. package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
  148. package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
  149. package/src/hooks/__tests__/use-notifications.test.ts +230 -0
  150. package/src/hooks/__tests__/use-polling.test.ts +274 -0
  151. package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
  152. package/src/hooks/__tests__/use-projects.test.ts +248 -0
  153. package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
  154. package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
  155. package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
  156. package/src/hooks/use-animated-number.ts +87 -0
  157. package/src/hooks/use-batched-updates.ts +150 -0
  158. package/src/hooks/use-event-stream.ts +150 -0
  159. package/src/hooks/use-keyboard.ts +45 -0
  160. package/src/hooks/use-notifications.ts +82 -0
  161. package/src/hooks/use-persisted-state.ts +60 -0
  162. package/src/hooks/use-polling.ts +60 -0
  163. package/src/hooks/use-project-runs.ts +51 -0
  164. package/src/hooks/use-projects.ts +26 -0
  165. package/src/hooks/use-run-dashboard.ts +207 -0
  166. package/src/hooks/use-run-detail.ts +77 -0
  167. package/src/hooks/use-smart-polling.ts +144 -0
  168. package/src/lib/.gitkeep +0 -0
  169. package/src/lib/__tests__/cn.test.ts +69 -0
  170. package/src/lib/__tests__/config-loader.test.ts +210 -0
  171. package/src/lib/__tests__/config.test.ts +561 -0
  172. package/src/lib/__tests__/error-handler.test.ts +143 -0
  173. package/src/lib/__tests__/fetcher.test.ts +517 -0
  174. package/src/lib/__tests__/global-registry.test.ts +214 -0
  175. package/src/lib/__tests__/parser.test.ts +1532 -0
  176. package/src/lib/__tests__/path-resolver.test.ts +112 -0
  177. package/src/lib/__tests__/run-cache.test.ts +591 -0
  178. package/src/lib/__tests__/server-init.test.ts +512 -0
  179. package/src/lib/__tests__/source-discovery.test.ts +246 -0
  180. package/src/lib/__tests__/utils.test.ts +160 -0
  181. package/src/lib/__tests__/watcher.test.ts +227 -0
  182. package/src/lib/cn.ts +6 -0
  183. package/src/lib/config-loader.ts +195 -0
  184. package/src/lib/config.ts +20 -0
  185. package/src/lib/error-handler.ts +76 -0
  186. package/src/lib/fetcher.ts +394 -0
  187. package/src/lib/global-registry.ts +117 -0
  188. package/src/lib/parser.ts +794 -0
  189. package/src/lib/path-resolver.ts +16 -0
  190. package/src/lib/run-cache.ts +404 -0
  191. package/src/lib/server-init.ts +226 -0
  192. package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
  193. package/src/lib/services/run-query-service.ts +286 -0
  194. package/src/lib/source-discovery.ts +216 -0
  195. package/src/lib/utils.ts +103 -0
  196. package/src/lib/watcher.ts +265 -0
  197. package/src/test/fixtures.ts +269 -0
  198. package/src/test/mocks/handlers.ts +110 -0
  199. package/src/test/mocks/server.ts +17 -0
  200. package/src/test/setup.ts +200 -0
  201. package/src/test/test-utils.tsx +36 -0
  202. package/src/types/.gitkeep +0 -0
  203. package/src/types/breakpoint.ts +17 -0
  204. package/src/types/index.ts +214 -0
  205. package/tsconfig.json +50 -0
@@ -0,0 +1,174 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { ProjectListView } from '../project-list-view';
4
+ import { createMockProjectSummary } from '@/test/fixtures';
5
+ import type { ProjectSummary } from '@/types';
6
+
7
+ // Mock ProjectHealthCard to keep tests focused on ProjectListView logic
8
+ vi.mock('../project-health-card', () => ({
9
+ ProjectHealthCard: ({ project }: { project: ProjectSummary }) => (
10
+ <div data-testid={`project-card-${project.projectName}`}>{project.projectName}</div>
11
+ ),
12
+ }));
13
+
14
+ describe('ProjectListView', () => {
15
+ const defaultProps = {
16
+ loading: false,
17
+ error: undefined,
18
+ filteredProjects: [] as ProjectSummary[],
19
+ activeProjects: [] as ProjectSummary[],
20
+ historyProjects: [] as ProjectSummary[],
21
+ statusFilter: 'all' as const,
22
+ sortMode: 'status' as const,
23
+ cardStatusFilter: 'all' as const,
24
+ historyCollapsed: false,
25
+ onHistoryCollapsedChange: vi.fn(),
26
+ onHideProject: vi.fn(),
27
+ };
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ });
32
+
33
+ it('renders loading skeleton when loading', () => {
34
+ const { container } = render(
35
+ <ProjectListView {...defaultProps} loading={true} />
36
+ );
37
+ const pulsingElements = container.querySelectorAll('.animate-pulse');
38
+ expect(pulsingElements.length).toBeGreaterThan(0);
39
+ });
40
+
41
+ it('renders error banner when error is set', () => {
42
+ render(
43
+ <ProjectListView {...defaultProps} error="Server error" />
44
+ );
45
+ expect(screen.getByTestId('error-banner')).toHaveTextContent('Failed to load projects: Server error');
46
+ });
47
+
48
+ it('renders empty state when no projects match filter', () => {
49
+ render(
50
+ <ProjectListView {...defaultProps} filteredProjects={[]} />
51
+ );
52
+ expect(screen.getByTestId('empty-state')).toBeInTheDocument();
53
+ expect(screen.getByText('No projects found')).toBeInTheDocument();
54
+ });
55
+
56
+ it('renders idle empty state when no active and no history', () => {
57
+ const projects = [createMockProjectSummary()];
58
+ render(
59
+ <ProjectListView
60
+ {...defaultProps}
61
+ filteredProjects={projects}
62
+ activeProjects={[]}
63
+ historyProjects={[]}
64
+ />
65
+ );
66
+ expect(screen.getByTestId('idle-empty-state')).toBeInTheDocument();
67
+ });
68
+
69
+ it('renders idle-with-history banner when no active but has history', () => {
70
+ const projects = [createMockProjectSummary()];
71
+ render(
72
+ <ProjectListView
73
+ {...defaultProps}
74
+ filteredProjects={projects}
75
+ activeProjects={[]}
76
+ historyProjects={projects}
77
+ statusFilter="all"
78
+ />
79
+ );
80
+ expect(screen.getByTestId('idle-with-history-banner')).toBeInTheDocument();
81
+ });
82
+
83
+ it('renders active runs section with project cards', () => {
84
+ const projects = [
85
+ createMockProjectSummary({ projectName: 'alpha' }),
86
+ createMockProjectSummary({ projectName: 'beta' }),
87
+ ];
88
+ render(
89
+ <ProjectListView
90
+ {...defaultProps}
91
+ filteredProjects={projects}
92
+ activeProjects={projects}
93
+ statusFilter="all"
94
+ />
95
+ );
96
+ expect(screen.getByTestId('active-runs-section')).toBeInTheDocument();
97
+ expect(screen.getByTestId('project-card-alpha')).toBeInTheDocument();
98
+ expect(screen.getByTestId('project-card-beta')).toBeInTheDocument();
99
+ });
100
+
101
+ it('renders filtered grid for completed filter', () => {
102
+ const projects = [createMockProjectSummary({ projectName: 'gamma' })];
103
+ render(
104
+ <ProjectListView
105
+ {...defaultProps}
106
+ filteredProjects={projects}
107
+ statusFilter="completed"
108
+ />
109
+ );
110
+ expect(screen.getByTestId('project-grid-filtered')).toBeInTheDocument();
111
+ expect(screen.getByTestId('project-card-gamma')).toBeInTheDocument();
112
+ });
113
+
114
+ it('renders history section when historyProjects exist', () => {
115
+ const activeP = [createMockProjectSummary({ projectName: 'active1' })];
116
+ const historyP = [createMockProjectSummary({ projectName: 'history1' })];
117
+ render(
118
+ <ProjectListView
119
+ {...defaultProps}
120
+ filteredProjects={[...activeP, ...historyP]}
121
+ activeProjects={activeP}
122
+ historyProjects={historyP}
123
+ statusFilter="all"
124
+ historyCollapsed={false}
125
+ />
126
+ );
127
+ expect(screen.getByTestId('recent-history-section')).toBeInTheDocument();
128
+ expect(screen.getByTestId('project-grid-history')).toBeInTheDocument();
129
+ expect(screen.getByTestId('project-card-history1')).toBeInTheDocument();
130
+ });
131
+
132
+ it('hides history grid when historyCollapsed is true', () => {
133
+ const historyP = [createMockProjectSummary({ projectName: 'history1' })];
134
+ render(
135
+ <ProjectListView
136
+ {...defaultProps}
137
+ filteredProjects={historyP}
138
+ historyProjects={historyP}
139
+ statusFilter="all"
140
+ historyCollapsed={true}
141
+ />
142
+ );
143
+ expect(screen.getByTestId('recent-history-section')).toBeInTheDocument();
144
+ expect(screen.queryByTestId('project-grid-history')).not.toBeInTheDocument();
145
+ });
146
+
147
+ it('shows "In Progress" header in status sort mode', () => {
148
+ const projects = [createMockProjectSummary({ projectName: 'proj1' })];
149
+ render(
150
+ <ProjectListView
151
+ {...defaultProps}
152
+ filteredProjects={projects}
153
+ activeProjects={projects}
154
+ sortMode="status"
155
+ statusFilter="all"
156
+ />
157
+ );
158
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
159
+ });
160
+
161
+ it('shows "Recent Activity" header in activity sort mode', () => {
162
+ const projects = [createMockProjectSummary({ projectName: 'proj1' })];
163
+ render(
164
+ <ProjectListView
165
+ {...defaultProps}
166
+ filteredProjects={projects}
167
+ activeProjects={projects}
168
+ sortMode="activity"
169
+ statusFilter="all"
170
+ />
171
+ );
172
+ expect(screen.getByText('Recent Activity')).toBeInTheDocument();
173
+ });
174
+ });
@@ -0,0 +1,110 @@
1
+ import { render, screen, act } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { ProjectSearchInput } from '../project-search-input';
4
+ import userEvent from '@testing-library/user-event';
5
+
6
+ describe('ProjectSearchInput', () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers({ shouldAdvanceTime: true });
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it('renders with default placeholder', () => {
16
+ render(<ProjectSearchInput onSearch={vi.fn()} />);
17
+ expect(screen.getByPlaceholderText('Filter runs...')).toBeInTheDocument();
18
+ });
19
+
20
+ it('renders with a custom placeholder', () => {
21
+ render(<ProjectSearchInput onSearch={vi.fn()} placeholder="Search here..." />);
22
+ expect(screen.getByPlaceholderText('Search here...')).toBeInTheDocument();
23
+ });
24
+
25
+ it('calls onSearch after debounce delay when typing', async () => {
26
+ const onSearch = vi.fn();
27
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
28
+ render(<ProjectSearchInput onSearch={onSearch} debounceMs={300} />);
29
+
30
+ const input = screen.getByPlaceholderText('Filter runs...');
31
+ await user.type(input, 'hello');
32
+
33
+ // Should not have called yet with "hello" (debounced)
34
+ // But will have been called with intermediate values as timers advance
35
+ // After all timers settle, it should have the final value
36
+ act(() => {
37
+ vi.advanceTimersByTime(300);
38
+ });
39
+
40
+ expect(onSearch).toHaveBeenLastCalledWith('hello');
41
+ });
42
+
43
+ it('debounces rapid typing', async () => {
44
+ const onSearch = vi.fn();
45
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
46
+ render(<ProjectSearchInput onSearch={onSearch} debounceMs={500} />);
47
+
48
+ const input = screen.getByPlaceholderText('Filter runs...');
49
+ await user.type(input, 'abc');
50
+
51
+ // Clear all calls so far
52
+ onSearch.mockClear();
53
+
54
+ // Advance to trigger the final debounced call
55
+ act(() => {
56
+ vi.advanceTimersByTime(500);
57
+ });
58
+
59
+ // Should have called with the final value
60
+ expect(onSearch).toHaveBeenCalledWith('abc');
61
+ });
62
+
63
+ it('calls onSearch with empty string initially after debounce', () => {
64
+ const onSearch = vi.fn();
65
+ render(<ProjectSearchInput onSearch={onSearch} debounceMs={300} />);
66
+
67
+ // Initial render triggers useEffect with empty value
68
+ act(() => {
69
+ vi.advanceTimersByTime(300);
70
+ });
71
+
72
+ expect(onSearch).toHaveBeenCalledWith('');
73
+ });
74
+
75
+ it('updates the input value as the user types', async () => {
76
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
77
+ render(<ProjectSearchInput onSearch={vi.fn()} />);
78
+
79
+ const input = screen.getByPlaceholderText('Filter runs...');
80
+ await user.type(input, 'test');
81
+
82
+ expect(input).toHaveValue('test');
83
+ });
84
+
85
+ it('uses custom debounceMs', async () => {
86
+ const onSearch = vi.fn();
87
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
88
+ render(<ProjectSearchInput onSearch={onSearch} debounceMs={100} />);
89
+
90
+ const input = screen.getByPlaceholderText('Filter runs...');
91
+ await user.type(input, 'x');
92
+
93
+ onSearch.mockClear();
94
+
95
+ // Advance less than debounce
96
+ act(() => {
97
+ vi.advanceTimersByTime(50);
98
+ });
99
+
100
+ // Not called yet with the last character value change
101
+ const _callsAfter50 = onSearch.mock.calls.length;
102
+
103
+ // Advance past debounce
104
+ act(() => {
105
+ vi.advanceTimersByTime(100);
106
+ });
107
+
108
+ expect(onSearch).toHaveBeenCalledWith('x');
109
+ });
110
+ });
@@ -0,0 +1,91 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { ProjectSectionHeader } from '../project-section-header';
3
+
4
+ describe('ProjectSectionHeader', () => {
5
+ const defaultProps = {
6
+ projectName: 'my-project',
7
+ activeRuns: 2,
8
+ completedRuns: 5,
9
+ failedRuns: 1,
10
+ totalRuns: 8,
11
+ };
12
+
13
+ it('renders the project name', () => {
14
+ render(<ProjectSectionHeader {...defaultProps} />);
15
+ expect(screen.getByText('my-project')).toBeInTheDocument();
16
+ });
17
+
18
+ it('shows active runs badge when activeRuns > 0', () => {
19
+ render(<ProjectSectionHeader {...defaultProps} activeRuns={3} />);
20
+ expect(screen.getByText('3 active')).toBeInTheDocument();
21
+ });
22
+
23
+ it('does not show active runs badge when activeRuns is 0', () => {
24
+ render(<ProjectSectionHeader {...defaultProps} activeRuns={0} />);
25
+ expect(screen.queryByText(/active/)).not.toBeInTheDocument();
26
+ });
27
+
28
+ it('shows completed runs badge when completedRuns > 0', () => {
29
+ render(<ProjectSectionHeader {...defaultProps} completedRuns={7} />);
30
+ expect(screen.getByText('7 completed')).toBeInTheDocument();
31
+ });
32
+
33
+ it('does not show completed runs badge when completedRuns is 0', () => {
34
+ render(<ProjectSectionHeader {...defaultProps} completedRuns={0} />);
35
+ expect(screen.queryByText(/completed/)).not.toBeInTheDocument();
36
+ });
37
+
38
+ it('shows failed runs badge when failedRuns > 0', () => {
39
+ render(<ProjectSectionHeader {...defaultProps} failedRuns={3} />);
40
+ expect(screen.getByText('3 failed')).toBeInTheDocument();
41
+ });
42
+
43
+ it('does not show failed runs badge when failedRuns is 0', () => {
44
+ render(<ProjectSectionHeader {...defaultProps} failedRuns={0} />);
45
+ expect(screen.queryByText(/failed/)).not.toBeInTheDocument();
46
+ });
47
+
48
+ it('shows total runs count with plural "runs"', () => {
49
+ render(<ProjectSectionHeader {...defaultProps} totalRuns={10} />);
50
+ expect(screen.getByText(/10 runs/)).toBeInTheDocument();
51
+ });
52
+
53
+ it('shows singular "run" for totalRuns === 1', () => {
54
+ render(<ProjectSectionHeader {...defaultProps} totalRuns={1} />);
55
+ expect(screen.getByText(/1 run(?!s)/)).toBeInTheDocument();
56
+ });
57
+
58
+ it('renders without latestUpdate', () => {
59
+ render(<ProjectSectionHeader {...defaultProps} />);
60
+ // Should render without errors; the total runs text should be there
61
+ expect(screen.getByText(/8 runs/)).toBeInTheDocument();
62
+ });
63
+
64
+ it('displays relative time when latestUpdate is provided', () => {
65
+ // Use a recent timestamp
66
+ const recent = new Date(Date.now() - 30000).toISOString(); // 30 seconds ago
67
+ render(<ProjectSectionHeader {...defaultProps} latestUpdate={recent} />);
68
+ // Should show something like "30s ago"
69
+ expect(screen.getByText(/ago/)).toBeInTheDocument();
70
+ });
71
+
72
+ it('shows "just now" for future timestamps', () => {
73
+ const future = new Date(Date.now() + 60000).toISOString();
74
+ render(<ProjectSectionHeader {...defaultProps} latestUpdate={future} />);
75
+ expect(screen.getByText(/just now/)).toBeInTheDocument();
76
+ });
77
+
78
+ it('hides all badges when counts are 0', () => {
79
+ render(
80
+ <ProjectSectionHeader
81
+ {...defaultProps}
82
+ activeRuns={0}
83
+ completedRuns={0}
84
+ failedRuns={0}
85
+ />
86
+ );
87
+ expect(screen.queryByText(/active/)).not.toBeInTheDocument();
88
+ expect(screen.queryByText(/completed/)).not.toBeInTheDocument();
89
+ expect(screen.queryByText(/failed/)).not.toBeInTheDocument();
90
+ });
91
+ });
@@ -0,0 +1,151 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { ProjectSection } from '../project-section';
4
+ import { createMockRun, resetIdCounter } from '@/test/fixtures';
5
+ import type { Run } from '@/types';
6
+
7
+ // Mock useProjectRuns hook
8
+ const mockUseProjectRuns = vi.fn();
9
+ vi.mock('@/hooks/use-project-runs', () => ({
10
+ useProjectRuns: (...args: any[]) => mockUseProjectRuns(...args),
11
+ }));
12
+
13
+ // Mock next/link
14
+ vi.mock('next/link', () => ({
15
+ __esModule: true,
16
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
17
+ <a href={href} data-testid="next-link">
18
+ {children}
19
+ </a>
20
+ ),
21
+ }));
22
+
23
+ beforeEach(() => {
24
+ resetIdCounter();
25
+ vi.clearAllMocks();
26
+ });
27
+
28
+ function setupMockHook(runs: Run[], totalCount: number, loading = false) {
29
+ mockUseProjectRuns.mockReturnValue({
30
+ runs,
31
+ totalCount,
32
+ loading,
33
+ error: null,
34
+ refresh: vi.fn(),
35
+ });
36
+ }
37
+
38
+ describe('ProjectSection', () => {
39
+ it('renders RunCards for fetched runs', () => {
40
+ const runs = [
41
+ createMockRun({ runId: 'run-1', processId: 'alpha-process' }),
42
+ createMockRun({ runId: 'run-2', processId: 'beta-process' }),
43
+ ];
44
+ setupMockHook(runs, 2);
45
+
46
+ render(<ProjectSection projectName="test-project" runs={[]} />);
47
+ expect(screen.getByText('Alpha Process')).toBeInTheDocument();
48
+ expect(screen.getByText('Beta Process')).toBeInTheDocument();
49
+ });
50
+
51
+ it('shows loading spinner when loading with no runs', () => {
52
+ setupMockHook([], 0, true);
53
+
54
+ const { container } = render(
55
+ <ProjectSection projectName="test-project" runs={[]} />
56
+ );
57
+ // The loading spinner uses animate-spin class
58
+ const spinner = container.querySelector('.animate-spin');
59
+ expect(spinner).toBeInTheDocument();
60
+ });
61
+
62
+ it('shows "No runs found" when not loading and no runs', () => {
63
+ setupMockHook([], 0, false);
64
+
65
+ render(<ProjectSection projectName="test-project" runs={[]} />);
66
+ expect(screen.getByText('No runs found')).toBeInTheDocument();
67
+ });
68
+
69
+ it('renders the search input', () => {
70
+ setupMockHook([], 0, false);
71
+
72
+ render(<ProjectSection projectName="test-project" runs={[]} />);
73
+ expect(
74
+ screen.getByPlaceholderText('Search by run ID, process, task, or error...')
75
+ ).toBeInTheDocument();
76
+ });
77
+
78
+ it('renders pagination controls when there are items', () => {
79
+ const runs = [createMockRun({ runId: 'run-1' })];
80
+ setupMockHook(runs, 15);
81
+
82
+ render(<ProjectSection projectName="test-project" runs={[]} />);
83
+ // PaginationControls renders range text
84
+ expect(screen.getByText(/of 15/)).toBeInTheDocument();
85
+ });
86
+
87
+ it('passes enabled=true by default to useProjectRuns', () => {
88
+ setupMockHook([], 0, false);
89
+
90
+ render(<ProjectSection projectName="test-project" runs={[]} />);
91
+ expect(mockUseProjectRuns).toHaveBeenCalledWith(
92
+ 'test-project',
93
+ expect.objectContaining({ enabled: true })
94
+ );
95
+ });
96
+
97
+ it('passes enabled=false when prop is false', () => {
98
+ setupMockHook([], 0, false);
99
+
100
+ render(
101
+ <ProjectSection projectName="test-project" runs={[]} enabled={false} />
102
+ );
103
+ expect(mockUseProjectRuns).toHaveBeenCalledWith(
104
+ 'test-project',
105
+ expect.objectContaining({ enabled: false })
106
+ );
107
+ });
108
+
109
+ it('passes statusFilter to useProjectRuns', () => {
110
+ setupMockHook([], 0, false);
111
+
112
+ render(
113
+ <ProjectSection
114
+ projectName="test-project"
115
+ runs={[]}
116
+ statusFilter="completed"
117
+ />
118
+ );
119
+ expect(mockUseProjectRuns).toHaveBeenCalledWith(
120
+ 'test-project',
121
+ expect.objectContaining({ status: 'completed' })
122
+ );
123
+ });
124
+
125
+ it('does not pass status when statusFilter is "all"', () => {
126
+ setupMockHook([], 0, false);
127
+
128
+ render(
129
+ <ProjectSection
130
+ projectName="test-project"
131
+ runs={[]}
132
+ statusFilter="all"
133
+ />
134
+ );
135
+ expect(mockUseProjectRuns).toHaveBeenCalledWith(
136
+ 'test-project',
137
+ expect.objectContaining({ status: undefined })
138
+ );
139
+ });
140
+
141
+ it('does not render pagination when totalCount is 0', () => {
142
+ setupMockHook([], 0, false);
143
+
144
+ const { container: _container } = render(
145
+ <ProjectSection projectName="test-project" runs={[]} />
146
+ );
147
+ // PaginationControls returns null when totalItems is 0
148
+ expect(screen.queryByLabelText('Previous page')).not.toBeInTheDocument();
149
+ expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument();
150
+ });
151
+ });
@@ -0,0 +1,164 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { RunCard } from '../run-card';
4
+ import {
5
+ createMockRun,
6
+ createMockTaskEffect,
7
+ resetIdCounter,
8
+ } from '@/test/fixtures';
9
+
10
+ // Mock next/link to render a plain anchor
11
+ vi.mock('next/link', () => ({
12
+ __esModule: true,
13
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
14
+ <a href={href} data-testid="next-link">
15
+ {children}
16
+ </a>
17
+ ),
18
+ }));
19
+
20
+ beforeEach(() => {
21
+ resetIdCounter();
22
+ });
23
+
24
+ describe('RunCard', () => {
25
+ it('renders the friendly process name', () => {
26
+ const run = createMockRun({ processId: 'data-pipeline/ingest' });
27
+ render(<RunCard run={run} />);
28
+ expect(screen.getByText('Data Pipeline Ingest')).toBeInTheDocument();
29
+ });
30
+
31
+ it('renders a status badge for the run status', () => {
32
+ const run = createMockRun({ status: 'completed' });
33
+ render(<RunCard run={run} />);
34
+ expect(screen.getByText('Completed')).toBeInTheDocument();
35
+ });
36
+
37
+ it('renders the task count', () => {
38
+ const run = createMockRun({ completedTasks: 2, totalTasks: 5 });
39
+ render(<RunCard run={run} />);
40
+ expect(screen.getByText('2/5 tasks')).toBeInTheDocument();
41
+ });
42
+
43
+ it('renders formatted duration', () => {
44
+ const run = createMockRun({ duration: 59000 });
45
+ render(<RunCard run={run} />);
46
+ expect(screen.getByText('59s')).toBeInTheDocument();
47
+ });
48
+
49
+ it('links to the run detail page', () => {
50
+ const run = createMockRun({ runId: 'run-abc-123' });
51
+ render(<RunCard run={run} />);
52
+ const link = screen.getByTestId('next-link');
53
+ expect(link).toHaveAttribute('href', '/runs/run-abc-123');
54
+ });
55
+
56
+ it('shows the project name tag when present', () => {
57
+ const run = createMockRun({ projectName: 'my-project' });
58
+ render(<RunCard run={run} />);
59
+ expect(screen.getByText('my-project')).toBeInTheDocument();
60
+ });
61
+
62
+ it('does not show source label', () => {
63
+ const run = createMockRun({ projectName: 'my-project', sourceLabel: 'cli' });
64
+ render(<RunCard run={run} />);
65
+ expect(screen.queryByText('cli')).not.toBeInTheDocument();
66
+ });
67
+
68
+ it('shows a progress bar when there are tasks', () => {
69
+ const run = createMockRun({ totalTasks: 5, completedTasks: 3 });
70
+ render(<RunCard run={run} />);
71
+ // The progress bar renders a div with style width
72
+ const progressBar = document.querySelector('[style*="width"]');
73
+ expect(progressBar).toBeInTheDocument();
74
+ });
75
+
76
+ it('does not show a progress bar when totalTasks is 0', () => {
77
+ const run = createMockRun({ totalTasks: 0, tasks: [] });
78
+ render(<RunCard run={run} />);
79
+ const progressBar = document.querySelector('[style*="width"]');
80
+ expect(progressBar).toBeNull();
81
+ });
82
+
83
+ it('shows 100% progress for completed runs', () => {
84
+ const run = createMockRun({ status: 'completed', totalTasks: 5, completedTasks: 3 });
85
+ render(<RunCard run={run} />);
86
+ const progressBar = document.querySelector('[style*="width"]') as HTMLElement;
87
+ expect(progressBar?.style.width).toBe('100%');
88
+ });
89
+
90
+ it('displays the failed step text for failed runs', () => {
91
+ const run = createMockRun({
92
+ status: 'failed',
93
+ failedStep: 'process-data step',
94
+ });
95
+ render(<RunCard run={run} />);
96
+ expect(screen.getByText(/Failed at: process-data step/)).toBeInTheDocument();
97
+ });
98
+
99
+ it('truncates long failed step text to 80 chars', () => {
100
+ const longStep = 'a'.repeat(100);
101
+ const run = createMockRun({
102
+ status: 'failed',
103
+ failedStep: longStep,
104
+ });
105
+ render(<RunCard run={run} />);
106
+ expect(screen.getByText(/Failed at:/)).toHaveTextContent(
107
+ `Failed at: ${'a'.repeat(80)}...`
108
+ );
109
+ });
110
+
111
+ it('shows breakpoint question when a pending breakpoint exists', () => {
112
+ const breakpointTask = createMockTaskEffect({
113
+ kind: 'breakpoint',
114
+ status: 'requested',
115
+ breakpointQuestion: 'Approve deployment?',
116
+ });
117
+ const run = createMockRun({
118
+ status: 'waiting',
119
+ tasks: [breakpointTask],
120
+ });
121
+ render(<RunCard run={run} />);
122
+ expect(screen.getByText('Approve deployment?')).toBeInTheDocument();
123
+ });
124
+
125
+ it('prefers run-level breakpointQuestion over task-level', () => {
126
+ const breakpointTask = createMockTaskEffect({
127
+ kind: 'breakpoint',
128
+ status: 'requested',
129
+ breakpointQuestion: 'Task-level question?',
130
+ });
131
+ const run = createMockRun({
132
+ status: 'waiting',
133
+ tasks: [breakpointTask],
134
+ breakpointQuestion: 'Run-level question?',
135
+ });
136
+ render(<RunCard run={run} />);
137
+ expect(screen.getByText('Run-level question?')).toBeInTheDocument();
138
+ });
139
+
140
+ it('applies selected styling when selected prop is true', () => {
141
+ const run = createMockRun();
142
+ const { container } = render(<RunCard run={run} selected />);
143
+ const card = container.querySelector('.ring-1');
144
+ expect(card).toBeInTheDocument();
145
+ });
146
+
147
+ it('renders with waiting status indicator', () => {
148
+ const run = createMockRun({ status: 'waiting' });
149
+ render(<RunCard run={run} />);
150
+ expect(screen.getByText('Waiting')).toBeInTheDocument();
151
+ });
152
+
153
+ it('renders with failed status indicator', () => {
154
+ const run = createMockRun({ status: 'failed' });
155
+ render(<RunCard run={run} />);
156
+ expect(screen.getByText('Failed')).toBeInTheDocument();
157
+ });
158
+
159
+ it('renders with pending status indicator', () => {
160
+ const run = createMockRun({ status: 'pending' });
161
+ render(<RunCard run={run} />);
162
+ expect(screen.getByText('Pending')).toBeInTheDocument();
163
+ });
164
+ });