@studio-foundation/ralph 0.3.0-beta.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.
@@ -0,0 +1,355 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ralph, type ExecutionContext } from '../src/loop.js';
3
+ import { noDelay } from '../src/retry-strategy.js';
4
+
5
+ describe('ralph loop', () => {
6
+ it('returns success on first attempt when valid', async () => {
7
+ const executor = vi.fn().mockResolvedValue('result');
8
+ const validator = vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [] });
9
+
10
+ const result = await ralph({
11
+ executor,
12
+ validator,
13
+ maxAttempts: 3,
14
+ retryStrategy: noDelay()
15
+ });
16
+
17
+ expect(result.status).toBe('success');
18
+ expect(result.attempts).toBe(1);
19
+ expect(executor).toHaveBeenCalledTimes(1);
20
+ if (result.status === 'success') {
21
+ expect(result.result).toBe('result');
22
+ }
23
+ });
24
+
25
+ it('retries and succeeds on second attempt', async () => {
26
+ const validator = vi.fn()
27
+ .mockReturnValueOnce({ valid: false, errors: ['fail 1'], warnings: [] })
28
+ .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
29
+
30
+ const result = await ralph({
31
+ executor: async () => 'result',
32
+ validator,
33
+ maxAttempts: 3,
34
+ retryStrategy: noDelay()
35
+ });
36
+
37
+ expect(result.status).toBe('success');
38
+ expect(result.attempts).toBe(2);
39
+ expect(validator).toHaveBeenCalledTimes(2);
40
+ });
41
+
42
+ it('returns exhausted after max attempts', async () => {
43
+ const validator = vi.fn().mockReturnValue({
44
+ valid: false,
45
+ errors: ['always fails'],
46
+ warnings: []
47
+ });
48
+
49
+ const result = await ralph({
50
+ executor: async () => 'result',
51
+ validator,
52
+ maxAttempts: 3,
53
+ retryStrategy: noDelay()
54
+ });
55
+
56
+ expect(result.status).toBe('exhausted');
57
+ expect(result.attempts).toBe(3);
58
+ if (result.status === 'exhausted') {
59
+ expect(result.failures).toHaveLength(3);
60
+ expect(result.failures).toEqual(['always fails', 'always fails', 'always fails']);
61
+ }
62
+ });
63
+
64
+ it('passes execution context with previousFailures', async () => {
65
+ const executorCalls: ExecutionContext[] = [];
66
+
67
+ const executor = vi.fn(async (ctx: ExecutionContext) => {
68
+ executorCalls.push({ ...ctx });
69
+ return 'result';
70
+ });
71
+
72
+ const validator = vi.fn()
73
+ .mockReturnValueOnce({ valid: false, errors: ['error 1'], warnings: [] })
74
+ .mockReturnValueOnce({ valid: false, errors: ['error 2'], warnings: [] })
75
+ .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
76
+
77
+ await ralph({
78
+ executor,
79
+ validator,
80
+ maxAttempts: 5,
81
+ retryStrategy: noDelay()
82
+ });
83
+
84
+ // First call: no previous failures
85
+ expect(executorCalls[0]).toEqual({ attempt: 1, previousFailures: [] });
86
+
87
+ // Second call: has first error
88
+ expect(executorCalls[1]).toEqual({ attempt: 2, previousFailures: ['error 1'] });
89
+
90
+ // Third call: has both errors
91
+ expect(executorCalls[2]).toEqual({ attempt: 3, previousFailures: ['error 1', 'error 2'] });
92
+ });
93
+
94
+ it('calls onRetry callback on each retry', async () => {
95
+ const onRetry = vi.fn();
96
+ const validator = vi.fn()
97
+ .mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] })
98
+ .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
99
+
100
+ await ralph({
101
+ executor: async () => 'result',
102
+ validator,
103
+ maxAttempts: 3,
104
+ retryStrategy: noDelay(),
105
+ onRetry
106
+ });
107
+
108
+ expect(onRetry).toHaveBeenCalledTimes(1);
109
+ expect(onRetry.mock.calls[0][0]).toMatchObject({
110
+ attempt: 1,
111
+ result: 'result',
112
+ validation: { valid: false, errors: ['fail'], warnings: [] },
113
+ allFailures: ['fail']
114
+ });
115
+ });
116
+
117
+ it('calls onSuccess on successful completion', async () => {
118
+ const onSuccess = vi.fn();
119
+
120
+ await ralph({
121
+ executor: async () => 'result',
122
+ validator: () => ({ valid: true, errors: [], warnings: [] }),
123
+ maxAttempts: 3,
124
+ retryStrategy: noDelay(),
125
+ onSuccess
126
+ });
127
+
128
+ expect(onSuccess).toHaveBeenCalledWith('result', 1);
129
+ });
130
+
131
+ it('calls onExhausted when max attempts reached', async () => {
132
+ const onExhausted = vi.fn();
133
+
134
+ await ralph({
135
+ executor: async () => 'result',
136
+ validator: () => ({ valid: false, errors: ['fail'], warnings: [] }),
137
+ maxAttempts: 2,
138
+ retryStrategy: noDelay(),
139
+ onExhausted
140
+ });
141
+
142
+ expect(onExhausted).toHaveBeenCalledWith('result', ['fail', 'fail']);
143
+ });
144
+
145
+ it('does not call onRetry on last failed attempt', async () => {
146
+ const onRetry = vi.fn();
147
+
148
+ await ralph({
149
+ executor: async () => 'result',
150
+ validator: () => ({ valid: false, errors: ['fail'], warnings: [] }),
151
+ maxAttempts: 2,
152
+ retryStrategy: noDelay(),
153
+ onRetry
154
+ });
155
+
156
+ // Should only retry once (between attempt 1 and 2)
157
+ expect(onRetry).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it('accumulates errors from multiple validation failures', async () => {
161
+ const validator = vi.fn()
162
+ .mockReturnValueOnce({ valid: false, errors: ['error A', 'error B'], warnings: [] })
163
+ .mockReturnValueOnce({ valid: false, errors: ['error C'], warnings: [] });
164
+
165
+ const result = await ralph({
166
+ executor: async () => 'result',
167
+ validator,
168
+ maxAttempts: 2,
169
+ retryStrategy: noDelay()
170
+ });
171
+
172
+ if (result.status === 'exhausted') {
173
+ expect(result.failures).toEqual(['error A', 'error B', 'error C']);
174
+ }
175
+ });
176
+
177
+ it('supports async validators', async () => {
178
+ const validator = vi.fn(async () => {
179
+ await new Promise(resolve => setTimeout(resolve, 1));
180
+ return { valid: true, errors: [], warnings: [] };
181
+ });
182
+
183
+ const result = await ralph({
184
+ executor: async () => 'result',
185
+ validator,
186
+ maxAttempts: 3,
187
+ retryStrategy: noDelay()
188
+ });
189
+
190
+ expect(result.status).toBe('success');
191
+ });
192
+
193
+ it('supports async callbacks', async () => {
194
+ const onRetry = vi.fn(async () => {
195
+ await new Promise(resolve => setTimeout(resolve, 1));
196
+ });
197
+
198
+ const validator = vi.fn()
199
+ .mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] })
200
+ .mockReturnValueOnce({ valid: true, errors: [], warnings: [] });
201
+
202
+ await ralph({
203
+ executor: async () => 'result',
204
+ validator,
205
+ maxAttempts: 3,
206
+ retryStrategy: noDelay(),
207
+ onRetry
208
+ });
209
+
210
+ expect(onRetry).toHaveBeenCalled();
211
+ });
212
+
213
+ it('returns cancelled immediately when signal is already aborted', async () => {
214
+ const controller = new AbortController();
215
+ controller.abort();
216
+
217
+ const executor = vi.fn().mockResolvedValue('result');
218
+ const validator = vi.fn().mockReturnValue({ valid: true, errors: [], warnings: [] });
219
+
220
+ const result = await ralph({
221
+ executor,
222
+ validator,
223
+ maxAttempts: 3,
224
+ retryStrategy: noDelay(),
225
+ signal: controller.signal,
226
+ });
227
+
228
+ expect(result.status).toBe('cancelled');
229
+ expect(executor).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it('returns cancelled when signal aborts between attempts', async () => {
233
+ const controller = new AbortController();
234
+
235
+ const executor = vi.fn().mockResolvedValue('result');
236
+ const validator = vi.fn().mockReturnValueOnce({ valid: false, errors: ['fail'], warnings: [] });
237
+
238
+ // Abort after first validation
239
+ validator.mockImplementationOnce(() => {
240
+ controller.abort();
241
+ return { valid: false, errors: ['fail 2'], warnings: [] };
242
+ });
243
+
244
+ const result = await ralph({
245
+ executor,
246
+ validator,
247
+ maxAttempts: 5,
248
+ retryStrategy: noDelay(),
249
+ signal: controller.signal,
250
+ });
251
+
252
+ expect(result.status).toBe('cancelled');
253
+ expect(executor).toHaveBeenCalledTimes(2);
254
+ });
255
+
256
+ it('returns cancelled when executor throws AbortError', async () => {
257
+ const controller = new AbortController();
258
+
259
+ const executor = vi.fn().mockImplementation(async () => {
260
+ controller.abort();
261
+ const err = new DOMException('The operation was aborted', 'AbortError');
262
+ throw err;
263
+ });
264
+ const validator = vi.fn();
265
+
266
+ const result = await ralph({
267
+ executor,
268
+ validator,
269
+ maxAttempts: 3,
270
+ retryStrategy: noDelay(),
271
+ signal: controller.signal,
272
+ });
273
+
274
+ expect(result.status).toBe('cancelled');
275
+ expect(validator).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it('cancellation resolves pending retry delay immediately', async () => {
279
+ const controller = new AbortController();
280
+
281
+ let attempt = 0;
282
+ const executor = vi.fn().mockImplementation(async () => {
283
+ attempt++;
284
+ if (attempt === 1) {
285
+ // After first attempt, schedule abort in 5ms (well before any real delay)
286
+ setTimeout(() => controller.abort(), 5);
287
+ }
288
+ return 'result';
289
+ });
290
+ const validator = vi.fn().mockReturnValue({ valid: false, errors: ['fail'], warnings: [] });
291
+
292
+ const start = Date.now();
293
+ const result = await ralph({
294
+ executor,
295
+ validator,
296
+ maxAttempts: 5,
297
+ retryStrategy: { getDelay: () => 60_000 }, // 60 second delay — should NOT wait
298
+ signal: controller.signal,
299
+ });
300
+
301
+ const elapsed = Date.now() - start;
302
+ expect(result.status).toBe('cancelled');
303
+ expect(elapsed).toBeLessThan(5000); // Way less than 60s
304
+ });
305
+
306
+ it('works with object result type (generic T)', async () => {
307
+ type Output = { value: number; label: string };
308
+
309
+ const result = await ralph<Output>({
310
+ executor: async () => ({ value: 42, label: 'ok' }),
311
+ validator: () => ({ valid: true, errors: [], warnings: [] }),
312
+ maxAttempts: 3,
313
+ retryStrategy: noDelay()
314
+ });
315
+
316
+ expect(result.status).toBe('success');
317
+ if (result.status === 'success') {
318
+ expect(result.result.value).toBe(42);
319
+ expect(result.result.label).toBe('ok');
320
+ }
321
+ });
322
+
323
+ it('exhausts immediately with maxAttempts=1 on first failure', async () => {
324
+ const validator = vi.fn().mockReturnValue({
325
+ valid: false,
326
+ errors: ['always fails'],
327
+ warnings: []
328
+ });
329
+
330
+ const result = await ralph({
331
+ executor: async () => 'result',
332
+ validator,
333
+ maxAttempts: 1,
334
+ retryStrategy: noDelay()
335
+ });
336
+
337
+ expect(result.status).toBe('exhausted');
338
+ expect(result.attempts).toBe(1);
339
+ if (result.status === 'exhausted') {
340
+ expect(result.failures).toEqual(['always fails']);
341
+ }
342
+ expect(validator).toHaveBeenCalledTimes(1);
343
+ });
344
+
345
+ it('propagates executor exceptions that are not abort errors', async () => {
346
+ const error = new Error('unexpected failure');
347
+
348
+ await expect(ralph({
349
+ executor: async () => { throw error; },
350
+ validator: () => ({ valid: true, errors: [], warnings: [] }),
351
+ maxAttempts: 3,
352
+ retryStrategy: noDelay()
353
+ })).rejects.toThrow('unexpected failure');
354
+ });
355
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { noDelay, fixedDelay, exponentialBackoff } from '../src/retry-strategy.js';
3
+
4
+ describe('noDelay', () => {
5
+ it('always returns 0', () => {
6
+ const strategy = noDelay();
7
+ expect(strategy.getDelay(1)).toBe(0);
8
+ expect(strategy.getDelay(2)).toBe(0);
9
+ expect(strategy.getDelay(100)).toBe(0);
10
+ });
11
+
12
+ it('returns 0 for all attempts', () => {
13
+ const strategy = noDelay();
14
+ for (let i = 1; i <= 10; i++) {
15
+ expect(strategy.getDelay(i)).toBe(0);
16
+ }
17
+ });
18
+ });
19
+
20
+ describe('fixedDelay', () => {
21
+ it('always returns same delay', () => {
22
+ const strategy = fixedDelay(1000);
23
+ expect(strategy.getDelay(1)).toBe(1000);
24
+ expect(strategy.getDelay(2)).toBe(1000);
25
+ expect(strategy.getDelay(5)).toBe(1000);
26
+ expect(strategy.getDelay(100)).toBe(1000);
27
+ });
28
+
29
+ it('works with different delay values', () => {
30
+ expect(fixedDelay(500).getDelay(1)).toBe(500);
31
+ expect(fixedDelay(2000).getDelay(1)).toBe(2000);
32
+ expect(fixedDelay(100).getDelay(1)).toBe(100);
33
+ });
34
+
35
+ it('works with zero delay', () => {
36
+ const strategy = fixedDelay(0);
37
+ expect(strategy.getDelay(1)).toBe(0);
38
+ });
39
+ });
40
+
41
+ describe('exponentialBackoff', () => {
42
+ it('doubles each attempt', () => {
43
+ const strategy = exponentialBackoff(1000, 10000);
44
+ expect(strategy.getDelay(1)).toBe(1000); // 1000 * 2^0
45
+ expect(strategy.getDelay(2)).toBe(2000); // 1000 * 2^1
46
+ expect(strategy.getDelay(3)).toBe(4000); // 1000 * 2^2
47
+ expect(strategy.getDelay(4)).toBe(8000); // 1000 * 2^3
48
+ });
49
+
50
+ it('caps at maxMs', () => {
51
+ const strategy = exponentialBackoff(1000, 5000);
52
+ expect(strategy.getDelay(1)).toBe(1000);
53
+ expect(strategy.getDelay(2)).toBe(2000);
54
+ expect(strategy.getDelay(3)).toBe(4000);
55
+ expect(strategy.getDelay(4)).toBe(5000); // Would be 8000 but capped
56
+ expect(strategy.getDelay(5)).toBe(5000); // Would be 16000 but capped
57
+ expect(strategy.getDelay(10)).toBe(5000); // Would be 512000 but capped
58
+ });
59
+
60
+ it('works with different base values', () => {
61
+ const strategy = exponentialBackoff(100, 10000);
62
+ expect(strategy.getDelay(1)).toBe(100);
63
+ expect(strategy.getDelay(2)).toBe(200);
64
+ expect(strategy.getDelay(3)).toBe(400);
65
+ expect(strategy.getDelay(4)).toBe(800);
66
+ });
67
+
68
+ it('respects max from the start if base > max', () => {
69
+ const strategy = exponentialBackoff(10000, 5000);
70
+ expect(strategy.getDelay(1)).toBe(5000); // 10000 capped to 5000
71
+ });
72
+
73
+ it('handles large attempt numbers without overflow', () => {
74
+ const strategy = exponentialBackoff(1000, 60000);
75
+ const delay = strategy.getDelay(100);
76
+ expect(delay).toBe(60000); // Should be capped, not Infinity
77
+ expect(delay).toBeLessThanOrEqual(60000);
78
+ });
79
+
80
+ it('works with small max values', () => {
81
+ const strategy = exponentialBackoff(10, 50);
82
+ expect(strategy.getDelay(1)).toBe(10);
83
+ expect(strategy.getDelay(2)).toBe(20);
84
+ expect(strategy.getDelay(3)).toBe(40);
85
+ expect(strategy.getDelay(4)).toBe(50); // Capped
86
+ });
87
+ });