@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,197 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ vi.mock("./notifier.js", () => ({
4
+ configure: vi.fn(),
5
+ notifySuccess: vi.fn(),
6
+ notifyError: vi.fn(),
7
+ _resetNotifierForTest: vi.fn(),
8
+ }));
9
+ import { defineAction, _resetForTest as resetDefine } from "./define.js";
10
+ import { _resetForTest as resetRegistry } from "./registry.js";
11
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
12
+ import { ActionError, retryNetwork } from "./error.js";
13
+
14
+ beforeEach(() => {
15
+ resetDefine();
16
+ resetRegistry();
17
+ resetCleanup();
18
+ vi.useFakeTimers();
19
+ vi.clearAllMocks();
20
+ });
21
+ afterEach(() => {
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ describe("defineAction retry { count, delay, factor }", () => {
26
+ it("retries up to count times on retry-class errors then succeeds", async () => {
27
+ let attempts = 0;
28
+ const action = defineAction<{ id: string }, string>({
29
+ name: "test.retry_recovers",
30
+ retryable: retryNetwork,
31
+ retry: { count: 2, delay: 100 },
32
+ run: () => {
33
+ attempts++;
34
+ if (attempts < 3) {
35
+ throw new ActionError("flaky", { code: "network" });
36
+ }
37
+ return Promise.resolve("ok");
38
+ },
39
+ });
40
+ const promise = action.dispatch({ id: "x" });
41
+ await vi.advanceTimersByTimeAsync(100);
42
+ await vi.advanceTimersByTimeAsync(200);
43
+ expect(await promise).toBe("ok");
44
+ expect(attempts).toBe(3);
45
+ });
46
+
47
+ it("returns null after exhausting retries", async () => {
48
+ let attempts = 0;
49
+ const action = defineAction<void, void>({
50
+ name: "test.retry_exhausts",
51
+ retryable: retryNetwork,
52
+ retry: { count: 2, delay: 50 },
53
+ error: false,
54
+ run: () => {
55
+ attempts++;
56
+ return Promise.reject(new ActionError("permanent network fail", { code: "network" }));
57
+ },
58
+ });
59
+ const promise = action.dispatch();
60
+ await vi.advanceTimersByTimeAsync(50);
61
+ await vi.advanceTimersByTimeAsync(100);
62
+ expect(await promise).toBeNull();
63
+ expect(attempts).toBe(3);
64
+ });
65
+
66
+ it("does NOT retry non-retry-class errors even with retry config", async () => {
67
+ let attempts = 0;
68
+ const action = defineAction<void, void>({
69
+ name: "test.retry_skips_4xx",
70
+ retryable: retryNetwork,
71
+ retry: { count: 3, delay: 50 },
72
+ error: false,
73
+ run: () => {
74
+ attempts++;
75
+ return Promise.reject(new ActionError("validation", { status: 422 }));
76
+ },
77
+ });
78
+ await action.dispatch();
79
+ expect(attempts).toBe(1);
80
+ });
81
+
82
+ it("aborts retry chain if action.cancel() during backoff", async () => {
83
+ let attempts = 0;
84
+ const action = defineAction<void, void>({
85
+ name: "test.retry_cancel_mid_backoff",
86
+ retryable: retryNetwork,
87
+ retry: { count: 5, delay: 1000 },
88
+ error: false,
89
+ run: () => {
90
+ attempts++;
91
+ return Promise.reject(new ActionError("fail", { code: "network" }));
92
+ },
93
+ });
94
+ const promise = action.dispatch();
95
+ await vi.advanceTimersByTimeAsync(50);
96
+ action.cancel();
97
+ await vi.advanceTimersByTimeAsync(2000);
98
+ await promise;
99
+ expect(attempts).toBe(1);
100
+ });
101
+ });
102
+
103
+ describe("defineAction scope (serial dispatch)", () => {
104
+ it("serializes dispatches sharing a static scope string", async () => {
105
+ const log: string[] = [];
106
+ const action = defineAction<{ tag: string }, string>({
107
+ name: "test.scope_static",
108
+ scope: "shared",
109
+ run: async (args) => {
110
+ log.push(`start:${args.tag}`);
111
+ await new Promise<void>((r) => setTimeout(r, 50));
112
+ log.push(`end:${args.tag}`);
113
+ return args.tag;
114
+ },
115
+ });
116
+ const p1 = action.dispatch({ tag: "A" });
117
+ const p2 = action.dispatch({ tag: "B" });
118
+ const p3 = action.dispatch({ tag: "C" });
119
+ await vi.advanceTimersByTimeAsync(50);
120
+ await vi.advanceTimersByTimeAsync(50);
121
+ await vi.advanceTimersByTimeAsync(50);
122
+ await Promise.all([p1, p2, p3]);
123
+ expect(log).toEqual(["start:A", "end:A", "start:B", "end:B", "start:C", "end:C"]);
124
+ });
125
+
126
+ it("scope queue continues after a failed dispatch", async () => {
127
+ const log: string[] = [];
128
+ const action = defineAction<{ tag: string; fail: boolean }, void>({
129
+ name: "test.scope_after_fail",
130
+ scope: "q",
131
+ error: false,
132
+ run: async (args) => {
133
+ log.push(`run:${args.tag}`);
134
+ if (args.fail) {
135
+ throw new ActionError("nope");
136
+ }
137
+ },
138
+ });
139
+ const p1 = action.dispatch({ tag: "A", fail: true });
140
+ const p2 = action.dispatch({ tag: "B", fail: false });
141
+ await Promise.all([p1, p2]);
142
+ expect(log).toEqual(["run:A", "run:B"]);
143
+ });
144
+ });
145
+
146
+ describe("DispatchOptions onSuccess / onError / onSettled", () => {
147
+ it("onSuccess fires with result + args", async () => {
148
+ const action = defineAction<{ x: number }, number>({
149
+ name: "test.cb_success",
150
+ run: (args) => Promise.resolve(args.x * 2),
151
+ });
152
+ const onSuccess = vi.fn();
153
+ const onError = vi.fn();
154
+ const onSettled = vi.fn();
155
+ await action.dispatch({ x: 21 }, { onSuccess, onError, onSettled });
156
+ expect(onSuccess).toHaveBeenCalledWith(42, { x: 21 });
157
+ expect(onError).not.toHaveBeenCalled();
158
+ expect(onSettled).toHaveBeenCalledWith({ x: 21 });
159
+ });
160
+
161
+ it("onError fires with error + args", async () => {
162
+ const action = defineAction<{ tag: string }, void>({
163
+ name: "test.cb_error",
164
+ error: false,
165
+ run: () => Promise.reject(new ActionError("nope", { status: 422 })),
166
+ });
167
+ const onError = vi.fn();
168
+ const onSettled = vi.fn();
169
+ await action.dispatch({ tag: "z" }, { onError, onSettled });
170
+ expect(onError).toHaveBeenCalledWith(
171
+ expect.objectContaining({ message: "nope", status: 422 }),
172
+ { tag: "z" },
173
+ );
174
+ expect(onSettled).toHaveBeenCalledWith({ tag: "z" });
175
+ });
176
+
177
+ it("onSettled fires on cancellation; onSuccess/onError do NOT", async () => {
178
+ const action = defineAction<void, void>({
179
+ name: "test.cb_cancel",
180
+ run: (_args, signal) =>
181
+ new Promise<void>((_, reject) => {
182
+ signal.addEventListener("abort", () => {
183
+ reject(new Error("cancelled"));
184
+ });
185
+ }),
186
+ });
187
+ const onSuccess = vi.fn();
188
+ const onError = vi.fn();
189
+ const onSettled = vi.fn();
190
+ const promise = action.dispatch(undefined, { onSuccess, onError, onSettled });
191
+ action.cancel();
192
+ await promise;
193
+ expect(onSuccess).not.toHaveBeenCalled();
194
+ expect(onError).not.toHaveBeenCalled();
195
+ expect(onSettled).toHaveBeenCalledTimes(1);
196
+ });
197
+ });
@@ -0,0 +1,81 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ vi.mock("./notifier.js", () => ({
4
+ configure: vi.fn(),
5
+ notifySuccess: vi.fn(),
6
+ notifyError: vi.fn(),
7
+ _resetNotifierForTest: vi.fn(),
8
+ }));
9
+ import { defineAction, _resetForTest as resetDefine } from "./define.js";
10
+ import { _resetForTest as resetRegistry, recentLog } from "./registry.js";
11
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
12
+ import { configureTransport, transportAction, _resetTransportForTest } from "./transport.js";
13
+ import { ActionError, retryNetwork } from "./error.js";
14
+
15
+ const mockSend = vi.fn();
16
+
17
+ beforeEach(() => {
18
+ resetDefine();
19
+ resetRegistry();
20
+ resetCleanup();
21
+ _resetTransportForTest();
22
+ vi.clearAllMocks();
23
+ configureTransport(mockSend);
24
+ });
25
+
26
+ describe("runWithRetry: no retry on abort even for retry-class errors", () => {
27
+ beforeEach(() => {
28
+ vi.useFakeTimers();
29
+ });
30
+ afterEach(() => {
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ it("signal aborted externally before run() throws — no retry", async () => {
35
+ let attempts = 0;
36
+ const action = defineAction<void, string>({
37
+ name: "test.abort_no_retry",
38
+ retryable: retryNetwork,
39
+ retry: { count: 3, delay: 100 },
40
+ error: false,
41
+ run: async (_args, signal) => {
42
+ attempts++;
43
+ if (attempts === 1) {
44
+ await new Promise<void>((resolve) => {
45
+ signal.addEventListener(
46
+ "abort",
47
+ () => {
48
+ resolve();
49
+ },
50
+ { once: true },
51
+ );
52
+ });
53
+ throw new ActionError("network error", { code: "network" });
54
+ }
55
+ return "should not reach";
56
+ },
57
+ });
58
+ const p = action.dispatch();
59
+ action.cancel();
60
+ await vi.advanceTimersByTimeAsync(1000);
61
+ const result = await p;
62
+ expect(result).toBeNull();
63
+ expect(attempts).toBe(1);
64
+ expect(recentLog()[0]?.status).toBe("cancelled");
65
+ });
66
+ });
67
+
68
+ describe("transportAction: spread preserves type field", () => {
69
+ it("idempotencyKey spread preserves the command type field", async () => {
70
+ mockSend.mockResolvedValue({ ok: true, status: 200 });
71
+ const action = transportAction<{ chatID: string }>({
72
+ name: "test.type_preserved",
73
+ idempotencyKey: true,
74
+ command: ({ chatID }) => ({ type: "cancel" as const, chat_id: chatID }),
75
+ error: false,
76
+ });
77
+ await action.dispatch({ chatID: "c1" });
78
+ const sentCmd = mockSend.mock.calls[0]![0] as { type: string };
79
+ expect(sentCmd.type).toBe("cancel");
80
+ });
81
+ });
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ ActionError,
4
+ hasErrorString,
5
+ toActionError,
6
+ classifyFetchError,
7
+ retryNetwork,
8
+ } from "./error.js";
9
+
10
+ describe("ActionError", () => {
11
+ it("sets message, status, code, and cause", () => {
12
+ const cause = new Error("root");
13
+ const err = new ActionError("fail", { status: 409, code: "conflict", cause });
14
+ expect(err.message).toBe("fail");
15
+ expect(err.status).toBe(409);
16
+ expect(err.code).toBe("conflict");
17
+ expect(err.cause).toBe(cause);
18
+ expect(err.name).toBe("ActionError");
19
+ expect(err).toBeInstanceOf(Error);
20
+ });
21
+
22
+ it("works with no options", () => {
23
+ const err = new ActionError("simple");
24
+ expect(err.message).toBe("simple");
25
+ expect(err.status).toBeUndefined();
26
+ expect(err.code).toBeUndefined();
27
+ expect(err.cause).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe("hasErrorString", () => {
32
+ it("returns true for { error: string }", () => {
33
+ expect(hasErrorString({ error: "bad" })).toBe(true);
34
+ });
35
+
36
+ it("returns false for null", () => {
37
+ expect(hasErrorString(null)).toBe(false);
38
+ });
39
+
40
+ it("returns false for non-object", () => {
41
+ expect(hasErrorString("string")).toBe(false);
42
+ expect(hasErrorString(42)).toBe(false);
43
+ expect(hasErrorString(undefined)).toBe(false);
44
+ });
45
+
46
+ it("returns false when error is not a string", () => {
47
+ expect(hasErrorString({ error: 123 })).toBe(false);
48
+ expect(hasErrorString({ error: null })).toBe(false);
49
+ expect(hasErrorString({})).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe("toActionError", () => {
54
+ it("preserves ActionError fields", () => {
55
+ const err = new ActionError("x", { status: 404, code: "not_found" });
56
+ const result = toActionError(err);
57
+ expect(result.message).toBe("x");
58
+ expect(result.status).toBe(404);
59
+ expect(result.code).toBe("not_found");
60
+ });
61
+
62
+ it("maps DOMException TimeoutError", () => {
63
+ const dom = new DOMException("timed out", "TimeoutError");
64
+ const result = toActionError(dom);
65
+ expect(result.code).toBe("timeout");
66
+ expect(result.status).toBe(0);
67
+ expect(result.cause).toBe(dom);
68
+ });
69
+
70
+ it("maps DOMException AbortError", () => {
71
+ const dom = new DOMException("aborted", "AbortError");
72
+ const result = toActionError(dom);
73
+ expect(result.code).toBe("cancelled");
74
+ expect(result.status).toBeUndefined();
75
+ });
76
+
77
+ it("maps DOMException NetworkError", () => {
78
+ const dom = new DOMException("network", "NetworkError");
79
+ const result = toActionError(dom);
80
+ expect(result.code).toBe("network");
81
+ expect(result.status).toBe(0);
82
+ });
83
+
84
+ it("maps unknown DOMException name to lowercase", () => {
85
+ const dom = new DOMException("custom", "CustomError");
86
+ const result = toActionError(dom);
87
+ expect(result.code).toBe("customerror");
88
+ });
89
+
90
+ it("handles plain Error", () => {
91
+ const err = new Error("plain");
92
+ const result = toActionError(err);
93
+ expect(result.message).toBe("plain");
94
+ expect(result.cause).toBe(err);
95
+ expect(result.status).toBeUndefined();
96
+ });
97
+
98
+ it("preserves numeric status from Error-like objects", () => {
99
+ const err = Object.assign(new Error("http fail"), { status: 503 });
100
+ const result = toActionError(err);
101
+ expect(result.message).toBe("http fail");
102
+ expect(result.status).toBe(503);
103
+ expect(result.cause).toBe(err);
104
+ });
105
+
106
+ it("ignores non-numeric status on Error-like objects", () => {
107
+ const err = Object.assign(new Error("bad"), { status: "not a number" });
108
+ const result = toActionError(err);
109
+ expect(result.status).toBeUndefined();
110
+ });
111
+
112
+ it("handles non-Error values (string)", () => {
113
+ const result = toActionError("oops");
114
+ expect(result.message).toBe("oops");
115
+ expect(result.cause).toBe("oops");
116
+ });
117
+
118
+ it("handles non-Error values (number)", () => {
119
+ const result = toActionError(42);
120
+ expect(result.message).toBe("42");
121
+ });
122
+
123
+ it("handles empty string thrown", () => {
124
+ const result = toActionError("");
125
+ expect(result.message).toBe("Unknown error (empty value thrown)");
126
+ expect(result.code).toBe("unknown");
127
+ expect(result.cause).toBe("");
128
+ });
129
+
130
+ it("handles false thrown", () => {
131
+ const result = toActionError(false);
132
+ expect(result.message).toBe("false");
133
+ expect(result.cause).toBe(false);
134
+ });
135
+
136
+ it("handles null thrown", () => {
137
+ const result = toActionError(null);
138
+ expect(result.message).toBe("Unknown error (null thrown)");
139
+ expect(result.code).toBe("unknown");
140
+ expect(result.cause).toBeUndefined();
141
+ });
142
+
143
+ it("handles undefined thrown", () => {
144
+ const result = toActionError(undefined);
145
+ expect(result.message).toBe("Unknown error (undefined thrown)");
146
+ expect(result.code).toBe("unknown");
147
+ expect(result.cause).toBeUndefined();
148
+ });
149
+
150
+ it("handles object with null message", () => {
151
+ const obj = { message: null };
152
+ const result = toActionError(obj);
153
+ expect(result.message).toBe("null");
154
+ expect(result.cause).toBe(obj);
155
+ });
156
+
157
+ it("handles object with undefined message", () => {
158
+ const obj = { message: undefined };
159
+ const result = toActionError(obj);
160
+ expect(result.message).toBe("undefined");
161
+ expect(result.cause).toBe(obj);
162
+ });
163
+ });
164
+
165
+ describe("classifyFetchError", () => {
166
+ it("returns cancelled when signal is aborted", () => {
167
+ const ac = new AbortController();
168
+ ac.abort();
169
+ const err = classifyFetchError(new Error("fetch failed"), ac.signal);
170
+ expect(err).toBeInstanceOf(ActionError);
171
+ expect(err.code).toBe("cancelled");
172
+ expect(err.cause).toBeInstanceOf(Error);
173
+ });
174
+
175
+ it("returns timeout for DOMException TimeoutError", () => {
176
+ const ac = new AbortController();
177
+ const dom = new DOMException("timed out", "TimeoutError");
178
+ const err = classifyFetchError(dom, ac.signal);
179
+ expect(err.code).toBe("timeout");
180
+ expect(err.status).toBe(0);
181
+ expect(err.message).toBe("Request timed out");
182
+ });
183
+
184
+ it("returns timeout for DOMException AbortError when signal is NOT aborted", () => {
185
+ const ac = new AbortController();
186
+ const dom = new DOMException("aborted", "AbortError");
187
+ const err = classifyFetchError(dom, ac.signal);
188
+ expect(err.code).toBe("timeout");
189
+ expect(err.status).toBe(0);
190
+ });
191
+
192
+ it("returns network for non-DOMException errors", () => {
193
+ const ac = new AbortController();
194
+ const err = classifyFetchError(new TypeError("Failed to fetch"), ac.signal);
195
+ expect(err.code).toBe("network");
196
+ expect(err.status).toBe(0);
197
+ expect(err.message).toBe("Failed to fetch");
198
+ });
199
+
200
+ it("returns network with generic message for non-Error values", () => {
201
+ const ac = new AbortController();
202
+ const err = classifyFetchError(42, ac.signal);
203
+ expect(err.code).toBe("network");
204
+ expect(err.status).toBe(0);
205
+ expect(err.message).toBe("network error");
206
+ });
207
+ });
208
+
209
+ describe("retryNetwork preset", () => {
210
+ it("returns true for network/timeout codes", () => {
211
+ expect(retryNetwork({ message: "x", code: "network" })).toBe(true);
212
+ expect(retryNetwork({ message: "x", code: "timeout" })).toBe(true);
213
+ expect(retryNetwork({ message: "x", status: 0 })).toBe(true);
214
+ });
215
+
216
+ it("returns true for transient HTTP statuses (408/429/502/503/504)", () => {
217
+ expect(retryNetwork({ message: "x", status: 408 })).toBe(true);
218
+ expect(retryNetwork({ message: "x", status: 429 })).toBe(true);
219
+ expect(retryNetwork({ message: "x", status: 502 })).toBe(true);
220
+ expect(retryNetwork({ message: "x", status: 503 })).toBe(true);
221
+ expect(retryNetwork({ message: "x", status: 504 })).toBe(true);
222
+ });
223
+
224
+ it("returns false for non-transient statuses", () => {
225
+ expect(retryNetwork({ message: "x", status: 400 })).toBe(false);
226
+ expect(retryNetwork({ message: "x", status: 500 })).toBe(false);
227
+ expect(retryNetwork({ message: "x", status: 404 })).toBe(false);
228
+ });
229
+
230
+ it("returns false for cancelled regardless of status", () => {
231
+ expect(retryNetwork({ message: "x", code: "cancelled", status: 503 })).toBe(false);
232
+ expect(retryNetwork({ message: "x", code: "cancelled" })).toBe(false);
233
+ });
234
+
235
+ it("returns false for app-specific codes that don't match network signature", () => {
236
+ expect(retryNetwork({ message: "x", code: "send_failed" })).toBe(false);
237
+ expect(retryNetwork({ message: "x", code: "clipboard" })).toBe(false);
238
+ expect(retryNetwork({ message: "x", code: "unsupported" })).toBe(false);
239
+ expect(retryNetwork({ message: "x", code: "server_rejected" })).toBe(false);
240
+ });
241
+
242
+ it("composes with custom permanent-code filter", () => {
243
+ const PERMANENT = new Set(["custom_permanent"]);
244
+ const composed = (err: { code?: string; status?: number; message: string }): boolean =>
245
+ !PERMANENT.has(err.code ?? "") && retryNetwork(err);
246
+ expect(composed({ message: "x", code: "network" })).toBe(true);
247
+ expect(composed({ message: "x", code: "custom_permanent", status: 0 })).toBe(false);
248
+ });
249
+ });
250
+
251
+ describe("classifyFetchError — TypeError branch", () => {
252
+ it("returns network with TypeError message for TypeError", () => {
253
+ const ac = new AbortController();
254
+ const te = new TypeError("Failed to fetch");
255
+ const err = classifyFetchError(te, ac.signal);
256
+ expect(err.code).toBe("network");
257
+ expect(err.status).toBe(0);
258
+ expect(err.message).toBe("Failed to fetch");
259
+ expect(err.cause).toBe(te);
260
+ });
261
+
262
+ it("prefers cancelled over TypeError when signal is aborted", () => {
263
+ const ac = new AbortController();
264
+ ac.abort();
265
+ const err = classifyFetchError(new TypeError("Failed to fetch"), ac.signal);
266
+ expect(err.code).toBe("cancelled");
267
+ });
268
+ });
package/src/error.ts CHANGED
File without changes
package/src/index.ts CHANGED
File without changes
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ vi.mock("./notifier.js", () => ({
3
+ configure: vi.fn(),
4
+ notifySuccess: vi.fn(),
5
+ notifyError: vi.fn(),
6
+ _resetNotifierForTest: vi.fn(),
7
+ }));
8
+ import { defineAction, _resetForTest as resetDefine, _internalsForTest } from "./define.js";
9
+ import { _resetForTest as resetRegistry, pendingCount, recentLog } from "./registry.js";
10
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
11
+
12
+ beforeEach(() => {
13
+ resetDefine();
14
+ resetRegistry();
15
+ resetCleanup();
16
+ });
17
+ afterEach(() => {
18
+ resetDefine();
19
+ resetRegistry();
20
+ resetCleanup();
21
+ });
22
+
23
+ describe("memory leak stress — scopeChains", () => {
24
+ it("1000 scoped dispatches leave scopeChains empty", async () => {
25
+ const action = defineAction({ name: "stress.scope", scope: "s", run: async () => "ok" });
26
+ const promises: Promise<unknown>[] = [];
27
+ for (let i = 0; i < 1000; i++) {
28
+ promises.push(action.dispatch({ i }));
29
+ }
30
+ await Promise.all(promises);
31
+ expect(_internalsForTest().scopeChains).toBe(0);
32
+ });
33
+ });
34
+
35
+ describe("memory leak stress — activeDedupes", () => {
36
+ it("1000 deduped dispatches leave activeDedupes empty", async () => {
37
+ let callCount = 0;
38
+ const action = defineAction({
39
+ name: "stress.dedupe",
40
+ dedupe: true,
41
+ run: async (args: { v: number }) => {
42
+ callCount++;
43
+ return args.v;
44
+ },
45
+ });
46
+ const promises: Promise<unknown>[] = [];
47
+ for (let i = 0; i < 1000; i++) {
48
+ promises.push(action.dispatch({ v: i % 5 }));
49
+ }
50
+ await Promise.all(promises);
51
+ expect(_internalsForTest().activeDedupes).toBe(0);
52
+ expect(callCount).toBe(5);
53
+ });
54
+ });
55
+
56
+ describe("memory leak stress — inFlight (via pendingCount)", () => {
57
+ it("1000 parallel dispatches leave no pending entries", async () => {
58
+ const action = defineAction({ name: "stress.parallel", run: async () => "ok" });
59
+ const promises: Promise<unknown>[] = [];
60
+ for (let i = 0; i < 1000; i++) {
61
+ promises.push(action.dispatch({ i }));
62
+ }
63
+ await Promise.all(promises);
64
+ expect(pendingCount(["stress.parallel"])).toBe(0);
65
+ });
66
+
67
+ it("1000 dispatches with errors leave no pending entries", async () => {
68
+ const action = defineAction({
69
+ name: "stress.errors",
70
+ error: false,
71
+ run: async () => {
72
+ throw new Error("fail");
73
+ },
74
+ });
75
+ const promises: Promise<unknown>[] = [];
76
+ for (let i = 0; i < 1000; i++) {
77
+ promises.push(action.dispatch({ i }));
78
+ }
79
+ await Promise.all(promises);
80
+ expect(pendingCount(["stress.errors"])).toBe(0);
81
+ });
82
+ });
83
+
84
+ describe("memory leak stress — combined scope + dedupe", () => {
85
+ it("1000 scoped+deduped dispatches leave maps empty", async () => {
86
+ const action = defineAction({
87
+ name: "stress.both",
88
+ scope: "shared",
89
+ dedupe: true,
90
+ run: async (args: { v: number }) => args.v,
91
+ });
92
+ const promises: Promise<unknown>[] = [];
93
+ for (let i = 0; i < 1000; i++) {
94
+ promises.push(action.dispatch({ v: i % 3 }));
95
+ }
96
+ await Promise.all(promises);
97
+ const internals = _internalsForTest();
98
+ expect(internals.scopeChains).toBe(0);
99
+ expect(internals.activeDedupes).toBe(0);
100
+ expect(pendingCount(["stress.both"])).toBe(0);
101
+ });
102
+ });
103
+
104
+ describe("memory leak stress — registry log eviction", () => {
105
+ it("1000 dispatches with full lifecycle: log stays bounded at MAX_LOG_SIZE", async () => {
106
+ const action = defineAction({
107
+ name: "stress.registry",
108
+ run: async (args: { i: number }) => args.i,
109
+ });
110
+ for (let i = 0; i < 1000; i++) {
111
+ await action.dispatch({ i });
112
+ }
113
+ const log = recentLog();
114
+ expect(log.length).toBeLessThanOrEqual(200);
115
+ expect(log.every((e) => e.status !== "pending")).toBe(true);
116
+ });
117
+ });