1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3import { describe, it, vi } from "vitest";
4import {
5 DEFAULT_RETRY_DELAY_MS,
6 invokeDelayFunction,
7 raceAgainstAbort,
8} from "../../lib/delay.ts";
9import { DelayFunctionError } from "../../lib/retries.ts";
10import type { WorkflowDynamicDelayContext } from "cloudflare:workers";
11
12const input: WorkflowDynamicDelayContext = {
13 ctx: {
14 step: { name: "step", count: 1 },
15 attempt: 1,
16 config: { retries: { limit: 5, backoff: "constant" }, timeout: 0 },
17 },
18 error: new Error("boom"),
19};
20
21const makeLogger = () => ({ warn: vi.fn(), debug: vi.fn() });
22
23const noTimeout =
24 (): ((ms: number, opts?: { signal?: AbortSignal }) => Promise<void>) => () =>
25 new Promise<void>(() => {});
26
27describe("raceAgainstAbort", () => {
28 it("removes the abort listener when the raced promise resolves", async ({
29 expect,
30 }) => {
31 const controller = new AbortController();
32 const addEventListener = vi.spyOn(controller.signal, "addEventListener");
33 const removeEventListener = vi.spyOn(
34 controller.signal,
35 "removeEventListener"
36 );
37
38 await expect(
39 raceAgainstAbort(Promise.resolve("done"), controller.signal)
40 ).resolves.toEqual({ aborted: false, value: "done" });
41
42 expect(addEventListener).toHaveBeenCalledOnce();
43 expect(removeEventListener).toHaveBeenCalledWith(
44 "abort",
45 addEventListener.mock.calls[0]?.[1]
46 );
47 });
48
49 it("removes the abort listener when the signal wins", async ({ expect }) => {
50 const controller = new AbortController();
51 const addEventListener = vi.spyOn(controller.signal, "addEventListener");
52 const removeEventListener = vi.spyOn(
53 controller.signal,
54 "removeEventListener"
55 );
56 const result = raceAgainstAbort(
57 new Promise<void>(() => {}),
58 controller.signal
59 );
60
61 controller.abort();
62
63 await expect(result).resolves.toEqual({ aborted: true });
64 expect(removeEventListener).toHaveBeenCalledWith(
65 "abort",
66 addEventListener.mock.calls[0]?.[1]
67 );
68 });
69
70 it("removes the abort listener when the raced promise rejects", async ({
71 expect,
72 }) => {
73 const controller = new AbortController();
74 const addEventListener = vi.spyOn(controller.signal, "addEventListener");
75 const removeEventListener = vi.spyOn(
76 controller.signal,
77 "removeEventListener"
78 );
79 const error = new Error("boom");
80
81 await expect(
82 raceAgainstAbort(Promise.reject(error), controller.signal)
83 ).rejects.toBe(error);
84 expect(removeEventListener).toHaveBeenCalledWith(
85 "abort",
86 addEventListener.mock.calls[0]?.[1]
87 );
88 });
89
90 it("does not register a listener for an already-aborted signal", async ({
91 expect,
92 }) => {
93 const controller = new AbortController();
94 controller.abort();
95 const addEventListener = vi.spyOn(controller.signal, "addEventListener");
96 const removeEventListener = vi.spyOn(
97 controller.signal,
98 "removeEventListener"
99 );
100
101 await expect(
102 raceAgainstAbort(
103 Promise.reject(new Error("late rejection")),
104 controller.signal
105 )
106 ).resolves.toEqual({ aborted: true });
107 expect(addEventListener).not.toHaveBeenCalled();
108 expect(removeEventListener).not.toHaveBeenCalled();
109 });
110});
111
112describe("invokeDelayFunction", () => {
113 it("returns the value a synchronous delay function produces", async ({
114 expect,
115 }) => {
116 const logger = makeLogger();
117 const result = await invokeDelayFunction(() => 1234, input, {
118 timeoutMs: 5000,
119 wait: noTimeout(),
120 logger,
121 });
122 expect(result).toBe(1234);
123 expect(logger.warn).not.toHaveBeenCalled();
124 expect(logger.debug).not.toHaveBeenCalled();
125 });
126
127 it("awaits an asynchronous delay function", async ({ expect }) => {
128 const logger = makeLogger();
129 const result = await invokeDelayFunction(async () => 1234, input, {
130 timeoutMs: 5000,
131 wait: noTimeout(),
132 logger,
133 });
134 expect(result).toBe(1234);
135 expect(logger.warn).not.toHaveBeenCalled();
136 expect(logger.debug).not.toHaveBeenCalled();
137 });
138
139 it("throws when the delay function never resolves (timeout)", async ({
140 expect,
141 }) => {
142 const logger = makeLogger();
143 const promise = invokeDelayFunction(
144 () => new Promise<number>(() => {}),
145 input,
146 {
147 timeoutMs: 5000,
148 wait: () => Promise.resolve(),
149 logger,
150 }
151 );
152 await expect(promise).rejects.toThrow(
153 new DelayFunctionError("did not return within 5 seconds")
154 );
155 expect(logger.warn).toHaveBeenCalledTimes(1);
156 expect(logger.warn).toHaveBeenCalledWith(
157 expect.stringContaining("did not resolve"),
158 { step: "step", attempt: 1 }
159 );
160 expect(logger.debug).not.toHaveBeenCalled();
161 });
162
163 it("throws when the delay function throws synchronously", async ({
164 expect,
165 }) => {
166 const logger = makeLogger();
167 const promise = invokeDelayFunction(
168 () => {
169 throw new Error("sync boom");
170 },
171 input,
172 {
173 timeoutMs: 5000,
174 wait: noTimeout(),
175 logger,
176 }
177 );
178 await expect(promise).rejects.toThrow(
179 new DelayFunctionError("threw an error: sync boom")
180 );
181 expect(logger.warn).toHaveBeenCalledWith(
182 expect.stringContaining("threw"),
183 expect.objectContaining({ error: "sync boom" })
184 );
185 });
186
187 it("throws when the delay function rejects", async ({ expect }) => {
188 const logger = makeLogger();
189 const promise = invokeDelayFunction(
190 async () => {
191 throw new Error("async boom");
192 },
193 input,
194 {
195 timeoutMs: 5000,
196 wait: noTimeout(),
197 logger,
198 }
199 );
200 await expect(promise).rejects.toThrow(
201 new DelayFunctionError("threw an error: async boom")
202 );
203 expect(logger.warn).toHaveBeenCalledWith(
204 expect.stringContaining("threw"),
205 expect.objectContaining({ error: "async boom" })
206 );
207 });
208
209 it("logs a debug (not a warn) when the provided signal aborts before the function resolves", async ({
210 expect,
211 }) => {
212 const logger = makeLogger();
213 const abort = new AbortController();
214 const promise = invokeDelayFunction(
215 () => new Promise<number>(() => {}),
216 input,
217 {
218 timeoutMs: 5000,
219 wait: (_ms, opts) =>
220 new Promise<void>((resolve) => {
221 opts?.signal?.addEventListener("abort", () => resolve(), {
222 once: true,
223 });
224 }),
225 signal: abort.signal,
226 logger,
227 }
228 );
229 abort.abort();
230 await expect(promise).resolves.toBe(DEFAULT_RETRY_DELAY_MS);
231 expect(logger.debug).toHaveBeenCalledTimes(1);
232 expect(logger.debug).toHaveBeenCalledWith(
233 expect.stringContaining("engine shutdown"),
234 { step: "step", attempt: 1 }
235 );
236 expect(logger.warn).not.toHaveBeenCalled();
237 });
238
239 it("throws without a logger", async ({ expect }) => {
240 const promise = invokeDelayFunction(
241 () => {
242 throw new Error("sync boom");
243 },
244 input,
245 {
246 timeoutMs: 5000,
247 wait: noTimeout(),
248 }
249 );
250 await expect(promise).rejects.toThrow(DelayFunctionError);
251 });
252});