@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,476 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { resetActionFramework } from "./__test-helpers__/action-test-setup.js";
|
|
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 { defineAction } from "./define.js";
|
|
13
|
+
import { ActionError, retryNetwork } from "./error.js";
|
|
14
|
+
import { recentLog, subscribe, pendingCount } from "./registry.js";
|
|
15
|
+
import * as notifier from "./notifier.js";
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
resetActionFramework();
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("defineAction — happy path", () => {
|
|
26
|
+
it("dispatch resolves with the run() result", async () => {
|
|
27
|
+
const action = defineAction({
|
|
28
|
+
name: "test.echo",
|
|
29
|
+
run: async (args: { msg: string }) => args.msg,
|
|
30
|
+
});
|
|
31
|
+
expect(await action.dispatch({ msg: "hello" })).toBe("hello");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("records pending then success in the registry", async () => {
|
|
35
|
+
const action = defineAction({ name: "test.ok", run: async () => "done" });
|
|
36
|
+
const events: string[] = [];
|
|
37
|
+
const unsub = subscribe((i) => {
|
|
38
|
+
events.push(i.status);
|
|
39
|
+
});
|
|
40
|
+
await action.dispatch({});
|
|
41
|
+
unsub();
|
|
42
|
+
expect(events).toEqual(["pending", "success"]);
|
|
43
|
+
const log = recentLog();
|
|
44
|
+
expect(log[0]?.status).toBe("success");
|
|
45
|
+
expect(log[0]?.result).toBe("done");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not notify on success by default", async () => {
|
|
49
|
+
const action = defineAction({ name: "test.silent_success", run: async () => "x" });
|
|
50
|
+
await action.dispatch({});
|
|
51
|
+
expect(notifier.notifySuccess).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("notifies on success when `success` is a string", async () => {
|
|
55
|
+
const action = defineAction({
|
|
56
|
+
name: "test.success_string",
|
|
57
|
+
run: async () => "x",
|
|
58
|
+
success: "Saved",
|
|
59
|
+
});
|
|
60
|
+
await action.dispatch({});
|
|
61
|
+
expect(notifier.notifySuccess).toHaveBeenCalledWith("Saved");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("notifies on success when `success` is a function", async () => {
|
|
65
|
+
const action = defineAction({
|
|
66
|
+
name: "test.success_fn",
|
|
67
|
+
run: async (args: string) => `${args}!`,
|
|
68
|
+
success: (args, result) => `Got ${result} for ${args}`,
|
|
69
|
+
});
|
|
70
|
+
await action.dispatch("hi");
|
|
71
|
+
expect(notifier.notifySuccess).toHaveBeenCalledWith("Got hi! for hi");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("dispatch({ silent: true }) suppresses success notification", async () => {
|
|
75
|
+
const action = defineAction({ name: "test.silenced", run: async () => "x", success: "Saved" });
|
|
76
|
+
await action.dispatch({}, { silent: true });
|
|
77
|
+
expect(notifier.notifySuccess).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("defineAction — error path", () => {
|
|
82
|
+
it("dispatch resolves to null on run() throw", async () => {
|
|
83
|
+
const action = defineAction({
|
|
84
|
+
name: "test.fail",
|
|
85
|
+
run: async () => {
|
|
86
|
+
throw new ActionError("boom");
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
expect(await action.dispatch({})).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("records error status with the error message", async () => {
|
|
93
|
+
const action = defineAction({
|
|
94
|
+
name: "test.error",
|
|
95
|
+
run: async () => {
|
|
96
|
+
throw new ActionError("bad", { status: 500 });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
await action.dispatch({});
|
|
100
|
+
const log = recentLog();
|
|
101
|
+
expect(log[0]?.status).toBe("error");
|
|
102
|
+
expect(log[0]?.error?.message).toBe("bad");
|
|
103
|
+
expect(log[0]?.error?.status).toBe(500);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("notifies on error by default with the action-name prefix", async () => {
|
|
107
|
+
const action = defineAction({
|
|
108
|
+
name: "chat.delete",
|
|
109
|
+
run: async () => {
|
|
110
|
+
throw new ActionError("not found");
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await action.dispatch({});
|
|
114
|
+
expect(notifier.notifyError).toHaveBeenCalledWith("Delete failed: not found", undefined);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("`error: 'Custom prefix'` becomes the notification prefix", async () => {
|
|
118
|
+
const action = defineAction({
|
|
119
|
+
name: "test.fail",
|
|
120
|
+
run: async () => {
|
|
121
|
+
throw new ActionError("nope");
|
|
122
|
+
},
|
|
123
|
+
error: "Couldn't do the thing",
|
|
124
|
+
});
|
|
125
|
+
await action.dispatch({});
|
|
126
|
+
expect(notifier.notifyError).toHaveBeenCalledWith("Couldn't do the thing: nope", undefined);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("`error: false` suppresses the error notification", async () => {
|
|
130
|
+
const action = defineAction({
|
|
131
|
+
name: "test.no_toast",
|
|
132
|
+
run: async () => {
|
|
133
|
+
throw new ActionError("silent");
|
|
134
|
+
},
|
|
135
|
+
error: false,
|
|
136
|
+
});
|
|
137
|
+
await action.dispatch({});
|
|
138
|
+
expect(notifier.notifyError).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("normalises non-ActionError throws", async () => {
|
|
142
|
+
const action = defineAction({
|
|
143
|
+
name: "test.weird",
|
|
144
|
+
run: async () => {
|
|
145
|
+
throw "string";
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
await action.dispatch({});
|
|
149
|
+
expect(recentLog()[0]?.error?.message).toBe("string");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("defineAction — optimistic + rollback", () => {
|
|
154
|
+
it("calls optimistic before run() with args", async () => {
|
|
155
|
+
const order: string[] = [];
|
|
156
|
+
const action = defineAction({
|
|
157
|
+
name: "test.opt",
|
|
158
|
+
optimistic: () => {
|
|
159
|
+
order.push("opt");
|
|
160
|
+
return undefined;
|
|
161
|
+
},
|
|
162
|
+
run: async () => {
|
|
163
|
+
order.push("run");
|
|
164
|
+
return undefined;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
await action.dispatch({});
|
|
168
|
+
expect(order).toEqual(["opt", "run"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rollback receives the TOp on error", async () => {
|
|
172
|
+
const rollback = vi.fn();
|
|
173
|
+
const action = defineAction({
|
|
174
|
+
name: "test.rollback",
|
|
175
|
+
optimistic: () => ({ undoToken: 42 }),
|
|
176
|
+
rollback,
|
|
177
|
+
run: async () => {
|
|
178
|
+
throw new ActionError("fail");
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
await action.dispatch({ id: "x" });
|
|
182
|
+
expect(rollback).toHaveBeenCalledWith(
|
|
183
|
+
{ id: "x" },
|
|
184
|
+
{ undoToken: 42 },
|
|
185
|
+
expect.objectContaining({ message: "fail" }),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rollback NOT called on success", async () => {
|
|
190
|
+
const rollback = vi.fn();
|
|
191
|
+
const action = defineAction({
|
|
192
|
+
name: "test.no_rollback",
|
|
193
|
+
optimistic: () => ({ x: 1 }),
|
|
194
|
+
rollback,
|
|
195
|
+
run: async () => "ok",
|
|
196
|
+
});
|
|
197
|
+
await action.dispatch({});
|
|
198
|
+
expect(rollback).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("optimistic throwing skips run() and notifies the error", async () => {
|
|
202
|
+
const run = vi.fn();
|
|
203
|
+
const action = defineAction({
|
|
204
|
+
name: "test.opt_throw",
|
|
205
|
+
optimistic: () => {
|
|
206
|
+
throw new Error("optimistic broke");
|
|
207
|
+
},
|
|
208
|
+
run: async () => {
|
|
209
|
+
run();
|
|
210
|
+
return "x";
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
await action.dispatch({});
|
|
214
|
+
expect(run).not.toHaveBeenCalled();
|
|
215
|
+
expect(notifier.notifyError).toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rollback exception is logged but doesn't crash", async () => {
|
|
219
|
+
const consoleErr = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
220
|
+
const action = defineAction({
|
|
221
|
+
name: "test.rollback_throw",
|
|
222
|
+
optimistic: () => ({}),
|
|
223
|
+
rollback: () => {
|
|
224
|
+
throw new Error("rollback broke");
|
|
225
|
+
},
|
|
226
|
+
run: async () => {
|
|
227
|
+
throw new ActionError("run failed");
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
await action.dispatch({});
|
|
231
|
+
expect(consoleErr).toHaveBeenCalled();
|
|
232
|
+
expect(notifier.notifyError).toHaveBeenCalled();
|
|
233
|
+
consoleErr.mockRestore();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("defineAction — cancellation", () => {
|
|
238
|
+
it("action.cancel() aborts the run()'s signal", async () => {
|
|
239
|
+
let aborted = false;
|
|
240
|
+
const action = defineAction({
|
|
241
|
+
name: "test.cancel",
|
|
242
|
+
run: async (_args, signal) =>
|
|
243
|
+
new Promise<string>((_resolve, reject) => {
|
|
244
|
+
signal.addEventListener("abort", () => {
|
|
245
|
+
aborted = true;
|
|
246
|
+
reject(new DOMException("aborted", "AbortError"));
|
|
247
|
+
});
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
const p = action.dispatch({});
|
|
251
|
+
action.cancel();
|
|
252
|
+
expect(await p).toBeNull();
|
|
253
|
+
expect(aborted).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("cancel records 'cancelled' status, not 'error'", async () => {
|
|
257
|
+
const action = defineAction({
|
|
258
|
+
name: "test.cancel_status",
|
|
259
|
+
run: (_args, signal) =>
|
|
260
|
+
new Promise<string>((_, reject) => {
|
|
261
|
+
signal.addEventListener("abort", () => {
|
|
262
|
+
reject(new Error("aborted"));
|
|
263
|
+
});
|
|
264
|
+
}),
|
|
265
|
+
});
|
|
266
|
+
const p = action.dispatch({});
|
|
267
|
+
action.cancel();
|
|
268
|
+
await p;
|
|
269
|
+
const log = recentLog();
|
|
270
|
+
expect(log[log.length - 1]?.status).toBe("cancelled");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("cancel still calls rollback to undo optimistic", async () => {
|
|
274
|
+
const rollback = vi.fn();
|
|
275
|
+
const action = defineAction({
|
|
276
|
+
name: "test.cancel_rollback",
|
|
277
|
+
optimistic: () => ({ token: "abc" }),
|
|
278
|
+
rollback,
|
|
279
|
+
run: (_args, signal) =>
|
|
280
|
+
new Promise<string>((_, reject) => {
|
|
281
|
+
signal.addEventListener("abort", () => {
|
|
282
|
+
reject(new Error("aborted"));
|
|
283
|
+
});
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
const p = action.dispatch({});
|
|
287
|
+
action.cancel();
|
|
288
|
+
await p;
|
|
289
|
+
expect(rollback).toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("cancel does NOT notify (cancellation is user-initiated)", async () => {
|
|
293
|
+
const action = defineAction({
|
|
294
|
+
name: "test.cancel_no_toast",
|
|
295
|
+
run: (_args, signal) =>
|
|
296
|
+
new Promise<string>((_, reject) => {
|
|
297
|
+
signal.addEventListener("abort", () => {
|
|
298
|
+
reject(new Error("aborted"));
|
|
299
|
+
});
|
|
300
|
+
}),
|
|
301
|
+
error: "Should not appear",
|
|
302
|
+
});
|
|
303
|
+
const p = action.dispatch({});
|
|
304
|
+
action.cancel();
|
|
305
|
+
await p;
|
|
306
|
+
expect(notifier.notifyError).not.toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("defineAction — concurrent instances", () => {
|
|
311
|
+
it("each dispatch gets a unique id", async () => {
|
|
312
|
+
const action = defineAction({ name: "test.multi", run: async () => "x" });
|
|
313
|
+
await Promise.all([action.dispatch({}), action.dispatch({}), action.dispatch({})]);
|
|
314
|
+
const ids = recentLog().map((i) => i.id);
|
|
315
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("cancel() aborts all in-flight instances", async () => {
|
|
319
|
+
const aborts: number[] = [];
|
|
320
|
+
let i = 0;
|
|
321
|
+
const action = defineAction({
|
|
322
|
+
name: "test.multi_cancel",
|
|
323
|
+
run: (_args, signal) => {
|
|
324
|
+
const me = ++i;
|
|
325
|
+
return new Promise<string>((_, reject) => {
|
|
326
|
+
signal.addEventListener("abort", () => {
|
|
327
|
+
aborts.push(me);
|
|
328
|
+
reject(new Error("aborted"));
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const ps = [action.dispatch({}), action.dispatch({}), action.dispatch({})];
|
|
334
|
+
action.cancel();
|
|
335
|
+
await Promise.all(ps);
|
|
336
|
+
expect(aborts.sort()).toEqual([1, 2, 3]);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("registry", () => {
|
|
341
|
+
it("recentLog is bounded", async () => {
|
|
342
|
+
const action = defineAction({ name: "test.bounded", run: async () => "x" });
|
|
343
|
+
for (let i = 0; i < 250; i++) {
|
|
344
|
+
await action.dispatch({});
|
|
345
|
+
}
|
|
346
|
+
expect(recentLog().length).toBe(200);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("subscriber unsubscribes cleanly", async () => {
|
|
350
|
+
const action = defineAction({ name: "test.unsub", run: async () => "x" });
|
|
351
|
+
let calls = 0;
|
|
352
|
+
const unsub = subscribe(() => {
|
|
353
|
+
calls += 1;
|
|
354
|
+
});
|
|
355
|
+
await action.dispatch({});
|
|
356
|
+
const after1 = calls;
|
|
357
|
+
unsub();
|
|
358
|
+
await action.dispatch({});
|
|
359
|
+
expect(calls).toBe(after1);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("pendingCount — public API", () => {
|
|
364
|
+
it("reports 1 for the named action mid-flight, 0 after completion", async () => {
|
|
365
|
+
let resolve!: () => void;
|
|
366
|
+
const action = defineAction({
|
|
367
|
+
name: "test.slow",
|
|
368
|
+
run: () =>
|
|
369
|
+
new Promise<void>((r) => {
|
|
370
|
+
resolve = r;
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
const p = action.dispatch({});
|
|
374
|
+
expect(pendingCount(["test.slow"])).toBe(1);
|
|
375
|
+
resolve();
|
|
376
|
+
await p;
|
|
377
|
+
expect(pendingCount(["test.slow"])).toBe(0);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("defineAction — retryable error notification", () => {
|
|
382
|
+
it("retryable passes a retry handler to error notification", async () => {
|
|
383
|
+
const action = defineAction({
|
|
384
|
+
name: "test.retry_always",
|
|
385
|
+
run: async () => {
|
|
386
|
+
throw new ActionError("network glitch");
|
|
387
|
+
},
|
|
388
|
+
retryable: (err) => err.code !== "cancelled",
|
|
389
|
+
});
|
|
390
|
+
await action.dispatch({ id: 1 });
|
|
391
|
+
expect(notifier.notifyError).toHaveBeenCalledTimes(1);
|
|
392
|
+
const args = vi.mocked(notifier.notifyError).mock.calls[0]!;
|
|
393
|
+
expect(args[1]).toBeDefined();
|
|
394
|
+
expect(typeof args[1]?.onClick).toBe("function");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("retryable: 'network' includes retry for status 0 / timeout", async () => {
|
|
398
|
+
const a1 = defineAction({
|
|
399
|
+
name: "test.retry_net_status0",
|
|
400
|
+
run: async () => {
|
|
401
|
+
throw new ActionError("fetch failed", { status: 0 });
|
|
402
|
+
},
|
|
403
|
+
retryable: retryNetwork,
|
|
404
|
+
});
|
|
405
|
+
await a1.dispatch({});
|
|
406
|
+
expect(vi.mocked(notifier.notifyError).mock.calls[0]?.[1]).toBeDefined();
|
|
407
|
+
vi.clearAllMocks();
|
|
408
|
+
const a2 = defineAction({
|
|
409
|
+
name: "test.retry_net_timeout",
|
|
410
|
+
run: async () => {
|
|
411
|
+
throw new ActionError("Request timed out", { code: "timeout" });
|
|
412
|
+
},
|
|
413
|
+
retryable: retryNetwork,
|
|
414
|
+
});
|
|
415
|
+
await a2.dispatch({});
|
|
416
|
+
expect(vi.mocked(notifier.notifyError).mock.calls[0]?.[1]).toBeDefined();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("retryable: 'network' suppresses retry for HTTP 4xx/5xx", async () => {
|
|
420
|
+
const action = defineAction({
|
|
421
|
+
name: "test.retry_net_4xx",
|
|
422
|
+
run: async () => {
|
|
423
|
+
throw new ActionError("not found", { status: 404 });
|
|
424
|
+
},
|
|
425
|
+
retryable: retryNetwork,
|
|
426
|
+
});
|
|
427
|
+
await action.dispatch({});
|
|
428
|
+
expect(vi.mocked(notifier.notifyError).mock.calls[0]?.[1]).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("retryable: false (default) never includes retry", async () => {
|
|
432
|
+
const action = defineAction({
|
|
433
|
+
name: "test.retry_default",
|
|
434
|
+
run: async () => {
|
|
435
|
+
throw new ActionError("oops");
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
await action.dispatch({});
|
|
439
|
+
expect(vi.mocked(notifier.notifyError).mock.calls[0]?.[1]).toBeUndefined();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("retry handler re-dispatches the same action with the same args", async () => {
|
|
443
|
+
let attempts = 0;
|
|
444
|
+
const action = defineAction({
|
|
445
|
+
name: "test.retry_redispatch",
|
|
446
|
+
run: async () => {
|
|
447
|
+
attempts += 1;
|
|
448
|
+
if (attempts === 1) {
|
|
449
|
+
throw new ActionError("first", { status: 0 });
|
|
450
|
+
}
|
|
451
|
+
return "ok";
|
|
452
|
+
},
|
|
453
|
+
retryable: retryNetwork,
|
|
454
|
+
});
|
|
455
|
+
await action.dispatch({ msg: "hello" });
|
|
456
|
+
const retryFn = vi.mocked(notifier.notifyError).mock.calls[0]?.[1]?.onClick as () => void;
|
|
457
|
+
expect(retryFn).toBeDefined();
|
|
458
|
+
retryFn();
|
|
459
|
+
await vi.waitFor(() => {
|
|
460
|
+
expect(attempts).toBe(2);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("retry suppressed when error: false (no notification at all)", async () => {
|
|
465
|
+
const action = defineAction({
|
|
466
|
+
name: "test.retry_no_toast",
|
|
467
|
+
run: async () => {
|
|
468
|
+
throw new ActionError("silent");
|
|
469
|
+
},
|
|
470
|
+
error: false,
|
|
471
|
+
retryable: (err) => err.code !== "cancelled",
|
|
472
|
+
});
|
|
473
|
+
await action.dispatch({});
|
|
474
|
+
expect(notifier.notifyError).not.toHaveBeenCalled();
|
|
475
|
+
});
|
|
476
|
+
});
|
package/src/define.ts
CHANGED
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
import { ActionError } from "./error.js";
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetDefine();
|
|
16
|
+
resetRegistry();
|
|
17
|
+
resetCleanup();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("dispatch callbacks — onSuccess", () => {
|
|
21
|
+
it("fires with result and args on success", async () => {
|
|
22
|
+
const onSuccess = vi.fn();
|
|
23
|
+
const action = defineAction({ name: "cb.ok", run: async (n: number) => n * 2 });
|
|
24
|
+
await action.dispatch(5, { onSuccess });
|
|
25
|
+
expect(onSuccess).toHaveBeenCalledWith(10, 5);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not fire on error", async () => {
|
|
29
|
+
const onSuccess = vi.fn();
|
|
30
|
+
const action = defineAction({
|
|
31
|
+
name: "cb.fail",
|
|
32
|
+
run: async () => {
|
|
33
|
+
throw new ActionError("nope");
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
await action.dispatch({}, { onSuccess });
|
|
37
|
+
expect(onSuccess).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throwing in onSuccess is caught and logged", async () => {
|
|
41
|
+
const consoleErr = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
42
|
+
const action = defineAction({ name: "cb.throw", run: async () => "ok" });
|
|
43
|
+
const result = await action.dispatch(
|
|
44
|
+
{},
|
|
45
|
+
{
|
|
46
|
+
onSuccess: () => {
|
|
47
|
+
throw new Error("callback boom");
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
expect(result).toBe("ok");
|
|
52
|
+
expect(consoleErr).toHaveBeenCalled();
|
|
53
|
+
consoleErr.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("dispatch callbacks — onError", () => {
|
|
58
|
+
it("fires with error and args on failure", async () => {
|
|
59
|
+
const onError = vi.fn();
|
|
60
|
+
const action = defineAction({
|
|
61
|
+
name: "cb.err",
|
|
62
|
+
run: async () => {
|
|
63
|
+
throw new ActionError("bad", { status: 500 });
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
await action.dispatch("arg", { onError });
|
|
67
|
+
expect(onError).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({ message: "bad", status: 500 }),
|
|
69
|
+
"arg",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not fire on cancellation", async () => {
|
|
74
|
+
const onError = vi.fn();
|
|
75
|
+
const action = defineAction({
|
|
76
|
+
name: "cb.cancel",
|
|
77
|
+
run: (_args, signal) =>
|
|
78
|
+
new Promise<void>((_, reject) => {
|
|
79
|
+
signal.addEventListener("abort", () => {
|
|
80
|
+
reject(new Error("aborted"));
|
|
81
|
+
});
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
const p = action.dispatch({}, { onError });
|
|
85
|
+
action.cancel();
|
|
86
|
+
await p;
|
|
87
|
+
expect(onError).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("dispatch callbacks — onSettled", () => {
|
|
92
|
+
it("fires on success", async () => {
|
|
93
|
+
const onSettled = vi.fn();
|
|
94
|
+
const action = defineAction({ name: "cb.settled.ok", run: async () => "x" });
|
|
95
|
+
await action.dispatch("a", { onSettled });
|
|
96
|
+
expect(onSettled).toHaveBeenCalledWith("a");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("fires on error", async () => {
|
|
100
|
+
const onSettled = vi.fn();
|
|
101
|
+
const action = defineAction({
|
|
102
|
+
name: "cb.settled.err",
|
|
103
|
+
run: async () => {
|
|
104
|
+
throw new ActionError("fail");
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
await action.dispatch("b", { onSettled });
|
|
108
|
+
expect(onSettled).toHaveBeenCalledWith("b");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("fires on cancellation", async () => {
|
|
112
|
+
const onSettled = vi.fn();
|
|
113
|
+
const action = defineAction({
|
|
114
|
+
name: "cb.settled.cancel",
|
|
115
|
+
run: (_args, signal) =>
|
|
116
|
+
new Promise<void>((_, reject) => {
|
|
117
|
+
signal.addEventListener("abort", () => {
|
|
118
|
+
reject(new Error("aborted"));
|
|
119
|
+
});
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const p = action.dispatch("c", { onSettled });
|
|
123
|
+
action.cancel();
|
|
124
|
+
await p;
|
|
125
|
+
expect(onSettled).toHaveBeenCalledWith("c");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("fires even when onSuccess callback throws", async () => {
|
|
129
|
+
const onSettled = vi.fn();
|
|
130
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
131
|
+
const action = defineAction({ name: "cb.settled.throw-success", run: async () => "ok" });
|
|
132
|
+
await action.dispatch("d", {
|
|
133
|
+
onSuccess: () => {
|
|
134
|
+
throw new Error("onSuccess boom");
|
|
135
|
+
},
|
|
136
|
+
onSettled,
|
|
137
|
+
});
|
|
138
|
+
expect(onSettled).toHaveBeenCalledWith("d");
|
|
139
|
+
vi.mocked(console.error).mockRestore();
|
|
140
|
+
});
|
|
141
|
+
});
|