@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,212 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, setupUser } from "@/test/test-utils";
3
+ import { BreakpointApproval } from "../breakpoint-approval";
4
+ import { createMockTaskDetail } from "@/test/fixtures";
5
+ import type { TaskDetail } from "@/types";
6
+
7
+ // Mock the server action
8
+ const mockApproveBreakpoint = vi.fn();
9
+ vi.mock("@/app/actions/approve-breakpoint", () => ({
10
+ approveBreakpoint: (...args: unknown[]) => mockApproveBreakpoint(...args),
11
+ }));
12
+
13
+ describe("BreakpointApproval", () => {
14
+ const defaultRunId = "run-123";
15
+
16
+ function makeBreakpointTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
17
+ return createMockTaskDetail({
18
+ kind: "breakpoint",
19
+ status: "requested",
20
+ breakpointQuestion: "Should we deploy to production?",
21
+ title: "Deploy Approval",
22
+ breakpoint: {
23
+ question: "Should we deploy to production?",
24
+ title: "Deploy Approval",
25
+ context: { files: [] },
26
+ },
27
+ ...overrides,
28
+ });
29
+ }
30
+
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ mockApproveBreakpoint.mockResolvedValue({ success: true });
34
+ });
35
+
36
+ // -------------------------------------------------------------------------
37
+ // Rendering
38
+ // -------------------------------------------------------------------------
39
+
40
+ it("renders for a waiting breakpoint task", () => {
41
+ const task = makeBreakpointTask();
42
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
43
+
44
+ expect(screen.getByTestId("breakpoint-approval")).toBeInTheDocument();
45
+ expect(screen.getByTestId("approve-btn")).toBeInTheDocument();
46
+ expect(screen.getByTestId("custom-answer-input")).toBeInTheDocument();
47
+ });
48
+
49
+ it("does not render for a resolved task", () => {
50
+ const task = makeBreakpointTask({ status: "resolved" });
51
+ const { container } = render(
52
+ <BreakpointApproval task={task} runId={defaultRunId} />
53
+ );
54
+
55
+ expect(container.innerHTML).toBe("");
56
+ });
57
+
58
+ it("does not render for an error task", () => {
59
+ const task = makeBreakpointTask({ status: "error" });
60
+ const { container } = render(
61
+ <BreakpointApproval task={task} runId={defaultRunId} />
62
+ );
63
+
64
+ expect(container.innerHTML).toBe("");
65
+ });
66
+
67
+ // -------------------------------------------------------------------------
68
+ // Option buttons
69
+ // -------------------------------------------------------------------------
70
+
71
+ it("renders option buttons when options are provided", () => {
72
+ const task = makeBreakpointTask({
73
+ breakpoint: {
74
+ question: "Choose environment",
75
+ title: "Deploy",
76
+ options: ["staging", "production"],
77
+ context: { files: [] },
78
+ },
79
+ });
80
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
81
+
82
+ expect(screen.getByTestId("breakpoint-options")).toBeInTheDocument();
83
+ expect(screen.getByTestId("option-btn-staging")).toBeInTheDocument();
84
+ expect(screen.getByTestId("option-btn-production")).toBeInTheDocument();
85
+ });
86
+
87
+ it("does not render option section when no options", () => {
88
+ const task = makeBreakpointTask();
89
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
90
+
91
+ expect(screen.queryByTestId("breakpoint-options")).not.toBeInTheDocument();
92
+ });
93
+
94
+ it("calls server action when an option button is clicked", async () => {
95
+ const user = setupUser();
96
+ const task = makeBreakpointTask({
97
+ breakpoint: {
98
+ question: "Choose environment",
99
+ title: "Deploy",
100
+ options: ["staging", "production"],
101
+ context: { files: [] },
102
+ },
103
+ });
104
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
105
+
106
+ await user.click(screen.getByTestId("option-btn-staging"));
107
+
108
+ expect(mockApproveBreakpoint).toHaveBeenCalledWith(
109
+ defaultRunId,
110
+ task.effectId,
111
+ "staging"
112
+ );
113
+ });
114
+
115
+ // -------------------------------------------------------------------------
116
+ // Custom answer
117
+ // -------------------------------------------------------------------------
118
+
119
+ it("disables the approve button when input is empty", () => {
120
+ const task = makeBreakpointTask();
121
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
122
+
123
+ expect(screen.getByTestId("approve-btn")).toBeDisabled();
124
+ });
125
+
126
+ it("enables the approve button when input has text", async () => {
127
+ const user = setupUser();
128
+ const task = makeBreakpointTask();
129
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
130
+
131
+ await user.type(screen.getByTestId("custom-answer-input"), "yes");
132
+
133
+ expect(screen.getByTestId("approve-btn")).not.toBeDisabled();
134
+ });
135
+
136
+ it("calls server action on form submit with custom answer", async () => {
137
+ const user = setupUser();
138
+ const task = makeBreakpointTask();
139
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
140
+
141
+ await user.type(screen.getByTestId("custom-answer-input"), "Deploy to staging");
142
+ await user.click(screen.getByTestId("approve-btn"));
143
+
144
+ expect(mockApproveBreakpoint).toHaveBeenCalledWith(
145
+ defaultRunId,
146
+ task.effectId,
147
+ "Deploy to staging"
148
+ );
149
+ });
150
+
151
+ // -------------------------------------------------------------------------
152
+ // Result feedback
153
+ // -------------------------------------------------------------------------
154
+
155
+ it("shows success message after approval", async () => {
156
+ const user = setupUser();
157
+ mockApproveBreakpoint.mockResolvedValue({ success: true });
158
+
159
+ const task = makeBreakpointTask();
160
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
161
+
162
+ await user.type(screen.getByTestId("custom-answer-input"), "yes");
163
+ await user.click(screen.getByTestId("approve-btn"));
164
+
165
+ const resultEl = await screen.findByTestId("approval-result");
166
+ expect(resultEl).toBeInTheDocument();
167
+ expect(resultEl.textContent).toContain("approved successfully");
168
+ });
169
+
170
+ it("shows error message on failure", async () => {
171
+ const user = setupUser();
172
+ mockApproveBreakpoint.mockResolvedValue({
173
+ success: false,
174
+ error: "Task directory not found",
175
+ });
176
+
177
+ const task = makeBreakpointTask();
178
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
179
+
180
+ await user.type(screen.getByTestId("custom-answer-input"), "yes");
181
+ await user.click(screen.getByTestId("approve-btn"));
182
+
183
+ const resultEl = await screen.findByTestId("approval-result");
184
+ expect(resultEl).toBeInTheDocument();
185
+ expect(resultEl.textContent).toContain("Task directory not found");
186
+ });
187
+
188
+ // -------------------------------------------------------------------------
189
+ // Label text
190
+ // -------------------------------------------------------------------------
191
+
192
+ it('shows "Or provide a custom answer" when options exist', () => {
193
+ const task = makeBreakpointTask({
194
+ breakpoint: {
195
+ question: "Pick",
196
+ title: "Pick",
197
+ options: ["a", "b"],
198
+ context: { files: [] },
199
+ },
200
+ });
201
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
202
+
203
+ expect(screen.getByText("Or provide a custom answer")).toBeInTheDocument();
204
+ });
205
+
206
+ it('shows "Provide an answer" when no options exist', () => {
207
+ const task = makeBreakpointTask();
208
+ render(<BreakpointApproval task={task} runId={defaultRunId} />);
209
+
210
+ expect(screen.getByText("Provide an answer")).toBeInTheDocument();
211
+ });
212
+ });
@@ -0,0 +1,130 @@
1
+ import { render, screen } from '@/test/test-utils';
2
+ import { vi } from 'vitest';
3
+ import { BreakpointPanel } from '../breakpoint-panel';
4
+ import { createMockTaskDetail } from '@/test/fixtures';
5
+ import type { TaskDetail } from '@/types';
6
+
7
+ // Mock the server action used by BreakpointApproval
8
+ vi.mock('@/app/actions/approve-breakpoint', () => ({
9
+ approveBreakpoint: vi.fn().mockResolvedValue({ success: true }),
10
+ }));
11
+
12
+ describe('BreakpointPanel', () => {
13
+ const defaultRunId = 'run-123';
14
+
15
+ function makeBreakpointTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
16
+ return createMockTaskDetail({
17
+ kind: 'breakpoint',
18
+ status: 'requested',
19
+ breakpointQuestion: 'Should we deploy to production?',
20
+ title: 'Deploy Approval',
21
+ breakpoint: {
22
+ question: 'Should we deploy to production?',
23
+ title: 'Deploy Approval',
24
+ context: { files: [] },
25
+ },
26
+ ...overrides,
27
+ });
28
+ }
29
+
30
+ // -----------------------------------------------------------------------
31
+ // Rendering
32
+ // -----------------------------------------------------------------------
33
+ it('renders the breakpoint title', () => {
34
+ const task = makeBreakpointTask();
35
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
36
+
37
+ expect(screen.getByText('Deploy Approval')).toBeInTheDocument();
38
+ });
39
+
40
+ it('renders the breakpoint question', () => {
41
+ const task = makeBreakpointTask();
42
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
43
+
44
+ expect(screen.getByText('Should we deploy to production?')).toBeInTheDocument();
45
+ });
46
+
47
+ it('renders the "Breakpoint" badge', () => {
48
+ const task = makeBreakpointTask();
49
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
50
+
51
+ expect(screen.getByText('Breakpoint')).toBeInTheDocument();
52
+ });
53
+
54
+ it('renders the "Awaiting decision" label', () => {
55
+ const task = makeBreakpointTask();
56
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
57
+
58
+ expect(screen.getByText('Awaiting decision')).toBeInTheDocument();
59
+ });
60
+
61
+ // -----------------------------------------------------------------------
62
+ // Approval form for pending breakpoints
63
+ // -----------------------------------------------------------------------
64
+ it('renders approval form for requested breakpoints', () => {
65
+ const task = makeBreakpointTask({ status: 'requested' });
66
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
67
+
68
+ expect(screen.getByTestId('breakpoint-approval')).toBeInTheDocument();
69
+ expect(screen.getByTestId('approve-btn')).toBeInTheDocument();
70
+ expect(screen.queryByText('Reject')).not.toBeInTheDocument();
71
+ });
72
+
73
+ it('does not render approval form for resolved breakpoints', () => {
74
+ const task = makeBreakpointTask({ status: 'resolved' });
75
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
76
+
77
+ expect(screen.queryByTestId('breakpoint-approval')).not.toBeInTheDocument();
78
+ });
79
+
80
+ // -----------------------------------------------------------------------
81
+ // Already resolved state (task.status === 'resolved')
82
+ // -----------------------------------------------------------------------
83
+ it('shows "Already Resolved" badge when task.status is resolved', () => {
84
+ const task = makeBreakpointTask({ status: 'resolved' });
85
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
86
+
87
+ expect(screen.getByText('Already Resolved')).toBeInTheDocument();
88
+ });
89
+
90
+ // -----------------------------------------------------------------------
91
+ // Resolved state display
92
+ // -----------------------------------------------------------------------
93
+ it('shows success message when task is resolved', () => {
94
+ const task = makeBreakpointTask({ status: 'resolved' });
95
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
96
+
97
+ expect(screen.getByText('Approved')).toBeInTheDocument();
98
+ expect(screen.getByText('Breakpoint has been resolved')).toBeInTheDocument();
99
+ });
100
+
101
+ // -----------------------------------------------------------------------
102
+ // Fallback question text
103
+ // -----------------------------------------------------------------------
104
+ it('falls back to breakpointQuestion when breakpoint payload is missing', () => {
105
+ const task = createMockTaskDetail({
106
+ kind: 'breakpoint',
107
+ status: 'requested',
108
+ breakpointQuestion: 'Fallback question?',
109
+ title: 'Fallback Title',
110
+ breakpoint: undefined,
111
+ });
112
+
113
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
114
+
115
+ expect(screen.getByText('Fallback question?')).toBeInTheDocument();
116
+ });
117
+
118
+ it('falls back to "Approval required" when no question is provided', () => {
119
+ const task = createMockTaskDetail({
120
+ kind: 'breakpoint',
121
+ status: 'requested',
122
+ breakpointQuestion: undefined,
123
+ breakpoint: undefined,
124
+ });
125
+
126
+ render(<BreakpointPanel task={task} runId={defaultRunId} />);
127
+
128
+ expect(screen.getByText('Approval required')).toBeInTheDocument();
129
+ });
130
+ });
@@ -0,0 +1,313 @@
1
+ import { render, screen, setupUser } from '@/test/test-utils';
2
+ import { FilePreview } from '../file-preview';
3
+ import type { BreakpointFile } from '@/types';
4
+
5
+ describe('FilePreview', () => {
6
+ const defaultProps = {
7
+ runId: 'run-1',
8
+ effectId: 'eff-1',
9
+ };
10
+
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ // Reset global fetch mock
14
+ vi.stubGlobal('fetch', vi.fn());
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ // -----------------------------------------------------------------------
22
+ // Null rendering
23
+ // -----------------------------------------------------------------------
24
+ it('returns null when files array is empty', () => {
25
+ const { container } = render(
26
+ <FilePreview files={[]} {...defaultProps} />,
27
+ );
28
+
29
+ expect(container.innerHTML).toBe('');
30
+ });
31
+
32
+ // -----------------------------------------------------------------------
33
+ // Renders file list
34
+ // -----------------------------------------------------------------------
35
+ it('renders "Attached Files" heading', () => {
36
+ const files: BreakpointFile[] = [
37
+ { path: 'src/index.ts', format: 'code', language: 'typescript' },
38
+ ];
39
+
40
+ render(<FilePreview files={files} {...defaultProps} />);
41
+
42
+ expect(screen.getByText('Attached Files')).toBeInTheDocument();
43
+ });
44
+
45
+ it('renders file paths in the list', () => {
46
+ const files: BreakpointFile[] = [
47
+ { path: 'src/main.ts', format: 'code' },
48
+ { path: 'README.md', format: 'markdown' },
49
+ ];
50
+
51
+ render(<FilePreview files={files} {...defaultProps} />);
52
+
53
+ expect(screen.getByText('src/main.ts')).toBeInTheDocument();
54
+ expect(screen.getByText('README.md')).toBeInTheDocument();
55
+ });
56
+
57
+ // -----------------------------------------------------------------------
58
+ // Format badges
59
+ // -----------------------------------------------------------------------
60
+ it('displays format badges for each file', () => {
61
+ const files: BreakpointFile[] = [
62
+ { path: 'config.json', format: 'json' },
63
+ { path: 'notes.md', format: 'markdown' },
64
+ { path: 'script.sh', format: 'code' },
65
+ ];
66
+
67
+ render(<FilePreview files={files} {...defaultProps} />);
68
+
69
+ expect(screen.getByText('json')).toBeInTheDocument();
70
+ expect(screen.getByText('markdown')).toBeInTheDocument();
71
+ expect(screen.getByText('code')).toBeInTheDocument();
72
+ });
73
+
74
+ // -----------------------------------------------------------------------
75
+ // Language badge
76
+ // -----------------------------------------------------------------------
77
+ it('displays language badge when language is specified', () => {
78
+ const files: BreakpointFile[] = [
79
+ { path: 'app.py', format: 'code', language: 'python' },
80
+ ];
81
+
82
+ render(<FilePreview files={files} {...defaultProps} />);
83
+
84
+ expect(screen.getByText('python')).toBeInTheDocument();
85
+ });
86
+
87
+ it('does not display language badge when language is not specified', () => {
88
+ const files: BreakpointFile[] = [
89
+ { path: 'data.json', format: 'json' },
90
+ ];
91
+
92
+ render(<FilePreview files={files} {...defaultProps} />);
93
+
94
+ // Only the format badge should be present
95
+ expect(screen.getByText('json')).toBeInTheDocument();
96
+ // No language badge -- check there's only one badge-like element for this file
97
+ expect(screen.queryByText('python')).not.toBeInTheDocument();
98
+ });
99
+
100
+ // -----------------------------------------------------------------------
101
+ // Icons by format
102
+ // -----------------------------------------------------------------------
103
+ it('renders markdown icon for markdown files', () => {
104
+ const files: BreakpointFile[] = [
105
+ { path: 'README.md', format: 'markdown' },
106
+ ];
107
+
108
+ render(<FilePreview files={files} {...defaultProps} />);
109
+
110
+ expect(screen.getByTestId('icon-FileText')).toBeInTheDocument();
111
+ });
112
+
113
+ it('renders json icon for json files', () => {
114
+ const files: BreakpointFile[] = [
115
+ { path: 'config.json', format: 'json' },
116
+ ];
117
+
118
+ render(<FilePreview files={files} {...defaultProps} />);
119
+
120
+ expect(screen.getByTestId('icon-FileJson')).toBeInTheDocument();
121
+ });
122
+
123
+ it('renders code icon for code files', () => {
124
+ const files: BreakpointFile[] = [
125
+ { path: 'main.ts', format: 'code' },
126
+ ];
127
+
128
+ render(<FilePreview files={files} {...defaultProps} />);
129
+
130
+ expect(screen.getByTestId('icon-Code')).toBeInTheDocument();
131
+ });
132
+
133
+ it('renders fallback icon for unknown format', () => {
134
+ const files: BreakpointFile[] = [
135
+ { path: 'file.xyz', format: 'unknown' },
136
+ ];
137
+
138
+ render(<FilePreview files={files} {...defaultProps} />);
139
+
140
+ // Fallback uses FileText icon
141
+ expect(screen.getByTestId('icon-FileText')).toBeInTheDocument();
142
+ });
143
+
144
+ // -----------------------------------------------------------------------
145
+ // File content loading via accordion expand
146
+ // -----------------------------------------------------------------------
147
+ it('fetches and displays file content when accordion item is expanded', async () => {
148
+ const user = setupUser();
149
+ const mockContent = 'const x = 42;';
150
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
151
+ Promise.resolve(new Response(JSON.stringify({ content: mockContent }), {
152
+ status: 200,
153
+ headers: { 'Content-Type': 'application/json' },
154
+ })),
155
+ ));
156
+
157
+ const files: BreakpointFile[] = [
158
+ { path: 'src/index.ts', format: 'code', language: 'typescript' },
159
+ ];
160
+
161
+ render(<FilePreview files={files} {...defaultProps} />);
162
+
163
+ // Click the accordion trigger to expand
164
+ await user.click(screen.getByText('src/index.ts'));
165
+
166
+ // Wait for the content to appear
167
+ expect(await screen.findByText('const x = 42;')).toBeInTheDocument();
168
+
169
+ // Verify fetch was called with the correct URL
170
+ expect(fetch).toHaveBeenCalledWith(
171
+ '/api/runs/run-1/tasks/eff-1?file=src%2Findex.ts',
172
+ expect.anything(),
173
+ );
174
+ });
175
+
176
+ it('shows fallback text when content fetch fails', async () => {
177
+ const user = setupUser();
178
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
179
+ Promise.resolve(new Response('Unprocessable Entity', {
180
+ status: 422,
181
+ headers: { 'Content-Type': 'text/plain' },
182
+ })),
183
+ ));
184
+
185
+ const files: BreakpointFile[] = [
186
+ { path: 'broken.ts', format: 'code' },
187
+ ];
188
+
189
+ render(<FilePreview files={files} {...defaultProps} />);
190
+
191
+ await user.click(screen.getByText('broken.ts'));
192
+
193
+ expect(await screen.findByText('// Failed to load file content')).toBeInTheDocument();
194
+ });
195
+
196
+ it('shows "No content available" when API returns empty content', async () => {
197
+ const user = setupUser();
198
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
199
+ Promise.resolve(new Response(JSON.stringify({}), {
200
+ status: 200,
201
+ headers: { 'Content-Type': 'application/json' },
202
+ })),
203
+ ));
204
+
205
+ const files: BreakpointFile[] = [
206
+ { path: 'empty.ts', format: 'code' },
207
+ ];
208
+
209
+ render(<FilePreview files={files} {...defaultProps} />);
210
+
211
+ await user.click(screen.getByText('empty.ts'));
212
+
213
+ expect(await screen.findByText('// No content available')).toBeInTheDocument();
214
+ });
215
+
216
+ // -----------------------------------------------------------------------
217
+ // JSON formatting
218
+ // -----------------------------------------------------------------------
219
+ it('pretty-prints JSON content', async () => {
220
+ const user = setupUser();
221
+ const jsonContent = '{"key":"value","num":42}';
222
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
223
+ Promise.resolve(new Response(JSON.stringify({ content: jsonContent }), {
224
+ status: 200,
225
+ headers: { 'Content-Type': 'application/json' },
226
+ })),
227
+ ));
228
+
229
+ const files: BreakpointFile[] = [
230
+ { path: 'data.json', format: 'json' },
231
+ ];
232
+
233
+ render(<FilePreview files={files} {...defaultProps} />);
234
+
235
+ await user.click(screen.getByText('data.json'));
236
+
237
+ // Pretty-printed JSON includes the key on a separate line
238
+ expect(await screen.findByText(/"key": "value",/)).toBeInTheDocument();
239
+ });
240
+
241
+ // -----------------------------------------------------------------------
242
+ // Markdown rendering
243
+ // -----------------------------------------------------------------------
244
+ it('renders markdown content as preformatted text', async () => {
245
+ const user = setupUser();
246
+ const mdContent = '# Hello World\nSome paragraph text';
247
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
248
+ Promise.resolve(new Response(JSON.stringify({ content: mdContent }), {
249
+ status: 200,
250
+ headers: { 'Content-Type': 'application/json' },
251
+ })),
252
+ ));
253
+
254
+ const files: BreakpointFile[] = [
255
+ { path: 'notes.md', format: 'markdown' },
256
+ ];
257
+
258
+ render(<FilePreview files={files} {...defaultProps} />);
259
+
260
+ await user.click(screen.getByText('notes.md'));
261
+
262
+ expect(await screen.findByText(/# Hello World/)).toBeInTheDocument();
263
+ });
264
+
265
+ // -----------------------------------------------------------------------
266
+ // Code content with line numbers
267
+ // -----------------------------------------------------------------------
268
+ it('renders code content with line numbers', async () => {
269
+ const user = setupUser();
270
+ const codeContent = 'line one\nline two\nline three';
271
+ vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
272
+ Promise.resolve(new Response(JSON.stringify({ content: codeContent }), {
273
+ status: 200,
274
+ headers: { 'Content-Type': 'application/json' },
275
+ })),
276
+ ));
277
+
278
+ const files: BreakpointFile[] = [
279
+ { path: 'main.ts', format: 'code' },
280
+ ];
281
+
282
+ render(<FilePreview files={files} {...defaultProps} />);
283
+
284
+ await user.click(screen.getByText('main.ts'));
285
+
286
+ // Line numbers should be rendered
287
+ expect(await screen.findByText('1')).toBeInTheDocument();
288
+ expect(screen.getByText('2')).toBeInTheDocument();
289
+ expect(screen.getByText('3')).toBeInTheDocument();
290
+
291
+ // Content should be rendered
292
+ expect(screen.getByText('line one')).toBeInTheDocument();
293
+ expect(screen.getByText('line two')).toBeInTheDocument();
294
+ expect(screen.getByText('line three')).toBeInTheDocument();
295
+ });
296
+
297
+ // -----------------------------------------------------------------------
298
+ // Multiple files
299
+ // -----------------------------------------------------------------------
300
+ it('renders multiple files in the accordion', () => {
301
+ const files: BreakpointFile[] = [
302
+ { path: 'file1.ts', format: 'code' },
303
+ { path: 'file2.json', format: 'json' },
304
+ { path: 'file3.md', format: 'markdown' },
305
+ ];
306
+
307
+ render(<FilePreview files={files} {...defaultProps} />);
308
+
309
+ expect(screen.getByText('file1.ts')).toBeInTheDocument();
310
+ expect(screen.getByText('file2.json')).toBeInTheDocument();
311
+ expect(screen.getByText('file3.md')).toBeInTheDocument();
312
+ });
313
+ });