@cplieger/actions 1.0.1 → 1.0.2

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.
@@ -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
- });