@cplieger/actions 1.0.0 → 1.0.1

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 (80) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +0 -0
  3. package/jsr.json +8 -12
  4. package/package.json +7 -9
  5. package/src/__test-helpers__/action-test-setup.ts +34 -0
  6. package/src/api-idempotency.test.ts +163 -0
  7. package/src/api.test.ts +96 -0
  8. package/src/api.ts +0 -0
  9. package/src/cleanup.test.ts +102 -0
  10. package/src/cleanup.ts +0 -0
  11. package/src/configure-api.test.ts +219 -0
  12. package/src/cross-action-coordination.test.ts +176 -0
  13. package/src/cross-action-races-extra.test.ts +231 -0
  14. package/src/cross-action-races.test.ts +213 -0
  15. package/src/cross-action-scope-serial.test.ts +121 -0
  16. package/src/debounce.test.ts +121 -0
  17. package/src/debounce.ts +0 -0
  18. package/src/define-helpers.ts +0 -0
  19. package/src/define.property.test.ts +133 -0
  20. package/src/define.test.ts +476 -0
  21. package/src/define.ts +0 -0
  22. package/src/dispatch-callbacks.test.ts +141 -0
  23. package/src/dispatch-options.test.ts +197 -0
  24. package/src/edge-cases.test.ts +81 -0
  25. package/src/error.test.ts +268 -0
  26. package/src/error.ts +0 -0
  27. package/src/index.ts +0 -0
  28. package/src/leak-stress.test.ts +117 -0
  29. package/src/loading.test.ts +146 -0
  30. package/src/loading.ts +0 -0
  31. package/src/new-features.test.ts +246 -0
  32. package/src/notifier.ts +0 -0
  33. package/src/poll.test.ts +385 -0
  34. package/src/poll.ts +0 -0
  35. package/src/primitive-edge-cases.test.ts +105 -0
  36. package/src/primitive-interactions.test.ts +112 -0
  37. package/src/primitives.test.ts +215 -0
  38. package/src/race-dedupe-cancel.test.ts +88 -0
  39. package/src/redteam.test.ts +254 -0
  40. package/src/redteam2.test.ts +337 -0
  41. package/src/redteam3.test.ts +454 -0
  42. package/src/registry.test.ts +271 -0
  43. package/src/registry.ts +0 -0
  44. package/src/retry-network-delay.test.ts +155 -0
  45. package/src/retry.ts +0 -0
  46. package/src/transport.test.ts +139 -0
  47. package/src/transport.ts +0 -0
  48. package/src/types.ts +0 -0
  49. package/.editorconfig +0 -19
  50. package/.htmlvalidate.json +0 -28
  51. package/.stylelintrc.json +0 -67
  52. package/dist/src/api.d.ts +0 -56
  53. package/dist/src/api.js +0 -158
  54. package/dist/src/cleanup.d.ts +0 -11
  55. package/dist/src/cleanup.js +0 -63
  56. package/dist/src/debounce.d.ts +0 -28
  57. package/dist/src/debounce.js +0 -91
  58. package/dist/src/define-helpers.d.ts +0 -20
  59. package/dist/src/define-helpers.js +0 -83
  60. package/dist/src/define.d.ts +0 -14
  61. package/dist/src/define.js +0 -627
  62. package/dist/src/error.d.ts +0 -42
  63. package/dist/src/error.js +0 -174
  64. package/dist/src/index.d.ts +0 -17
  65. package/dist/src/index.js +0 -22
  66. package/dist/src/loading.d.ts +0 -15
  67. package/dist/src/loading.js +0 -114
  68. package/dist/src/notifier.d.ts +0 -21
  69. package/dist/src/notifier.js +0 -21
  70. package/dist/src/poll.d.ts +0 -23
  71. package/dist/src/poll.js +0 -120
  72. package/dist/src/registry.d.ts +0 -18
  73. package/dist/src/registry.js +0 -210
  74. package/dist/src/retry.d.ts +0 -9
  75. package/dist/src/retry.js +0 -78
  76. package/dist/src/transport.d.ts +0 -46
  77. package/dist/src/transport.js +0 -70
  78. package/dist/src/types.d.ts +0 -157
  79. package/dist/src/types.js +0 -7
  80. package/eslint.config.base.mjs +0 -186
@@ -0,0 +1,454 @@
1
+ // @vitest-environment happy-dom
2
+ // RED-TEAM round 3 (final sweep): full lifecycle race matrix, AbortSignal
3
+ // listener leaks, prepareHeaders/fetchFn error paths, registry bounds under load.
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+
6
+ vi.mock("./notifier.js", () => ({
7
+ configure: vi.fn(),
8
+ notifySuccess: vi.fn(),
9
+ notifyError: vi.fn(),
10
+ _resetNotifierForTest: vi.fn(),
11
+ }));
12
+
13
+ import { apiAction, configureApi, _resetApiConfigForTest } from "./api.js";
14
+ import { defineAction, _resetForTest as resetDefine, _internalsForTest } from "./define.js";
15
+ import { _resetForTest as resetRegistry, recentLog, isPending, pendingCount } from "./registry.js";
16
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
17
+
18
+ const mockFetch = vi.fn();
19
+
20
+ beforeEach(() => {
21
+ resetDefine();
22
+ resetRegistry();
23
+ resetCleanup();
24
+ _resetApiConfigForTest();
25
+ mockFetch.mockReset();
26
+ vi.stubGlobal("fetch", mockFetch);
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // 1. Full lifecycle race matrix: dispatch/cancel/abort/timeout/retry/dedupe/scope
35
+ // ---------------------------------------------------------------------------
36
+ describe("lifecycle race matrix", () => {
37
+ it("cancel() during retry backoff with dedupe + scope — no leak, correct status", async () => {
38
+ let attempt = 0;
39
+ const action = defineAction({
40
+ name: "race.matrix.1",
41
+ scope: "race-scope",
42
+ dedupe: true,
43
+ timeout: 5000,
44
+ retry: { count: 5, delay: 100 },
45
+ retryable: () => true,
46
+ run: async (_a, signal) => {
47
+ attempt++;
48
+ if (attempt <= 2) throw new Error("transient");
49
+ // After 2 failures, wait for signal
50
+ await new Promise<never>((_r, reject) => {
51
+ signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
52
+ });
53
+ },
54
+ });
55
+ const handle = action.dispatch("x");
56
+ // Let first two attempts fail and enter backoff
57
+ await new Promise((r) => setTimeout(r, 50));
58
+ // Cancel mid-flight
59
+ action.cancel();
60
+ const result = await handle;
61
+ expect(result).toBeNull();
62
+ const entry = recentLog().find((e) => e.name === "race.matrix.1");
63
+ expect(entry).toBeDefined();
64
+ expect(entry!.status).toBe("cancelled");
65
+ // Verify no leaks in internal maps
66
+ const internals = _internalsForTest();
67
+ expect(internals.activeDedupes).toBe(0);
68
+ });
69
+
70
+ it("abort() on deduped follower while leader is in-flight — follower settles, leader continues", async () => {
71
+ let leaderResolve!: (v: string) => void;
72
+ const action = defineAction({
73
+ name: "race.dedupe.abort",
74
+ dedupe: true,
75
+ run: () =>
76
+ new Promise<string>((r) => {
77
+ leaderResolve = r;
78
+ }),
79
+ });
80
+ const h1 = action.dispatch("a");
81
+ const onError2 = vi.fn();
82
+ const onSettled2 = vi.fn();
83
+ const h2 = action.dispatch("a", { onError: onError2, onSettled: onSettled2 });
84
+ // Abort the follower handle — but since dedupe shares the promise, abort is a no-op
85
+ h2.abort();
86
+ // Resolve the leader
87
+ leaderResolve("done");
88
+ const r1 = await h1;
89
+ const r2 = await h2;
90
+ expect(r1).toBe("done");
91
+ // Follower gets the result too (abort on follower is NOOP for dedupe)
92
+ expect(r2).toBe("done");
93
+ expect(onSettled2).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ it("scope-queued dispatch cancelled via action.cancel() before run starts — records cancelled, no run", async () => {
97
+ const runSpy = vi.fn().mockResolvedValue("ok");
98
+ let blockerResolve!: (v: string) => void;
99
+ const action = defineAction({
100
+ name: "race.scope.cancel",
101
+ scope: "cancel-scope",
102
+ run: (args, _signal) => {
103
+ if (args === "blocker") {
104
+ return new Promise<string>((r) => {
105
+ blockerResolve = r;
106
+ });
107
+ }
108
+ runSpy();
109
+ return Promise.resolve("target-done");
110
+ },
111
+ });
112
+ const h1 = action.dispatch("blocker" as string);
113
+ await Promise.resolve(); // let blocker start
114
+ const h2 = action.dispatch("target" as string);
115
+ // Cancel all before target starts
116
+ action.cancel();
117
+ blockerResolve("blocker-done");
118
+ await h1;
119
+ const r2 = await h2;
120
+ expect(r2).toBeNull();
121
+ expect(runSpy).not.toHaveBeenCalled();
122
+ const entries = recentLog().filter((e) => e.name === "race.scope.cancel");
123
+ const targetEntry = entries.find((e) => e.args === "target");
124
+ expect(targetEntry?.status).toBe("cancelled");
125
+ });
126
+
127
+ it("rapid dispatch+cancel+dispatch cycle does not corrupt state", async () => {
128
+ let callCount = 0;
129
+ const action = defineAction({
130
+ name: "race.rapid",
131
+ run: async () => {
132
+ callCount++;
133
+ return callCount;
134
+ },
135
+ });
136
+ const h1 = action.dispatch("a");
137
+ h1.abort();
138
+ const h2 = action.dispatch("b");
139
+ const h3 = action.dispatch("c");
140
+ h3.abort();
141
+ const [r1, r2, r3] = await Promise.all([h1, h2, h3]);
142
+ expect(r1).toBeNull(); // aborted
143
+ expect(r2).toBeGreaterThanOrEqual(1); // should succeed
144
+ expect(r3).toBeNull(); // aborted
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // 2. AbortSignal listener leaks
150
+ // ---------------------------------------------------------------------------
151
+ describe("AbortSignal listener leaks", () => {
152
+ it("sleep() cleans up abort listener on normal resolve", async () => {
153
+ const { sleep } = await import("./retry.js");
154
+ const ac = new AbortController();
155
+ // Spy on removeEventListener
156
+ const removeSpy = vi.spyOn(ac.signal, "removeEventListener");
157
+ await sleep(1, ac.signal);
158
+ // The abort listener should have been removed after timer fires
159
+ expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
160
+ });
161
+
162
+ it("sleep() cleans up timer on abort", async () => {
163
+ const { sleep } = await import("./retry.js");
164
+ const ac = new AbortController();
165
+ const p = sleep(100000, ac.signal);
166
+ ac.abort();
167
+ await expect(p).rejects.toThrow();
168
+ // No lingering timer — if it leaked, the test process would hang
169
+ });
170
+
171
+ it("waitForOnline() cleans up listeners on abort", async () => {
172
+ const { waitForOnline } = await import("./retry.js");
173
+ // Simulate offline
174
+ Object.defineProperty(navigator, "onLine", { value: false, configurable: true });
175
+ const ac = new AbortController();
176
+ const removeSpy = vi.spyOn(ac.signal, "removeEventListener");
177
+ const p = waitForOnline(ac.signal);
178
+ ac.abort();
179
+ await expect(p).rejects.toThrow();
180
+ expect(removeSpy).toHaveBeenCalled();
181
+ Object.defineProperty(navigator, "onLine", { value: true, configurable: true });
182
+ });
183
+
184
+ it("many dispatches with timeout do not accumulate listeners", async () => {
185
+ vi.useFakeTimers();
186
+ const action = defineAction({
187
+ name: "leak.timeout",
188
+ timeout: 1000,
189
+ run: async () => "ok",
190
+ });
191
+ const handles = [];
192
+ for (let i = 0; i < 50; i++) {
193
+ handles.push(action.dispatch(`arg-${i}`));
194
+ }
195
+ await vi.advanceTimersByTimeAsync(10);
196
+ const results = await Promise.all(handles);
197
+ // All should succeed (run is instant)
198
+ expect(results.every((r) => r === "ok")).toBe(true);
199
+ // Verify no internal map leaks
200
+ const internals = _internalsForTest();
201
+ expect(internals.scopeChains).toBe(0);
202
+ expect(internals.activeDedupes).toBe(0);
203
+ vi.useRealTimers();
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // 3. prepareHeaders / fetchFn error paths
209
+ // ---------------------------------------------------------------------------
210
+ describe("prepareHeaders / fetchFn error paths", () => {
211
+ it("prepareHeaders returning a rejected promise with non-Error value", async () => {
212
+ configureApi({
213
+ prepareHeaders: () => Promise.reject("string-rejection"),
214
+ });
215
+ mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
216
+ const action = apiAction<void>({
217
+ name: "prep.string.reject",
218
+ request: () => ({ method: "GET", path: "/x" }),
219
+ });
220
+ const result = await action.dispatch(undefined);
221
+ expect(result).toBeNull();
222
+ const entry = recentLog().find((e) => e.name === "prep.string.reject");
223
+ expect(entry?.status).toBe("error");
224
+ expect(mockFetch).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it("fetchFn returning a rejected promise with DOMException TimeoutError", async () => {
228
+ configureApi({
229
+ fetchFn: () => Promise.reject(new DOMException("timed out", "TimeoutError")),
230
+ });
231
+ const action = apiAction<void>({
232
+ name: "fetch.timeout.dom",
233
+ request: () => ({ method: "GET", path: "/x" }),
234
+ });
235
+ const result = await action.dispatch(undefined);
236
+ expect(result).toBeNull();
237
+ const entry = recentLog().find((e) => e.name === "fetch.timeout.dom");
238
+ expect(entry?.status).toBe("error");
239
+ expect(entry?.error?.code).toBe("timeout");
240
+ });
241
+
242
+ it("fetchFn throwing after signal is aborted classifies as cancelled", async () => {
243
+ configureApi({
244
+ fetchFn: async (_url, init) => {
245
+ const signal = (init as RequestInit).signal!;
246
+ // Wait until signal aborts
247
+ await new Promise<void>((r) => {
248
+ signal.addEventListener("abort", () => r());
249
+ });
250
+ throw new DOMException("aborted", "AbortError");
251
+ },
252
+ });
253
+ const action = apiAction<void>({
254
+ name: "fetch.abort.classify",
255
+ request: () => ({ method: "GET", path: "/x" }),
256
+ });
257
+ const handle = action.dispatch(undefined);
258
+ // Abort immediately
259
+ handle.abort();
260
+ const result = await handle;
261
+ expect(result).toBeNull();
262
+ const entry = recentLog().find((e) => e.name === "fetch.abort.classify");
263
+ expect(entry?.status).toBe("cancelled");
264
+ });
265
+
266
+ it("prepareHeaders mutating headers does not affect subsequent requests", async () => {
267
+ let callCount = 0;
268
+ configureApi({
269
+ prepareHeaders: (headers) => {
270
+ callCount++;
271
+ headers.set("X-Seq", String(callCount));
272
+ },
273
+ });
274
+ mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
275
+ const action = apiAction<number>({
276
+ name: "prep.no.bleed",
277
+ request: (n) => ({ method: "GET", path: `/x/${n}` }),
278
+ });
279
+ await action.dispatch(1);
280
+ await action.dispatch(2);
281
+ const h1 = (mockFetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>;
282
+ const h2 = (mockFetch.mock.calls[1]![1] as RequestInit).headers as Record<string, string>;
283
+ expect(h1["x-seq"]).toBe("1");
284
+ expect(h2["x-seq"]).toBe("2");
285
+ });
286
+ });
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // 4. Registry bounds under load
290
+ // ---------------------------------------------------------------------------
291
+ describe("registry bounds under load", () => {
292
+ it("registry does not grow unbounded with 500+ dispatches", async () => {
293
+ const action = defineAction({
294
+ name: "reg.load",
295
+ run: async (n: number) => n * 2,
296
+ });
297
+ const promises = [];
298
+ for (let i = 0; i < 500; i++) {
299
+ promises.push(action.dispatch(i));
300
+ }
301
+ await Promise.all(promises);
302
+ const log = recentLog();
303
+ // Registry should be bounded (MAX_LOG_SIZE = 200)
304
+ expect(log.length).toBeLessThanOrEqual(250);
305
+ // All should be terminal (no pending leaks)
306
+ expect(log.every((e) => e.status !== "pending")).toBe(true);
307
+ expect(pendingCount()).toBe(0);
308
+ });
309
+
310
+ it("pendingCount stays accurate under rapid dispatch/settle", async () => {
311
+ const resolvers: ((v: string) => void)[] = [];
312
+ const action = defineAction({
313
+ name: "reg.pending",
314
+ run: () =>
315
+ new Promise<string>((r) => {
316
+ resolvers.push(r);
317
+ }),
318
+ });
319
+ // Dispatch 10 concurrent
320
+ for (let i = 0; i < 10; i++) {
321
+ action.dispatch(`arg-${i}`);
322
+ }
323
+ await Promise.resolve();
324
+ expect(pendingCount(["reg.pending"])).toBe(10);
325
+ expect(isPending("reg.pending")).toBe(true);
326
+ // Resolve half
327
+ for (let i = 0; i < 5; i++) {
328
+ resolvers[i]!("done");
329
+ }
330
+ await Promise.resolve();
331
+ await Promise.resolve();
332
+ expect(pendingCount(["reg.pending"])).toBe(5);
333
+ // Resolve rest
334
+ for (let i = 5; i < 10; i++) {
335
+ resolvers[i]!("done");
336
+ }
337
+ await Promise.resolve();
338
+ await Promise.resolve();
339
+ expect(pendingCount(["reg.pending"])).toBe(0);
340
+ expect(isPending("reg.pending")).toBe(false);
341
+ });
342
+
343
+ it("registry eviction preserves pending entries", async () => {
344
+ let pendingResolve!: (v: string) => void;
345
+ const slowAction = defineAction({
346
+ name: "reg.evict.slow",
347
+ run: () =>
348
+ new Promise<string>((r) => {
349
+ pendingResolve = r;
350
+ }),
351
+ });
352
+ const fastAction = defineAction({
353
+ name: "reg.evict.fast",
354
+ run: async () => "fast",
355
+ });
356
+ // Start a slow action
357
+ const slowHandle = slowAction.dispatch("slow");
358
+ await Promise.resolve();
359
+ // Flood with fast actions to trigger eviction
360
+ for (let i = 0; i < 300; i++) {
361
+ await fastAction.dispatch(String(i));
362
+ }
363
+ // Slow action should still be tracked as pending
364
+ expect(isPending("reg.evict.slow")).toBe(true);
365
+ // Resolve it
366
+ pendingResolve("finally");
367
+ const result = await slowHandle;
368
+ expect(result).toBe("finally");
369
+ expect(isPending("reg.evict.slow")).toBe(false);
370
+ });
371
+ });
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // 5. Verify round 1-2 fixes are sound
375
+ // ---------------------------------------------------------------------------
376
+ describe("verify round 1-2 fixes", () => {
377
+ it("baseUrl join: no double slash (regression)", async () => {
378
+ configureApi({ baseUrl: "https://api.example.com/" });
379
+ mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
380
+ const action = apiAction<void>({
381
+ name: "verify.baseurl",
382
+ request: () => ({ method: "GET", path: "/users/1" }),
383
+ });
384
+ await action.dispatch(undefined);
385
+ expect(mockFetch.mock.calls[0]![0]).toBe("https://api.example.com/users/1");
386
+ });
387
+
388
+ it("timeout error code is 'timeout' not 'cancelled' (regression)", async () => {
389
+ const action = defineAction({
390
+ name: "verify.timeout.code",
391
+ timeout: 10,
392
+ run: (_a, signal) =>
393
+ new Promise<string>((_resolve, reject) => {
394
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
395
+ }),
396
+ });
397
+ const result = await action.dispatch("x");
398
+ expect(result).toBeNull();
399
+ const entry = recentLog().find(
400
+ (e) => e.name === "verify.timeout.code" && e.status !== "pending",
401
+ );
402
+ expect(entry).toBeDefined();
403
+ // Must be error with timeout code, not cancelled
404
+ expect(entry!.status).toBe("error");
405
+ expect(entry!.error?.code).toBe("timeout");
406
+ });
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // 6. Scope chain cleanup after errors
411
+ // ---------------------------------------------------------------------------
412
+ describe("scope chain cleanup", () => {
413
+ it("scope chain is released after run() throws — next dispatch proceeds", async () => {
414
+ let callCount = 0;
415
+ const action = defineAction({
416
+ name: "scope.error.release",
417
+ scope: "err-scope",
418
+ run: async () => {
419
+ callCount++;
420
+ if (callCount === 1) throw new Error("first fails");
421
+ return "second-ok";
422
+ },
423
+ });
424
+ const r1 = await action.dispatch("a");
425
+ expect(r1).toBeNull(); // first fails
426
+ const r2 = await action.dispatch("b");
427
+ expect(r2).toBe("second-ok"); // second should not be stuck
428
+ expect(_internalsForTest().scopeChains).toBe(0);
429
+ });
430
+
431
+ it("scope chain is released after cancel — next dispatch proceeds", async () => {
432
+ let resolve1!: (v: string) => void;
433
+ const action = defineAction({
434
+ name: "scope.cancel.release",
435
+ scope: "cancel-scope-2",
436
+ run: (args) => {
437
+ if (args === "first") {
438
+ return new Promise<string>((r) => {
439
+ resolve1 = r;
440
+ });
441
+ }
442
+ return Promise.resolve("second-ok");
443
+ },
444
+ });
445
+ const h1 = action.dispatch("first" as string);
446
+ await Promise.resolve();
447
+ h1.abort();
448
+ resolve1("ignored");
449
+ await h1;
450
+ const r2 = await action.dispatch("second" as string);
451
+ expect(r2).toBe("second-ok");
452
+ expect(_internalsForTest().scopeChains).toBe(0);
453
+ });
454
+ });