@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,168 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
3
+ import { useRunDashboard } from '../use-run-dashboard';
4
+ import { createMockProjectSummary } from '@/test/fixtures';
5
+
6
+ // Mock useProjects
7
+ const mockRefresh = vi.fn();
8
+ vi.mock('../use-projects', () => ({
9
+ useProjects: vi.fn(() => ({
10
+ projects: [],
11
+ recentCompletionWindowMs: 14400000,
12
+ loading: false,
13
+ error: undefined,
14
+ refresh: mockRefresh,
15
+ })),
16
+ }));
17
+
18
+ // Mock usePersistedState to behave like useState
19
+ vi.mock('../use-persisted-state', () => ({
20
+ usePersistedState: <T>(key: string, defaultValue: T) => {
21
+ const { useState } = require('react');
22
+ return useState<T>(defaultValue);
23
+ },
24
+ }));
25
+
26
+ // Import the mocked module so we can change its return value
27
+ import { useProjects } from '../use-projects';
28
+ const mockedUseProjects = vi.mocked(useProjects);
29
+
30
+ describe('useRunDashboard', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ mockedUseProjects.mockReturnValue({
34
+ projects: [],
35
+ recentCompletionWindowMs: 14400000,
36
+ loading: false,
37
+ error: undefined,
38
+ refresh: mockRefresh,
39
+ });
40
+ });
41
+
42
+ it('returns default state when no projects', () => {
43
+ const { result } = renderHook(() => useRunDashboard());
44
+
45
+ expect(result.current.projects).toEqual([]);
46
+ expect(result.current.loading).toBe(false);
47
+ expect(result.current.error).toBeUndefined();
48
+ expect(result.current.statusFilter).toBe('all');
49
+ expect(result.current.sortMode).toBe('status');
50
+ expect(result.current.metrics.totalRuns).toBe(0);
51
+ expect(result.current.hasStaleRuns).toBe(false);
52
+ });
53
+
54
+ it('aggregates metrics from multiple projects', () => {
55
+ mockedUseProjects.mockReturnValue({
56
+ projects: [
57
+ createMockProjectSummary({ totalRuns: 10, activeRuns: 3, completedRuns: 5, failedRuns: 2, staleRuns: 1 }),
58
+ createMockProjectSummary({ totalRuns: 8, activeRuns: 1, completedRuns: 6, failedRuns: 1, staleRuns: 0 }),
59
+ ],
60
+ recentCompletionWindowMs: 14400000,
61
+ loading: false,
62
+ error: undefined,
63
+ refresh: mockRefresh,
64
+ });
65
+
66
+ const { result } = renderHook(() => useRunDashboard());
67
+
68
+ expect(result.current.metrics.totalRuns).toBe(18);
69
+ expect(result.current.metrics.activeRuns).toBe(4);
70
+ expect(result.current.metrics.completedRuns).toBe(11);
71
+ expect(result.current.metrics.failedRuns).toBe(3);
72
+ expect(result.current.metrics.staleRuns).toBe(1);
73
+ expect(result.current.hasStaleRuns).toBe(true);
74
+ });
75
+
76
+ it('toggleMetricFilter toggles between filter and "all"', () => {
77
+ const { result } = renderHook(() => useRunDashboard());
78
+
79
+ act(() => {
80
+ result.current.toggleMetricFilter('failed');
81
+ });
82
+ expect(result.current.statusFilter).toBe('failed');
83
+
84
+ act(() => {
85
+ result.current.toggleMetricFilter('failed');
86
+ });
87
+ expect(result.current.statusFilter).toBe('all');
88
+ });
89
+
90
+ it('filters projects by status', () => {
91
+ mockedUseProjects.mockReturnValue({
92
+ projects: [
93
+ createMockProjectSummary({ projectName: 'a', failedRuns: 2, activeRuns: 0 }),
94
+ createMockProjectSummary({ projectName: 'b', failedRuns: 0, activeRuns: 1 }),
95
+ ],
96
+ recentCompletionWindowMs: 14400000,
97
+ loading: false,
98
+ error: undefined,
99
+ refresh: mockRefresh,
100
+ });
101
+
102
+ const { result } = renderHook(() => useRunDashboard());
103
+
104
+ act(() => {
105
+ result.current.setStatusFilter('failed');
106
+ });
107
+
108
+ expect(result.current.filteredProjects).toHaveLength(1);
109
+ expect(result.current.filteredProjects[0].projectName).toBe('a');
110
+ });
111
+
112
+ it('handleHideProject calls refresh', () => {
113
+ const { result } = renderHook(() => useRunDashboard());
114
+
115
+ act(() => {
116
+ result.current.handleHideProject('some-project');
117
+ });
118
+
119
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it('maps stale statusFilter to "all" for cardStatusFilter', () => {
123
+ const { result } = renderHook(() => useRunDashboard());
124
+
125
+ act(() => {
126
+ result.current.setStatusFilter('stale');
127
+ });
128
+
129
+ expect(result.current.cardStatusFilter).toBe('all');
130
+ });
131
+
132
+ it('computes filterCounts from metrics', () => {
133
+ mockedUseProjects.mockReturnValue({
134
+ projects: [
135
+ createMockProjectSummary({ totalRuns: 15, activeRuns: 3, completedRuns: 10, failedRuns: 2, staleRuns: 1 }),
136
+ ],
137
+ recentCompletionWindowMs: 14400000,
138
+ loading: false,
139
+ error: undefined,
140
+ refresh: mockRefresh,
141
+ });
142
+
143
+ const { result } = renderHook(() => useRunDashboard());
144
+
145
+ expect(result.current.filterCounts.all).toBe(15);
146
+ expect(result.current.filterCounts.waiting).toBe(3);
147
+ expect(result.current.filterCounts.completed).toBe(10);
148
+ expect(result.current.filterCounts.failed).toBe(2);
149
+ expect(result.current.filterCounts.stale).toBe(1);
150
+ });
151
+
152
+ it('computes bannerFingerprint from issue metrics', () => {
153
+ mockedUseProjects.mockReturnValue({
154
+ projects: [
155
+ createMockProjectSummary({ failedRuns: 2, staleRuns: 1 }),
156
+ ],
157
+ recentCompletionWindowMs: 14400000,
158
+ loading: false,
159
+ error: undefined,
160
+ refresh: mockRefresh,
161
+ });
162
+
163
+ const { result } = renderHook(() => useRunDashboard());
164
+
165
+ // fingerprint format: failedRuns-staleRuns-pendingBreakpoints
166
+ expect(result.current.bannerFingerprint).toBe('2-1-0');
167
+ });
168
+ });
@@ -0,0 +1,273 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { createMockRun, createMockTaskEffect, createMockTaskDetail } from '@/test/fixtures';
3
+ import { useRunDetail, useTaskDetail } from '../use-run-detail';
4
+
5
+ type MockEventSourceInstance = {
6
+ onopen: ((event: Event) => void) | null;
7
+ onmessage: ((event: MessageEvent) => void) | null;
8
+ onerror: ((event: Event) => void) | null;
9
+ close: ReturnType<typeof vi.fn>;
10
+ readyState: number;
11
+ url: string;
12
+ };
13
+
14
+ let mockEventSourceInstances: MockEventSourceInstance[] = [];
15
+
16
+ class MockEventSource {
17
+ static CONNECTING = 0;
18
+ static OPEN = 1;
19
+ static CLOSED = 2;
20
+
21
+ onopen: ((event: Event) => void) | null = null;
22
+ onmessage: ((event: MessageEvent) => void) | null = null;
23
+ onerror: ((event: Event) => void) | null = null;
24
+ close = vi.fn();
25
+ readyState = MockEventSource.OPEN;
26
+ url: string;
27
+
28
+ constructor(url: string) {
29
+ this.url = url;
30
+ mockEventSourceInstances.push(this);
31
+ }
32
+ }
33
+
34
+ describe('useRunDetail', () => {
35
+ const mockRun = createMockRun({
36
+ runId: 'run-123',
37
+ tasks: [
38
+ createMockTaskEffect({ effectId: 'eff-1', kind: 'node', status: 'resolved' }),
39
+ createMockTaskEffect({ effectId: 'eff-2', kind: 'agent', status: 'resolved' }),
40
+ ],
41
+ });
42
+
43
+ beforeEach(() => {
44
+ vi.useFakeTimers();
45
+ mockEventSourceInstances = [];
46
+ vi.stubGlobal('EventSource', MockEventSource);
47
+ vi.stubGlobal(
48
+ 'fetch',
49
+ vi.fn().mockResolvedValue(
50
+ new Response(JSON.stringify({ run: mockRun }), {
51
+ status: 200,
52
+ headers: { 'Content-Type': 'application/json' },
53
+ })
54
+ )
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.useRealTimers();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ it('fetches run detail and returns run data', async () => {
64
+ const { result } = renderHook(() => useRunDetail('run-123'));
65
+
66
+ expect(result.current.loading).toBe(true);
67
+
68
+ await act(async () => {
69
+ await vi.advanceTimersByTimeAsync(0);
70
+ });
71
+
72
+ expect(result.current.run).toEqual(mockRun);
73
+ expect(result.current.loading).toBe(false);
74
+ expect(result.current.error).toBeNull();
75
+ });
76
+
77
+ it('calls the correct API endpoint', async () => {
78
+ renderHook(() => useRunDetail('run-123'));
79
+
80
+ await act(async () => {
81
+ await vi.advanceTimersByTimeAsync(0);
82
+ });
83
+
84
+ expect(fetch).toHaveBeenCalledWith('/api/runs/run-123?maxEvents=50', expect.anything());
85
+ });
86
+
87
+ it('returns null run when data is not loaded', () => {
88
+ const { result } = renderHook(() => useRunDetail('run-123'));
89
+
90
+ // Before data loads
91
+ expect(result.current.run).toBeNull();
92
+ });
93
+
94
+ it('detects hasBreakpointWaiting when breakpoint task is requested', async () => {
95
+ const runWithBreakpoint = createMockRun({
96
+ runId: 'run-bp',
97
+ tasks: [
98
+ createMockTaskEffect({ effectId: 'eff-1', kind: 'node', status: 'resolved' }),
99
+ createMockTaskEffect({ effectId: 'eff-bp', kind: 'breakpoint', status: 'requested' }),
100
+ ],
101
+ });
102
+
103
+ vi.stubGlobal(
104
+ 'fetch',
105
+ vi.fn().mockResolvedValue(
106
+ new Response(JSON.stringify({ run: runWithBreakpoint }), {
107
+ status: 200,
108
+ headers: { 'Content-Type': 'application/json' },
109
+ })
110
+ )
111
+ );
112
+
113
+ const { result } = renderHook(() => useRunDetail('run-bp'));
114
+
115
+ await act(async () => {
116
+ await vi.advanceTimersByTimeAsync(0);
117
+ });
118
+
119
+ expect(result.current.hasBreakpointWaiting).toBe(true);
120
+ });
121
+
122
+ it('hasBreakpointWaiting is false when no breakpoints are requested', async () => {
123
+ const { result } = renderHook(() => useRunDetail('run-123'));
124
+
125
+ await act(async () => {
126
+ await vi.advanceTimersByTimeAsync(0);
127
+ });
128
+
129
+ expect(result.current.hasBreakpointWaiting).toBe(false);
130
+ });
131
+
132
+ it('hasBreakpointWaiting is false when run is null', () => {
133
+ const { result } = renderHook(() => useRunDetail('run-123'));
134
+
135
+ // Before data loads
136
+ expect(result.current.hasBreakpointWaiting).toBe(false);
137
+ });
138
+
139
+ it('handles fetch error', async () => {
140
+ // Use 422 (non-retryable) to avoid retries from resilientFetch (404 is retryable)
141
+ vi.stubGlobal(
142
+ 'fetch',
143
+ vi.fn().mockResolvedValue(
144
+ new Response('HTTP 422', { status: 422 })
145
+ )
146
+ );
147
+
148
+ const { result } = renderHook(() => useRunDetail('run-123'));
149
+
150
+ await act(async () => {
151
+ await vi.advanceTimersByTimeAsync(0);
152
+ });
153
+
154
+ expect(result.current.error).toBe('HTTP 422');
155
+ expect(result.current.run).toBeNull();
156
+ });
157
+
158
+ it('provides a refresh function', async () => {
159
+ const { result } = renderHook(() => useRunDetail('run-123'));
160
+
161
+ await act(async () => {
162
+ await vi.advanceTimersByTimeAsync(0);
163
+ });
164
+ const callsAfterMount = vi.mocked(fetch).mock.calls.length;
165
+
166
+ await act(async () => {
167
+ result.current.refresh();
168
+ await vi.advanceTimersByTimeAsync(0);
169
+ });
170
+ // refresh() should trigger at least one additional fetch
171
+ expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan(callsAfterMount);
172
+ });
173
+
174
+ it('accepts custom polling interval', async () => {
175
+ renderHook(() => useRunDetail('run-123', 10000));
176
+
177
+ await act(async () => {
178
+ await vi.advanceTimersByTimeAsync(0);
179
+ });
180
+ expect(fetch).toHaveBeenCalledTimes(1);
181
+
182
+ await act(async () => {
183
+ await vi.advanceTimersByTimeAsync(10000);
184
+ });
185
+ expect(fetch).toHaveBeenCalledTimes(2);
186
+ });
187
+ });
188
+
189
+ describe('useTaskDetail', () => {
190
+ const mockTask = createMockTaskDetail({
191
+ effectId: 'eff-42',
192
+ kind: 'shell',
193
+ status: 'resolved',
194
+ });
195
+
196
+ beforeEach(() => {
197
+ vi.useFakeTimers();
198
+ mockEventSourceInstances = [];
199
+ vi.stubGlobal('EventSource', MockEventSource);
200
+ vi.stubGlobal(
201
+ 'fetch',
202
+ vi.fn().mockResolvedValue(
203
+ new Response(JSON.stringify({ task: mockTask }), {
204
+ status: 200,
205
+ headers: { 'Content-Type': 'application/json' },
206
+ })
207
+ )
208
+ );
209
+ });
210
+
211
+ afterEach(() => {
212
+ vi.useRealTimers();
213
+ vi.restoreAllMocks();
214
+ });
215
+
216
+ it('fetches task detail when effectId is provided', async () => {
217
+ const { result } = renderHook(() => useTaskDetail('run-123', 'eff-42'));
218
+
219
+ await act(async () => {
220
+ await vi.advanceTimersByTimeAsync(0);
221
+ });
222
+
223
+ expect(result.current.task).toEqual(mockTask);
224
+ expect(result.current.loading).toBe(false);
225
+ expect(result.current.error).toBeNull();
226
+ });
227
+
228
+ it('calls correct API endpoint', async () => {
229
+ renderHook(() => useTaskDetail('run-123', 'eff-42'));
230
+
231
+ await act(async () => {
232
+ await vi.advanceTimersByTimeAsync(0);
233
+ });
234
+
235
+ expect(fetch).toHaveBeenCalledWith('/api/runs/run-123/tasks/eff-42', expect.anything());
236
+ });
237
+
238
+ it('does not fetch when effectId is null', async () => {
239
+ const { result } = renderHook(() => useTaskDetail('run-123', null));
240
+
241
+ await act(async () => {
242
+ await vi.advanceTimersByTimeAsync(5000);
243
+ });
244
+
245
+ expect(fetch).not.toHaveBeenCalled();
246
+ expect(result.current.task).toBeNull();
247
+ });
248
+
249
+ it('returns null task when data is not loaded', () => {
250
+ const { result } = renderHook(() => useTaskDetail('run-123', 'eff-42'));
251
+
252
+ expect(result.current.task).toBeNull();
253
+ });
254
+
255
+ it('handles fetch error', async () => {
256
+ // Use 422 (non-retryable) to avoid retries from resilientFetch (404 is retryable)
257
+ vi.stubGlobal(
258
+ 'fetch',
259
+ vi.fn().mockResolvedValue(
260
+ new Response('HTTP 422', { status: 422 })
261
+ )
262
+ );
263
+
264
+ const { result } = renderHook(() => useTaskDetail('run-123', 'eff-42'));
265
+
266
+ await act(async () => {
267
+ await vi.advanceTimersByTimeAsync(0);
268
+ });
269
+
270
+ expect(result.current.error).toBe('HTTP 422');
271
+ expect(result.current.task).toBeNull();
272
+ });
273
+ });