@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,219 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Tests for the configureApi HTTP-customization seam.
|
|
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 { _resetForTest as resetDefine } from "./define.js";
|
|
14
|
+
import { _resetForTest as resetRegistry } 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
|
+
describe("configureApi — baseUrl", () => {
|
|
33
|
+
it("prepends baseUrl to request path", async () => {
|
|
34
|
+
configureApi({ baseUrl: "https://api.example.com/v1" });
|
|
35
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
36
|
+
const action = apiAction<string>({
|
|
37
|
+
name: "base.url",
|
|
38
|
+
request: (id) => ({ method: "GET", path: `/items/${id}` }),
|
|
39
|
+
});
|
|
40
|
+
await action.dispatch("42");
|
|
41
|
+
expect(mockFetch.mock.calls[0]![0]).toBe("https://api.example.com/v1/items/42");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("works without baseUrl (relative path)", async () => {
|
|
45
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
46
|
+
const action = apiAction<string>({
|
|
47
|
+
name: "no.base",
|
|
48
|
+
request: (id) => ({ method: "GET", path: `/items/${id}` }),
|
|
49
|
+
});
|
|
50
|
+
await action.dispatch("1");
|
|
51
|
+
expect(mockFetch.mock.calls[0]![0]).toBe("/items/1");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("configureApi — prepareHeaders", () => {
|
|
56
|
+
it("injects auth headers on every request", async () => {
|
|
57
|
+
configureApi({
|
|
58
|
+
prepareHeaders: (headers) => {
|
|
59
|
+
headers.set("Authorization", "Bearer test-token");
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
63
|
+
const action = apiAction<string>({
|
|
64
|
+
name: "auth.header",
|
|
65
|
+
request: () => ({ method: "GET", path: "/me" }),
|
|
66
|
+
});
|
|
67
|
+
await action.dispatch("x");
|
|
68
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
|
|
69
|
+
expect(headers["authorization"]).toBe("Bearer test-token");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("supports async prepareHeaders", async () => {
|
|
73
|
+
configureApi({
|
|
74
|
+
prepareHeaders: async (headers) => {
|
|
75
|
+
await Promise.resolve();
|
|
76
|
+
headers.set("X-CSRF", "async-token");
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
80
|
+
const action = apiAction<string>({
|
|
81
|
+
name: "async.header",
|
|
82
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
83
|
+
});
|
|
84
|
+
await action.dispatch("x");
|
|
85
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
|
|
86
|
+
expect(headers["x-csrf"]).toBe("async-token");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("receives the request spec as context", async () => {
|
|
90
|
+
const spy = vi.fn();
|
|
91
|
+
configureApi({
|
|
92
|
+
prepareHeaders: (headers, ctx) => {
|
|
93
|
+
spy(ctx.spec);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
97
|
+
const action = apiAction<string>({
|
|
98
|
+
name: "ctx.spec",
|
|
99
|
+
request: (id) => ({ method: "POST", path: `/items/${id}`, body: { id } }),
|
|
100
|
+
});
|
|
101
|
+
await action.dispatch("5");
|
|
102
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/items/5" }));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not override per-request headers set in RequestSpec", async () => {
|
|
106
|
+
configureApi({
|
|
107
|
+
prepareHeaders: (headers) => {
|
|
108
|
+
headers.set("X-Global", "global");
|
|
109
|
+
headers.set("X-Override", "from-global");
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
113
|
+
// Per-request headers are set before prepareHeaders, so prepareHeaders wins
|
|
114
|
+
// This matches RTK behavior where prepareHeaders runs last
|
|
115
|
+
const action = apiAction<string>({
|
|
116
|
+
name: "override.test",
|
|
117
|
+
request: () => ({ method: "GET", path: "/x", headers: { "X-Override": "from-spec" } }),
|
|
118
|
+
});
|
|
119
|
+
await action.dispatch("x");
|
|
120
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
|
|
121
|
+
expect(headers["x-global"]).toBe("global");
|
|
122
|
+
// prepareHeaders runs after spec headers, so it overrides
|
|
123
|
+
expect(headers["x-override"]).toBe("from-global");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("configureApi — credentials", () => {
|
|
128
|
+
it("sets credentials on every request", async () => {
|
|
129
|
+
configureApi({ credentials: "include" });
|
|
130
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
131
|
+
const action = apiAction<string>({
|
|
132
|
+
name: "creds",
|
|
133
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
134
|
+
});
|
|
135
|
+
await action.dispatch("x");
|
|
136
|
+
expect(mockFetch.mock.calls[0]![1].credentials).toBe("include");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("omits credentials when not configured", async () => {
|
|
140
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
141
|
+
const action = apiAction<string>({
|
|
142
|
+
name: "no.creds",
|
|
143
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
144
|
+
});
|
|
145
|
+
await action.dispatch("x");
|
|
146
|
+
expect(mockFetch.mock.calls[0]![1].credentials).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("configureApi — fetchFn", () => {
|
|
151
|
+
it("uses custom fetch implementation", async () => {
|
|
152
|
+
const customFetch = vi
|
|
153
|
+
.fn()
|
|
154
|
+
.mockResolvedValue(new Response(JSON.stringify({ custom: true }), { status: 200 }));
|
|
155
|
+
configureApi({ fetchFn: customFetch });
|
|
156
|
+
const action = apiAction<string>({
|
|
157
|
+
name: "custom.fetch",
|
|
158
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
159
|
+
});
|
|
160
|
+
const result = await action.dispatch("x");
|
|
161
|
+
expect(customFetch).toHaveBeenCalled();
|
|
162
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
163
|
+
expect(result).toEqual({ custom: true });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("configureApi — combined", () => {
|
|
168
|
+
it("baseUrl + prepareHeaders + credentials work together", async () => {
|
|
169
|
+
configureApi({
|
|
170
|
+
baseUrl: "https://api.test.io",
|
|
171
|
+
credentials: "include",
|
|
172
|
+
prepareHeaders: (headers) => {
|
|
173
|
+
headers.set("Authorization", "Bearer combo");
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
177
|
+
const action = apiAction<{ name: string }>({
|
|
178
|
+
name: "combo",
|
|
179
|
+
request: ({ name }) => ({ method: "POST", path: "/items", body: { name } }),
|
|
180
|
+
});
|
|
181
|
+
await action.dispatch({ name: "test" });
|
|
182
|
+
const [url, opts] = mockFetch.mock.calls[0]!;
|
|
183
|
+
expect(url).toBe("https://api.test.io/items");
|
|
184
|
+
expect(opts.credentials).toBe("include");
|
|
185
|
+
const headers = opts.headers as Record<string, string>;
|
|
186
|
+
expect(headers["authorization"]).toBe("Bearer combo");
|
|
187
|
+
expect(headers["content-type"]).toBe("application/json");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("RequestSpec.headers — per-request headers", () => {
|
|
192
|
+
it("sends per-request headers on GET", async () => {
|
|
193
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
194
|
+
const action = apiAction<string>({
|
|
195
|
+
name: "per.req.get",
|
|
196
|
+
request: () => ({ method: "GET", path: "/x", headers: { "X-Custom": "val" } }),
|
|
197
|
+
});
|
|
198
|
+
await action.dispatch("x");
|
|
199
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
|
|
200
|
+
expect(headers["x-custom"]).toBe("val");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("sends per-request headers on POST alongside Content-Type", async () => {
|
|
204
|
+
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
205
|
+
const action = apiAction<string>({
|
|
206
|
+
name: "per.req.post",
|
|
207
|
+
request: () => ({
|
|
208
|
+
method: "POST",
|
|
209
|
+
path: "/x",
|
|
210
|
+
body: { a: 1 },
|
|
211
|
+
headers: { "X-Request-Id": "abc" },
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
await action.dispatch("x");
|
|
215
|
+
const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
|
|
216
|
+
expect(headers["content-type"]).toBe("application/json");
|
|
217
|
+
expect(headers["x-request-id"]).toBe("abc");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
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 { ActionError, retryNetwork } from "./error.js";
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDefine();
|
|
16
|
+
resetRegistry();
|
|
17
|
+
resetCleanup();
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("two retry-configured actions in the same scope", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("second action waits for first action's retries to complete before starting", async () => {
|
|
30
|
+
let attemptA = 0;
|
|
31
|
+
let attemptB = 0;
|
|
32
|
+
const actionA = defineAction<void, string>({
|
|
33
|
+
name: "test.scope_retry_A",
|
|
34
|
+
scope: "shared",
|
|
35
|
+
retryable: retryNetwork,
|
|
36
|
+
retry: { count: 2, delay: 100 },
|
|
37
|
+
error: false,
|
|
38
|
+
run: () => {
|
|
39
|
+
attemptA++;
|
|
40
|
+
if (attemptA < 3) {
|
|
41
|
+
throw new ActionError("net", { code: "network" });
|
|
42
|
+
}
|
|
43
|
+
return Promise.resolve("A-done");
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const actionB = defineAction<void, string>({
|
|
47
|
+
name: "test.scope_retry_B",
|
|
48
|
+
scope: "shared",
|
|
49
|
+
retryable: retryNetwork,
|
|
50
|
+
retry: { count: 1, delay: 50 },
|
|
51
|
+
error: false,
|
|
52
|
+
run: () => {
|
|
53
|
+
attemptB++;
|
|
54
|
+
return Promise.resolve("B-done");
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const pA = actionA.dispatch();
|
|
58
|
+
await Promise.resolve();
|
|
59
|
+
const pB = actionB.dispatch();
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
expect(attemptA).toBe(1);
|
|
62
|
+
expect(attemptB).toBe(0);
|
|
63
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
64
|
+
expect(attemptA).toBe(2);
|
|
65
|
+
expect(attemptB).toBe(0);
|
|
66
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
67
|
+
const rA = await pA;
|
|
68
|
+
expect(rA).toBe("A-done");
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
const rB = await pB;
|
|
71
|
+
expect(rB).toBe("B-done");
|
|
72
|
+
expect(attemptB).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("onSuccess → dispatch chain with same scope", () => {
|
|
77
|
+
it("chained dispatch via onSuccess runs after the triggering action completes", async () => {
|
|
78
|
+
const order: string[] = [];
|
|
79
|
+
const actionA = defineAction<void, string>({
|
|
80
|
+
name: "test.chain_A",
|
|
81
|
+
scope: "chain",
|
|
82
|
+
run: () => {
|
|
83
|
+
order.push("A-run");
|
|
84
|
+
return Promise.resolve("A-result");
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const actionB = defineAction<void, string>({
|
|
88
|
+
name: "test.chain_B",
|
|
89
|
+
scope: "chain",
|
|
90
|
+
run: () => {
|
|
91
|
+
order.push("B-run");
|
|
92
|
+
return Promise.resolve("B-result");
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
let chainedPromise: Promise<string | null> | null = null;
|
|
96
|
+
const pA = actionA.dispatch(undefined, {
|
|
97
|
+
onSuccess: () => {
|
|
98
|
+
order.push("A-onSuccess");
|
|
99
|
+
chainedPromise = actionB.dispatch();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const rA = await pA;
|
|
103
|
+
expect(rA).toBe("A-result");
|
|
104
|
+
const rB = await chainedPromise!;
|
|
105
|
+
expect(rB).toBe("B-result");
|
|
106
|
+
expect(order).toEqual(["A-run", "A-onSuccess", "B-run"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("cancellation during retry unblocks queued action", () => {
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
vi.useFakeTimers();
|
|
113
|
+
});
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
vi.useRealTimers();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("cancelling a retrying action lets the queued action proceed", async () => {
|
|
119
|
+
let attemptA = 0;
|
|
120
|
+
let attemptB = 0;
|
|
121
|
+
const actionA = defineAction<void, string>({
|
|
122
|
+
name: "test.cancel_retry_A",
|
|
123
|
+
scope: "cancel-scope",
|
|
124
|
+
retryable: (err) => err.code !== "cancelled",
|
|
125
|
+
retry: { count: 5, delay: 100 },
|
|
126
|
+
error: false,
|
|
127
|
+
run: () => {
|
|
128
|
+
attemptA++;
|
|
129
|
+
throw new ActionError("fail", { status: 500 });
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const actionB = defineAction<void, string>({
|
|
133
|
+
name: "test.cancel_retry_B",
|
|
134
|
+
scope: "cancel-scope",
|
|
135
|
+
error: false,
|
|
136
|
+
run: () => {
|
|
137
|
+
attemptB++;
|
|
138
|
+
return Promise.resolve("B-done");
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const pA = actionA.dispatch();
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
const pB = actionB.dispatch();
|
|
144
|
+
await Promise.resolve();
|
|
145
|
+
expect(attemptA).toBe(1);
|
|
146
|
+
expect(attemptB).toBe(0);
|
|
147
|
+
actionA.cancel();
|
|
148
|
+
const rA = await pA;
|
|
149
|
+
expect(rA).toBeNull();
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
const rB = await pB;
|
|
152
|
+
expect(rB).toBe("B-done");
|
|
153
|
+
expect(attemptB).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("throwing callbacks don't break scope chain", () => {
|
|
158
|
+
it("onSuccess throwing does not re-record action as error", async () => {
|
|
159
|
+
const action = defineAction<void, string>({
|
|
160
|
+
name: "test.cb_throw_success",
|
|
161
|
+
scope: "cb-scope",
|
|
162
|
+
run: () => Promise.resolve("ok"),
|
|
163
|
+
});
|
|
164
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
165
|
+
const result = await action.dispatch(undefined, {
|
|
166
|
+
onSuccess: () => {
|
|
167
|
+
throw new Error("callback boom");
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
consoleSpy.mockRestore();
|
|
171
|
+
expect(result).toBe("ok");
|
|
172
|
+
const log = recentLog();
|
|
173
|
+
const entry = log.find((e) => e.name === "test.cb_throw_success");
|
|
174
|
+
expect(entry?.status).toBe("success");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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, _internalsForTest } 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.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("dedupe + scope combined behavior", () => {
|
|
22
|
+
it("deduped dispatch shares the scope-queued promise without double-queuing", async () => {
|
|
23
|
+
let runCount = 0;
|
|
24
|
+
let resolveRun: ((v: string) => void) | null = null;
|
|
25
|
+
const action = defineAction<string, string>({
|
|
26
|
+
name: "test.dedupe_scope",
|
|
27
|
+
dedupe: true,
|
|
28
|
+
scope: "ds",
|
|
29
|
+
run: () => {
|
|
30
|
+
runCount++;
|
|
31
|
+
return new Promise<string>((r) => {
|
|
32
|
+
resolveRun = r;
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const p1 = action.dispatch("x");
|
|
37
|
+
const p2 = action.dispatch("x");
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
expect(runCount).toBe(1);
|
|
40
|
+
resolveRun!("done");
|
|
41
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
42
|
+
expect(r1).toBe("done");
|
|
43
|
+
expect(r2).toBe("done");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("optimistic persistence across retries", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rollback fires exactly once after all retries exhaust", async () => {
|
|
56
|
+
const rollbackCalls: string[] = [];
|
|
57
|
+
let attempt = 0;
|
|
58
|
+
const action = defineAction<string, string, string>({
|
|
59
|
+
name: "test.opt_retry_rollback",
|
|
60
|
+
retryable: (err) => err.code !== "cancelled",
|
|
61
|
+
retry: { count: 2, delay: 50 },
|
|
62
|
+
error: false,
|
|
63
|
+
optimistic: (args) => `snap-${args}`,
|
|
64
|
+
rollback: (_args, op) => {
|
|
65
|
+
rollbackCalls.push(op ?? "none");
|
|
66
|
+
},
|
|
67
|
+
run: () => {
|
|
68
|
+
attempt++;
|
|
69
|
+
throw new ActionError("fail", { status: 500 });
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const p = action.dispatch("x");
|
|
73
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
74
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
75
|
+
await p;
|
|
76
|
+
expect(attempt).toBe(3);
|
|
77
|
+
expect(rollbackCalls).toEqual(["snap-x"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rollback does NOT fire when retries eventually succeed", async () => {
|
|
81
|
+
const rollbackCalls: string[] = [];
|
|
82
|
+
let attempt = 0;
|
|
83
|
+
const action = defineAction<string, string, string>({
|
|
84
|
+
name: "test.opt_retry_success",
|
|
85
|
+
retryable: retryNetwork,
|
|
86
|
+
retry: { count: 2, delay: 30 },
|
|
87
|
+
optimistic: (args) => `snap-${args}`,
|
|
88
|
+
rollback: (_args, op) => {
|
|
89
|
+
rollbackCalls.push(op ?? "none");
|
|
90
|
+
},
|
|
91
|
+
run: () => {
|
|
92
|
+
attempt++;
|
|
93
|
+
if (attempt < 3) {
|
|
94
|
+
throw new ActionError("net", { code: "network" });
|
|
95
|
+
}
|
|
96
|
+
return Promise.resolve("recovered");
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const p = action.dispatch("y");
|
|
100
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
101
|
+
await vi.advanceTimersByTimeAsync(60);
|
|
102
|
+
const result = await p;
|
|
103
|
+
expect(result).toBe("recovered");
|
|
104
|
+
expect(rollbackCalls).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("onSettled re-dispatch with dedupe", () => {
|
|
109
|
+
it("dispatch from onSettled with same dedupe key starts a fresh run", async () => {
|
|
110
|
+
let runCount = 0;
|
|
111
|
+
const action = defineAction<string, string>({
|
|
112
|
+
name: "test.settled_redispatch",
|
|
113
|
+
dedupe: true,
|
|
114
|
+
run: () => {
|
|
115
|
+
runCount++;
|
|
116
|
+
return Promise.resolve(`run-${String(runCount)}`);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
let chainedPromise: Promise<string | null> | null = null;
|
|
120
|
+
await action.dispatch("k", {
|
|
121
|
+
onSettled: () => {
|
|
122
|
+
chainedPromise = action.dispatch("k");
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const result = await chainedPromise!;
|
|
126
|
+
expect(runCount).toBe(2);
|
|
127
|
+
expect(result).toBe("run-2");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("scope chain after optimistic throw", () => {
|
|
132
|
+
it("next action in scope runs after optimistic throw", async () => {
|
|
133
|
+
const order: string[] = [];
|
|
134
|
+
const broken = defineAction<void, string>({
|
|
135
|
+
name: "test.opt_throw",
|
|
136
|
+
scope: "opt-throw",
|
|
137
|
+
error: false,
|
|
138
|
+
optimistic: () => {
|
|
139
|
+
throw new Error("optimistic boom");
|
|
140
|
+
},
|
|
141
|
+
run: () => Promise.resolve("never"),
|
|
142
|
+
});
|
|
143
|
+
const follower = defineAction<void, string>({
|
|
144
|
+
name: "test.opt_throw_follower",
|
|
145
|
+
scope: "opt-throw",
|
|
146
|
+
run: () => {
|
|
147
|
+
order.push("follower-run");
|
|
148
|
+
return Promise.resolve("ok");
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const pBroken = broken.dispatch();
|
|
152
|
+
const pFollower = follower.dispatch();
|
|
153
|
+
const rBroken = await pBroken;
|
|
154
|
+
expect(rBroken).toBeNull();
|
|
155
|
+
const rFollower = await pFollower;
|
|
156
|
+
expect(rFollower).toBe("ok");
|
|
157
|
+
expect(order).toEqual(["follower-run"]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("rapid cancel + re-dispatch", () => {
|
|
162
|
+
it("re-dispatch after cancel uses a fresh AbortController", async () => {
|
|
163
|
+
const signalAborted: boolean[] = [];
|
|
164
|
+
const action = defineAction<void, string>({
|
|
165
|
+
name: "test.rapid_cancel",
|
|
166
|
+
error: false,
|
|
167
|
+
run: (_args, signal) => {
|
|
168
|
+
signalAborted.push(signal.aborted);
|
|
169
|
+
if (signal.aborted) {
|
|
170
|
+
throw new DOMException("aborted", "AbortError");
|
|
171
|
+
}
|
|
172
|
+
return Promise.resolve("ok");
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const p1 = action.dispatch();
|
|
176
|
+
action.cancel();
|
|
177
|
+
await p1;
|
|
178
|
+
const p2 = action.dispatch();
|
|
179
|
+
const r2 = await p2;
|
|
180
|
+
expect(r2).toBe("ok");
|
|
181
|
+
expect(signalAborted[1]).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("idempotency key stability across retries", () => {
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
});
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
vi.useRealTimers();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("same idempotency key is passed to all retry attempts", async () => {
|
|
194
|
+
const keys: (string | undefined)[] = [];
|
|
195
|
+
const action = defineAction<void, string>({
|
|
196
|
+
name: "test.idem_retry",
|
|
197
|
+
idempotencyKey: true,
|
|
198
|
+
retryable: (err) => err.code !== "cancelled",
|
|
199
|
+
retry: { count: 2, delay: 20 },
|
|
200
|
+
error: false,
|
|
201
|
+
run: (_args, _signal, ctx) => {
|
|
202
|
+
keys.push(ctx?.idempotencyKey);
|
|
203
|
+
throw new ActionError("fail", { status: 500 });
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
const p = action.dispatch();
|
|
207
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
208
|
+
await vi.advanceTimersByTimeAsync(40);
|
|
209
|
+
await p;
|
|
210
|
+
expect(keys.length).toBe(3);
|
|
211
|
+
expect(keys[0]).toBeDefined();
|
|
212
|
+
expect(keys[0]).toBe(keys[1]);
|
|
213
|
+
expect(keys[1]).toBe(keys[2]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("different dispatches get different idempotency keys", async () => {
|
|
217
|
+
const keys: (string | undefined)[] = [];
|
|
218
|
+
const action = defineAction<void, string>({
|
|
219
|
+
name: "test.idem_unique",
|
|
220
|
+
idempotencyKey: true,
|
|
221
|
+
run: (_args, _signal, ctx) => {
|
|
222
|
+
keys.push(ctx?.idempotencyKey);
|
|
223
|
+
return Promise.resolve("ok");
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
await action.dispatch();
|
|
227
|
+
await action.dispatch();
|
|
228
|
+
expect(keys.length).toBe(2);
|
|
229
|
+
expect(keys[0]).not.toBe(keys[1]);
|
|
230
|
+
});
|
|
231
|
+
});
|