@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,141 @@
1
+ import { render, screen } from "@/test/test-utils";
2
+ import { CatchUpBanner } from "../catch-up-banner";
3
+ import type { CatchUpState } from "@/hooks/use-batched-updates";
4
+
5
+ describe("CatchUpBanner", () => {
6
+ it("renders nothing when catch-up mode is inactive", () => {
7
+ const catchUp: CatchUpState = {
8
+ active: false,
9
+ bufferedCount: 0,
10
+ flush: vi.fn(),
11
+ };
12
+
13
+ const { container } = render(<CatchUpBanner catchUp={catchUp} />);
14
+ expect(container.firstChild).toBeNull();
15
+ });
16
+
17
+ it("renders the banner when catch-up mode is active", () => {
18
+ const catchUp: CatchUpState = {
19
+ active: true,
20
+ bufferedCount: 12,
21
+ flush: vi.fn(),
22
+ };
23
+
24
+ render(<CatchUpBanner catchUp={catchUp} />);
25
+
26
+ expect(screen.getByTestId("catch-up-banner")).toBeInTheDocument();
27
+ expect(screen.getByText("12")).toBeInTheDocument();
28
+ expect(screen.getByText(/runs updated while you were away/)).toBeInTheDocument();
29
+ });
30
+
31
+ it("shows the refresh button", () => {
32
+ const catchUp: CatchUpState = {
33
+ active: true,
34
+ bufferedCount: 5,
35
+ flush: vi.fn(),
36
+ };
37
+
38
+ render(<CatchUpBanner catchUp={catchUp} />);
39
+
40
+ expect(screen.getByTestId("catch-up-refresh-btn")).toBeInTheDocument();
41
+ expect(screen.getByText("Refresh now")).toBeInTheDocument();
42
+ });
43
+
44
+ it("calls flush when refresh button is clicked", async () => {
45
+ const flush = vi.fn();
46
+ const catchUp: CatchUpState = {
47
+ active: true,
48
+ bufferedCount: 8,
49
+ flush,
50
+ };
51
+
52
+ render(<CatchUpBanner catchUp={catchUp} />);
53
+
54
+ const button = screen.getByTestId("catch-up-refresh-btn");
55
+ button.click();
56
+
57
+ expect(flush).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it("displays the correct buffered count", () => {
61
+ const catchUp: CatchUpState = {
62
+ active: true,
63
+ bufferedCount: 42,
64
+ flush: vi.fn(),
65
+ };
66
+
67
+ render(<CatchUpBanner catchUp={catchUp} />);
68
+
69
+ expect(screen.getByText("42")).toBeInTheDocument();
70
+ });
71
+
72
+ it("shows summary context when summary prop is provided", () => {
73
+ const catchUp: CatchUpState = {
74
+ active: true,
75
+ bufferedCount: 15,
76
+ flush: vi.fn(),
77
+ };
78
+
79
+ render(
80
+ <CatchUpBanner
81
+ catchUp={catchUp}
82
+ summary={{ failedRuns: 2, completedRuns: 10, pendingBreakpoints: 1 }}
83
+ />
84
+ );
85
+
86
+ const summaryEl = screen.getByTestId("catch-up-summary");
87
+ expect(summaryEl).toBeInTheDocument();
88
+ expect(summaryEl).toHaveTextContent("2 failed");
89
+ expect(summaryEl).toHaveTextContent("1 awaiting input");
90
+ expect(summaryEl).toHaveTextContent("10 completed");
91
+ });
92
+
93
+ it("omits summary line when all summary counts are zero", () => {
94
+ const catchUp: CatchUpState = {
95
+ active: true,
96
+ bufferedCount: 5,
97
+ flush: vi.fn(),
98
+ };
99
+
100
+ render(
101
+ <CatchUpBanner
102
+ catchUp={catchUp}
103
+ summary={{ failedRuns: 0, completedRuns: 0, pendingBreakpoints: 0 }}
104
+ />
105
+ );
106
+
107
+ expect(screen.queryByTestId("catch-up-summary")).not.toBeInTheDocument();
108
+ });
109
+
110
+ it("omits summary line when summary prop is not provided", () => {
111
+ const catchUp: CatchUpState = {
112
+ active: true,
113
+ bufferedCount: 5,
114
+ flush: vi.fn(),
115
+ };
116
+
117
+ render(<CatchUpBanner catchUp={catchUp} />);
118
+
119
+ expect(screen.queryByTestId("catch-up-summary")).not.toBeInTheDocument();
120
+ });
121
+
122
+ it("shows only failed runs in summary when others are zero", () => {
123
+ const catchUp: CatchUpState = {
124
+ active: true,
125
+ bufferedCount: 3,
126
+ flush: vi.fn(),
127
+ };
128
+
129
+ render(
130
+ <CatchUpBanner
131
+ catchUp={catchUp}
132
+ summary={{ failedRuns: 4, completedRuns: 0, pendingBreakpoints: 0 }}
133
+ />
134
+ );
135
+
136
+ const summaryEl = screen.getByTestId("catch-up-summary");
137
+ expect(summaryEl).toHaveTextContent("4 failed");
138
+ expect(summaryEl).not.toHaveTextContent("completed");
139
+ expect(summaryEl).not.toHaveTextContent("awaiting");
140
+ });
141
+ });
@@ -0,0 +1,164 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { ExecutiveSummaryBanner, type ExecutiveSummaryMetrics } from '../executive-summary-banner';
5
+
6
+ function makeMetrics(overrides: Partial<ExecutiveSummaryMetrics> = {}): ExecutiveSummaryMetrics {
7
+ return {
8
+ totalProjects: 5,
9
+ activeRuns: 0,
10
+ failedRuns: 0,
11
+ completedRuns: 10,
12
+ staleRuns: 0,
13
+ pendingBreakpoints: 0,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe('ExecutiveSummaryBanner', () => {
19
+ it('renders with role="status" for accessibility', () => {
20
+ render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
21
+ expect(screen.getByRole('status')).toBeInTheDocument();
22
+ });
23
+
24
+ it('renders data-testid for querying', () => {
25
+ render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
26
+ expect(screen.getByTestId('executive-summary-banner')).toBeInTheDocument();
27
+ });
28
+
29
+ // --- Healthy state ---
30
+ it('shows all-healthy message when no issues exist', () => {
31
+ render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
32
+ expect(screen.getByText('All 5 projects healthy')).toBeInTheDocument();
33
+ });
34
+
35
+ it('includes active run count in healthy message when runs are active', () => {
36
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ activeRuns: 3 })} />);
37
+ expect(screen.getByText(/All 5 projects healthy/)).toBeInTheDocument();
38
+ expect(screen.getByText(/3 runs in progress/)).toBeInTheDocument();
39
+ });
40
+
41
+ it('uses singular "project" for single project', () => {
42
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ totalProjects: 1 })} />);
43
+ expect(screen.getByText('All 1 project healthy')).toBeInTheDocument();
44
+ });
45
+
46
+ it('uses singular "run" for single active run', () => {
47
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ activeRuns: 1 })} />);
48
+ expect(screen.getByText(/1 run in progress/)).toBeInTheDocument();
49
+ });
50
+
51
+ // --- Failure state (red) ---
52
+ it('shows failure message when runs are failing', () => {
53
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 2 })} />);
54
+ expect(screen.getByText(/2 runs failing/)).toBeInTheDocument();
55
+ });
56
+
57
+ it('uses singular "run" for single failure', () => {
58
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} />);
59
+ expect(screen.getByText(/1 run failing/)).toBeInTheDocument();
60
+ });
61
+
62
+ it('applies error styling for failures', () => {
63
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} />);
64
+ const banner = screen.getByTestId('executive-summary-banner');
65
+ expect(banner.className).toMatch(/border-error/);
66
+ });
67
+
68
+ // --- Amber state (pending approvals) ---
69
+ it('shows pending approval message', () => {
70
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ pendingBreakpoints: 2 })} />);
71
+ expect(screen.getByText(/2 approvals need your attention/)).toBeInTheDocument();
72
+ });
73
+
74
+ it('uses singular for single approval', () => {
75
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ pendingBreakpoints: 1 })} />);
76
+ expect(screen.getByText(/1 approval needs your attention/)).toBeInTheDocument();
77
+ });
78
+
79
+ it('applies warning styling for pending approvals', () => {
80
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ pendingBreakpoints: 1 })} />);
81
+ const banner = screen.getByTestId('executive-summary-banner');
82
+ expect(banner.className).toMatch(/border-warning/);
83
+ });
84
+
85
+ // --- Stale state (amber) ---
86
+ it('shows stale run message', () => {
87
+ render(<ExecutiveSummaryBanner metrics={makeMetrics({ staleRuns: 3 })} />);
88
+ expect(screen.getByText(/3 stale runs/)).toBeInTheDocument();
89
+ });
90
+
91
+ // --- Combined states ---
92
+ it('combines failures and approvals', () => {
93
+ render(
94
+ <ExecutiveSummaryBanner
95
+ metrics={makeMetrics({ failedRuns: 1, pendingBreakpoints: 2 })}
96
+ />
97
+ );
98
+ expect(screen.getByText(/1 run failing/)).toBeInTheDocument();
99
+ expect(screen.getByText(/2 approvals need your attention/)).toBeInTheDocument();
100
+ });
101
+
102
+ it('applies error styling when both failures and approvals exist', () => {
103
+ render(
104
+ <ExecutiveSummaryBanner
105
+ metrics={makeMetrics({ failedRuns: 1, pendingBreakpoints: 2 })}
106
+ />
107
+ );
108
+ const banner = screen.getByTestId('executive-summary-banner');
109
+ expect(banner.className).toMatch(/border-error/);
110
+ });
111
+
112
+ it('applies success styling when all healthy', () => {
113
+ render(<ExecutiveSummaryBanner metrics={makeMetrics()} />);
114
+ const banner = screen.getByTestId('executive-summary-banner');
115
+ expect(banner.className).toMatch(/border-success/);
116
+ });
117
+
118
+ // --- Dismissed state ---
119
+ it('returns null when dismissed is true', () => {
120
+ const { container } = render(
121
+ <ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} dismissed={true} />
122
+ );
123
+ expect(container.innerHTML).toBe('');
124
+ expect(screen.queryByTestId('executive-summary-banner')).not.toBeInTheDocument();
125
+ });
126
+
127
+ it('renders normally when dismissed is false', () => {
128
+ render(
129
+ <ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} dismissed={false} />
130
+ );
131
+ expect(screen.getByTestId('executive-summary-banner')).toBeInTheDocument();
132
+ });
133
+
134
+ // --- onDismiss callback ---
135
+ it('fires onDismiss callback when X button is clicked', async () => {
136
+ const user = userEvent.setup();
137
+ const onDismiss = vi.fn();
138
+ render(
139
+ <ExecutiveSummaryBanner
140
+ metrics={makeMetrics({ failedRuns: 1 })}
141
+ onDismiss={onDismiss}
142
+ />
143
+ );
144
+
145
+ const dismissBtn = screen.getByTestId('executive-summary-dismiss');
146
+ await user.click(dismissBtn);
147
+
148
+ expect(onDismiss).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it('does not show dismiss button when onDismiss is not provided', () => {
152
+ render(
153
+ <ExecutiveSummaryBanner metrics={makeMetrics({ failedRuns: 1 })} />
154
+ );
155
+ expect(screen.queryByTestId('executive-summary-dismiss')).not.toBeInTheDocument();
156
+ });
157
+
158
+ it('does not show dismiss button in healthy state even if onDismiss is provided', () => {
159
+ render(
160
+ <ExecutiveSummaryBanner metrics={makeMetrics()} onDismiss={vi.fn()} />
161
+ );
162
+ expect(screen.queryByTestId('executive-summary-dismiss')).not.toBeInTheDocument();
163
+ });
164
+ });
@@ -0,0 +1,101 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { KpiGrid } from '../kpi-grid';
4
+ import userEvent from '@testing-library/user-event';
5
+ import type { DashboardMetrics } from '@/hooks/use-run-dashboard';
6
+
7
+ const baseMetrics: DashboardMetrics = {
8
+ totalRuns: 20,
9
+ activeRuns: 5,
10
+ completedRuns: 12,
11
+ failedRuns: 3,
12
+ staleRuns: 0,
13
+ totalTasks: 100,
14
+ completedTasks: 80,
15
+ };
16
+
17
+ describe('KpiGrid', () => {
18
+ it('renders all four metric tiles without stale', () => {
19
+ render(
20
+ <KpiGrid
21
+ metrics={baseMetrics}
22
+ statusFilter="all"
23
+ hasStaleRuns={false}
24
+ onToggleFilter={vi.fn()}
25
+ />
26
+ );
27
+ expect(screen.getByTestId('metric-tile-total-runs')).toBeInTheDocument();
28
+ expect(screen.getByTestId('metric-tile-active')).toBeInTheDocument();
29
+ expect(screen.getByTestId('metric-tile-completed')).toBeInTheDocument();
30
+ expect(screen.getByTestId('metric-tile-failed')).toBeInTheDocument();
31
+ expect(screen.queryByTestId('metric-tile-stale')).not.toBeInTheDocument();
32
+ });
33
+
34
+ it('renders stale tile when hasStaleRuns is true', () => {
35
+ render(
36
+ <KpiGrid
37
+ metrics={{ ...baseMetrics, staleRuns: 2 }}
38
+ statusFilter="all"
39
+ hasStaleRuns={true}
40
+ onToggleFilter={vi.fn()}
41
+ />
42
+ );
43
+ expect(screen.getByTestId('metric-tile-stale')).toBeInTheDocument();
44
+ });
45
+
46
+ it('displays correct metric values', () => {
47
+ render(
48
+ <KpiGrid
49
+ metrics={baseMetrics}
50
+ statusFilter="all"
51
+ hasStaleRuns={false}
52
+ onToggleFilter={vi.fn()}
53
+ />
54
+ );
55
+ expect(screen.getByTestId('metric-tile-total-runs')).toHaveTextContent('20');
56
+ expect(screen.getByTestId('metric-tile-active')).toHaveTextContent('5');
57
+ expect(screen.getByTestId('metric-tile-completed')).toHaveTextContent('12');
58
+ expect(screen.getByTestId('metric-tile-failed')).toHaveTextContent('3');
59
+ });
60
+
61
+ it('calls onToggleFilter when a tile is clicked', async () => {
62
+ const onToggle = vi.fn();
63
+ const user = userEvent.setup();
64
+ render(
65
+ <KpiGrid
66
+ metrics={baseMetrics}
67
+ statusFilter="all"
68
+ hasStaleRuns={false}
69
+ onToggleFilter={onToggle}
70
+ />
71
+ );
72
+
73
+ await user.click(screen.getByTestId('metric-tile-failed'));
74
+ expect(onToggle).toHaveBeenCalledWith('failed');
75
+ });
76
+
77
+ it('marks active tile with aria-pressed true', () => {
78
+ render(
79
+ <KpiGrid
80
+ metrics={baseMetrics}
81
+ statusFilter="waiting"
82
+ hasStaleRuns={false}
83
+ onToggleFilter={vi.fn()}
84
+ />
85
+ );
86
+ expect(screen.getByTestId('metric-tile-active')).toHaveAttribute('aria-pressed', 'true');
87
+ expect(screen.getByTestId('metric-tile-total-runs')).toHaveAttribute('aria-pressed', 'false');
88
+ });
89
+
90
+ it('renders the kpi-grid container with aria-label', () => {
91
+ render(
92
+ <KpiGrid
93
+ metrics={baseMetrics}
94
+ statusFilter="all"
95
+ hasStaleRuns={false}
96
+ onToggleFilter={vi.fn()}
97
+ />
98
+ );
99
+ expect(screen.getByTestId('kpi-grid')).toHaveAttribute('aria-label', 'Key metrics');
100
+ });
101
+ });
@@ -0,0 +1,125 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { PaginationControls } from '../pagination-controls';
4
+ import userEvent from '@testing-library/user-event';
5
+
6
+ describe('PaginationControls', () => {
7
+ const defaultProps = {
8
+ currentPage: 0,
9
+ totalItems: 25,
10
+ itemsPerPage: 10,
11
+ onPageChange: vi.fn(),
12
+ };
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ it('renders nothing when totalItems is 0', () => {
19
+ const { container } = render(
20
+ <PaginationControls {...defaultProps} totalItems={0} />
21
+ );
22
+ expect(container.firstChild).toBeNull();
23
+ });
24
+
25
+ it('displays the item range text', () => {
26
+ render(<PaginationControls {...defaultProps} currentPage={0} />);
27
+ // Rendered as "1-10 of 25" (with en-dash or hyphen)
28
+ // The range text span contains startItem, endItem, and totalItems
29
+ const rangeText = screen.getByText(/of 25/);
30
+ expect(rangeText).toBeInTheDocument();
31
+ expect(rangeText).toHaveTextContent('1');
32
+ expect(rangeText).toHaveTextContent('10');
33
+ });
34
+
35
+ it('displays correct range on second page', () => {
36
+ render(<PaginationControls {...defaultProps} currentPage={1} />);
37
+ expect(screen.getByText(/11/)).toBeInTheDocument();
38
+ });
39
+
40
+ it('caps the end item at totalItems on the last page', () => {
41
+ render(
42
+ <PaginationControls {...defaultProps} currentPage={2} totalItems={25} itemsPerPage={10} />
43
+ );
44
+ // Page 3 (index 2): items 21-25
45
+ expect(screen.getByText(/25/)).toBeInTheDocument();
46
+ });
47
+
48
+ it('displays the current page number (1-indexed)', () => {
49
+ render(<PaginationControls {...defaultProps} currentPage={1} />);
50
+ // Should show "2" as the current page indicator
51
+ expect(screen.getByText('2')).toBeInTheDocument();
52
+ });
53
+
54
+ it('disables the previous button on the first page', () => {
55
+ render(<PaginationControls {...defaultProps} currentPage={0} />);
56
+ const prevBtn = screen.getByLabelText('Previous page');
57
+ expect(prevBtn).toBeDisabled();
58
+ });
59
+
60
+ it('enables the previous button when not on the first page', () => {
61
+ render(<PaginationControls {...defaultProps} currentPage={1} />);
62
+ const prevBtn = screen.getByLabelText('Previous page');
63
+ expect(prevBtn).not.toBeDisabled();
64
+ });
65
+
66
+ it('disables the next button on the last page', () => {
67
+ // totalItems=25, itemsPerPage=10 => 3 pages (0,1,2), last page is index 2
68
+ render(<PaginationControls {...defaultProps} currentPage={2} />);
69
+ const nextBtn = screen.getByLabelText('Next page');
70
+ expect(nextBtn).toBeDisabled();
71
+ });
72
+
73
+ it('enables the next button when not on the last page', () => {
74
+ render(<PaginationControls {...defaultProps} currentPage={0} />);
75
+ const nextBtn = screen.getByLabelText('Next page');
76
+ expect(nextBtn).not.toBeDisabled();
77
+ });
78
+
79
+ it('calls onPageChange with previous page when clicking prev', async () => {
80
+ const onPageChange = vi.fn();
81
+ const user = userEvent.setup();
82
+ render(
83
+ <PaginationControls {...defaultProps} currentPage={1} onPageChange={onPageChange} />
84
+ );
85
+
86
+ await user.click(screen.getByLabelText('Previous page'));
87
+ expect(onPageChange).toHaveBeenCalledWith(0);
88
+ });
89
+
90
+ it('calls onPageChange with next page when clicking next', async () => {
91
+ const onPageChange = vi.fn();
92
+ const user = userEvent.setup();
93
+ render(
94
+ <PaginationControls {...defaultProps} currentPage={0} onPageChange={onPageChange} />
95
+ );
96
+
97
+ await user.click(screen.getByLabelText('Next page'));
98
+ expect(onPageChange).toHaveBeenCalledWith(1);
99
+ });
100
+
101
+ it('handles single page of items (both buttons disabled)', () => {
102
+ render(
103
+ <PaginationControls
104
+ {...defaultProps}
105
+ currentPage={0}
106
+ totalItems={5}
107
+ itemsPerPage={10}
108
+ />
109
+ );
110
+ expect(screen.getByLabelText('Previous page')).toBeDisabled();
111
+ expect(screen.getByLabelText('Next page')).toBeDisabled();
112
+ });
113
+
114
+ it('shows page 1 for a single page', () => {
115
+ render(
116
+ <PaginationControls
117
+ {...defaultProps}
118
+ currentPage={0}
119
+ totalItems={5}
120
+ itemsPerPage={10}
121
+ />
122
+ );
123
+ expect(screen.getByText('1')).toBeInTheDocument();
124
+ });
125
+ });
@@ -0,0 +1,97 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { ProjectAccordion } from '../project-accordion';
4
+ import { createMockProjectSummary, resetIdCounter } from '@/test/fixtures';
5
+ import userEvent from '@testing-library/user-event';
6
+
7
+ // Mock ProjectSection to avoid hook side effects (useProjectRuns calls useSmartPolling)
8
+ vi.mock('../project-section', () => ({
9
+ ProjectSection: ({ projectName, enabled }: { projectName: string; enabled?: boolean }) => (
10
+ <div data-testid={`project-section-${projectName}`} data-enabled={String(enabled ?? true)}>
11
+ Section: {projectName}
12
+ </div>
13
+ ),
14
+ }));
15
+
16
+ // Mock ProjectSectionHeader to simplify testing
17
+ vi.mock('../project-section-header', () => ({
18
+ ProjectSectionHeader: ({
19
+ projectName,
20
+ activeRuns,
21
+ totalRuns,
22
+ }: {
23
+ projectName: string;
24
+ activeRuns: number;
25
+ totalRuns: number;
26
+ }) => (
27
+ <div data-testid={`header-${projectName}`}>
28
+ {projectName} ({activeRuns} active, {totalRuns} total)
29
+ </div>
30
+ ),
31
+ }));
32
+
33
+ beforeEach(() => {
34
+ resetIdCounter();
35
+ });
36
+
37
+ describe('ProjectAccordion', () => {
38
+ it('renders "No projects found" when projects list is empty', () => {
39
+ render(<ProjectAccordion projects={[]} />);
40
+ expect(screen.getByText('No projects found')).toBeInTheDocument();
41
+ });
42
+
43
+ it('renders project section headers for each project', () => {
44
+ const projects = [
45
+ createMockProjectSummary({ projectName: 'project-a', activeRuns: 1 }),
46
+ createMockProjectSummary({ projectName: 'project-b', activeRuns: 0 }),
47
+ ];
48
+ render(<ProjectAccordion projects={projects} />);
49
+ expect(screen.getByTestId('header-project-a')).toBeInTheDocument();
50
+ expect(screen.getByTestId('header-project-b')).toBeInTheDocument();
51
+ });
52
+
53
+ it('renders header text with active runs and total runs', () => {
54
+ const projects = [
55
+ createMockProjectSummary({ projectName: 'alpha', activeRuns: 3, totalRuns: 10 }),
56
+ ];
57
+ render(<ProjectAccordion projects={projects} />);
58
+ expect(screen.getByText('alpha (3 active, 10 total)')).toBeInTheDocument();
59
+ });
60
+
61
+ it('auto-expands projects with active runs by default', () => {
62
+ const projects = [
63
+ createMockProjectSummary({ projectName: 'active-project', activeRuns: 2 }),
64
+ createMockProjectSummary({ projectName: 'idle-project', activeRuns: 0 }),
65
+ ];
66
+ render(<ProjectAccordion projects={projects} />);
67
+ // The active project section should be visible since it's expanded
68
+ expect(screen.getByTestId('project-section-active-project')).toBeInTheDocument();
69
+ });
70
+
71
+ it('can expand a collapsed project by clicking its trigger', async () => {
72
+ const user = userEvent.setup();
73
+ const projects = [
74
+ createMockProjectSummary({ projectName: 'idle-project', activeRuns: 0 }),
75
+ ];
76
+ render(<ProjectAccordion projects={projects} />);
77
+
78
+ // Click the trigger to expand
79
+ const trigger = screen.getByTestId('header-idle-project');
80
+ await user.click(trigger);
81
+
82
+ // Now the section should become visible
83
+ expect(screen.getByTestId('project-section-idle-project')).toBeInTheDocument();
84
+ });
85
+
86
+ it('renders multiple projects', () => {
87
+ const projects = [
88
+ createMockProjectSummary({ projectName: 'project-1' }),
89
+ createMockProjectSummary({ projectName: 'project-2' }),
90
+ createMockProjectSummary({ projectName: 'project-3' }),
91
+ ];
92
+ render(<ProjectAccordion projects={projects} />);
93
+ expect(screen.getByTestId('header-project-1')).toBeInTheDocument();
94
+ expect(screen.getByTestId('header-project-2')).toBeInTheDocument();
95
+ expect(screen.getByTestId('header-project-3')).toBeInTheDocument();
96
+ });
97
+ });