@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,271 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // Targeted tests for registry.ts: tombstone eviction, Set-based listener
3
- // iteration, pendingCount/recentLog correctness, _resetForTest.
4
- import { describe, it, expect, vi, beforeEach } from "vitest";
5
- import {
6
- record,
7
- subscribe,
8
- recentLog,
9
- pendingCount,
10
- isPending,
11
- _resetForTest,
12
- } from "./registry.js";
13
- import type { ActionInstance } from "./types.js";
14
-
15
- function makeInstance(overrides: Partial<ActionInstance> = {}): ActionInstance {
16
- return {
17
- id: `id-${Math.random().toString(36).slice(2)}`,
18
- name: "test.action",
19
- status: "pending",
20
- args: {},
21
- dispatchedAt: Date.now(),
22
- startedAt: Date.now(),
23
- ...overrides,
24
- };
25
- }
26
-
27
- beforeEach(() => {
28
- _resetForTest();
29
- });
30
-
31
- describe("tombstone eviction", () => {
32
- it("evicts oldest non-pending entry when log exceeds MAX_LOG_SIZE (200)", () => {
33
- for (let i = 0; i < 200; i++) {
34
- record(makeInstance({ id: `a-${i}`, status: "success" }));
35
- }
36
- expect(recentLog()).toHaveLength(200);
37
- record(makeInstance({ id: "overflow", status: "success" }));
38
- expect(recentLog()).toHaveLength(200);
39
- });
40
-
41
- it("preserves pending entries during soft eviction", () => {
42
- for (let i = 0; i < 200; i++) {
43
- record(makeInstance({ id: `p-${i}`, status: "pending" }));
44
- }
45
- record(makeInstance({ id: "extra", status: "pending" }));
46
- expect(recentLog()).toHaveLength(201);
47
- expect(pendingCount()).toBe(201);
48
- });
49
-
50
- it("hard cap (1000) force-evicts pending entries and decrements pendingCount", () => {
51
- for (let i = 0; i < 1000; i++) {
52
- record(makeInstance({ id: `h-${i}`, status: "pending" }));
53
- }
54
- expect(pendingCount()).toBe(1000);
55
- record(makeInstance({ id: "hard-overflow", status: "pending" }));
56
- expect(pendingCount()).toBe(1000);
57
- expect(recentLog()).toHaveLength(1000);
58
- });
59
-
60
- it("compaction splices leading nulls when head > 256", () => {
61
- for (let i = 0; i < 260; i++) {
62
- record(makeInstance({ id: `c-${i}`, status: "success" }));
63
- }
64
- for (let i = 0; i < 260; i++) {
65
- record(makeInstance({ id: `d-${i}`, status: "success" }));
66
- }
67
- const log = recentLog();
68
- expect(log.length).toBeLessThanOrEqual(200);
69
- for (const entry of log) {
70
- expect(entry).not.toBeNull();
71
- }
72
- });
73
- });
74
-
75
- describe("listener iteration", () => {
76
- it("listener can unsubscribe itself during notification", () => {
77
- const calls: string[] = [];
78
- const unsub = subscribe(() => {
79
- calls.push("self");
80
- unsub();
81
- });
82
- subscribe(() => calls.push("other"));
83
- record(makeInstance());
84
- expect(calls).toContain("self");
85
- expect(calls).toContain("other");
86
- calls.length = 0;
87
- record(makeInstance());
88
- expect(calls).toEqual(["other"]);
89
- });
90
-
91
- it("listener that unsubscribes a later listener prevents it from firing", () => {
92
- const calls: string[] = [];
93
- // eslint-disable-next-line prefer-const
94
- let unsubB: (() => void) | undefined;
95
- subscribe(() => {
96
- calls.push("A");
97
- unsubB?.();
98
- });
99
- unsubB = subscribe(() => {
100
- calls.push("B");
101
- });
102
- record(makeInstance());
103
- expect(calls).toEqual(["A"]);
104
- });
105
-
106
- it("listener added during iteration fires for the current event", () => {
107
- const calls: string[] = [];
108
- subscribe(() => {
109
- calls.push("first");
110
- subscribe(() => calls.push("dynamic"));
111
- });
112
- record(makeInstance());
113
- expect(calls).toContain("first");
114
- expect(calls).toContain("dynamic");
115
- });
116
-
117
- it("throwing listener does not prevent other listeners from firing", () => {
118
- const calls: string[] = [];
119
-
120
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
121
- subscribe(() => {
122
- throw new Error("boom");
123
- });
124
- subscribe(() => calls.push("survived"));
125
- record(makeInstance());
126
- expect(calls).toEqual(["survived"]);
127
- expect(consoleSpy).toHaveBeenCalledWith("[actions] registry listener threw", expect.any(Error));
128
- consoleSpy.mockRestore();
129
- });
130
- });
131
-
132
- describe("recentLog", () => {
133
- it("never returns null entries", () => {
134
- for (let i = 0; i < 250; i++) {
135
- record(makeInstance({ id: `r-${i}`, status: "success" }));
136
- }
137
- const log = recentLog();
138
- for (const entry of log) {
139
- expect(entry).not.toBeNull();
140
- expect(entry).not.toBeUndefined();
141
- }
142
- });
143
-
144
- it("returns entries in insertion order", () => {
145
- record(makeInstance({ id: "first", status: "success" }));
146
- record(makeInstance({ id: "second", status: "success" }));
147
- record(makeInstance({ id: "third", status: "success" }));
148
- const log = recentLog();
149
- expect(log.map((e) => e.id)).toEqual(["first", "second", "third"]);
150
- });
151
- });
152
-
153
- describe("_resetForTest", () => {
154
- it("clears all state including pendingCount and listeners", () => {
155
- const listener = vi.fn();
156
- subscribe(listener);
157
- record(makeInstance({ id: "pre", status: "pending" }));
158
- expect(pendingCount()).toBe(1);
159
- expect(listener).toHaveBeenCalledTimes(1);
160
- _resetForTest();
161
- listener.mockClear();
162
- expect(recentLog()).toHaveLength(0);
163
- expect(pendingCount()).toBe(0);
164
- expect(pendingCount(["test.action"])).toBe(0);
165
- record(makeInstance({ id: "post", status: "pending" }));
166
- expect(listener).not.toHaveBeenCalled();
167
- });
168
- });
169
-
170
- describe("pendingN tracking", () => {
171
- it("increments on new pending, decrements on transition to success", () => {
172
- record(makeInstance({ id: "t1", status: "pending" }));
173
- expect(pendingCount()).toBe(1);
174
- record(makeInstance({ id: "t1", status: "success", completedAt: Date.now() }));
175
- expect(pendingCount()).toBe(0);
176
- });
177
-
178
- it("handles pending -> error transition", () => {
179
- record(makeInstance({ id: "t2", status: "pending" }));
180
- expect(pendingCount()).toBe(1);
181
- record(makeInstance({ id: "t2", status: "error", error: { message: "fail" } }));
182
- expect(pendingCount()).toBe(0);
183
- });
184
-
185
- it("handles pending -> cancelled transition", () => {
186
- record(makeInstance({ id: "t3", status: "pending" }));
187
- record(makeInstance({ id: "t3", status: "cancelled" }));
188
- expect(pendingCount()).toBe(0);
189
- });
190
-
191
- it("does not double-decrement on repeated terminal transitions", () => {
192
- record(makeInstance({ id: "t4", status: "pending" }));
193
- record(makeInstance({ id: "t4", status: "success" }));
194
- record(makeInstance({ id: "t4", status: "success" }));
195
- expect(pendingCount()).toBe(0);
196
- });
197
-
198
- it("handles non-pending to pending transition (unusual but valid)", () => {
199
- record(makeInstance({ id: "t5", status: "success" }));
200
- expect(pendingCount()).toBe(0);
201
- record(makeInstance({ id: "t5", status: "pending" }));
202
- expect(pendingCount()).toBe(1);
203
- });
204
- });
205
-
206
- describe("pendingByName index", () => {
207
- it("isPending returns true for pending action, false after completion", () => {
208
- record(makeInstance({ id: "p1", name: "chat.send", status: "pending" }));
209
- expect(isPending("chat.send")).toBe(true);
210
- record(makeInstance({ id: "p1", name: "chat.send", status: "success" }));
211
- expect(isPending("chat.send")).toBe(false);
212
- });
213
-
214
- it("isPending returns false for unknown action name", () => {
215
- expect(isPending("nonexistent")).toBe(false);
216
- });
217
-
218
- it("tracks multiple pending instances of the same name", () => {
219
- record(makeInstance({ id: "a1", name: "file.upload", status: "pending" }));
220
- record(makeInstance({ id: "a2", name: "file.upload", status: "pending" }));
221
- expect(isPending("file.upload")).toBe(true);
222
- expect(pendingCount(["file.upload"])).toBe(2);
223
- record(makeInstance({ id: "a1", name: "file.upload", status: "success" }));
224
- expect(isPending("file.upload")).toBe(true);
225
- expect(pendingCount(["file.upload"])).toBe(1);
226
- record(
227
- makeInstance({ id: "a2", name: "file.upload", status: "error", error: { message: "fail" } }),
228
- );
229
- expect(isPending("file.upload")).toBe(false);
230
- expect(pendingCount(["file.upload"])).toBe(0);
231
- });
232
-
233
- it("retry (terminal→pending) re-adds to pendingByName", () => {
234
- record(makeInstance({ id: "r1", name: "git.push", status: "pending" }));
235
- record(
236
- makeInstance({ id: "r1", name: "git.push", status: "error", error: { message: "timeout" } }),
237
- );
238
- expect(isPending("git.push")).toBe(false);
239
- record(makeInstance({ id: "r1", name: "git.push", status: "pending" }));
240
- expect(isPending("git.push")).toBe(true);
241
- expect(pendingCount(["git.push"])).toBe(1);
242
- });
243
-
244
- it("pending→pending re-record does not duplicate in index", () => {
245
- record(makeInstance({ id: "d1", name: "chat.send", status: "pending" }));
246
- record(makeInstance({ id: "d1", name: "chat.send", status: "pending" }));
247
- expect(pendingCount(["chat.send"])).toBe(1);
248
- expect(pendingCount()).toBe(1);
249
- });
250
-
251
- it("hard-cap eviction removes from pendingByName", () => {
252
- for (let i = 0; i < 1000; i++) {
253
- record(makeInstance({ id: `hc-${i}`, name: "bulk.op", status: "pending" }));
254
- }
255
- expect(isPending("bulk.op")).toBe(true);
256
- record(makeInstance({ id: "hc-overflow", name: "bulk.op", status: "pending" }));
257
- expect(pendingCount()).toBe(1000);
258
- const pending = recentLog().filter((i) => i.status === "pending" && i.name === "bulk.op");
259
- expect(pending.find((e) => e.id === "hc-0")).toBeUndefined();
260
- });
261
-
262
- it("_resetForTest clears pendingByName", () => {
263
- record(makeInstance({ id: "z1", name: "action.a", status: "pending" }));
264
- record(makeInstance({ id: "z2", name: "action.b", status: "pending" }));
265
- expect(isPending("action.a")).toBe(true);
266
- expect(isPending("action.b")).toBe(true);
267
- _resetForTest();
268
- expect(isPending("action.a")).toBe(false);
269
- expect(isPending("action.b")).toBe(false);
270
- });
271
- });
@@ -1,155 +0,0 @@
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 } 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
- afterEach(() => {
21
- if ("onLine" in navigator) {
22
- Object.defineProperty(navigator, "onLine", { value: true, configurable: true });
23
- }
24
- });
25
-
26
- describe("networkMode: 'online' (default)", () => {
27
- it("waits for online event before retrying when navigator.onLine is false", async () => {
28
- let attempts = 0;
29
- Object.defineProperty(navigator, "onLine", { value: false, configurable: true });
30
- const action = defineAction<void, string>({
31
- name: "test.online.pause",
32
- retryable: retryNetwork,
33
- retry: { count: 1, delay: 5 },
34
- error: false,
35
- run: () => {
36
- attempts++;
37
- throw new ActionError("offline", { code: "network" });
38
- },
39
- });
40
- const p = action.dispatch();
41
- await new Promise((r) => setTimeout(r, 20));
42
- expect(attempts).toBe(1);
43
- Object.defineProperty(navigator, "onLine", { value: true, configurable: true });
44
- window.dispatchEvent(new Event("online"));
45
- await p;
46
- expect(attempts).toBe(2);
47
- });
48
-
49
- it("doesn't pause when online to begin with", async () => {
50
- let attempts = 0;
51
- const action = defineAction<void, string>({
52
- name: "test.online.normal",
53
- retryable: retryNetwork,
54
- retry: { count: 2, delay: 1 },
55
- error: false,
56
- run: () => {
57
- attempts++;
58
- throw new ActionError("blip", { code: "network" });
59
- },
60
- });
61
- await action.dispatch();
62
- expect(attempts).toBe(3);
63
- });
64
-
65
- it("cancel during offline pause unwinds cleanly without retry", async () => {
66
- let attempts = 0;
67
- Object.defineProperty(navigator, "onLine", { value: false, configurable: true });
68
- const action = defineAction<void, string>({
69
- name: "test.online.cancel",
70
- retryable: retryNetwork,
71
- retry: { count: 5, delay: 1 },
72
- error: false,
73
- run: () => {
74
- attempts++;
75
- throw new ActionError("offline", { code: "network" });
76
- },
77
- });
78
- const p = action.dispatch();
79
- await new Promise((r) => setTimeout(r, 20));
80
- expect(attempts).toBe(1);
81
- action.cancel();
82
- await p;
83
- expect(attempts).toBe(1);
84
- });
85
- });
86
-
87
- describe("networkMode: 'always'", () => {
88
- it("retries even when navigator.onLine is false", async () => {
89
- let attempts = 0;
90
- Object.defineProperty(navigator, "onLine", { value: false, configurable: true });
91
- const action = defineAction<void, string>({
92
- name: "test.always.retry",
93
- retryable: retryNetwork,
94
- retry: { count: 2, delay: 1 },
95
- networkMode: "always",
96
- error: false,
97
- run: () => {
98
- attempts++;
99
- throw new ActionError("ignored", { code: "network" });
100
- },
101
- });
102
- await action.dispatch();
103
- expect(attempts).toBe(3);
104
- });
105
- });
106
-
107
- describe("retry.delay as a function", () => {
108
- it("invokes delay function with attempt number and error", async () => {
109
- const seen: { attempt: number; code: string | undefined }[] = [];
110
- let runs = 0;
111
- const action = defineAction<void, string>({
112
- name: "test.delay.fn",
113
- retryable: retryNetwork,
114
- retry: {
115
- count: 2,
116
- delay: (attempt, err) => {
117
- seen.push({ attempt, code: err.code });
118
- return 0;
119
- },
120
- },
121
- error: false,
122
- run: () => {
123
- runs++;
124
- throw new ActionError("blip", { code: "network" });
125
- },
126
- });
127
- await action.dispatch();
128
- expect(runs).toBe(3);
129
- expect(seen).toHaveLength(2);
130
- expect(seen[0]?.attempt).toBe(1);
131
- expect(seen[1]?.attempt).toBe(2);
132
- expect(seen[0]?.code).toBe("network");
133
- });
134
-
135
- it("falls back to 0ms if delay function throws", async () => {
136
- let attempts = 0;
137
- const action = defineAction<void, string>({
138
- name: "test.delay.fn_throws",
139
- retryable: retryNetwork,
140
- retry: {
141
- count: 1,
142
- delay: () => {
143
- throw new Error("bad delay fn");
144
- },
145
- },
146
- error: false,
147
- run: () => {
148
- attempts++;
149
- throw new ActionError("blip", { code: "network" });
150
- },
151
- });
152
- await action.dispatch();
153
- expect(attempts).toBe(2);
154
- });
155
- });
@@ -1,139 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // Tests for transportAction error classification and the configureTransport seam.
3
- import { describe, it, expect, vi, beforeEach } from "vitest";
4
- import { resetActionFramework } from "./__test-helpers__/action-test-setup.js";
5
- import { configureTransport, transportAction, _resetTransportForTest } from "./transport.js";
6
- import { recentLog } from "./registry.js";
7
-
8
- vi.mock("./notifier.js", () => ({
9
- configure: vi.fn(),
10
- notifySuccess: vi.fn(),
11
- notifyError: vi.fn(),
12
- _resetNotifierForTest: vi.fn(),
13
- }));
14
-
15
- const mockSend = vi.fn();
16
-
17
- beforeEach(() => {
18
- resetActionFramework();
19
- vi.clearAllMocks();
20
- mockSend.mockReset();
21
- configureTransport(mockSend);
22
- });
23
-
24
- const testAction = () =>
25
- transportAction<{ chatID: string }>({
26
- name: "test.transport",
27
- command: ({ chatID }) => ({ type: "cancel", chat_id: chatID }),
28
- error: "Test failed",
29
- });
30
-
31
- describe("transportAction error classification", () => {
32
- it("classifies timeout via r.code", async () => {
33
- mockSend.mockResolvedValue({
34
- ok: false,
35
- status: 0,
36
- error: "Request timed out",
37
- code: "timeout",
38
- });
39
- const action = testAction();
40
- await action.dispatch({ chatID: "c1" });
41
- const log = recentLog();
42
- expect(log[0]?.status).toBe("error");
43
- expect(log[0]?.error?.code).toBe("timeout");
44
- });
45
-
46
- it("classifies cancelled via r.code", async () => {
47
- mockSend.mockResolvedValue({
48
- ok: false,
49
- status: 0,
50
- error: "Request cancelled",
51
- code: "cancelled",
52
- });
53
- const action = testAction();
54
- await action.dispatch({ chatID: "c1" });
55
- const log = recentLog();
56
- expect(log[0]?.status).toBe("error");
57
- expect(log[0]?.error?.code).toBe("cancelled");
58
- });
59
-
60
- it("classifies network error via r.code", async () => {
61
- mockSend.mockResolvedValue({ ok: false, status: 0, error: "Failed to fetch", code: "network" });
62
- const action = testAction();
63
- await action.dispatch({ chatID: "c1" });
64
- const log = recentLog();
65
- expect(log[0]?.status).toBe("error");
66
- expect(log[0]?.error?.code).toBe("network");
67
- });
68
-
69
- it("HTTP errors without code throw with status only", async () => {
70
- mockSend.mockResolvedValue({ ok: false, status: 500, error: "Internal Server Error" });
71
- const action = testAction();
72
- await action.dispatch({ chatID: "c1" });
73
- const log = recentLog();
74
- expect(log[0]?.status).toBe("error");
75
- expect(log[0]?.error?.status).toBe(500);
76
- expect(log[0]?.error?.code).toBeUndefined();
77
- });
78
-
79
- it("signal.aborted takes precedence", async () => {
80
- mockSend.mockImplementation(async (_cmd, { signal }) => {
81
- // Simulate delay so cancel can fire
82
- await new Promise((r) => setTimeout(r, 10));
83
- if (signal.aborted) return { ok: false, status: 0, error: "cancelled", code: "network" };
84
- return { ok: true, status: 200 };
85
- });
86
- const action = testAction();
87
- const promise = action.dispatch({ chatID: "c1" });
88
- action.cancel();
89
- await promise;
90
- const log = recentLog();
91
- expect(log[0]?.status).toBe("cancelled");
92
- });
93
- });
94
-
95
- describe("transportAction — unconfigured transport", () => {
96
- it("throws ActionError with code transport_not_configured when transport is not set", async () => {
97
- _resetTransportForTest();
98
- const action = transportAction<{ id: string }>({
99
- name: "test.no_transport",
100
- command: ({ id }) => ({ type: "test", id }),
101
- error: false,
102
- });
103
- const result = await action.dispatch({ id: "x" });
104
- expect(result).toBeNull();
105
- const log = recentLog();
106
- expect(log[0]?.error?.code).toBe("transport_not_configured");
107
- });
108
- });
109
-
110
- describe("transportAction — idempotency key", () => {
111
- it("adds idempotency_key to command when configured", async () => {
112
- mockSend.mockResolvedValue({ ok: true, status: 200 });
113
- const action = transportAction<{ chatID: string }>({
114
- name: "test.idem_transport",
115
- idempotencyKey: true,
116
- command: ({ chatID }) => ({ type: "cancel", chat_id: chatID }),
117
- error: false,
118
- });
119
- await action.dispatch({ chatID: "c1" });
120
- expect(mockSend).toHaveBeenCalledTimes(1);
121
- const sentCmd = mockSend.mock.calls[0]![0] as { idempotency_key?: string };
122
- expect(sentCmd.idempotency_key).toEqual(expect.any(String));
123
- });
124
-
125
- it("dispatching with a frozen command object does NOT throw", async () => {
126
- mockSend.mockResolvedValue({ ok: true, status: 200 });
127
- const frozenCmd = Object.freeze({ type: "cancel" as const, chat_id: "c1" });
128
- const action = transportAction<void>({
129
- name: "test.frozen_cmd",
130
- idempotencyKey: true,
131
- command: () => frozenCmd,
132
- error: "Frozen failed",
133
- });
134
- await action.dispatch(undefined);
135
- expect(mockSend).toHaveBeenCalledTimes(1);
136
- const sent = mockSend.mock.calls[0]![0] as { idempotency_key?: string };
137
- expect(sent.idempotency_key).toEqual(expect.any(String));
138
- });
139
- });