@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,215 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, beforeEach } 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, IDEMPOTENCY_HEADER, _resetForTest as resetDefine } from "./define.js";
10
+ import { apiAction } from "./api.js";
11
+ import { _resetForTest as resetRegistry, pendingCount } from "./registry.js";
12
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
13
+ import { debouncedDispatch } from "./debounce.js";
14
+ import { retryNetwork } from "./error.js";
15
+
16
+ beforeEach(() => {
17
+ resetDefine();
18
+ resetRegistry();
19
+ resetCleanup();
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ describe("idempotencyKey", () => {
24
+ it("apiAction sends Idempotency-Key header when configured: true", async () => {
25
+ const fetchSpy = vi.fn<typeof fetch>(() =>
26
+ Promise.resolve(new Response("{}", { status: 200 })),
27
+ );
28
+ vi.stubGlobal("fetch", fetchSpy);
29
+ const action = apiAction<{ id: string }>({
30
+ name: "test.idem.true",
31
+ idempotencyKey: true,
32
+ request: ({ id }) => ({ method: "POST", path: `/api/x/${id}`, body: {} }),
33
+ });
34
+ await action.dispatch({ id: "abc" });
35
+ const init = fetchSpy.mock.calls[0]?.[1]!;
36
+ const headers = init.headers as Record<string, string>;
37
+ const hk = IDEMPOTENCY_HEADER.toLowerCase();
38
+ expect(headers[hk]).toBeDefined();
39
+ expect(headers[hk]!.length).toBeGreaterThan(5);
40
+ vi.unstubAllGlobals();
41
+ });
42
+
43
+ it("no Idempotency-Key when idempotencyKey is undefined", async () => {
44
+ const fetchSpy = vi.fn<typeof fetch>(() =>
45
+ Promise.resolve(new Response("{}", { status: 200 })),
46
+ );
47
+ vi.stubGlobal("fetch", fetchSpy);
48
+ const action = apiAction<void>({
49
+ name: "test.idem.none",
50
+ request: () => ({ method: "POST", path: "/api/x", body: {} }),
51
+ });
52
+ await action.dispatch();
53
+ const init = fetchSpy.mock.calls[0]?.[1]!;
54
+ const headers = (init.headers ?? {}) as Record<string, string>;
55
+ expect(headers[IDEMPOTENCY_HEADER.toLowerCase()]).toBeUndefined();
56
+ vi.unstubAllGlobals();
57
+ });
58
+
59
+ it("custom defineAction can read idempotencyKey from ctx", async () => {
60
+ const seen: string[] = [];
61
+ const action = defineAction<void, void>({
62
+ name: "test.idem.ctx",
63
+ idempotencyKey: true,
64
+ run: (_args, _signal, ctx) => {
65
+ if (ctx?.idempotencyKey !== undefined) {
66
+ seen.push(ctx.idempotencyKey);
67
+ }
68
+ return Promise.resolve();
69
+ },
70
+ });
71
+ await action.dispatch();
72
+ await action.dispatch();
73
+ expect(seen).toHaveLength(2);
74
+ expect(seen[0]).not.toBe(seen[1]);
75
+ });
76
+
77
+ it("retries reuse the same idempotency key across attempts", async () => {
78
+ let attempt = 0;
79
+ const fetchSpy = vi.fn<typeof fetch>(() => {
80
+ attempt++;
81
+ if (attempt < 3) {
82
+ return Promise.reject(new TypeError("Failed to fetch"));
83
+ }
84
+ return Promise.resolve(new Response("{}", { status: 200 }));
85
+ });
86
+ vi.stubGlobal("fetch", fetchSpy);
87
+ vi.useFakeTimers();
88
+ const action = apiAction<void>({
89
+ name: "test.idem.retry",
90
+ idempotencyKey: true,
91
+ retryable: retryNetwork,
92
+ retry: { count: 2, delay: 50 },
93
+ request: () => ({ method: "POST", path: "/api/x", body: {} }),
94
+ });
95
+ const p = action.dispatch();
96
+ await vi.advanceTimersByTimeAsync(50);
97
+ await vi.advanceTimersByTimeAsync(100);
98
+ await p;
99
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
100
+ const k1 = (fetchSpy.mock.calls[0]?.[1])!.headers as Record<string, string>;
101
+ const k2 = (fetchSpy.mock.calls[1]?.[1])!.headers as Record<string, string>;
102
+ const k3 = (fetchSpy.mock.calls[2]?.[1])!.headers as Record<string, string>;
103
+ const hk = IDEMPOTENCY_HEADER.toLowerCase();
104
+ expect(k1[hk]).toBe(k2[hk]);
105
+ expect(k2[hk]).toBe(k3[hk]);
106
+ vi.unstubAllGlobals();
107
+ vi.useRealTimers();
108
+ });
109
+ });
110
+
111
+ describe("dedupe", () => {
112
+ it("two concurrent dispatches with matching args share one in-flight promise", async () => {
113
+ let resolveRun: ((v: string) => void) | undefined;
114
+ let runCalls = 0;
115
+ const action = defineAction<{ id: string }, string>({
116
+ name: "test.dedupe",
117
+ dedupe: true,
118
+ run: () => {
119
+ runCalls++;
120
+ return new Promise<string>((r) => {
121
+ resolveRun = r;
122
+ });
123
+ },
124
+ });
125
+ const p1 = action.dispatch({ id: "a" });
126
+ const p2 = action.dispatch({ id: "a" });
127
+ expect(runCalls).toBe(1);
128
+ resolveRun?.("ok");
129
+ const [r1, r2] = await Promise.all([p1, p2]);
130
+ expect(r1).toBe("ok");
131
+ expect(r2).toBe("ok");
132
+ });
133
+
134
+ it("different args do NOT collapse", async () => {
135
+ let runCalls = 0;
136
+ const action = defineAction<{ id: string }, string>({
137
+ name: "test.dedupe.different",
138
+ dedupe: true,
139
+ run: (args) => {
140
+ runCalls++;
141
+ return Promise.resolve(args.id);
142
+ },
143
+ });
144
+ await Promise.all([action.dispatch({ id: "a" }), action.dispatch({ id: "b" })]);
145
+ expect(runCalls).toBe(2);
146
+ });
147
+
148
+ it("dedupe entry clears after resolution; subsequent dispatch starts fresh", async () => {
149
+ let runCalls = 0;
150
+ const action = defineAction<{ id: string }, void>({
151
+ name: "test.dedupe.clear",
152
+ dedupe: true,
153
+ run: () => {
154
+ runCalls++;
155
+ return Promise.resolve();
156
+ },
157
+ });
158
+ await action.dispatch({ id: "a" });
159
+ await action.dispatch({ id: "a" });
160
+ expect(runCalls).toBe(2);
161
+ });
162
+ });
163
+
164
+ describe("pendingCount", () => {
165
+ it("sums across all action names without arguments", async () => {
166
+ let resolveA: () => void = () => {};
167
+ let resolveB: () => void = () => {};
168
+ const a = defineAction<void, void>({
169
+ name: "test.pc.a",
170
+ run: () =>
171
+ new Promise<void>((r) => {
172
+ resolveA = r;
173
+ }),
174
+ });
175
+ const b = defineAction<void, void>({
176
+ name: "test.pc.b",
177
+ run: () =>
178
+ new Promise<void>((r) => {
179
+ resolveB = r;
180
+ }),
181
+ });
182
+ expect(pendingCount()).toBe(0);
183
+ const pa = a.dispatch();
184
+ const pb = b.dispatch();
185
+ expect(pendingCount()).toBe(2);
186
+ resolveA();
187
+ await pa;
188
+ expect(pendingCount()).toBe(1);
189
+ resolveB();
190
+ await pb;
191
+ expect(pendingCount()).toBe(0);
192
+ });
193
+ });
194
+
195
+ describe("debouncedDispatch", () => {
196
+ it("coalesces rapid calls into a single dispatch with the latest args", async () => {
197
+ vi.useFakeTimers();
198
+ const runArgs: string[] = [];
199
+ const action = defineAction<string, void>({
200
+ name: "test.debounce.basic",
201
+ run: (args) => {
202
+ runArgs.push(args);
203
+ return Promise.resolve();
204
+ },
205
+ });
206
+ const dbg = debouncedDispatch(action, { wait: 100 });
207
+ dbg("a");
208
+ dbg("b");
209
+ dbg("c");
210
+ expect(runArgs).toEqual([]);
211
+ await vi.advanceTimersByTimeAsync(100);
212
+ expect(runArgs).toEqual(["c"]);
213
+ vi.useRealTimers();
214
+ });
215
+ });
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, beforeEach } 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
+
13
+ beforeEach(() => {
14
+ resetDefine();
15
+ resetRegistry();
16
+ resetCleanup();
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ describe("dedupe + cancel + immediate re-dispatch race", () => {
21
+ it("re-dispatch after cancel should start a fresh run, not collapse onto cancelled", async () => {
22
+ let runCount = 0;
23
+ const action = defineAction<string, string>({
24
+ name: "test.dedupe_cancel_redispatch",
25
+ dedupe: true,
26
+ error: false,
27
+ run: (_args, signal) => {
28
+ runCount++;
29
+ const myRun = runCount;
30
+ return new Promise<string>((resolve, reject) => {
31
+ if (signal.aborted) {
32
+ reject(new DOMException("aborted", "AbortError"));
33
+ return;
34
+ }
35
+ signal.addEventListener("abort", () => {
36
+ reject(new DOMException("aborted", "AbortError"));
37
+ });
38
+ setTimeout(() => {
39
+ resolve(`result-${myRun}`);
40
+ }, 10);
41
+ });
42
+ },
43
+ });
44
+ const p1 = action.dispatch("x");
45
+ action.cancel();
46
+ const p2 = action.dispatch("x");
47
+ const [r1, r2] = await Promise.all([p1, p2]);
48
+ expect(r1).toBeNull();
49
+ expect(r2).toBe("result-2");
50
+ expect(runCount).toBe(2);
51
+ });
52
+
53
+ it("second cancel after re-dispatch still works correctly", async () => {
54
+ let runCount = 0;
55
+ const action = defineAction<string, string>({
56
+ name: "test.dedupe_double_cancel",
57
+ dedupe: true,
58
+ error: false,
59
+ run: (_args, signal) => {
60
+ runCount++;
61
+ return new Promise<string>((resolve, reject) => {
62
+ if (signal.aborted) {
63
+ reject(new DOMException("aborted", "AbortError"));
64
+ return;
65
+ }
66
+ signal.addEventListener("abort", () => {
67
+ reject(new DOMException("aborted", "AbortError"));
68
+ });
69
+ setTimeout(() => {
70
+ resolve(`result-${runCount}`);
71
+ }, 10);
72
+ });
73
+ },
74
+ });
75
+ const p1 = action.dispatch("x");
76
+ action.cancel();
77
+ await p1;
78
+ await Promise.resolve();
79
+ await Promise.resolve();
80
+ const p2 = action.dispatch("x");
81
+ action.cancel();
82
+ await p2;
83
+ const p3 = action.dispatch("x");
84
+ const r3 = await p3;
85
+ expect(r3).toBe(`result-${runCount}`);
86
+ expect(runCount).toBe(3);
87
+ });
88
+ });
@@ -0,0 +1,254 @@
1
+ // @vitest-environment happy-dom
2
+ // RED-TEAM adversarial tests: probing edge cases not covered by existing tests.
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+
5
+ vi.mock("./notifier.js", () => ({
6
+ configure: vi.fn(),
7
+ notifySuccess: vi.fn(),
8
+ notifyError: vi.fn(),
9
+ _resetNotifierForTest: vi.fn(),
10
+ }));
11
+
12
+ import { apiAction, configureApi, _resetApiConfigForTest } from "./api.js";
13
+ import { defineAction, _resetForTest as resetDefine } from "./define.js";
14
+ import { _resetForTest as resetRegistry, recentLog } from "./registry.js";
15
+ import { _resetForTest as resetCleanup } from "./cleanup.js";
16
+
17
+ const mockFetch = vi.fn();
18
+
19
+ beforeEach(() => {
20
+ resetDefine();
21
+ resetRegistry();
22
+ resetCleanup();
23
+ _resetApiConfigForTest();
24
+ mockFetch.mockReset();
25
+ vi.stubGlobal("fetch", mockFetch);
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 1. configureApi baseUrl joining — double slash
34
+ // ---------------------------------------------------------------------------
35
+ describe("configureApi baseUrl joining edge cases", () => {
36
+ it("does NOT produce double slash when baseUrl ends with / and path starts with /", async () => {
37
+ configureApi({ baseUrl: "https://api.example.com/" });
38
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
39
+ const action = apiAction<string>({
40
+ name: "base.dblslash",
41
+ request: (id) => ({ method: "GET", path: `/items/${id}` }),
42
+ });
43
+ await action.dispatch("42");
44
+ const url = mockFetch.mock.calls[0]![0] as string;
45
+ expect(url).toBe("https://api.example.com/items/42");
46
+ expect(url).not.toContain("//items");
47
+ });
48
+
49
+ it("handles baseUrl without trailing slash and path with leading slash", async () => {
50
+ configureApi({ baseUrl: "https://api.example.com" });
51
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
52
+ const action = apiAction<string>({
53
+ name: "base.noslash",
54
+ request: () => ({ method: "GET", path: "/items" }),
55
+ });
56
+ await action.dispatch("x");
57
+ expect(mockFetch.mock.calls[0]![0]).toBe("https://api.example.com/items");
58
+ });
59
+
60
+ it("handles baseUrl with trailing slash and path without leading slash", async () => {
61
+ configureApi({ baseUrl: "https://api.example.com/" });
62
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
63
+ const action = apiAction<string>({
64
+ name: "base.trailslash",
65
+ request: () => ({ method: "GET", path: "items" }),
66
+ });
67
+ await action.dispatch("x");
68
+ expect(mockFetch.mock.calls[0]![0]).toBe("https://api.example.com/items");
69
+ });
70
+ });
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // 2. prepareHeaders async rejection
74
+ // ---------------------------------------------------------------------------
75
+ describe("prepareHeaders async rejection", () => {
76
+ it("surfaces as an action error (not unhandled rejection)", async () => {
77
+ configureApi({
78
+ prepareHeaders: async () => {
79
+ throw new Error("token refresh failed");
80
+ },
81
+ });
82
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
83
+ const action = apiAction<string>({
84
+ name: "prep.reject",
85
+ request: () => ({ method: "GET", path: "/x" }),
86
+ });
87
+ const result = await action.dispatch("x");
88
+ expect(result).toBeNull();
89
+ expect(recentLog()[0]?.status).toBe("error");
90
+ expect(recentLog()[0]?.error?.message).toContain("token refresh failed");
91
+ // fetch should NOT have been called
92
+ expect(mockFetch).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("prepareHeaders sync throw also surfaces as action error", async () => {
96
+ configureApi({
97
+ prepareHeaders: () => {
98
+ throw new Error("sync boom");
99
+ },
100
+ });
101
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
102
+ const action = apiAction<string>({
103
+ name: "prep.sync.throw",
104
+ request: () => ({ method: "GET", path: "/x" }),
105
+ });
106
+ const result = await action.dispatch("x");
107
+ expect(result).toBeNull();
108
+ expect(recentLog()[0]?.status).toBe("error");
109
+ expect(mockFetch).not.toHaveBeenCalled();
110
+ });
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // 3. fetchFn throwing synchronously
115
+ // ---------------------------------------------------------------------------
116
+ describe("fetchFn throwing synchronously", () => {
117
+ it("surfaces as a classified action error", async () => {
118
+ configureApi({
119
+ fetchFn: () => {
120
+ throw new TypeError("sync network failure");
121
+ },
122
+ });
123
+ const action = apiAction<string>({
124
+ name: "fetch.sync.throw",
125
+ request: () => ({ method: "GET", path: "/x" }),
126
+ });
127
+ const result = await action.dispatch("x");
128
+ expect(result).toBeNull();
129
+ expect(recentLog()[0]?.status).toBe("error");
130
+ expect(recentLog()[0]?.error?.code).toBe("network");
131
+ });
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // 4. abort handle — abort after settle (double-abort)
136
+ // ---------------------------------------------------------------------------
137
+ describe("abort handle edge cases", () => {
138
+ it("abort() after settlement is a no-op (no double-fire of callbacks)", async () => {
139
+ const onSettled = vi.fn();
140
+ const action = defineAction({
141
+ name: "abort.after.settle",
142
+ run: async () => "done",
143
+ });
144
+ const handle = action.dispatch("x", { onSettled });
145
+ const result = await handle;
146
+ expect(result).toBe("done");
147
+ expect(onSettled).toHaveBeenCalledTimes(1);
148
+ // abort after settle
149
+ handle.abort();
150
+ // Give microtask queue a chance to flush
151
+ await Promise.resolve();
152
+ expect(onSettled).toHaveBeenCalledTimes(1); // still 1
153
+ });
154
+
155
+ it("double-abort does not throw or double-fire", async () => {
156
+ const onSettled = vi.fn();
157
+ const action = defineAction({
158
+ name: "abort.double",
159
+ run: (_a, signal) =>
160
+ new Promise<string>((resolve, reject) => {
161
+ signal.addEventListener("abort", () => reject(new Error("aborted")));
162
+ setTimeout(() => resolve("late"), 1000);
163
+ }),
164
+ });
165
+ const handle = action.dispatch("x", { onSettled });
166
+ handle.abort();
167
+ handle.abort(); // second abort — should not throw
168
+ const result = await handle;
169
+ expect(result).toBeNull();
170
+ expect(onSettled).toHaveBeenCalledTimes(1);
171
+ });
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // 5. timeout + external signal interaction (AbortSignal.any cleanup)
176
+ // ---------------------------------------------------------------------------
177
+ describe("timeout + external signal interaction", () => {
178
+ it("cancel before timeout does not leave dangling timeout firing later", async () => {
179
+ vi.useFakeTimers();
180
+ const onError = vi.fn();
181
+ const action = defineAction({
182
+ name: "timeout.cancel.before",
183
+ timeout: 5000,
184
+ run: (_a, signal) =>
185
+ new Promise<string>((resolve, reject) => {
186
+ signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
187
+ }),
188
+ });
189
+ const handle = action.dispatch("x", { onError });
190
+ // Cancel immediately
191
+ handle.abort();
192
+ const result = await handle;
193
+ expect(result).toBeNull();
194
+ // Advance past the timeout — should not cause any additional errors
195
+ await vi.advanceTimersByTimeAsync(10000);
196
+ expect(onError).not.toHaveBeenCalled(); // cancellation doesn't fire onError
197
+ vi.useRealTimers();
198
+ });
199
+
200
+ it("timeout fires correctly with fake timers", async () => {
201
+ vi.useFakeTimers();
202
+ const action = defineAction({
203
+ name: "timeout.fake.timers",
204
+ timeout: 100,
205
+ run: (_a, signal) =>
206
+ new Promise<string>((resolve, reject) => {
207
+ signal.addEventListener("abort", () => reject(signal.reason));
208
+ }),
209
+ });
210
+ const handle = action.dispatch("x");
211
+ await vi.advanceTimersByTimeAsync(150);
212
+ const result = await handle;
213
+ expect(result).toBeNull();
214
+ expect(recentLog().find((e) => e.name === "timeout.fake.timers")?.status).toMatch(
215
+ /error|cancelled/,
216
+ );
217
+ vi.useRealTimers();
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // 6. idempotency key reuse across retries
223
+ // ---------------------------------------------------------------------------
224
+ describe("idempotency key reuse across retries", () => {
225
+ it("uses the SAME idempotency key for all retry attempts", async () => {
226
+ let attempt = 0;
227
+ const keys: string[] = [];
228
+ configureApi({
229
+ fetchFn: async (_url, init) => {
230
+ const hdrs = (init as RequestInit).headers as Record<string, string>;
231
+ keys.push(hdrs["idempotency-key"] ?? "");
232
+ attempt++;
233
+ if (attempt < 3) {
234
+ return new Response(JSON.stringify({ error: "busy" }), { status: 503 });
235
+ }
236
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
237
+ },
238
+ });
239
+ const action = apiAction<string>({
240
+ name: "idem.retry",
241
+ request: () => ({ method: "POST", path: "/x", body: {} }),
242
+ idempotencyKey: true,
243
+ retry: { count: 3, delay: 0 },
244
+ retryable: (err) => err.status === 503,
245
+ });
246
+ const result = await action.dispatch("x");
247
+ expect(result).toEqual({ ok: true });
248
+ expect(keys.length).toBe(3);
249
+ // All keys must be the same
250
+ expect(keys[0]).toBe(keys[1]);
251
+ expect(keys[1]).toBe(keys[2]);
252
+ expect(keys[0]!.length).toBeGreaterThan(5);
253
+ });
254
+ });