@cplieger/actions 1.0.1 → 1.1.0
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/jsr.json +1 -1
- package/package.json +6 -2
- package/src/__test-helpers__/action-test-setup.ts +0 -34
- package/src/api-idempotency.test.ts +0 -163
- package/src/api.test.ts +0 -96
- package/src/cleanup.test.ts +0 -102
- package/src/configure-api.test.ts +0 -219
- package/src/cross-action-coordination.test.ts +0 -176
- package/src/cross-action-races-extra.test.ts +0 -231
- package/src/cross-action-races.test.ts +0 -213
- package/src/cross-action-scope-serial.test.ts +0 -121
- package/src/debounce.test.ts +0 -121
- package/src/define.property.test.ts +0 -133
- package/src/define.test.ts +0 -476
- package/src/dispatch-callbacks.test.ts +0 -141
- package/src/dispatch-options.test.ts +0 -197
- package/src/edge-cases.test.ts +0 -81
- package/src/error.test.ts +0 -268
- package/src/leak-stress.test.ts +0 -117
- package/src/loading.test.ts +0 -146
- package/src/new-features.test.ts +0 -246
- package/src/poll.test.ts +0 -385
- package/src/primitive-edge-cases.test.ts +0 -105
- package/src/primitive-interactions.test.ts +0 -112
- package/src/primitives.test.ts +0 -215
- package/src/race-dedupe-cancel.test.ts +0 -88
- package/src/redteam.test.ts +0 -254
- package/src/redteam2.test.ts +0 -337
- package/src/redteam3.test.ts +0 -454
- package/src/registry.test.ts +0 -271
- package/src/retry-network-delay.test.ts +0 -155
- package/src/transport.test.ts +0 -139
package/src/redteam2.test.ts
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
// RED-TEAM round 2: deeper adversarial 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, getActionLog, subscribe } 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. Timeout firing during retry backoff — must report "timeout" not "cancelled"
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
describe("timeout during retry backoff", () => {
|
|
36
|
-
it("reports error code 'timeout' when timeout fires during backoff sleep", async () => {
|
|
37
|
-
let attempt = 0;
|
|
38
|
-
const action = defineAction({
|
|
39
|
-
name: "timeout.during.backoff",
|
|
40
|
-
timeout: 80,
|
|
41
|
-
retry: { count: 3, delay: 200 },
|
|
42
|
-
retryable: () => true,
|
|
43
|
-
run: async () => {
|
|
44
|
-
attempt++;
|
|
45
|
-
throw new Error("transient");
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
const result = await action.dispatch("x");
|
|
49
|
-
expect(result).toBeNull();
|
|
50
|
-
const entry = recentLog().find((e) => e.name === "timeout.during.backoff");
|
|
51
|
-
expect(entry).toBeDefined();
|
|
52
|
-
expect(entry!.status).toBe("error");
|
|
53
|
-
// The error code must be "timeout", not "cancelled"
|
|
54
|
-
expect(entry!.error?.code).toBe("timeout");
|
|
55
|
-
// Should have attempted at least once before timeout during backoff
|
|
56
|
-
expect(attempt).toBeGreaterThanOrEqual(1);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// 2. baseUrl with absolute URL — should NOT prepend baseUrl
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
describe("baseUrl does not break absolute URLs", () => {
|
|
64
|
-
it("preserves absolute http:// URLs without prepending baseUrl", async () => {
|
|
65
|
-
configureApi({ baseUrl: "https://api.example.com/v1" });
|
|
66
|
-
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
67
|
-
// If path is already absolute, the current implementation still prepends.
|
|
68
|
-
// This test documents the current behavior.
|
|
69
|
-
const action = apiAction<string>({
|
|
70
|
-
name: "abs.url",
|
|
71
|
-
request: () => ({ method: "GET", path: "/items?foo=bar&baz=1" }),
|
|
72
|
-
});
|
|
73
|
-
await action.dispatch("x");
|
|
74
|
-
const url = mockFetch.mock.calls[0]![0] as string;
|
|
75
|
-
// Query strings must be preserved
|
|
76
|
-
expect(url).toBe("https://api.example.com/v1/items?foo=bar&baz=1");
|
|
77
|
-
expect(url).toContain("?foo=bar&baz=1");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("preserves query strings when baseUrl has trailing slash", async () => {
|
|
81
|
-
configureApi({ baseUrl: "https://api.example.com/" });
|
|
82
|
-
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
83
|
-
const action = apiAction<string>({
|
|
84
|
-
name: "qs.preserve",
|
|
85
|
-
request: () => ({ method: "GET", path: "/search?q=hello&page=2" }),
|
|
86
|
-
});
|
|
87
|
-
await action.dispatch("x");
|
|
88
|
-
const url = mockFetch.mock.calls[0]![0] as string;
|
|
89
|
-
expect(url).toBe("https://api.example.com/search?q=hello&page=2");
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// 3. prepareHeaders does NOT mutate a shared object across requests
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
describe("prepareHeaders isolation", () => {
|
|
97
|
-
it("each request gets a fresh Headers object (no cross-request mutation)", async () => {
|
|
98
|
-
const headersReceived: Headers[] = [];
|
|
99
|
-
configureApi({
|
|
100
|
-
prepareHeaders: (headers) => {
|
|
101
|
-
headersReceived.push(headers);
|
|
102
|
-
headers.set("X-Count", String(headersReceived.length));
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
106
|
-
const action = apiAction<string>({
|
|
107
|
-
name: "headers.isolation",
|
|
108
|
-
request: () => ({ method: "GET", path: "/x" }),
|
|
109
|
-
});
|
|
110
|
-
await action.dispatch("a");
|
|
111
|
-
await action.dispatch("b");
|
|
112
|
-
// Each call should have received a distinct Headers instance
|
|
113
|
-
expect(headersReceived[0]).not.toBe(headersReceived[1]);
|
|
114
|
-
// First request should have X-Count: 1, second X-Count: 2
|
|
115
|
-
const h1 = (mockFetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>;
|
|
116
|
-
const h2 = (mockFetch.mock.calls[1]![1] as RequestInit).headers as Record<string, string>;
|
|
117
|
-
expect(h1["x-count"]).toBe("1");
|
|
118
|
-
expect(h2["x-count"]).toBe("2");
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// 4. fetchFn returning non-Response (type violation at runtime)
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
describe("fetchFn returning non-Response", () => {
|
|
126
|
-
it("surfaces as an error when fetchFn returns null", async () => {
|
|
127
|
-
configureApi({
|
|
128
|
-
fetchFn: (async () => null) as unknown as typeof fetch,
|
|
129
|
-
});
|
|
130
|
-
const action = apiAction<string>({
|
|
131
|
-
name: "fetch.null",
|
|
132
|
-
request: () => ({ method: "GET", path: "/x" }),
|
|
133
|
-
});
|
|
134
|
-
const result = await action.dispatch("x");
|
|
135
|
-
expect(result).toBeNull();
|
|
136
|
-
const entry = recentLog().find((e) => e.name === "fetch.null");
|
|
137
|
-
expect(entry?.status).toBe("error");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("surfaces as an error when fetchFn returns a non-object", async () => {
|
|
141
|
-
configureApi({
|
|
142
|
-
fetchFn: (async () => 42) as unknown as typeof fetch,
|
|
143
|
-
});
|
|
144
|
-
const action = apiAction<string>({
|
|
145
|
-
name: "fetch.number",
|
|
146
|
-
request: () => ({ method: "GET", path: "/x" }),
|
|
147
|
-
});
|
|
148
|
-
const result = await action.dispatch("x");
|
|
149
|
-
expect(result).toBeNull();
|
|
150
|
-
const entry = recentLog().find((e) => e.name === "fetch.number");
|
|
151
|
-
expect(entry?.status).toBe("error");
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// 5. DispatchHandle.abort before run starts (scope-queued)
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
describe("DispatchHandle.abort before run starts", () => {
|
|
159
|
-
it("abort() on a scope-queued dispatch cancels without running once scope frees", async () => {
|
|
160
|
-
const runSpy = vi.fn().mockResolvedValue("ok");
|
|
161
|
-
let blockerResolve: ((v: string) => void) | undefined;
|
|
162
|
-
const blocker = defineAction({
|
|
163
|
-
name: "abort.pre.blocker2",
|
|
164
|
-
scope: "abort-pre-scope2",
|
|
165
|
-
run: () =>
|
|
166
|
-
new Promise<string>((resolve) => {
|
|
167
|
-
blockerResolve = resolve;
|
|
168
|
-
}),
|
|
169
|
-
});
|
|
170
|
-
const target = defineAction({
|
|
171
|
-
name: "abort.pre.target2",
|
|
172
|
-
scope: "abort-pre-scope2",
|
|
173
|
-
run: runSpy,
|
|
174
|
-
});
|
|
175
|
-
const h1 = blocker.dispatch("a");
|
|
176
|
-
// Wait a tick for blocker's run() to start and capture the resolver
|
|
177
|
-
await Promise.resolve();
|
|
178
|
-
await Promise.resolve();
|
|
179
|
-
expect(blockerResolve).toBeDefined();
|
|
180
|
-
const h2 = target.dispatch("b");
|
|
181
|
-
// Abort target before it starts (it's queued behind blocker)
|
|
182
|
-
h2.abort();
|
|
183
|
-
// Now let blocker finish — target should detect abort and not run
|
|
184
|
-
blockerResolve!("done");
|
|
185
|
-
await h1;
|
|
186
|
-
const r2 = await h2;
|
|
187
|
-
expect(r2).toBeNull();
|
|
188
|
-
// run should never have been called for the aborted dispatch
|
|
189
|
-
expect(runSpy).not.toHaveBeenCalled();
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("abort() on non-scope dispatch that hasn't started yet records cancelled", async () => {
|
|
193
|
-
const action = defineAction({
|
|
194
|
-
name: "abort.immediate",
|
|
195
|
-
run: async () => "should not reach",
|
|
196
|
-
});
|
|
197
|
-
const handle = action.dispatch("x");
|
|
198
|
-
handle.abort();
|
|
199
|
-
const result = await handle;
|
|
200
|
-
expect(result).toBeNull();
|
|
201
|
-
const entry = recentLog().find((e) => e.name === "abort.immediate");
|
|
202
|
-
expect(entry?.status).toBe("cancelled");
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
// 6. Dedupe key collisions across different actions
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
describe("dedupe key collisions across different actions", () => {
|
|
210
|
-
it("two actions with same args but different names do NOT share dedupe", async () => {
|
|
211
|
-
const action1 = defineAction({
|
|
212
|
-
name: "dedupe.action1",
|
|
213
|
-
dedupe: true,
|
|
214
|
-
run: async () => "result1",
|
|
215
|
-
});
|
|
216
|
-
const action2 = defineAction({
|
|
217
|
-
name: "dedupe.action2",
|
|
218
|
-
dedupe: true,
|
|
219
|
-
run: async () => "result2",
|
|
220
|
-
});
|
|
221
|
-
const [r1, r2] = await Promise.all([
|
|
222
|
-
action1.dispatch("same-args"),
|
|
223
|
-
action2.dispatch("same-args"),
|
|
224
|
-
]);
|
|
225
|
-
expect(r1).toBe("result1");
|
|
226
|
-
expect(r2).toBe("result2");
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
// ---------------------------------------------------------------------------
|
|
231
|
-
// 7. getActionLog mutation by caller
|
|
232
|
-
// ---------------------------------------------------------------------------
|
|
233
|
-
describe("getActionLog mutation safety", () => {
|
|
234
|
-
it("mutating returned array does not affect internal log", async () => {
|
|
235
|
-
const action = defineAction({ name: "log.mutate", run: async () => "ok" });
|
|
236
|
-
await action.dispatch("x");
|
|
237
|
-
const log1 = getActionLog();
|
|
238
|
-
expect(log1.length).toBeGreaterThan(0);
|
|
239
|
-
// Attempt to mutate the returned array
|
|
240
|
-
(log1 as unknown[]).length = 0;
|
|
241
|
-
// Internal log should be unaffected
|
|
242
|
-
const log2 = getActionLog();
|
|
243
|
-
expect(log2.length).toBeGreaterThan(0);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// ---------------------------------------------------------------------------
|
|
248
|
-
// 8. Registry listener throwing does not prevent other listeners
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
describe("registry listener throwing", () => {
|
|
251
|
-
it("throwing listener does not prevent subsequent listeners from firing", async () => {
|
|
252
|
-
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
253
|
-
const results: string[] = [];
|
|
254
|
-
subscribe(() => {
|
|
255
|
-
throw new Error("listener boom");
|
|
256
|
-
});
|
|
257
|
-
subscribe((inst) => {
|
|
258
|
-
results.push(inst.status);
|
|
259
|
-
});
|
|
260
|
-
const action = defineAction({ name: "listener.throw", run: async () => "ok" });
|
|
261
|
-
await action.dispatch("x");
|
|
262
|
-
expect(results).toContain("pending");
|
|
263
|
-
expect(results).toContain("success");
|
|
264
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
265
|
-
consoleSpy.mockRestore();
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
// 9. Scope serialization: action A dispatching B in same scope
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
describe("scope serialization", () => {
|
|
273
|
-
it("action A dispatching B in same scope — B runs after A completes (no deadlock if not awaited)", async () => {
|
|
274
|
-
let innerResult: string | null = null;
|
|
275
|
-
const actionB = defineAction({
|
|
276
|
-
name: "scope.serial.B",
|
|
277
|
-
scope: "shared-scope-serial",
|
|
278
|
-
run: async () => "B-done",
|
|
279
|
-
});
|
|
280
|
-
const actionA = defineAction({
|
|
281
|
-
name: "scope.serial.A",
|
|
282
|
-
scope: "shared-scope-serial",
|
|
283
|
-
run: async () => {
|
|
284
|
-
// Dispatch B from within A — B will queue behind A
|
|
285
|
-
// Do NOT await it here (that would deadlock by design)
|
|
286
|
-
const handleB = actionB.dispatch("b");
|
|
287
|
-
void handleB.then((r) => {
|
|
288
|
-
innerResult = r;
|
|
289
|
-
});
|
|
290
|
-
return "A-done";
|
|
291
|
-
},
|
|
292
|
-
});
|
|
293
|
-
const resultA = await actionA.dispatch("a");
|
|
294
|
-
expect(resultA).toBe("A-done");
|
|
295
|
-
// Give B time to run (it was queued behind A, runs after A's tail resolves)
|
|
296
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
297
|
-
expect(innerResult).toBe("B-done");
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
// 10. abort during prepareHeaders (signal already aborted when prepareHeaders runs)
|
|
303
|
-
// ---------------------------------------------------------------------------
|
|
304
|
-
describe("abort during prepareHeaders", () => {
|
|
305
|
-
it("abort signal checked after prepareHeaders — cancelled if aborted during prep", async () => {
|
|
306
|
-
let _prepDone = false;
|
|
307
|
-
let prepResolve!: () => void;
|
|
308
|
-
const prepPromise = new Promise<void>((r) => {
|
|
309
|
-
prepResolve = r;
|
|
310
|
-
});
|
|
311
|
-
configureApi({
|
|
312
|
-
prepareHeaders: async (headers) => {
|
|
313
|
-
await prepPromise;
|
|
314
|
-
_prepDone = true;
|
|
315
|
-
headers.set("Authorization", "Bearer token");
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
319
|
-
const action = apiAction<string>({
|
|
320
|
-
name: "abort.during.prep",
|
|
321
|
-
request: () => ({ method: "GET", path: "/x" }),
|
|
322
|
-
});
|
|
323
|
-
const handle = action.dispatch("x");
|
|
324
|
-
// Abort before prepareHeaders completes
|
|
325
|
-
handle.abort();
|
|
326
|
-
// Now let prepareHeaders finish
|
|
327
|
-
prepResolve();
|
|
328
|
-
const result = await handle;
|
|
329
|
-
expect(result).toBeNull();
|
|
330
|
-
const entry = recentLog().find((e) => e.name === "abort.during.prep");
|
|
331
|
-
expect(entry?.status).toBe("cancelled");
|
|
332
|
-
// fetch should still have been called because prepareHeaders completed
|
|
333
|
-
// and the signal check happens in run() not in executeRequest
|
|
334
|
-
// Actually: the signal is passed to fetch, so fetch may or may not have been called
|
|
335
|
-
// The key assertion is that the action is cancelled
|
|
336
|
-
});
|
|
337
|
-
});
|