-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathuseBatchNotificationWatcher.test.ts
More file actions
239 lines (201 loc) · 8.15 KB
/
Copy pathuseBatchNotificationWatcher.test.ts
File metadata and controls
239 lines (201 loc) · 8.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import { renderHook, act, waitFor } from '@testing-library/react';
import { useBatchNotificationWatcher } from '@/hooks/useBatchNotificationWatcher';
import { batchesApi, tasksApi } from '@/lib/api';
import { getSelectedWorkspacePath } from '@/lib/workspace-storage';
import type { BatchListResponse, BatchResponse, Task } from '@/types';
jest.mock('@/lib/api');
jest.mock('@/lib/workspace-storage');
const mockList = batchesApi.list as jest.MockedFunction<typeof batchesApi.list>;
const mockGetTask = tasksApi.getOne as jest.MockedFunction<typeof tasksApi.getOne>;
const mockGetWorkspacePath = getSelectedWorkspacePath as jest.MockedFunction<
typeof getSelectedWorkspacePath
>;
function batch(overrides: Partial<BatchResponse> = {}): BatchResponse {
return {
id: 'batch-1234abcd',
workspace_id: 'ws-1',
task_ids: ['t1'],
status: 'RUNNING',
strategy: 'serial',
max_parallel: 1,
on_failure: 'continue',
started_at: null,
completed_at: null,
results: { t1: 'IN_PROGRESS' },
...overrides,
};
}
function listResponse(batches: BatchResponse[]): BatchListResponse {
return { batches, total: batches.length, by_status: {} };
}
// Queue a sequence of list() responses, one per poll tick.
function queueResponses(...responses: BatchListResponse[]) {
mockList.mockReset();
responses.forEach((r) => mockList.mockResolvedValueOnce(r));
// Any further polls repeat the last response.
if (responses.length > 0) {
mockList.mockResolvedValue(responses[responses.length - 1]);
}
}
const INTERVAL = 1000;
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
mockGetWorkspacePath.mockReturnValue('/ws');
mockGetTask.mockResolvedValue({ id: 't1', title: 'Build login form' } as Task);
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
/** Run the immediate mount poll + flush its async work. */
async function flushPoll() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
/** Advance one polling interval and flush async work. */
async function tick() {
await act(async () => {
jest.advanceTimersByTime(INTERVAL);
await Promise.resolve();
await Promise.resolve();
});
}
describe('useBatchNotificationWatcher', () => {
it('does not notify for batches already terminal on the first poll (baseline)', async () => {
const addNotification = jest.fn();
queueResponses(listResponse([batch({ status: 'COMPLETED', results: { t1: 'COMPLETED' } })]));
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll();
expect(addNotification).not.toHaveBeenCalled();
});
it('fires batch.completed when a running batch transitions to a terminal state', async () => {
const addNotification = jest.fn();
queueResponses(
listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]),
listResponse([batch({ status: 'COMPLETED', results: { t1: 'COMPLETED' } })])
);
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll(); // baseline = RUNNING
expect(addNotification).not.toHaveBeenCalled();
await tick(); // now COMPLETED
expect(addNotification).toHaveBeenCalledTimes(1);
expect(addNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'batch.completed',
batchStatus: 'COMPLETED',
batchId: 'batch-1234abcd',
})
);
});
it('fires batch.completed only once across repeated polls', async () => {
const addNotification = jest.fn();
queueResponses(
listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]),
listResponse([batch({ status: 'FAILED', results: { t1: 'FAILED' } })])
);
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll();
await tick(); // FAILED
await tick(); // still FAILED — must not re-fire
expect(addNotification).toHaveBeenCalledTimes(1);
expect(addNotification).toHaveBeenCalledWith(
expect.objectContaining({ type: 'batch.completed', batchStatus: 'FAILED' })
);
});
it('fires blocker.created with the task title when a task transitions to BLOCKED', async () => {
const addNotification = jest.fn();
queueResponses(
listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })]),
listResponse([batch({ status: 'RUNNING', results: { t1: 'BLOCKED' } })])
);
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll();
await tick();
await waitFor(() =>
expect(addNotification).toHaveBeenCalledWith(
expect.objectContaining({
type: 'blocker.created',
taskId: 't1',
message: expect.stringContaining('Build login form'),
})
)
);
});
it('does nothing when no workspace is selected', async () => {
const addNotification = jest.fn();
mockGetWorkspacePath.mockReturnValue(null);
queueResponses(listResponse([batch()]));
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll();
expect(mockList).not.toHaveBeenCalled();
expect(addNotification).not.toHaveBeenCalled();
});
it('does not start an overlapping poll while one is still in flight', async () => {
const addNotification = jest.fn();
// First list() never resolves during the test window — simulates a slow poll.
let resolveSlow: (v: BatchListResponse) => void = () => {};
const slow = new Promise<BatchListResponse>((res) => {
resolveSlow = res;
});
mockList.mockReset();
mockList.mockReturnValueOnce(slow);
mockList.mockResolvedValue(listResponse([batch()]));
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll(); // immediate poll starts, awaiting `slow`
await tick(); // interval fires but must be skipped (in-flight)
await tick();
// Only the one still-pending call was made; no overlap.
expect(mockList).toHaveBeenCalledTimes(1);
// Let the slow poll finish; subsequent ticks resume normally.
await act(async () => {
resolveSlow(listResponse([batch()]));
await Promise.resolve();
await Promise.resolve();
});
await tick();
expect(mockList.mock.calls.length).toBeGreaterThan(1);
});
it('does not dispatch stale notifications when the workspace changes mid-poll', async () => {
const addNotification = jest.fn();
// A slow poll for workspace /ws-a that resolves with a terminal transition.
let resolveSlow: (v: BatchListResponse) => void = () => {};
const slow = new Promise<BatchListResponse>((res) => {
resolveSlow = res;
});
mockGetWorkspacePath.mockReturnValue('/ws-a');
mockList.mockReset();
// First poll (baseline) sees RUNNING; second poll returns the slow promise.
mockList.mockResolvedValueOnce(
listResponse([batch({ status: 'RUNNING', results: { t1: 'IN_PROGRESS' } })])
);
mockList.mockReturnValueOnce(slow);
mockList.mockResolvedValue(listResponse([batch()]));
renderHook(() => useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL }));
await flushPoll(); // baseline RUNNING for /ws-a
await tick(); // second poll starts, awaiting `slow`
// Workspace switches away before the slow poll resolves.
mockGetWorkspacePath.mockReturnValue('/ws-b');
await act(async () => {
resolveSlow(listResponse([batch({ status: 'COMPLETED', results: { t1: 'COMPLETED' } })]));
await Promise.resolve();
await Promise.resolve();
});
// The terminal transition belongs to /ws-a, which is no longer active.
expect(addNotification).not.toHaveBeenCalled();
});
it('stops polling after unmount', async () => {
const addNotification = jest.fn();
queueResponses(listResponse([batch()]));
const { unmount } = renderHook(() =>
useBatchNotificationWatcher(addNotification, { intervalMs: INTERVAL })
);
await flushPoll();
const callsBefore = mockList.mock.calls.length;
unmount();
await tick();
expect(mockList.mock.calls.length).toBe(callsBefore);
});
});