mvc-kit 2.12.0 → 2.12.2
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.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Pending } from './Pending';
|
|
3
|
+
import { HttpError } from './errors';
|
|
4
|
+
import { ViewModel } from './ViewModel';
|
|
5
|
+
|
|
6
|
+
describe('Pending', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('initial state', () => {
|
|
16
|
+
it('starts with count 0, hasPending false, hasFailed false', () => {
|
|
17
|
+
const p = new Pending();
|
|
18
|
+
expect(p.count).toBe(0);
|
|
19
|
+
expect(p.hasPending).toBe(false);
|
|
20
|
+
expect(p.hasFailed).toBe(false);
|
|
21
|
+
expect(p.failedCount).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('getStatus returns null for unknown ID', () => {
|
|
25
|
+
const p = new Pending();
|
|
26
|
+
expect(p.getStatus('unknown')).toBe(null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('has returns false for unknown ID', () => {
|
|
30
|
+
const p = new Pending();
|
|
31
|
+
expect(p.has('unknown')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('enqueue', () => {
|
|
36
|
+
it('sets status to active and notifies', async () => {
|
|
37
|
+
const p = new Pending<string>();
|
|
38
|
+
const listener = vi.fn();
|
|
39
|
+
p.subscribe(listener);
|
|
40
|
+
|
|
41
|
+
let resolve!: () => void;
|
|
42
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
43
|
+
|
|
44
|
+
p.enqueue('item-1', 'delete', () => promise);
|
|
45
|
+
|
|
46
|
+
// Immediately after enqueue, snapshot is active (pre-microtask)
|
|
47
|
+
const preStatus = p.getStatus('item-1');
|
|
48
|
+
expect(preStatus).not.toBe(null);
|
|
49
|
+
expect(preStatus!.status).toBe('active');
|
|
50
|
+
|
|
51
|
+
// After microtask, execute runs
|
|
52
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
53
|
+
|
|
54
|
+
const status = p.getStatus('item-1');
|
|
55
|
+
expect(status).not.toBe(null);
|
|
56
|
+
expect(status!.status).toBe('active');
|
|
57
|
+
expect(status!.operation).toBe('delete');
|
|
58
|
+
expect(status!.attempts).toBe(1);
|
|
59
|
+
expect(status!.error).toBe(null);
|
|
60
|
+
expect(status!.errorCode).toBe(null);
|
|
61
|
+
expect(status!.nextRetryAt).toBe(null);
|
|
62
|
+
expect(typeof status!.createdAt).toBe('number');
|
|
63
|
+
expect(listener).toHaveBeenCalled();
|
|
64
|
+
|
|
65
|
+
resolve();
|
|
66
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
67
|
+
p.dispose();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('getStatus returns PendingOperation with correct maxRetries', async () => {
|
|
71
|
+
const p = new Pending<string>();
|
|
72
|
+
let resolve!: () => void;
|
|
73
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
74
|
+
|
|
75
|
+
p.enqueue('a', 'update', () => promise);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
77
|
+
|
|
78
|
+
const status = p.getStatus('a');
|
|
79
|
+
expect(status!.maxRetries).toBe(5);
|
|
80
|
+
|
|
81
|
+
resolve();
|
|
82
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
83
|
+
p.dispose();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('has returns true for enqueued ID', async () => {
|
|
87
|
+
const p = new Pending<string>();
|
|
88
|
+
let resolve!: () => void;
|
|
89
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
90
|
+
|
|
91
|
+
p.enqueue('a', 'delete', () => promise);
|
|
92
|
+
expect(p.has('a')).toBe(true);
|
|
93
|
+
|
|
94
|
+
resolve();
|
|
95
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
96
|
+
p.dispose();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('count reflects number of pending operations', () => {
|
|
100
|
+
const p = new Pending<string>();
|
|
101
|
+
|
|
102
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
103
|
+
p.enqueue('b', 'op', () => new Promise(() => {}));
|
|
104
|
+
expect(p.count).toBe(2);
|
|
105
|
+
|
|
106
|
+
p.dispose();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('hasPending is true when operations exist', () => {
|
|
110
|
+
const p = new Pending<string>();
|
|
111
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
112
|
+
expect(p.hasPending).toBe(true);
|
|
113
|
+
p.dispose();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('execute receives an AbortSignal', async () => {
|
|
117
|
+
const p = new Pending<string>();
|
|
118
|
+
let receivedSignal: AbortSignal | null = null;
|
|
119
|
+
|
|
120
|
+
p.enqueue('a', 'op', async (signal) => {
|
|
121
|
+
receivedSignal = signal;
|
|
122
|
+
});
|
|
123
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
124
|
+
|
|
125
|
+
expect(receivedSignal).toBeInstanceOf(AbortSignal);
|
|
126
|
+
p.dispose();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('supersedes existing operation for same ID (aborts previous signal)', async () => {
|
|
130
|
+
const p = new Pending<string>();
|
|
131
|
+
let secondSignal: AbortSignal | null = null;
|
|
132
|
+
|
|
133
|
+
let firstAborted = false;
|
|
134
|
+
|
|
135
|
+
p.enqueue('a', 'first', async (signal) => {
|
|
136
|
+
signal.addEventListener('abort', () => { firstAborted = true; });
|
|
137
|
+
await new Promise(() => {}); // never resolves
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Process the first microtask so execute runs and signal listener is attached
|
|
141
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
142
|
+
|
|
143
|
+
p.enqueue('a', 'second', async (signal) => {
|
|
144
|
+
secondSignal = signal;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
148
|
+
|
|
149
|
+
expect(firstAborted).toBe(true);
|
|
150
|
+
expect(secondSignal!.aborted).toBe(false);
|
|
151
|
+
expect(p.getStatus('a')).toBe(null); // second completed
|
|
152
|
+
p.dispose();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('is no-op after dispose', () => {
|
|
156
|
+
const p = new Pending<string>();
|
|
157
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
158
|
+
p.dispose();
|
|
159
|
+
|
|
160
|
+
p.enqueue('a', 'op', async () => {});
|
|
161
|
+
expect(p.count).toBe(0);
|
|
162
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
163
|
+
'[mvc-kit] Pending.enqueue() called after dispose — ignored.',
|
|
164
|
+
);
|
|
165
|
+
warnSpy.mockRestore();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('confirmation', () => {
|
|
170
|
+
it('removes operation on successful execute', async () => {
|
|
171
|
+
const p = new Pending<string>();
|
|
172
|
+
p.enqueue('a', 'delete', async () => {});
|
|
173
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
|
+
|
|
175
|
+
expect(p.has('a')).toBe(false);
|
|
176
|
+
expect(p.getStatus('a')).toBe(null);
|
|
177
|
+
expect(p.count).toBe(0);
|
|
178
|
+
p.dispose();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('notifies on confirmation', async () => {
|
|
182
|
+
const p = new Pending<string>();
|
|
183
|
+
p.enqueue('a', 'op', async () => {});
|
|
184
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
185
|
+
|
|
186
|
+
// Now subscribe after active notification
|
|
187
|
+
const listener = vi.fn();
|
|
188
|
+
p.subscribe(listener);
|
|
189
|
+
|
|
190
|
+
p.enqueue('b', 'op', async () => {});
|
|
191
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
192
|
+
|
|
193
|
+
// Should have notified: enqueue (active), _process (active), confirmed (removed)
|
|
194
|
+
expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
195
|
+
p.dispose();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('calls onConfirmed hook', async () => {
|
|
199
|
+
const confirmed = vi.fn();
|
|
200
|
+
|
|
201
|
+
class TestPending extends Pending<string> {
|
|
202
|
+
protected onConfirmed(id: string, operation: string) {
|
|
203
|
+
confirmed(id, operation);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const p = new TestPending();
|
|
208
|
+
p.enqueue('item-1', 'delete', async () => {});
|
|
209
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
210
|
+
|
|
211
|
+
expect(confirmed).toHaveBeenCalledWith('item-1', 'delete');
|
|
212
|
+
p.dispose();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('getStatus returns null after confirmation', async () => {
|
|
216
|
+
const p = new Pending<string>();
|
|
217
|
+
p.enqueue('a', 'op', async () => {});
|
|
218
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
219
|
+
|
|
220
|
+
expect(p.getStatus('a')).toBe(null);
|
|
221
|
+
p.dispose();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('supersede race condition', () => {
|
|
226
|
+
it('does not delete superseding operation when superseded op rejects', async () => {
|
|
227
|
+
const p = new Pending<string>();
|
|
228
|
+
let secondCompleted = false;
|
|
229
|
+
|
|
230
|
+
// First operation: hangs forever (will be superseded)
|
|
231
|
+
p.enqueue('a', 'first', async (signal) => {
|
|
232
|
+
// Wait until aborted
|
|
233
|
+
await new Promise((_, reject) => {
|
|
234
|
+
signal.addEventListener('abort', () => {
|
|
235
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
241
|
+
expect(p.getStatus('a')!.operation).toBe('first');
|
|
242
|
+
|
|
243
|
+
// Second operation: will succeed
|
|
244
|
+
p.enqueue('a', 'second', async () => {
|
|
245
|
+
secondCompleted = true;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Process the second microtask — both the abort rejection from op1
|
|
249
|
+
// and the execution of op2 happen here
|
|
250
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
251
|
+
|
|
252
|
+
// The second operation should have completed successfully
|
|
253
|
+
expect(secondCompleted).toBe(true);
|
|
254
|
+
// And the ID should be gone (confirmed), NOT still present
|
|
255
|
+
expect(p.has('a')).toBe(false);
|
|
256
|
+
expect(p.getStatus('a')).toBe(null);
|
|
257
|
+
p.dispose();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not confirm superseded operation when it resolves late', async () => {
|
|
261
|
+
let resolveFirst!: () => void;
|
|
262
|
+
const confirmed = vi.fn();
|
|
263
|
+
|
|
264
|
+
class TestPending extends Pending<string> {
|
|
265
|
+
protected onConfirmed(id: string, operation: string) {
|
|
266
|
+
confirmed(id, operation);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const tp = new TestPending();
|
|
271
|
+
|
|
272
|
+
// First operation: controlled resolution
|
|
273
|
+
tp.enqueue('a', 'first', async () => {
|
|
274
|
+
await new Promise<void>(r => { resolveFirst = r; });
|
|
275
|
+
});
|
|
276
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
277
|
+
|
|
278
|
+
// Supersede before first resolves
|
|
279
|
+
tp.enqueue('a', 'second', async () => {
|
|
280
|
+
await new Promise(() => {}); // hang
|
|
281
|
+
});
|
|
282
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
283
|
+
|
|
284
|
+
// Now resolve the first (it's been superseded)
|
|
285
|
+
resolveFirst();
|
|
286
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
287
|
+
|
|
288
|
+
// onConfirmed should NOT have been called for 'first'
|
|
289
|
+
// because it was superseded by 'second'
|
|
290
|
+
expect(confirmed).not.toHaveBeenCalled();
|
|
291
|
+
// Second should still be active
|
|
292
|
+
expect(tp.getStatus('a')!.operation).toBe('second');
|
|
293
|
+
tp.dispose();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('superseding a retrying operation clears the retry timer', async () => {
|
|
297
|
+
const p = new Pending<string>();
|
|
298
|
+
let firstCallCount = 0;
|
|
299
|
+
let secondCalled = false;
|
|
300
|
+
|
|
301
|
+
p.enqueue('a', 'first', async () => {
|
|
302
|
+
firstCallCount++;
|
|
303
|
+
throw new TypeError('Failed to fetch');
|
|
304
|
+
});
|
|
305
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
306
|
+
|
|
307
|
+
// First op is now retrying with a timer
|
|
308
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
309
|
+
expect(firstCallCount).toBe(1);
|
|
310
|
+
|
|
311
|
+
// Supersede with a new operation
|
|
312
|
+
p.enqueue('a', 'second', async () => {
|
|
313
|
+
secondCalled = true;
|
|
314
|
+
});
|
|
315
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
316
|
+
|
|
317
|
+
expect(secondCalled).toBe(true);
|
|
318
|
+
expect(p.has('a')).toBe(false); // second completed
|
|
319
|
+
|
|
320
|
+
// Advance past when the old retry timer would have fired — should be no-op
|
|
321
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
322
|
+
expect(firstCallCount).toBe(1); // never retried
|
|
323
|
+
p.dispose();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('retry on transient error', () => {
|
|
328
|
+
it('retries on network error', async () => {
|
|
329
|
+
const p = new Pending<string>();
|
|
330
|
+
let callCount = 0;
|
|
331
|
+
|
|
332
|
+
p.enqueue('a', 'op', async () => {
|
|
333
|
+
callCount++;
|
|
334
|
+
if (callCount === 1) throw new TypeError('Failed to fetch');
|
|
335
|
+
});
|
|
336
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
337
|
+
|
|
338
|
+
// First call fails — should be retrying
|
|
339
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
340
|
+
expect(callCount).toBe(1);
|
|
341
|
+
|
|
342
|
+
// Advance past retry delay
|
|
343
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
344
|
+
|
|
345
|
+
// Second call succeeds
|
|
346
|
+
expect(p.has('a')).toBe(false);
|
|
347
|
+
expect(callCount).toBe(2);
|
|
348
|
+
p.dispose();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('retries on timeout error', async () => {
|
|
352
|
+
const p = new Pending<string>();
|
|
353
|
+
let callCount = 0;
|
|
354
|
+
const timeoutErr = new Error('Request timed out');
|
|
355
|
+
timeoutErr.name = 'TimeoutError';
|
|
356
|
+
|
|
357
|
+
p.enqueue('a', 'op', async () => {
|
|
358
|
+
callCount++;
|
|
359
|
+
if (callCount === 1) throw timeoutErr;
|
|
360
|
+
});
|
|
361
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
362
|
+
|
|
363
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
364
|
+
expect(p.getStatus('a')!.errorCode).toBe('timeout');
|
|
365
|
+
|
|
366
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
367
|
+
expect(callCount).toBe(2);
|
|
368
|
+
p.dispose();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('retries on server error', async () => {
|
|
372
|
+
const p = new Pending<string>();
|
|
373
|
+
let callCount = 0;
|
|
374
|
+
|
|
375
|
+
p.enqueue('a', 'op', async () => {
|
|
376
|
+
callCount++;
|
|
377
|
+
if (callCount === 1) throw new HttpError(500, 'Internal Server Error');
|
|
378
|
+
});
|
|
379
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
380
|
+
|
|
381
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
382
|
+
expect(p.getStatus('a')!.errorCode).toBe('server_error');
|
|
383
|
+
|
|
384
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
385
|
+
expect(callCount).toBe(2);
|
|
386
|
+
p.dispose();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('sets status to retrying between attempts', async () => {
|
|
390
|
+
const p = new Pending<string>();
|
|
391
|
+
const statuses: string[] = [];
|
|
392
|
+
const listener = () => {
|
|
393
|
+
const s = p.getStatus('a');
|
|
394
|
+
if (s) statuses.push(s.status);
|
|
395
|
+
};
|
|
396
|
+
p.subscribe(listener);
|
|
397
|
+
|
|
398
|
+
p.enqueue('a', 'op', async () => {
|
|
399
|
+
throw new TypeError('Failed to fetch');
|
|
400
|
+
});
|
|
401
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
402
|
+
|
|
403
|
+
expect(statuses).toContain('retrying');
|
|
404
|
+
p.dispose();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('increments attempts counter', async () => {
|
|
408
|
+
vi.spyOn(Math, 'random').mockReturnValue(1);
|
|
409
|
+
|
|
410
|
+
class SlowRetryPending extends Pending<string> {
|
|
411
|
+
static override RETRY_BASE = 10000;
|
|
412
|
+
static override MAX_RETRIES = 5;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const p = new SlowRetryPending();
|
|
416
|
+
|
|
417
|
+
p.enqueue('a', 'op', async () => {
|
|
418
|
+
throw new TypeError('Failed to fetch');
|
|
419
|
+
});
|
|
420
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
421
|
+
|
|
422
|
+
expect(p.getStatus('a')!.attempts).toBe(1);
|
|
423
|
+
|
|
424
|
+
// Advance just past the first retry delay (10000ms)
|
|
425
|
+
await vi.advanceTimersByTimeAsync(10001);
|
|
426
|
+
expect(p.getStatus('a')!.attempts).toBe(2);
|
|
427
|
+
|
|
428
|
+
vi.spyOn(Math, 'random').mockRestore();
|
|
429
|
+
p.dispose();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('sets nextRetryAt with delay', async () => {
|
|
433
|
+
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
|
434
|
+
|
|
435
|
+
const p = new Pending<string>();
|
|
436
|
+
|
|
437
|
+
p.enqueue('a', 'op', async () => {
|
|
438
|
+
throw new TypeError('Failed to fetch');
|
|
439
|
+
});
|
|
440
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
441
|
+
|
|
442
|
+
const status = p.getStatus('a')!;
|
|
443
|
+
expect(status.nextRetryAt).not.toBe(null);
|
|
444
|
+
expect(status.nextRetryAt).toBeGreaterThan(0);
|
|
445
|
+
|
|
446
|
+
vi.spyOn(Math, 'random').mockRestore();
|
|
447
|
+
p.dispose();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('respects MAX_RETRIES limit', async () => {
|
|
451
|
+
class LimitedPending extends Pending<string> {
|
|
452
|
+
static override MAX_RETRIES = 2;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const p = new LimitedPending();
|
|
456
|
+
let callCount = 0;
|
|
457
|
+
|
|
458
|
+
p.enqueue('a', 'op', async () => {
|
|
459
|
+
callCount++;
|
|
460
|
+
throw new TypeError('Failed to fetch');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Process through all retries
|
|
464
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
465
|
+
expect(callCount).toBe(1);
|
|
466
|
+
|
|
467
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
468
|
+
expect(callCount).toBe(2);
|
|
469
|
+
|
|
470
|
+
// Should be failed, not retrying again
|
|
471
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
472
|
+
p.dispose();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('uses exponential backoff with jitter', async () => {
|
|
476
|
+
vi.spyOn(Math, 'random').mockReturnValue(1); // Max jitter for deterministic testing
|
|
477
|
+
|
|
478
|
+
const p = new Pending<string>();
|
|
479
|
+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
480
|
+
|
|
481
|
+
p.enqueue('a', 'op', async () => {
|
|
482
|
+
throw new TypeError('Failed to fetch');
|
|
483
|
+
});
|
|
484
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
485
|
+
|
|
486
|
+
// Collect the delay from the retry setTimeout call
|
|
487
|
+
const retryCalls = setTimeoutSpy.mock.calls.filter(
|
|
488
|
+
call => typeof call[1] === 'number' && call[1] > 0,
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// With Math.random()=1, RETRY_BASE=1000, RETRY_FACTOR=2:
|
|
492
|
+
// attempt 0: Math.random() * min(1000 * 2^0, 30000) = 1 * 1000 = 1000
|
|
493
|
+
expect(retryCalls[retryCalls.length - 1][1]).toBe(1000);
|
|
494
|
+
|
|
495
|
+
vi.spyOn(Math, 'random').mockRestore();
|
|
496
|
+
setTimeoutSpy.mockRestore();
|
|
497
|
+
p.dispose();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('transitions to failed after max retries', async () => {
|
|
501
|
+
class LimitedPending extends Pending<string> {
|
|
502
|
+
static override MAX_RETRIES = 1;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const p = new LimitedPending();
|
|
506
|
+
|
|
507
|
+
p.enqueue('a', 'op', async () => {
|
|
508
|
+
throw new HttpError(503, 'Service Unavailable');
|
|
509
|
+
});
|
|
510
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
511
|
+
|
|
512
|
+
// First attempt fails, at max retries
|
|
513
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
514
|
+
expect(p.getStatus('a')!.error).toBe('Service Unavailable');
|
|
515
|
+
expect(p.getStatus('a')!.errorCode).toBe('server_error');
|
|
516
|
+
p.dispose();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('calls onFailed hook after max retries', async () => {
|
|
520
|
+
const failed = vi.fn();
|
|
521
|
+
|
|
522
|
+
class LimitedPending extends Pending<string> {
|
|
523
|
+
static override MAX_RETRIES = 1;
|
|
524
|
+
protected onFailed(id: string, operation: string, error: unknown) {
|
|
525
|
+
failed(id, operation, error);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const p = new LimitedPending();
|
|
530
|
+
const err = new HttpError(500, 'Server Error');
|
|
531
|
+
|
|
532
|
+
p.enqueue('a', 'delete', async () => { throw err; });
|
|
533
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
534
|
+
|
|
535
|
+
expect(failed).toHaveBeenCalledWith('a', 'delete', err);
|
|
536
|
+
p.dispose();
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
describe('non-retryable errors', () => {
|
|
541
|
+
it('fails immediately on 401 (unauthorized)', async () => {
|
|
542
|
+
const p = new Pending<string>();
|
|
543
|
+
|
|
544
|
+
p.enqueue('a', 'op', async () => {
|
|
545
|
+
throw new HttpError(401, 'Unauthorized');
|
|
546
|
+
});
|
|
547
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
548
|
+
|
|
549
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
550
|
+
expect(p.getStatus('a')!.errorCode).toBe('unauthorized');
|
|
551
|
+
p.dispose();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('fails immediately on 403 (forbidden)', async () => {
|
|
555
|
+
const p = new Pending<string>();
|
|
556
|
+
|
|
557
|
+
p.enqueue('a', 'op', async () => {
|
|
558
|
+
throw new HttpError(403, 'Forbidden');
|
|
559
|
+
});
|
|
560
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
561
|
+
|
|
562
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
563
|
+
expect(p.getStatus('a')!.errorCode).toBe('forbidden');
|
|
564
|
+
p.dispose();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('fails immediately on 404 (not_found)', async () => {
|
|
568
|
+
const p = new Pending<string>();
|
|
569
|
+
|
|
570
|
+
p.enqueue('a', 'op', async () => {
|
|
571
|
+
throw new HttpError(404, 'Not Found');
|
|
572
|
+
});
|
|
573
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
574
|
+
|
|
575
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
576
|
+
expect(p.getStatus('a')!.errorCode).toBe('not_found');
|
|
577
|
+
p.dispose();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('fails immediately on 422 (validation)', async () => {
|
|
581
|
+
const p = new Pending<string>();
|
|
582
|
+
|
|
583
|
+
p.enqueue('a', 'op', async () => {
|
|
584
|
+
throw new HttpError(422, 'Unprocessable Entity');
|
|
585
|
+
});
|
|
586
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
587
|
+
|
|
588
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
589
|
+
expect(p.getStatus('a')!.errorCode).toBe('validation');
|
|
590
|
+
p.dispose();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('calls onFailed hook immediately', async () => {
|
|
594
|
+
const failed = vi.fn();
|
|
595
|
+
|
|
596
|
+
class TestPending extends Pending<string> {
|
|
597
|
+
protected onFailed(id: string, operation: string, error: unknown) {
|
|
598
|
+
failed(id, operation, error);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const p = new TestPending();
|
|
603
|
+
const err = new HttpError(403, 'Forbidden');
|
|
604
|
+
|
|
605
|
+
p.enqueue('a', 'update', async () => { throw err; });
|
|
606
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
607
|
+
|
|
608
|
+
expect(failed).toHaveBeenCalledWith('a', 'update', err);
|
|
609
|
+
p.dispose();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('sets error and errorCode on the operation', async () => {
|
|
613
|
+
const p = new Pending<string>();
|
|
614
|
+
|
|
615
|
+
p.enqueue('a', 'op', async () => {
|
|
616
|
+
throw new HttpError(404, 'Not Found');
|
|
617
|
+
});
|
|
618
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
619
|
+
|
|
620
|
+
const status = p.getStatus('a')!;
|
|
621
|
+
expect(status.error).toBe('Not Found');
|
|
622
|
+
expect(status.errorCode).toBe('not_found');
|
|
623
|
+
p.dispose();
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe('abort handling', () => {
|
|
628
|
+
it('removes operation silently on AbortError', async () => {
|
|
629
|
+
const p = new Pending<string>();
|
|
630
|
+
|
|
631
|
+
p.enqueue('a', 'op', async () => {
|
|
632
|
+
const err = new Error('The operation was aborted');
|
|
633
|
+
err.name = 'AbortError';
|
|
634
|
+
throw err;
|
|
635
|
+
});
|
|
636
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
637
|
+
|
|
638
|
+
expect(p.has('a')).toBe(false);
|
|
639
|
+
expect(p.getStatus('a')).toBe(null);
|
|
640
|
+
p.dispose();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('does not call onFailed for AbortError', async () => {
|
|
644
|
+
const failed = vi.fn();
|
|
645
|
+
|
|
646
|
+
class TestPending extends Pending<string> {
|
|
647
|
+
protected onFailed(id: string, operation: string, error: unknown) {
|
|
648
|
+
failed(id, operation, error);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const p = new TestPending();
|
|
653
|
+
|
|
654
|
+
p.enqueue('a', 'op', async () => {
|
|
655
|
+
const err = new Error('Aborted');
|
|
656
|
+
err.name = 'AbortError';
|
|
657
|
+
throw err;
|
|
658
|
+
});
|
|
659
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
660
|
+
|
|
661
|
+
expect(failed).not.toHaveBeenCalled();
|
|
662
|
+
p.dispose();
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
describe('retry(id)', () => {
|
|
667
|
+
it('re-processes a failed operation', async () => {
|
|
668
|
+
class LimitedPending extends Pending<string> {
|
|
669
|
+
static override MAX_RETRIES = 1;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const p = new LimitedPending();
|
|
673
|
+
let callCount = 0;
|
|
674
|
+
|
|
675
|
+
p.enqueue('a', 'op', async () => {
|
|
676
|
+
callCount++;
|
|
677
|
+
if (callCount === 1) throw new HttpError(500, 'Server Error');
|
|
678
|
+
// Second call succeeds
|
|
679
|
+
});
|
|
680
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
681
|
+
|
|
682
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
683
|
+
expect(callCount).toBe(1);
|
|
684
|
+
|
|
685
|
+
p.retry('a');
|
|
686
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
687
|
+
|
|
688
|
+
expect(p.has('a')).toBe(false); // succeeded on retry
|
|
689
|
+
expect(callCount).toBe(2);
|
|
690
|
+
p.dispose();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('resets attempts to 0', async () => {
|
|
694
|
+
class LimitedPending extends Pending<string> {
|
|
695
|
+
static override MAX_RETRIES = 1;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const p = new LimitedPending();
|
|
699
|
+
|
|
700
|
+
p.enqueue('a', 'op', async () => {
|
|
701
|
+
throw new HttpError(500, 'Error');
|
|
702
|
+
});
|
|
703
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
704
|
+
|
|
705
|
+
expect(p.getStatus('a')!.attempts).toBe(1);
|
|
706
|
+
|
|
707
|
+
p.retry('a');
|
|
708
|
+
// After retry starts, attempts should be 1 (reset to 0, then incremented by _process)
|
|
709
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
710
|
+
expect(p.getStatus('a')!.attempts).toBe(1);
|
|
711
|
+
p.dispose();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('is no-op for non-failed operations', async () => {
|
|
715
|
+
const p = new Pending<string>();
|
|
716
|
+
|
|
717
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
718
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
719
|
+
|
|
720
|
+
const statusBefore = p.getStatus('a');
|
|
721
|
+
p.retry('a'); // should be no-op since status is 'active'
|
|
722
|
+
const statusAfter = p.getStatus('a');
|
|
723
|
+
|
|
724
|
+
expect(statusBefore).toEqual(statusAfter);
|
|
725
|
+
p.dispose();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('is no-op for unknown IDs', () => {
|
|
729
|
+
const p = new Pending<string>();
|
|
730
|
+
// Should not throw
|
|
731
|
+
p.retry('nonexistent');
|
|
732
|
+
p.dispose();
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe('retryAll', () => {
|
|
737
|
+
it('retries all failed operations', async () => {
|
|
738
|
+
class LimitedPending extends Pending<string> {
|
|
739
|
+
static override MAX_RETRIES = 1;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const p = new LimitedPending();
|
|
743
|
+
let aCount = 0;
|
|
744
|
+
let bCount = 0;
|
|
745
|
+
|
|
746
|
+
p.enqueue('a', 'op', async () => {
|
|
747
|
+
aCount++;
|
|
748
|
+
if (aCount === 1) throw new HttpError(500, 'Error');
|
|
749
|
+
});
|
|
750
|
+
p.enqueue('b', 'op', async () => {
|
|
751
|
+
bCount++;
|
|
752
|
+
if (bCount === 1) throw new HttpError(502, 'Error');
|
|
753
|
+
});
|
|
754
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
755
|
+
|
|
756
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
757
|
+
expect(p.getStatus('b')!.status).toBe('failed');
|
|
758
|
+
|
|
759
|
+
p.retryAll();
|
|
760
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
761
|
+
|
|
762
|
+
// Both should have succeeded on retry
|
|
763
|
+
expect(p.has('a')).toBe(false);
|
|
764
|
+
expect(p.has('b')).toBe(false);
|
|
765
|
+
expect(aCount).toBe(2);
|
|
766
|
+
expect(bCount).toBe(2);
|
|
767
|
+
p.dispose();
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
describe('cancel', () => {
|
|
772
|
+
it('aborts the signal', async () => {
|
|
773
|
+
const p = new Pending<string>();
|
|
774
|
+
let capturedSignal: AbortSignal | null = null;
|
|
775
|
+
|
|
776
|
+
p.enqueue('a', 'op', async (signal) => {
|
|
777
|
+
capturedSignal = signal;
|
|
778
|
+
await new Promise(() => {}); // hang
|
|
779
|
+
});
|
|
780
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
781
|
+
|
|
782
|
+
p.cancel('a');
|
|
783
|
+
expect(capturedSignal!.aborted).toBe(true);
|
|
784
|
+
p.dispose();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('clears the retry timer', async () => {
|
|
788
|
+
const p = new Pending<string>();
|
|
789
|
+
|
|
790
|
+
p.enqueue('a', 'op', async () => {
|
|
791
|
+
throw new TypeError('Failed to fetch');
|
|
792
|
+
});
|
|
793
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
794
|
+
|
|
795
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
796
|
+
|
|
797
|
+
p.cancel('a');
|
|
798
|
+
expect(p.has('a')).toBe(false);
|
|
799
|
+
|
|
800
|
+
// Advance timers — nothing should happen
|
|
801
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
802
|
+
expect(p.count).toBe(0);
|
|
803
|
+
p.dispose();
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('removes from maps', async () => {
|
|
807
|
+
const p = new Pending<string>();
|
|
808
|
+
|
|
809
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
810
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
811
|
+
|
|
812
|
+
p.cancel('a');
|
|
813
|
+
expect(p.has('a')).toBe(false);
|
|
814
|
+
expect(p.getStatus('a')).toBe(null);
|
|
815
|
+
expect(p.count).toBe(0);
|
|
816
|
+
p.dispose();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it('notifies listeners', async () => {
|
|
820
|
+
const p = new Pending<string>();
|
|
821
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
822
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
823
|
+
|
|
824
|
+
const listener = vi.fn();
|
|
825
|
+
p.subscribe(listener);
|
|
826
|
+
p.cancel('a');
|
|
827
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
828
|
+
p.dispose();
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('is no-op for unknown IDs', () => {
|
|
832
|
+
const p = new Pending<string>();
|
|
833
|
+
const listener = vi.fn();
|
|
834
|
+
p.subscribe(listener);
|
|
835
|
+
p.cancel('nonexistent');
|
|
836
|
+
expect(listener).not.toHaveBeenCalled();
|
|
837
|
+
p.dispose();
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
describe('cancelAll', () => {
|
|
842
|
+
it('cancels all pending operations', async () => {
|
|
843
|
+
const p = new Pending<string>();
|
|
844
|
+
let signalA: AbortSignal | null = null;
|
|
845
|
+
let signalB: AbortSignal | null = null;
|
|
846
|
+
|
|
847
|
+
p.enqueue('a', 'op', async (signal) => {
|
|
848
|
+
signalA = signal;
|
|
849
|
+
await new Promise(() => {});
|
|
850
|
+
});
|
|
851
|
+
p.enqueue('b', 'op', async (signal) => {
|
|
852
|
+
signalB = signal;
|
|
853
|
+
await new Promise(() => {});
|
|
854
|
+
});
|
|
855
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
856
|
+
|
|
857
|
+
p.cancelAll();
|
|
858
|
+
|
|
859
|
+
expect(signalA!.aborted).toBe(true);
|
|
860
|
+
expect(signalB!.aborted).toBe(true);
|
|
861
|
+
expect(p.count).toBe(0);
|
|
862
|
+
expect(p.has('a')).toBe(false);
|
|
863
|
+
expect(p.has('b')).toBe(false);
|
|
864
|
+
p.dispose();
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe('dismiss', () => {
|
|
869
|
+
it('removes a failed operation', async () => {
|
|
870
|
+
class LimitedPending extends Pending<string> {
|
|
871
|
+
static override MAX_RETRIES = 1;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const p = new LimitedPending();
|
|
875
|
+
|
|
876
|
+
p.enqueue('a', 'op', async () => {
|
|
877
|
+
throw new HttpError(404, 'Not Found');
|
|
878
|
+
});
|
|
879
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
880
|
+
|
|
881
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
882
|
+
|
|
883
|
+
p.dismiss('a');
|
|
884
|
+
expect(p.has('a')).toBe(false);
|
|
885
|
+
expect(p.getStatus('a')).toBe(null);
|
|
886
|
+
expect(p.count).toBe(0);
|
|
887
|
+
p.dispose();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('notifies listeners', async () => {
|
|
891
|
+
class LimitedPending extends Pending<string> {
|
|
892
|
+
static override MAX_RETRIES = 1;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const p = new LimitedPending();
|
|
896
|
+
|
|
897
|
+
p.enqueue('a', 'op', async () => {
|
|
898
|
+
throw new HttpError(404, 'Not Found');
|
|
899
|
+
});
|
|
900
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
901
|
+
|
|
902
|
+
const listener = vi.fn();
|
|
903
|
+
p.subscribe(listener);
|
|
904
|
+
p.dismiss('a');
|
|
905
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
906
|
+
p.dispose();
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('is no-op for active operations', async () => {
|
|
910
|
+
const p = new Pending<string>();
|
|
911
|
+
|
|
912
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
913
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
914
|
+
|
|
915
|
+
const listener = vi.fn();
|
|
916
|
+
p.subscribe(listener);
|
|
917
|
+
p.dismiss('a'); // active, not failed
|
|
918
|
+
expect(listener).not.toHaveBeenCalled();
|
|
919
|
+
expect(p.has('a')).toBe(true);
|
|
920
|
+
p.dispose();
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('is no-op for retrying operations', async () => {
|
|
924
|
+
const p = new Pending<string>();
|
|
925
|
+
|
|
926
|
+
p.enqueue('a', 'op', async () => {
|
|
927
|
+
throw new TypeError('Failed to fetch');
|
|
928
|
+
});
|
|
929
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
930
|
+
|
|
931
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
932
|
+
|
|
933
|
+
const listener = vi.fn();
|
|
934
|
+
p.subscribe(listener);
|
|
935
|
+
p.dismiss('a'); // retrying, not failed
|
|
936
|
+
expect(listener).not.toHaveBeenCalled();
|
|
937
|
+
expect(p.has('a')).toBe(true);
|
|
938
|
+
p.dispose();
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('is no-op for unknown IDs', () => {
|
|
942
|
+
const p = new Pending<string>();
|
|
943
|
+
const listener = vi.fn();
|
|
944
|
+
p.subscribe(listener);
|
|
945
|
+
p.dismiss('nonexistent');
|
|
946
|
+
expect(listener).not.toHaveBeenCalled();
|
|
947
|
+
p.dispose();
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
describe('dismissAll', () => {
|
|
952
|
+
it('removes all failed operations', async () => {
|
|
953
|
+
class LimitedPending extends Pending<string> {
|
|
954
|
+
static override MAX_RETRIES = 1;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const p = new LimitedPending();
|
|
958
|
+
|
|
959
|
+
p.enqueue('a', 'op', async () => { throw new HttpError(404, 'Not Found'); });
|
|
960
|
+
p.enqueue('b', 'op', async () => { throw new HttpError(403, 'Forbidden'); });
|
|
961
|
+
p.enqueue('c', 'op', () => new Promise(() => {})); // stays active
|
|
962
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
963
|
+
|
|
964
|
+
expect(p.failedCount).toBe(2);
|
|
965
|
+
expect(p.count).toBe(3);
|
|
966
|
+
|
|
967
|
+
p.dismissAll();
|
|
968
|
+
|
|
969
|
+
expect(p.failedCount).toBe(0);
|
|
970
|
+
expect(p.count).toBe(1); // only 'c' remains
|
|
971
|
+
expect(p.has('a')).toBe(false);
|
|
972
|
+
expect(p.has('b')).toBe(false);
|
|
973
|
+
expect(p.has('c')).toBe(true);
|
|
974
|
+
p.dispose();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it('fires single notification', async () => {
|
|
978
|
+
class LimitedPending extends Pending<string> {
|
|
979
|
+
static override MAX_RETRIES = 1;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const p = new LimitedPending();
|
|
983
|
+
|
|
984
|
+
p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
|
|
985
|
+
p.enqueue('b', 'op', async () => { throw new HttpError(403, 'F'); });
|
|
986
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
987
|
+
|
|
988
|
+
const listener = vi.fn();
|
|
989
|
+
p.subscribe(listener);
|
|
990
|
+
p.dismissAll();
|
|
991
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
992
|
+
p.dispose();
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('does not affect retrying operations', async () => {
|
|
996
|
+
class LimitedPending extends Pending<string> {
|
|
997
|
+
static override MAX_RETRIES = 2;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const p = new LimitedPending();
|
|
1001
|
+
|
|
1002
|
+
// 'a' will fail immediately (non-retryable)
|
|
1003
|
+
p.enqueue('a', 'op', async () => { throw new HttpError(404, 'Not Found'); });
|
|
1004
|
+
// 'b' will be retrying (retryable, under max)
|
|
1005
|
+
p.enqueue('b', 'op', async () => { throw new TypeError('Failed to fetch'); });
|
|
1006
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1007
|
+
|
|
1008
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
1009
|
+
expect(p.getStatus('b')!.status).toBe('retrying');
|
|
1010
|
+
|
|
1011
|
+
p.dismissAll();
|
|
1012
|
+
|
|
1013
|
+
expect(p.has('a')).toBe(false); // dismissed
|
|
1014
|
+
expect(p.has('b')).toBe(true); // still retrying
|
|
1015
|
+
expect(p.getStatus('b')!.status).toBe('retrying');
|
|
1016
|
+
p.dispose();
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it('is no-op when no failed operations', () => {
|
|
1020
|
+
const p = new Pending<string>();
|
|
1021
|
+
const listener = vi.fn();
|
|
1022
|
+
p.subscribe(listener);
|
|
1023
|
+
p.dismissAll();
|
|
1024
|
+
expect(listener).not.toHaveBeenCalled();
|
|
1025
|
+
p.dispose();
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
describe('hasPending', () => {
|
|
1030
|
+
it('is false when only failed operations exist', async () => {
|
|
1031
|
+
class LimitedPending extends Pending<string> {
|
|
1032
|
+
static override MAX_RETRIES = 1;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const p = new LimitedPending();
|
|
1036
|
+
|
|
1037
|
+
p.enqueue('a', 'op', async () => {
|
|
1038
|
+
throw new HttpError(404, 'Not Found');
|
|
1039
|
+
});
|
|
1040
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1041
|
+
|
|
1042
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
1043
|
+
expect(p.count).toBe(1);
|
|
1044
|
+
expect(p.hasPending).toBe(false);
|
|
1045
|
+
expect(p.hasFailed).toBe(true);
|
|
1046
|
+
p.dispose();
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('is true when there are active and failed operations', async () => {
|
|
1050
|
+
class LimitedPending extends Pending<string> {
|
|
1051
|
+
static override MAX_RETRIES = 1;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const p = new LimitedPending();
|
|
1055
|
+
|
|
1056
|
+
p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
|
|
1057
|
+
p.enqueue('b', 'op', () => new Promise(() => {})); // stays active
|
|
1058
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1059
|
+
|
|
1060
|
+
expect(p.hasPending).toBe(true);
|
|
1061
|
+
expect(p.hasFailed).toBe(true);
|
|
1062
|
+
p.dispose();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
it('is true when there are retrying operations (no active, no failed)', async () => {
|
|
1066
|
+
const p = new Pending<string>();
|
|
1067
|
+
|
|
1068
|
+
p.enqueue('a', 'op', async () => {
|
|
1069
|
+
throw new TypeError('Failed to fetch');
|
|
1070
|
+
});
|
|
1071
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1072
|
+
|
|
1073
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
1074
|
+
expect(p.hasPending).toBe(true);
|
|
1075
|
+
expect(p.hasFailed).toBe(false);
|
|
1076
|
+
p.dispose();
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
describe('failedCount', () => {
|
|
1081
|
+
it('returns 0 when no failures', () => {
|
|
1082
|
+
const p = new Pending<string>();
|
|
1083
|
+
expect(p.failedCount).toBe(0);
|
|
1084
|
+
p.dispose();
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it('counts failed operations', async () => {
|
|
1088
|
+
class LimitedPending extends Pending<string> {
|
|
1089
|
+
static override MAX_RETRIES = 1;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const p = new LimitedPending();
|
|
1093
|
+
|
|
1094
|
+
p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
|
|
1095
|
+
p.enqueue('b', 'op', async () => { throw new HttpError(403, 'F'); });
|
|
1096
|
+
p.enqueue('c', 'op', () => new Promise(() => {})); // active
|
|
1097
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1098
|
+
|
|
1099
|
+
expect(p.failedCount).toBe(2);
|
|
1100
|
+
expect(p.count).toBe(3);
|
|
1101
|
+
p.dispose();
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('decreases when failed op is dismissed', async () => {
|
|
1105
|
+
class LimitedPending extends Pending<string> {
|
|
1106
|
+
static override MAX_RETRIES = 1;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const p = new LimitedPending();
|
|
1110
|
+
|
|
1111
|
+
p.enqueue('a', 'op', async () => { throw new HttpError(404, 'NF'); });
|
|
1112
|
+
p.enqueue('b', 'op', async () => { throw new HttpError(403, 'F'); });
|
|
1113
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1114
|
+
|
|
1115
|
+
expect(p.failedCount).toBe(2);
|
|
1116
|
+
p.dismiss('a');
|
|
1117
|
+
expect(p.failedCount).toBe(1);
|
|
1118
|
+
p.dispose();
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('decreases when failed op is retried', async () => {
|
|
1122
|
+
class LimitedPending extends Pending<string> {
|
|
1123
|
+
static override MAX_RETRIES = 1;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const p = new LimitedPending();
|
|
1127
|
+
let callCount = 0;
|
|
1128
|
+
|
|
1129
|
+
p.enqueue('a', 'op', async () => {
|
|
1130
|
+
callCount++;
|
|
1131
|
+
if (callCount === 1) throw new HttpError(500, 'Error');
|
|
1132
|
+
});
|
|
1133
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1134
|
+
|
|
1135
|
+
expect(p.failedCount).toBe(1);
|
|
1136
|
+
|
|
1137
|
+
p.retry('a');
|
|
1138
|
+
// After retry starts, op is active — no longer failed
|
|
1139
|
+
expect(p.failedCount).toBe(0);
|
|
1140
|
+
|
|
1141
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1142
|
+
// Succeeded on retry — removed entirely
|
|
1143
|
+
expect(p.count).toBe(0);
|
|
1144
|
+
p.dispose();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe('no queued status', () => {
|
|
1149
|
+
it('initial snapshot from enqueue is active, not queued', () => {
|
|
1150
|
+
const p = new Pending<string>();
|
|
1151
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
1152
|
+
|
|
1153
|
+
// Synchronously after enqueue, before microtask
|
|
1154
|
+
const status = p.getStatus('a');
|
|
1155
|
+
expect(status!.status).toBe('active');
|
|
1156
|
+
p.dispose();
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it('status transitions are active → retrying → failed (no queued)', async () => {
|
|
1160
|
+
class LimitedPending extends Pending<string> {
|
|
1161
|
+
static override MAX_RETRIES = 1;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const p = new LimitedPending();
|
|
1165
|
+
const statuses: string[] = [];
|
|
1166
|
+
p.subscribe(() => {
|
|
1167
|
+
const s = p.getStatus('a');
|
|
1168
|
+
if (s && !statuses.includes(s.status)) statuses.push(s.status);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
p.enqueue('a', 'op', async () => {
|
|
1172
|
+
throw new TypeError('Failed to fetch');
|
|
1173
|
+
});
|
|
1174
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1175
|
+
|
|
1176
|
+
expect(statuses).toEqual(['active', 'failed']);
|
|
1177
|
+
expect(statuses).not.toContain('queued');
|
|
1178
|
+
p.dispose();
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
describe('isRetryable override', () => {
|
|
1183
|
+
it('subclass can override to customize retry logic', async () => {
|
|
1184
|
+
class CustomPending extends Pending<string> {
|
|
1185
|
+
protected override isRetryable(_error: unknown): boolean {
|
|
1186
|
+
return false; // never retry
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const p = new CustomPending();
|
|
1191
|
+
|
|
1192
|
+
p.enqueue('a', 'op', async () => {
|
|
1193
|
+
throw new TypeError('Failed to fetch'); // normally retryable
|
|
1194
|
+
});
|
|
1195
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1196
|
+
|
|
1197
|
+
// Should fail immediately despite being a network error
|
|
1198
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
1199
|
+
p.dispose();
|
|
1200
|
+
});
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
describe('static config override', () => {
|
|
1204
|
+
it('subclass can override MAX_RETRIES', async () => {
|
|
1205
|
+
class CustomPending extends Pending<string> {
|
|
1206
|
+
static override MAX_RETRIES = 2;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const p = new CustomPending();
|
|
1210
|
+
let callCount = 0;
|
|
1211
|
+
|
|
1212
|
+
p.enqueue('a', 'op', async () => {
|
|
1213
|
+
callCount++;
|
|
1214
|
+
throw new TypeError('Failed to fetch');
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1218
|
+
expect(callCount).toBe(1);
|
|
1219
|
+
|
|
1220
|
+
await vi.advanceTimersByTimeAsync(30000);
|
|
1221
|
+
expect(callCount).toBe(2);
|
|
1222
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
1223
|
+
expect(p.getStatus('a')!.maxRetries).toBe(2);
|
|
1224
|
+
p.dispose();
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('backoff respects overridden RETRY_BASE and RETRY_FACTOR', async () => {
|
|
1228
|
+
vi.spyOn(Math, 'random').mockReturnValue(1);
|
|
1229
|
+
|
|
1230
|
+
class FastPending extends Pending<string> {
|
|
1231
|
+
static override RETRY_BASE = 100;
|
|
1232
|
+
static override RETRY_FACTOR = 3;
|
|
1233
|
+
static override RETRY_MAX = 50000;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const p = new FastPending();
|
|
1237
|
+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
1238
|
+
|
|
1239
|
+
p.enqueue('a', 'op', async () => {
|
|
1240
|
+
throw new TypeError('Failed to fetch');
|
|
1241
|
+
});
|
|
1242
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1243
|
+
|
|
1244
|
+
// With random()=1: delay = 1 * min(100 * 3^0, 50000) = 100
|
|
1245
|
+
const retryCalls = setTimeoutSpy.mock.calls.filter(
|
|
1246
|
+
call => typeof call[1] === 'number' && call[1] > 0,
|
|
1247
|
+
);
|
|
1248
|
+
expect(retryCalls[retryCalls.length - 1][1]).toBe(100);
|
|
1249
|
+
|
|
1250
|
+
vi.spyOn(Math, 'random').mockRestore();
|
|
1251
|
+
setTimeoutSpy.mockRestore();
|
|
1252
|
+
p.dispose();
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
describe('subscribe', () => {
|
|
1257
|
+
it('notifies on enqueue', () => {
|
|
1258
|
+
const p = new Pending<string>();
|
|
1259
|
+
const listener = vi.fn();
|
|
1260
|
+
p.subscribe(listener);
|
|
1261
|
+
|
|
1262
|
+
p.enqueue('a', 'op', async () => {});
|
|
1263
|
+
expect(listener).toHaveBeenCalled();
|
|
1264
|
+
p.dispose();
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
it('notifies on status change', async () => {
|
|
1268
|
+
const p = new Pending<string>();
|
|
1269
|
+
const listener = vi.fn();
|
|
1270
|
+
p.subscribe(listener);
|
|
1271
|
+
|
|
1272
|
+
p.enqueue('a', 'op', async () => {});
|
|
1273
|
+
const countAfterEnqueue = listener.mock.calls.length;
|
|
1274
|
+
|
|
1275
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1276
|
+
// Should have been notified again when _process ran
|
|
1277
|
+
expect(listener.mock.calls.length).toBeGreaterThan(countAfterEnqueue);
|
|
1278
|
+
p.dispose();
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it('notifies on confirmation', async () => {
|
|
1282
|
+
const p = new Pending<string>();
|
|
1283
|
+
const listener = vi.fn();
|
|
1284
|
+
p.subscribe(listener);
|
|
1285
|
+
|
|
1286
|
+
p.enqueue('a', 'op', async () => {});
|
|
1287
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1288
|
+
|
|
1289
|
+
// Should have notifications for: enqueue (active), _process (active), confirmed (removed)
|
|
1290
|
+
expect(listener.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
1291
|
+
p.dispose();
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
it('notifies on cancellation', async () => {
|
|
1295
|
+
const p = new Pending<string>();
|
|
1296
|
+
p.enqueue('a', 'op', () => new Promise(() => {}));
|
|
1297
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1298
|
+
|
|
1299
|
+
const listener = vi.fn();
|
|
1300
|
+
p.subscribe(listener);
|
|
1301
|
+
p.cancel('a');
|
|
1302
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
1303
|
+
p.dispose();
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it('unsubscribe stops notifications', () => {
|
|
1307
|
+
const p = new Pending<string>();
|
|
1308
|
+
const listener = vi.fn();
|
|
1309
|
+
const unsub = p.subscribe(listener);
|
|
1310
|
+
unsub();
|
|
1311
|
+
|
|
1312
|
+
p.enqueue('a', 'op', async () => {});
|
|
1313
|
+
expect(listener).not.toHaveBeenCalled();
|
|
1314
|
+
p.dispose();
|
|
1315
|
+
});
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
describe('dispose', () => {
|
|
1319
|
+
it('cancels all operations', async () => {
|
|
1320
|
+
const p = new Pending<string>();
|
|
1321
|
+
let signal: AbortSignal | null = null;
|
|
1322
|
+
|
|
1323
|
+
p.enqueue('a', 'op', async (s) => {
|
|
1324
|
+
signal = s;
|
|
1325
|
+
await new Promise(() => {});
|
|
1326
|
+
});
|
|
1327
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1328
|
+
|
|
1329
|
+
p.dispose();
|
|
1330
|
+
expect(signal!.aborted).toBe(true);
|
|
1331
|
+
expect(p.count).toBe(0);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('clears all listeners', async () => {
|
|
1335
|
+
const p = new Pending<string>();
|
|
1336
|
+
const listener = vi.fn();
|
|
1337
|
+
p.subscribe(listener);
|
|
1338
|
+
|
|
1339
|
+
p.dispose();
|
|
1340
|
+
listener.mockClear();
|
|
1341
|
+
|
|
1342
|
+
// Even if somehow a notification fires, listener shouldn't be called
|
|
1343
|
+
expect(listener).not.toHaveBeenCalled();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it('subsequent enqueue is no-op', () => {
|
|
1347
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1348
|
+
const p = new Pending<string>();
|
|
1349
|
+
p.dispose();
|
|
1350
|
+
|
|
1351
|
+
p.enqueue('a', 'op', async () => {});
|
|
1352
|
+
expect(p.count).toBe(0);
|
|
1353
|
+
expect(p.disposed).toBe(true);
|
|
1354
|
+
warnSpy.mockRestore();
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
describe('ViewModel auto-tracking integration', () => {
|
|
1359
|
+
it('is auto-tracked when assigned as ViewModel property', () => {
|
|
1360
|
+
class TestVM extends ViewModel {
|
|
1361
|
+
readonly pending = new Pending<string>();
|
|
1362
|
+
|
|
1363
|
+
get pendingCount() {
|
|
1364
|
+
return this.pending.count;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const vm = new TestVM();
|
|
1369
|
+
vm.init();
|
|
1370
|
+
|
|
1371
|
+
const listener = vi.fn();
|
|
1372
|
+
vm.subscribe(listener);
|
|
1373
|
+
|
|
1374
|
+
vm.pending.enqueue('a', 'op', () => new Promise(() => {}));
|
|
1375
|
+
expect(listener).toHaveBeenCalled();
|
|
1376
|
+
|
|
1377
|
+
vm.dispose();
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
it('ViewModel getter recomputes when operation status changes', async () => {
|
|
1381
|
+
let computeCount = 0;
|
|
1382
|
+
|
|
1383
|
+
class TestVM extends ViewModel {
|
|
1384
|
+
readonly pending = new Pending<string>();
|
|
1385
|
+
|
|
1386
|
+
get pendingCount() {
|
|
1387
|
+
computeCount++;
|
|
1388
|
+
return this.pending.count;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const vm = new TestVM();
|
|
1393
|
+
vm.init();
|
|
1394
|
+
|
|
1395
|
+
expect(vm.pendingCount).toBe(0);
|
|
1396
|
+
expect(computeCount).toBe(1);
|
|
1397
|
+
|
|
1398
|
+
// Cached
|
|
1399
|
+
vm.pendingCount;
|
|
1400
|
+
expect(computeCount).toBe(1);
|
|
1401
|
+
|
|
1402
|
+
// Enqueue — getter should recompute
|
|
1403
|
+
vm.pending.enqueue('a', 'op', async () => {});
|
|
1404
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1405
|
+
|
|
1406
|
+
// After completion, count is 0 again
|
|
1407
|
+
expect(vm.pendingCount).toBe(0);
|
|
1408
|
+
expect(computeCount).toBeGreaterThan(1);
|
|
1409
|
+
|
|
1410
|
+
vm.dispose();
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('ViewModel getter caches when no change', () => {
|
|
1414
|
+
let computeCount = 0;
|
|
1415
|
+
|
|
1416
|
+
class TestVM extends ViewModel {
|
|
1417
|
+
readonly pending = new Pending<string>();
|
|
1418
|
+
|
|
1419
|
+
get hasFailed() {
|
|
1420
|
+
computeCount++;
|
|
1421
|
+
return this.pending.hasFailed;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const vm = new TestVM();
|
|
1426
|
+
vm.init();
|
|
1427
|
+
|
|
1428
|
+
expect(vm.hasFailed).toBe(false);
|
|
1429
|
+
expect(computeCount).toBe(1);
|
|
1430
|
+
|
|
1431
|
+
// Cached — same result
|
|
1432
|
+
vm.hasFailed;
|
|
1433
|
+
expect(computeCount).toBe(1);
|
|
1434
|
+
|
|
1435
|
+
vm.dispose();
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
describe('meta', () => {
|
|
1440
|
+
interface TestMeta { label: string; priority: number }
|
|
1441
|
+
|
|
1442
|
+
it('enqueue with meta stores it on snapshot', async () => {
|
|
1443
|
+
const p = new Pending<string, TestMeta>();
|
|
1444
|
+
let resolve!: () => void;
|
|
1445
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1446
|
+
|
|
1447
|
+
p.enqueue('a', 'send', () => promise, { label: 'hello', priority: 1 });
|
|
1448
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1449
|
+
|
|
1450
|
+
const status = p.getStatus('a');
|
|
1451
|
+
expect(status!.meta).toEqual({ label: 'hello', priority: 1 });
|
|
1452
|
+
|
|
1453
|
+
resolve();
|
|
1454
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1455
|
+
p.dispose();
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it('enqueue without meta defaults to null', async () => {
|
|
1459
|
+
const p = new Pending<string, TestMeta>();
|
|
1460
|
+
let resolve!: () => void;
|
|
1461
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1462
|
+
|
|
1463
|
+
p.enqueue('a', 'send', () => promise);
|
|
1464
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1465
|
+
|
|
1466
|
+
const status = p.getStatus('a');
|
|
1467
|
+
expect(status!.meta).toBe(null);
|
|
1468
|
+
|
|
1469
|
+
resolve();
|
|
1470
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1471
|
+
p.dispose();
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it('untyped Pending has meta: null', async () => {
|
|
1475
|
+
const p = new Pending<string>();
|
|
1476
|
+
let resolve!: () => void;
|
|
1477
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1478
|
+
|
|
1479
|
+
p.enqueue('a', 'delete', () => promise);
|
|
1480
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1481
|
+
|
|
1482
|
+
expect(p.getStatus('a')!.meta).toBe(null);
|
|
1483
|
+
|
|
1484
|
+
resolve();
|
|
1485
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1486
|
+
p.dispose();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('meta survives retry cycles', async () => {
|
|
1490
|
+
const p = new Pending<string, TestMeta>();
|
|
1491
|
+
let callCount = 0;
|
|
1492
|
+
|
|
1493
|
+
p.enqueue('a', 'send', async () => {
|
|
1494
|
+
callCount++;
|
|
1495
|
+
if (callCount === 1) throw new TypeError('Failed to fetch');
|
|
1496
|
+
}, { label: 'retry-test', priority: 5 });
|
|
1497
|
+
|
|
1498
|
+
// First attempt — fails, enters retrying
|
|
1499
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1500
|
+
expect(p.getStatus('a')!.status).toBe('retrying');
|
|
1501
|
+
expect(p.getStatus('a')!.meta).toEqual({ label: 'retry-test', priority: 5 });
|
|
1502
|
+
|
|
1503
|
+
// Second attempt — succeeds, removed
|
|
1504
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1505
|
+
expect(p.getStatus('a')).toBe(null);
|
|
1506
|
+
expect(callCount).toBe(2);
|
|
1507
|
+
|
|
1508
|
+
p.dispose();
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
it('supersede replaces meta', async () => {
|
|
1512
|
+
const p = new Pending<string, TestMeta>();
|
|
1513
|
+
let resolve1!: () => void;
|
|
1514
|
+
const promise1 = new Promise<void>(r => { resolve1 = r; });
|
|
1515
|
+
|
|
1516
|
+
p.enqueue('a', 'send', () => promise1, { label: 'first', priority: 1 });
|
|
1517
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1518
|
+
expect(p.getStatus('a')!.meta).toEqual({ label: 'first', priority: 1 });
|
|
1519
|
+
|
|
1520
|
+
// Supersede with different meta
|
|
1521
|
+
let resolve2!: () => void;
|
|
1522
|
+
const promise2 = new Promise<void>(r => { resolve2 = r; });
|
|
1523
|
+
p.enqueue('a', 'send', () => promise2, { label: 'second', priority: 2 });
|
|
1524
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1525
|
+
expect(p.getStatus('a')!.meta).toEqual({ label: 'second', priority: 2 });
|
|
1526
|
+
|
|
1527
|
+
resolve2();
|
|
1528
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1529
|
+
p.dispose();
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it('meta is frozen on snapshot', async () => {
|
|
1533
|
+
const p = new Pending<string, TestMeta>();
|
|
1534
|
+
let resolve!: () => void;
|
|
1535
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1536
|
+
|
|
1537
|
+
p.enqueue('a', 'send', () => promise, { label: 'frozen', priority: 1 });
|
|
1538
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1539
|
+
|
|
1540
|
+
const status = p.getStatus('a')!;
|
|
1541
|
+
expect(() => { (status as any).meta = 'changed'; }).toThrow();
|
|
1542
|
+
|
|
1543
|
+
resolve();
|
|
1544
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1545
|
+
p.dispose();
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
describe('entries', () => {
|
|
1550
|
+
it('returns empty array when no operations', () => {
|
|
1551
|
+
const p = new Pending<string>();
|
|
1552
|
+
expect(p.entries).toEqual([]);
|
|
1553
|
+
expect(p.entries.length).toBe(0);
|
|
1554
|
+
p.dispose();
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('returns all operations with ids', async () => {
|
|
1558
|
+
const p = new Pending<string>();
|
|
1559
|
+
let resolve1!: () => void;
|
|
1560
|
+
let resolve2!: () => void;
|
|
1561
|
+
const p1 = new Promise<void>(r => { resolve1 = r; });
|
|
1562
|
+
const p2 = new Promise<void>(r => { resolve2 = r; });
|
|
1563
|
+
|
|
1564
|
+
p.enqueue('a', 'delete', () => p1);
|
|
1565
|
+
p.enqueue('b', 'update', () => p2);
|
|
1566
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1567
|
+
|
|
1568
|
+
const entries = p.entries;
|
|
1569
|
+
expect(entries.length).toBe(2);
|
|
1570
|
+
|
|
1571
|
+
const entryA = entries.find(e => e.id === 'a');
|
|
1572
|
+
const entryB = entries.find(e => e.id === 'b');
|
|
1573
|
+
expect(entryA).toBeDefined();
|
|
1574
|
+
expect(entryA!.operation).toBe('delete');
|
|
1575
|
+
expect(entryA!.status).toBe('active');
|
|
1576
|
+
expect(entryB).toBeDefined();
|
|
1577
|
+
expect(entryB!.operation).toBe('update');
|
|
1578
|
+
|
|
1579
|
+
resolve1();
|
|
1580
|
+
resolve2();
|
|
1581
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1582
|
+
p.dispose();
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
it('entries include meta', async () => {
|
|
1586
|
+
interface Meta { label: string }
|
|
1587
|
+
const p = new Pending<string, Meta>();
|
|
1588
|
+
let resolve!: () => void;
|
|
1589
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1590
|
+
|
|
1591
|
+
p.enqueue('a', 'send', () => promise, { label: 'test-meta' });
|
|
1592
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1593
|
+
|
|
1594
|
+
const entry = p.entries[0]!;
|
|
1595
|
+
expect(entry.id).toBe('a');
|
|
1596
|
+
expect(entry.meta).toEqual({ label: 'test-meta' });
|
|
1597
|
+
|
|
1598
|
+
resolve();
|
|
1599
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1600
|
+
p.dispose();
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it('entries are reference-stable between reads without changes', async () => {
|
|
1604
|
+
const p = new Pending<string>();
|
|
1605
|
+
let resolve!: () => void;
|
|
1606
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1607
|
+
|
|
1608
|
+
p.enqueue('a', 'delete', () => promise);
|
|
1609
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1610
|
+
|
|
1611
|
+
const first = p.entries;
|
|
1612
|
+
const second = p.entries;
|
|
1613
|
+
expect(first).toBe(second); // same reference
|
|
1614
|
+
|
|
1615
|
+
resolve();
|
|
1616
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1617
|
+
p.dispose();
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
it('entries cache invalidates on enqueue', async () => {
|
|
1621
|
+
const p = new Pending<string>();
|
|
1622
|
+
let resolve1!: () => void;
|
|
1623
|
+
let resolve2!: () => void;
|
|
1624
|
+
const p1 = new Promise<void>(r => { resolve1 = r; });
|
|
1625
|
+
const p2 = new Promise<void>(r => { resolve2 = r; });
|
|
1626
|
+
|
|
1627
|
+
p.enqueue('a', 'delete', () => p1);
|
|
1628
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1629
|
+
|
|
1630
|
+
const before = p.entries;
|
|
1631
|
+
expect(before.length).toBe(1);
|
|
1632
|
+
|
|
1633
|
+
p.enqueue('b', 'update', () => p2);
|
|
1634
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1635
|
+
|
|
1636
|
+
const after = p.entries;
|
|
1637
|
+
expect(after).not.toBe(before); // new reference
|
|
1638
|
+
expect(after.length).toBe(2);
|
|
1639
|
+
|
|
1640
|
+
resolve1();
|
|
1641
|
+
resolve2();
|
|
1642
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1643
|
+
p.dispose();
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
it('entries cache invalidates on cancel', async () => {
|
|
1647
|
+
const p = new Pending<string>();
|
|
1648
|
+
p.enqueue('a', 'delete', () => new Promise(() => {}));
|
|
1649
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1650
|
+
|
|
1651
|
+
const before = p.entries;
|
|
1652
|
+
expect(before.length).toBe(1);
|
|
1653
|
+
|
|
1654
|
+
p.cancel('a');
|
|
1655
|
+
|
|
1656
|
+
const after = p.entries;
|
|
1657
|
+
expect(after).not.toBe(before);
|
|
1658
|
+
expect(after.length).toBe(0);
|
|
1659
|
+
|
|
1660
|
+
p.dispose();
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
it('entries cache invalidates on dismiss', async () => {
|
|
1664
|
+
const p = new Pending<string>();
|
|
1665
|
+
p.enqueue('a', 'delete', async () => {
|
|
1666
|
+
throw new HttpError(422, 'Validation failed');
|
|
1667
|
+
});
|
|
1668
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1669
|
+
|
|
1670
|
+
expect(p.getStatus('a')!.status).toBe('failed');
|
|
1671
|
+
const before = p.entries;
|
|
1672
|
+
expect(before.length).toBe(1);
|
|
1673
|
+
|
|
1674
|
+
p.dismiss('a');
|
|
1675
|
+
|
|
1676
|
+
const after = p.entries;
|
|
1677
|
+
expect(after).not.toBe(before);
|
|
1678
|
+
expect(after.length).toBe(0);
|
|
1679
|
+
|
|
1680
|
+
p.dispose();
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
it('entries are frozen', async () => {
|
|
1684
|
+
const p = new Pending<string>();
|
|
1685
|
+
let resolve!: () => void;
|
|
1686
|
+
const promise = new Promise<void>(r => { resolve = r; });
|
|
1687
|
+
|
|
1688
|
+
p.enqueue('a', 'delete', () => promise);
|
|
1689
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1690
|
+
|
|
1691
|
+
const entries = p.entries;
|
|
1692
|
+
expect(() => { (entries as any).push('x'); }).toThrow();
|
|
1693
|
+
expect(() => { (entries[0] as any).id = 'changed'; }).toThrow();
|
|
1694
|
+
|
|
1695
|
+
resolve();
|
|
1696
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1697
|
+
p.dispose();
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it('empty entries array is reference-stable', () => {
|
|
1701
|
+
const p = new Pending<string>();
|
|
1702
|
+
const first = p.entries;
|
|
1703
|
+
const second = p.entries;
|
|
1704
|
+
expect(first).toBe(second);
|
|
1705
|
+
expect(first.length).toBe(0);
|
|
1706
|
+
p.dispose();
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
describe('method binding', () => {
|
|
1711
|
+
it('destructured methods work point-free', () => {
|
|
1712
|
+
const pending = new Pending<string>();
|
|
1713
|
+
const { enqueue, has, getStatus } = pending;
|
|
1714
|
+
enqueue('1', 'save', async () => {});
|
|
1715
|
+
expect(has('1')).toBe(true);
|
|
1716
|
+
expect(getStatus('1')).not.toBeNull();
|
|
1717
|
+
});
|
|
1718
|
+
});
|
|
1719
|
+
});
|