@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.
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/jsr.json +8 -12
- package/package.json +7 -9
- package/src/__test-helpers__/action-test-setup.ts +34 -0
- package/src/api-idempotency.test.ts +163 -0
- package/src/api.test.ts +96 -0
- package/src/api.ts +0 -0
- package/src/cleanup.test.ts +102 -0
- package/src/cleanup.ts +0 -0
- package/src/configure-api.test.ts +219 -0
- package/src/cross-action-coordination.test.ts +176 -0
- package/src/cross-action-races-extra.test.ts +231 -0
- package/src/cross-action-races.test.ts +213 -0
- package/src/cross-action-scope-serial.test.ts +121 -0
- package/src/debounce.test.ts +121 -0
- package/src/debounce.ts +0 -0
- package/src/define-helpers.ts +0 -0
- package/src/define.property.test.ts +133 -0
- package/src/define.test.ts +476 -0
- package/src/define.ts +0 -0
- package/src/dispatch-callbacks.test.ts +141 -0
- package/src/dispatch-options.test.ts +197 -0
- package/src/edge-cases.test.ts +81 -0
- package/src/error.test.ts +268 -0
- package/src/error.ts +0 -0
- package/src/index.ts +0 -0
- package/src/leak-stress.test.ts +117 -0
- package/src/loading.test.ts +146 -0
- package/src/loading.ts +0 -0
- package/src/new-features.test.ts +246 -0
- package/src/notifier.ts +0 -0
- package/src/poll.test.ts +385 -0
- package/src/poll.ts +0 -0
- package/src/primitive-edge-cases.test.ts +105 -0
- package/src/primitive-interactions.test.ts +112 -0
- package/src/primitives.test.ts +215 -0
- package/src/race-dedupe-cancel.test.ts +88 -0
- package/src/redteam.test.ts +254 -0
- package/src/redteam2.test.ts +337 -0
- package/src/redteam3.test.ts +454 -0
- package/src/registry.test.ts +271 -0
- package/src/registry.ts +0 -0
- package/src/retry-network-delay.test.ts +155 -0
- package/src/retry.ts +0 -0
- package/src/transport.test.ts +139 -0
- package/src/transport.ts +0 -0
- package/src/types.ts +0 -0
- package/.editorconfig +0 -19
- package/.htmlvalidate.json +0 -28
- package/.stylelintrc.json +0 -67
- package/dist/src/api.d.ts +0 -56
- package/dist/src/api.js +0 -158
- package/dist/src/cleanup.d.ts +0 -11
- package/dist/src/cleanup.js +0 -63
- package/dist/src/debounce.d.ts +0 -28
- package/dist/src/debounce.js +0 -91
- package/dist/src/define-helpers.d.ts +0 -20
- package/dist/src/define-helpers.js +0 -83
- package/dist/src/define.d.ts +0 -14
- package/dist/src/define.js +0 -627
- package/dist/src/error.d.ts +0 -42
- package/dist/src/error.js +0 -174
- package/dist/src/index.d.ts +0 -17
- package/dist/src/index.js +0 -22
- package/dist/src/loading.d.ts +0 -15
- package/dist/src/loading.js +0 -114
- package/dist/src/notifier.d.ts +0 -21
- package/dist/src/notifier.js +0 -21
- package/dist/src/poll.d.ts +0 -23
- package/dist/src/poll.js +0 -120
- package/dist/src/registry.d.ts +0 -18
- package/dist/src/registry.js +0 -210
- package/dist/src/retry.d.ts +0 -9
- package/dist/src/retry.js +0 -78
- package/dist/src/transport.d.ts +0 -46
- package/dist/src/transport.js +0 -70
- package/dist/src/types.d.ts +0 -157
- package/dist/src/types.js +0 -7
- 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
|
+
});
|