@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,454 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// RED-TEAM round 3 (final sweep): full lifecycle race matrix, AbortSignal
|
|
3
|
+
// listener leaks, prepareHeaders/fetchFn error paths, registry bounds under load.
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
|
+
|
|
6
|
+
vi.mock("./notifier.js", () => ({
|
|
7
|
+
configure: vi.fn(),
|
|
8
|
+
notifySuccess: vi.fn(),
|
|
9
|
+
notifyError: vi.fn(),
|
|
10
|
+
_resetNotifierForTest: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { apiAction, configureApi, _resetApiConfigForTest } from "./api.js";
|
|
14
|
+
import { defineAction, _resetForTest as resetDefine, _internalsForTest } from "./define.js";
|
|
15
|
+
import { _resetForTest as resetRegistry, recentLog, isPending, pendingCount } from "./registry.js";
|
|
16
|
+
import { _resetForTest as resetCleanup } from "./cleanup.js";
|
|
17
|
+
|
|
18
|
+
const mockFetch = vi.fn();
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
resetDefine();
|
|
22
|
+
resetRegistry();
|
|
23
|
+
resetCleanup();
|
|
24
|
+
_resetApiConfigForTest();
|
|
25
|
+
mockFetch.mockReset();
|
|
26
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// 1. Full lifecycle race matrix: dispatch/cancel/abort/timeout/retry/dedupe/scope
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
describe("lifecycle race matrix", () => {
|
|
37
|
+
it("cancel() during retry backoff with dedupe + scope — no leak, correct status", async () => {
|
|
38
|
+
let attempt = 0;
|
|
39
|
+
const action = defineAction({
|
|
40
|
+
name: "race.matrix.1",
|
|
41
|
+
scope: "race-scope",
|
|
42
|
+
dedupe: true,
|
|
43
|
+
timeout: 5000,
|
|
44
|
+
retry: { count: 5, delay: 100 },
|
|
45
|
+
retryable: () => true,
|
|
46
|
+
run: async (_a, signal) => {
|
|
47
|
+
attempt++;
|
|
48
|
+
if (attempt <= 2) throw new Error("transient");
|
|
49
|
+
// After 2 failures, wait for signal
|
|
50
|
+
await new Promise<never>((_r, reject) => {
|
|
51
|
+
signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const handle = action.dispatch("x");
|
|
56
|
+
// Let first two attempts fail and enter backoff
|
|
57
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
58
|
+
// Cancel mid-flight
|
|
59
|
+
action.cancel();
|
|
60
|
+
const result = await handle;
|
|
61
|
+
expect(result).toBeNull();
|
|
62
|
+
const entry = recentLog().find((e) => e.name === "race.matrix.1");
|
|
63
|
+
expect(entry).toBeDefined();
|
|
64
|
+
expect(entry!.status).toBe("cancelled");
|
|
65
|
+
// Verify no leaks in internal maps
|
|
66
|
+
const internals = _internalsForTest();
|
|
67
|
+
expect(internals.activeDedupes).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("abort() on deduped follower while leader is in-flight — follower settles, leader continues", async () => {
|
|
71
|
+
let leaderResolve!: (v: string) => void;
|
|
72
|
+
const action = defineAction({
|
|
73
|
+
name: "race.dedupe.abort",
|
|
74
|
+
dedupe: true,
|
|
75
|
+
run: () =>
|
|
76
|
+
new Promise<string>((r) => {
|
|
77
|
+
leaderResolve = r;
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
const h1 = action.dispatch("a");
|
|
81
|
+
const onError2 = vi.fn();
|
|
82
|
+
const onSettled2 = vi.fn();
|
|
83
|
+
const h2 = action.dispatch("a", { onError: onError2, onSettled: onSettled2 });
|
|
84
|
+
// Abort the follower handle — but since dedupe shares the promise, abort is a no-op
|
|
85
|
+
h2.abort();
|
|
86
|
+
// Resolve the leader
|
|
87
|
+
leaderResolve("done");
|
|
88
|
+
const r1 = await h1;
|
|
89
|
+
const r2 = await h2;
|
|
90
|
+
expect(r1).toBe("done");
|
|
91
|
+
// Follower gets the result too (abort on follower is NOOP for dedupe)
|
|
92
|
+
expect(r2).toBe("done");
|
|
93
|
+
expect(onSettled2).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("scope-queued dispatch cancelled via action.cancel() before run starts — records cancelled, no run", async () => {
|
|
97
|
+
const runSpy = vi.fn().mockResolvedValue("ok");
|
|
98
|
+
let blockerResolve!: (v: string) => void;
|
|
99
|
+
const action = defineAction({
|
|
100
|
+
name: "race.scope.cancel",
|
|
101
|
+
scope: "cancel-scope",
|
|
102
|
+
run: (args, _signal) => {
|
|
103
|
+
if (args === "blocker") {
|
|
104
|
+
return new Promise<string>((r) => {
|
|
105
|
+
blockerResolve = r;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
runSpy();
|
|
109
|
+
return Promise.resolve("target-done");
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const h1 = action.dispatch("blocker" as string);
|
|
113
|
+
await Promise.resolve(); // let blocker start
|
|
114
|
+
const h2 = action.dispatch("target" as string);
|
|
115
|
+
// Cancel all before target starts
|
|
116
|
+
action.cancel();
|
|
117
|
+
blockerResolve("blocker-done");
|
|
118
|
+
await h1;
|
|
119
|
+
const r2 = await h2;
|
|
120
|
+
expect(r2).toBeNull();
|
|
121
|
+
expect(runSpy).not.toHaveBeenCalled();
|
|
122
|
+
const entries = recentLog().filter((e) => e.name === "race.scope.cancel");
|
|
123
|
+
const targetEntry = entries.find((e) => e.args === "target");
|
|
124
|
+
expect(targetEntry?.status).toBe("cancelled");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rapid dispatch+cancel+dispatch cycle does not corrupt state", async () => {
|
|
128
|
+
let callCount = 0;
|
|
129
|
+
const action = defineAction({
|
|
130
|
+
name: "race.rapid",
|
|
131
|
+
run: async () => {
|
|
132
|
+
callCount++;
|
|
133
|
+
return callCount;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const h1 = action.dispatch("a");
|
|
137
|
+
h1.abort();
|
|
138
|
+
const h2 = action.dispatch("b");
|
|
139
|
+
const h3 = action.dispatch("c");
|
|
140
|
+
h3.abort();
|
|
141
|
+
const [r1, r2, r3] = await Promise.all([h1, h2, h3]);
|
|
142
|
+
expect(r1).toBeNull(); // aborted
|
|
143
|
+
expect(r2).toBeGreaterThanOrEqual(1); // should succeed
|
|
144
|
+
expect(r3).toBeNull(); // aborted
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// 2. AbortSignal listener leaks
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
describe("AbortSignal listener leaks", () => {
|
|
152
|
+
it("sleep() cleans up abort listener on normal resolve", async () => {
|
|
153
|
+
const { sleep } = await import("./retry.js");
|
|
154
|
+
const ac = new AbortController();
|
|
155
|
+
// Spy on removeEventListener
|
|
156
|
+
const removeSpy = vi.spyOn(ac.signal, "removeEventListener");
|
|
157
|
+
await sleep(1, ac.signal);
|
|
158
|
+
// The abort listener should have been removed after timer fires
|
|
159
|
+
expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("sleep() cleans up timer on abort", async () => {
|
|
163
|
+
const { sleep } = await import("./retry.js");
|
|
164
|
+
const ac = new AbortController();
|
|
165
|
+
const p = sleep(100000, ac.signal);
|
|
166
|
+
ac.abort();
|
|
167
|
+
await expect(p).rejects.toThrow();
|
|
168
|
+
// No lingering timer — if it leaked, the test process would hang
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("waitForOnline() cleans up listeners on abort", async () => {
|
|
172
|
+
const { waitForOnline } = await import("./retry.js");
|
|
173
|
+
// Simulate offline
|
|
174
|
+
Object.defineProperty(navigator, "onLine", { value: false, configurable: true });
|
|
175
|
+
const ac = new AbortController();
|
|
176
|
+
const removeSpy = vi.spyOn(ac.signal, "removeEventListener");
|
|
177
|
+
const p = waitForOnline(ac.signal);
|
|
178
|
+
ac.abort();
|
|
179
|
+
await expect(p).rejects.toThrow();
|
|
180
|
+
expect(removeSpy).toHaveBeenCalled();
|
|
181
|
+
Object.defineProperty(navigator, "onLine", { value: true, configurable: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("many dispatches with timeout do not accumulate listeners", async () => {
|
|
185
|
+
vi.useFakeTimers();
|
|
186
|
+
const action = defineAction({
|
|
187
|
+
name: "leak.timeout",
|
|
188
|
+
timeout: 1000,
|
|
189
|
+
run: async () => "ok",
|
|
190
|
+
});
|
|
191
|
+
const handles = [];
|
|
192
|
+
for (let i = 0; i < 50; i++) {
|
|
193
|
+
handles.push(action.dispatch(`arg-${i}`));
|
|
194
|
+
}
|
|
195
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
196
|
+
const results = await Promise.all(handles);
|
|
197
|
+
// All should succeed (run is instant)
|
|
198
|
+
expect(results.every((r) => r === "ok")).toBe(true);
|
|
199
|
+
// Verify no internal map leaks
|
|
200
|
+
const internals = _internalsForTest();
|
|
201
|
+
expect(internals.scopeChains).toBe(0);
|
|
202
|
+
expect(internals.activeDedupes).toBe(0);
|
|
203
|
+
vi.useRealTimers();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// 3. prepareHeaders / fetchFn error paths
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
describe("prepareHeaders / fetchFn error paths", () => {
|
|
211
|
+
it("prepareHeaders returning a rejected promise with non-Error value", async () => {
|
|
212
|
+
configureApi({
|
|
213
|
+
prepareHeaders: () => Promise.reject("string-rejection"),
|
|
214
|
+
});
|
|
215
|
+
mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
|
216
|
+
const action = apiAction<void>({
|
|
217
|
+
name: "prep.string.reject",
|
|
218
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
219
|
+
});
|
|
220
|
+
const result = await action.dispatch(undefined);
|
|
221
|
+
expect(result).toBeNull();
|
|
222
|
+
const entry = recentLog().find((e) => e.name === "prep.string.reject");
|
|
223
|
+
expect(entry?.status).toBe("error");
|
|
224
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("fetchFn returning a rejected promise with DOMException TimeoutError", async () => {
|
|
228
|
+
configureApi({
|
|
229
|
+
fetchFn: () => Promise.reject(new DOMException("timed out", "TimeoutError")),
|
|
230
|
+
});
|
|
231
|
+
const action = apiAction<void>({
|
|
232
|
+
name: "fetch.timeout.dom",
|
|
233
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
234
|
+
});
|
|
235
|
+
const result = await action.dispatch(undefined);
|
|
236
|
+
expect(result).toBeNull();
|
|
237
|
+
const entry = recentLog().find((e) => e.name === "fetch.timeout.dom");
|
|
238
|
+
expect(entry?.status).toBe("error");
|
|
239
|
+
expect(entry?.error?.code).toBe("timeout");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("fetchFn throwing after signal is aborted classifies as cancelled", async () => {
|
|
243
|
+
configureApi({
|
|
244
|
+
fetchFn: async (_url, init) => {
|
|
245
|
+
const signal = (init as RequestInit).signal!;
|
|
246
|
+
// Wait until signal aborts
|
|
247
|
+
await new Promise<void>((r) => {
|
|
248
|
+
signal.addEventListener("abort", () => r());
|
|
249
|
+
});
|
|
250
|
+
throw new DOMException("aborted", "AbortError");
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
const action = apiAction<void>({
|
|
254
|
+
name: "fetch.abort.classify",
|
|
255
|
+
request: () => ({ method: "GET", path: "/x" }),
|
|
256
|
+
});
|
|
257
|
+
const handle = action.dispatch(undefined);
|
|
258
|
+
// Abort immediately
|
|
259
|
+
handle.abort();
|
|
260
|
+
const result = await handle;
|
|
261
|
+
expect(result).toBeNull();
|
|
262
|
+
const entry = recentLog().find((e) => e.name === "fetch.abort.classify");
|
|
263
|
+
expect(entry?.status).toBe("cancelled");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("prepareHeaders mutating headers does not affect subsequent requests", async () => {
|
|
267
|
+
let callCount = 0;
|
|
268
|
+
configureApi({
|
|
269
|
+
prepareHeaders: (headers) => {
|
|
270
|
+
callCount++;
|
|
271
|
+
headers.set("X-Seq", String(callCount));
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
|
275
|
+
const action = apiAction<number>({
|
|
276
|
+
name: "prep.no.bleed",
|
|
277
|
+
request: (n) => ({ method: "GET", path: `/x/${n}` }),
|
|
278
|
+
});
|
|
279
|
+
await action.dispatch(1);
|
|
280
|
+
await action.dispatch(2);
|
|
281
|
+
const h1 = (mockFetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>;
|
|
282
|
+
const h2 = (mockFetch.mock.calls[1]![1] as RequestInit).headers as Record<string, string>;
|
|
283
|
+
expect(h1["x-seq"]).toBe("1");
|
|
284
|
+
expect(h2["x-seq"]).toBe("2");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// 4. Registry bounds under load
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
describe("registry bounds under load", () => {
|
|
292
|
+
it("registry does not grow unbounded with 500+ dispatches", async () => {
|
|
293
|
+
const action = defineAction({
|
|
294
|
+
name: "reg.load",
|
|
295
|
+
run: async (n: number) => n * 2,
|
|
296
|
+
});
|
|
297
|
+
const promises = [];
|
|
298
|
+
for (let i = 0; i < 500; i++) {
|
|
299
|
+
promises.push(action.dispatch(i));
|
|
300
|
+
}
|
|
301
|
+
await Promise.all(promises);
|
|
302
|
+
const log = recentLog();
|
|
303
|
+
// Registry should be bounded (MAX_LOG_SIZE = 200)
|
|
304
|
+
expect(log.length).toBeLessThanOrEqual(250);
|
|
305
|
+
// All should be terminal (no pending leaks)
|
|
306
|
+
expect(log.every((e) => e.status !== "pending")).toBe(true);
|
|
307
|
+
expect(pendingCount()).toBe(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("pendingCount stays accurate under rapid dispatch/settle", async () => {
|
|
311
|
+
const resolvers: ((v: string) => void)[] = [];
|
|
312
|
+
const action = defineAction({
|
|
313
|
+
name: "reg.pending",
|
|
314
|
+
run: () =>
|
|
315
|
+
new Promise<string>((r) => {
|
|
316
|
+
resolvers.push(r);
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
// Dispatch 10 concurrent
|
|
320
|
+
for (let i = 0; i < 10; i++) {
|
|
321
|
+
action.dispatch(`arg-${i}`);
|
|
322
|
+
}
|
|
323
|
+
await Promise.resolve();
|
|
324
|
+
expect(pendingCount(["reg.pending"])).toBe(10);
|
|
325
|
+
expect(isPending("reg.pending")).toBe(true);
|
|
326
|
+
// Resolve half
|
|
327
|
+
for (let i = 0; i < 5; i++) {
|
|
328
|
+
resolvers[i]!("done");
|
|
329
|
+
}
|
|
330
|
+
await Promise.resolve();
|
|
331
|
+
await Promise.resolve();
|
|
332
|
+
expect(pendingCount(["reg.pending"])).toBe(5);
|
|
333
|
+
// Resolve rest
|
|
334
|
+
for (let i = 5; i < 10; i++) {
|
|
335
|
+
resolvers[i]!("done");
|
|
336
|
+
}
|
|
337
|
+
await Promise.resolve();
|
|
338
|
+
await Promise.resolve();
|
|
339
|
+
expect(pendingCount(["reg.pending"])).toBe(0);
|
|
340
|
+
expect(isPending("reg.pending")).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("registry eviction preserves pending entries", async () => {
|
|
344
|
+
let pendingResolve!: (v: string) => void;
|
|
345
|
+
const slowAction = defineAction({
|
|
346
|
+
name: "reg.evict.slow",
|
|
347
|
+
run: () =>
|
|
348
|
+
new Promise<string>((r) => {
|
|
349
|
+
pendingResolve = r;
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
const fastAction = defineAction({
|
|
353
|
+
name: "reg.evict.fast",
|
|
354
|
+
run: async () => "fast",
|
|
355
|
+
});
|
|
356
|
+
// Start a slow action
|
|
357
|
+
const slowHandle = slowAction.dispatch("slow");
|
|
358
|
+
await Promise.resolve();
|
|
359
|
+
// Flood with fast actions to trigger eviction
|
|
360
|
+
for (let i = 0; i < 300; i++) {
|
|
361
|
+
await fastAction.dispatch(String(i));
|
|
362
|
+
}
|
|
363
|
+
// Slow action should still be tracked as pending
|
|
364
|
+
expect(isPending("reg.evict.slow")).toBe(true);
|
|
365
|
+
// Resolve it
|
|
366
|
+
pendingResolve("finally");
|
|
367
|
+
const result = await slowHandle;
|
|
368
|
+
expect(result).toBe("finally");
|
|
369
|
+
expect(isPending("reg.evict.slow")).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// 5. Verify round 1-2 fixes are sound
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
describe("verify round 1-2 fixes", () => {
|
|
377
|
+
it("baseUrl join: no double slash (regression)", async () => {
|
|
378
|
+
configureApi({ baseUrl: "https://api.example.com/" });
|
|
379
|
+
mockFetch.mockResolvedValue(new Response("{}", { status: 200 }));
|
|
380
|
+
const action = apiAction<void>({
|
|
381
|
+
name: "verify.baseurl",
|
|
382
|
+
request: () => ({ method: "GET", path: "/users/1" }),
|
|
383
|
+
});
|
|
384
|
+
await action.dispatch(undefined);
|
|
385
|
+
expect(mockFetch.mock.calls[0]![0]).toBe("https://api.example.com/users/1");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("timeout error code is 'timeout' not 'cancelled' (regression)", async () => {
|
|
389
|
+
const action = defineAction({
|
|
390
|
+
name: "verify.timeout.code",
|
|
391
|
+
timeout: 10,
|
|
392
|
+
run: (_a, signal) =>
|
|
393
|
+
new Promise<string>((_resolve, reject) => {
|
|
394
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
395
|
+
}),
|
|
396
|
+
});
|
|
397
|
+
const result = await action.dispatch("x");
|
|
398
|
+
expect(result).toBeNull();
|
|
399
|
+
const entry = recentLog().find(
|
|
400
|
+
(e) => e.name === "verify.timeout.code" && e.status !== "pending",
|
|
401
|
+
);
|
|
402
|
+
expect(entry).toBeDefined();
|
|
403
|
+
// Must be error with timeout code, not cancelled
|
|
404
|
+
expect(entry!.status).toBe("error");
|
|
405
|
+
expect(entry!.error?.code).toBe("timeout");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// 6. Scope chain cleanup after errors
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
describe("scope chain cleanup", () => {
|
|
413
|
+
it("scope chain is released after run() throws — next dispatch proceeds", async () => {
|
|
414
|
+
let callCount = 0;
|
|
415
|
+
const action = defineAction({
|
|
416
|
+
name: "scope.error.release",
|
|
417
|
+
scope: "err-scope",
|
|
418
|
+
run: async () => {
|
|
419
|
+
callCount++;
|
|
420
|
+
if (callCount === 1) throw new Error("first fails");
|
|
421
|
+
return "second-ok";
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
const r1 = await action.dispatch("a");
|
|
425
|
+
expect(r1).toBeNull(); // first fails
|
|
426
|
+
const r2 = await action.dispatch("b");
|
|
427
|
+
expect(r2).toBe("second-ok"); // second should not be stuck
|
|
428
|
+
expect(_internalsForTest().scopeChains).toBe(0);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("scope chain is released after cancel — next dispatch proceeds", async () => {
|
|
432
|
+
let resolve1!: (v: string) => void;
|
|
433
|
+
const action = defineAction({
|
|
434
|
+
name: "scope.cancel.release",
|
|
435
|
+
scope: "cancel-scope-2",
|
|
436
|
+
run: (args) => {
|
|
437
|
+
if (args === "first") {
|
|
438
|
+
return new Promise<string>((r) => {
|
|
439
|
+
resolve1 = r;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return Promise.resolve("second-ok");
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
const h1 = action.dispatch("first" as string);
|
|
446
|
+
await Promise.resolve();
|
|
447
|
+
h1.abort();
|
|
448
|
+
resolve1("ignored");
|
|
449
|
+
await h1;
|
|
450
|
+
const r2 = await action.dispatch("second" as string);
|
|
451
|
+
expect(r2).toBe("second-ok");
|
|
452
|
+
expect(_internalsForTest().scopeChains).toBe(0);
|
|
453
|
+
});
|
|
454
|
+
});
|