@forgehive/task 0.1.4 → 0.1.6

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,116 @@
1
+ import { createTask, Schema } from '../index'
2
+ import { createMockBoundary } from '../utils/mock'
3
+
4
+ describe('Task boundary mocking', () => {
5
+ it('can mock specific boundaries for testing', async () => {
6
+ // Create a schema for the task
7
+ const schema = new Schema({
8
+ value: Schema.number()
9
+ })
10
+
11
+ // Define the boundaries
12
+ const boundaries = {
13
+ fetchExternalData: async (int: number): Promise<number> => {
14
+ // This would normally fetch data from an external source
15
+ return int * 2
16
+ }
17
+ }
18
+
19
+ // Create the task using createTask
20
+ const multiplyTask = createTask(
21
+ schema,
22
+ boundaries,
23
+ async function ({ value }, { fetchExternalData }) {
24
+ const result = value * await fetchExternalData(value)
25
+ return result
26
+ }
27
+ )
28
+
29
+ // Create mock for fetchExternalData boundary that returns a specific value
30
+ const mockFetchData = jest.fn().mockResolvedValue(5)
31
+ const wrappedMockFetchData = createMockBoundary(mockFetchData)
32
+
33
+ // Mock only the fetchExternalData boundary, leaving logData boundary as is
34
+ multiplyTask.mockBoundary('fetchExternalData', wrappedMockFetchData)
35
+
36
+ // Run the task with mocked boundary
37
+ const result = await multiplyTask.run({ value: 3 })
38
+
39
+ // Verify the correct result was returned
40
+ // Since fetchExternalData is mocked to always return 5, result should be 3 * 5 = 15
41
+ expect(result).toBe(15)
42
+
43
+ // Verify the mock was called with correct arguments
44
+ expect(mockFetchData).toHaveBeenCalledWith(3)
45
+ expect(mockFetchData).toHaveBeenCalledTimes(1)
46
+
47
+ // Reset the mocks
48
+ multiplyTask.resetMocks()
49
+
50
+ // Run the task again, now with original boundaries
51
+ const result2 = await multiplyTask.run({ value: 3 })
52
+
53
+ // With original boundaries, result should be 3 * (3 * 2) = 18
54
+ expect(result2).toBe(18)
55
+ })
56
+
57
+ it('can mock multiple boundaries at once', async () => {
58
+ // Create a schema for the task
59
+ const schema = new Schema({
60
+ value: Schema.number()
61
+ })
62
+
63
+ // Define the boundaries
64
+ const boundaries = {
65
+ doubleValue: async (int: number): Promise<number> => {
66
+ return int * 2
67
+ },
68
+ tripleValue: async (int: number): Promise<number> => {
69
+ return int * 3
70
+ }
71
+ }
72
+
73
+ // Create the task
74
+ const calculateTask = createTask(
75
+ schema,
76
+ boundaries,
77
+ async function ({ value }, { doubleValue, tripleValue }) {
78
+ const doubled = await doubleValue(value)
79
+ const tripled = await tripleValue(value)
80
+ return doubled + tripled
81
+ }
82
+ )
83
+
84
+ // Create wrapped mock functions
85
+ const mockDoubleValue = jest.fn().mockResolvedValue(10)
86
+ const mockTripleValue = jest.fn().mockResolvedValue(20)
87
+
88
+ // Mock both boundaries
89
+ calculateTask.mockBoundary('doubleValue', createMockBoundary(mockDoubleValue))
90
+ calculateTask.mockBoundary('tripleValue', createMockBoundary(mockTripleValue))
91
+
92
+ // Run the task with both mocked boundaries
93
+ const result = await calculateTask.run({ value: 5 })
94
+
95
+ // Result should be 10 + 20 = 30
96
+ expect(result).toBe(30)
97
+
98
+ // Reset only the doubleValue mock
99
+ calculateTask.resetMock('doubleValue')
100
+
101
+ // Run the task with only tripleValue mocked
102
+ const result2 = await calculateTask.run({ value: 5 })
103
+
104
+ // Result should be (5 * 2) + 20 = 30
105
+ expect(result2).toBe(30)
106
+
107
+ // Reset all mocks
108
+ calculateTask.resetMocks()
109
+
110
+ // Run the task with original boundaries
111
+ const result3 = await calculateTask.run({ value: 5 })
112
+
113
+ // Result should be (5 * 2) + (5 * 3) = 25
114
+ expect(result3).toBe(25)
115
+ })
116
+ })
@@ -1,4 +1,4 @@
1
- import { createTask, Schema } from '../index'
1
+ import { createTask, Schema, TaskRecord } from '../index'
2
2
 
3
3
  // Need to add proxy cache mode to the boundaries
4
4
  describe('Boundaries tasks tests', () => {
@@ -134,4 +134,201 @@ describe('Boundaries tasks tests', () => {
134
134
  expect(six).toBe(6)
135
135
  expect(fifteen).toBe(15)
136
136
  })
137
+
138
+ it('Multiple parallel task runs with boundaries', async () => {
139
+ // Define a type for the boundary data structure we expect
140
+ type BoundaryData = {
141
+ input: unknown[];
142
+ output?: unknown;
143
+ };
144
+
145
+ // Define a type for the record boundaries
146
+ interface RecordBoundaries {
147
+ fetchExternalData: BoundaryData[];
148
+ }
149
+
150
+ // Use the correct type definition for records
151
+ const records: TaskRecord<{value: number}, Promise<number>>[] = []
152
+
153
+ // Create a schema for the task that accepts a number
154
+ const schema = new Schema({
155
+ value: Schema.number()
156
+ })
157
+
158
+ // Define the boundaries with a function that returns different values based on input
159
+ const boundaries = {
160
+ fetchExternalData: async (int: number): Promise<number> => {
161
+ return int * 2
162
+ }
163
+ }
164
+
165
+ // Create the task using createTask
166
+ const multiplyTask = createTask(
167
+ schema,
168
+ boundaries,
169
+ async function ({ value }, { fetchExternalData}) {
170
+ const externalData: number = await fetchExternalData(value)
171
+ return value * externalData
172
+ }
173
+ )
174
+
175
+ multiplyTask.addListener((record) => {
176
+ records.push(record)
177
+ })
178
+
179
+ // Run multiple tasks in parallel
180
+ const results = await Promise.all([
181
+ multiplyTask.run({ value: 2 }),
182
+ multiplyTask.run({ value: 3 }),
183
+ multiplyTask.run({ value: 4 })
184
+ ])
185
+
186
+ // Check the results
187
+ expect(results).toEqual([8, 18, 32])
188
+
189
+ // Test records array
190
+ // Should have exactly 3 elements (one for each task run)
191
+ expect(records.length).toBe(3)
192
+
193
+ // Sort records by input value for consistent testing
194
+ const sortedRecords = [...records].sort((a, b) => a.input.value - b.input.value)
195
+
196
+ // Check record for first task (value: 2)
197
+ expect(sortedRecords[0].input).toEqual({ value: 2 })
198
+ expect(sortedRecords[0].output).toBe(8)
199
+
200
+ // Use type assertion to access the boundary data safely
201
+ const boundaries0 = sortedRecords[0].boundaries as unknown as RecordBoundaries
202
+ expect(boundaries0.fetchExternalData).toHaveLength(1)
203
+ expect(boundaries0.fetchExternalData[0].input).toEqual([2])
204
+ expect(boundaries0.fetchExternalData[0].output).toBe(4)
205
+
206
+ // Check record for second task (value: 3)
207
+ expect(sortedRecords[1].input).toEqual({ value: 3 })
208
+ expect(sortedRecords[1].output).toBe(18)
209
+
210
+ // Use type assertion to access the boundary data safely
211
+ const boundaries1 = sortedRecords[1].boundaries as unknown as RecordBoundaries
212
+ expect(boundaries1.fetchExternalData).toHaveLength(1)
213
+ expect(boundaries1.fetchExternalData[0].input).toEqual([3])
214
+ expect(boundaries1.fetchExternalData[0].output).toBe(6)
215
+
216
+ // Check record for third task (value: 4)
217
+ expect(sortedRecords[2].input).toEqual({ value: 4 })
218
+ expect(sortedRecords[2].output).toBe(32)
219
+
220
+ // Use type assertion to access the boundary data safely
221
+ const boundaries2 = sortedRecords[2].boundaries as unknown as RecordBoundaries
222
+ expect(boundaries2.fetchExternalData).toHaveLength(1)
223
+ expect(boundaries2.fetchExternalData[0].input).toEqual([4])
224
+ expect(boundaries2.fetchExternalData[0].output).toBe(8)
225
+ })
226
+
227
+ it('Boundary data accumulates run data correctly', async () => {
228
+ // Create a schema for the task that accepts a number
229
+ const schema = new Schema({
230
+ value: Schema.number()
231
+ })
232
+
233
+ // Define the boundaries
234
+ const boundaries = {
235
+ fetchExternalData: async (int: number): Promise<number> => {
236
+ return int * 2
237
+ }
238
+ }
239
+
240
+ // Create the task using createTask
241
+ const multiplyTask = createTask(
242
+ schema,
243
+ boundaries,
244
+ async function ({ value }, { fetchExternalData }) {
245
+ const externalData: number = await fetchExternalData(value)
246
+ return value * externalData
247
+ }
248
+ )
249
+
250
+ // Run task with value 2
251
+ await multiplyTask.run({ value: 2 })
252
+
253
+ // Get boundary data after first run
254
+ const boundariesData1 = multiplyTask.getBondariesData()
255
+
256
+ // Verify data structure
257
+ expect(boundariesData1).toHaveProperty('fetchExternalData')
258
+ expect(Array.isArray(boundariesData1.fetchExternalData)).toBe(true)
259
+ expect(boundariesData1.fetchExternalData).toHaveLength(1)
260
+
261
+ // Verify the tape entry for first run
262
+ const firstRunTape = boundariesData1.fetchExternalData as Array<{input: unknown[], output: unknown}>
263
+ expect(firstRunTape[0].input).toEqual([2])
264
+ expect(firstRunTape[0].output).toBe(4)
265
+
266
+ // Run task with value 3
267
+ await multiplyTask.run({ value: 3 })
268
+
269
+ // Get boundary data after second run
270
+ const boundariesData2 = multiplyTask.getBondariesData()
271
+
272
+ // Tape should now have 2 entries
273
+ expect(boundariesData2.fetchExternalData).toHaveLength(2)
274
+
275
+ // Sort the tape by input value for consistent testing
276
+ const secondRunTape = boundariesData2.fetchExternalData as Array<{input: unknown[], output: unknown}>
277
+ const sortedTape = [...secondRunTape].sort((a, b) => (a.input[0] as number) - (b.input[0] as number))
278
+
279
+ // First entry should still be the same
280
+ expect(sortedTape[0].input).toEqual([2])
281
+ expect(sortedTape[0].output).toBe(4)
282
+
283
+ // Second entry should be from the second run
284
+ expect(sortedTape[1].input).toEqual([3])
285
+ expect(sortedTape[1].output).toBe(6)
286
+
287
+ // Run task with value 4
288
+ await multiplyTask.run({ value: 4 })
289
+
290
+ // Get boundary data after third run
291
+ const boundariesData3 = multiplyTask.getBondariesData()
292
+
293
+ // Tape should now have 3 entries
294
+ expect(boundariesData3.fetchExternalData).toHaveLength(3)
295
+
296
+ // Sort the tape again
297
+ const thirdRunTape = boundariesData3.fetchExternalData as Array<{input: unknown[], output: unknown}>
298
+ const finalSortedTape = [...thirdRunTape].sort((a, b) => (a.input[0] as number) - (b.input[0] as number))
299
+
300
+ // Verify all three entries
301
+ expect(finalSortedTape[0].input).toEqual([2])
302
+ expect(finalSortedTape[0].output).toBe(4)
303
+
304
+ expect(finalSortedTape[1].input).toEqual([3])
305
+ expect(finalSortedTape[1].output).toBe(6)
306
+
307
+ expect(finalSortedTape[2].input).toEqual([4])
308
+ expect(finalSortedTape[2].output).toBe(8)
309
+
310
+ // Verify tape can be used for replay in proxy-pass mode
311
+ const replayTask = createTask(
312
+ schema,
313
+ boundaries,
314
+ async function ({ value }, { fetchExternalData }) {
315
+ const externalData: number = await fetchExternalData(value)
316
+ return value * externalData
317
+ },
318
+ {
319
+ boundariesData: boundariesData3,
320
+ mode: 'proxy-pass'
321
+ }
322
+ )
323
+
324
+ // Run task with all three values from the tape
325
+ const result2 = await replayTask.run({ value: 2 })
326
+ const result3 = await replayTask.run({ value: 3 })
327
+ const result4 = await replayTask.run({ value: 4 })
328
+
329
+ // Results should match original runs
330
+ expect(result2).toBe(8)
331
+ expect(result3).toBe(18)
332
+ expect(result4).toBe(32)
333
+ })
137
334
  })
@@ -34,8 +34,22 @@ describe('Validation tests', () => {
34
34
  // If we get here, the test should fail
35
35
  expect('no error thrown').toBeUndefined()
36
36
  } catch (e) {
37
- // Test passes if we get here
38
- expect(e).toBeDefined()
37
+ const error = e as Error
38
+
39
+ expect(error.message).toEqual('Invalid input on: value: Expected number, received null')
40
+ }
41
+ })
42
+
43
+ it('Should provide detailed error messages', async () => {
44
+ try {
45
+ await task.run({ value: 'null' })
46
+ // If we get here, the test should fail
47
+ expect('no error thrown').toBeUndefined()
48
+ } catch (e: unknown) {
49
+ const error = e as Error
50
+
51
+ // Test the error message format
52
+ expect(error.message).toEqual('Invalid input on: value: Expected number, received string')
39
53
  }
40
54
  })
41
55
 
@@ -50,7 +64,7 @@ describe('Validation tests on param', () => {
50
64
 
51
65
  beforeEach(() => {
52
66
  const schema = new Schema({
53
- value: Schema.number()
67
+ name: Schema.string()
54
68
  })
55
69
 
56
70
  task = new Task(function (argv: InferSchema<typeof schema>) {
@@ -61,35 +75,32 @@ describe('Validation tests on param', () => {
61
75
  })
62
76
 
63
77
  it('Should be invalid', () => {
64
- const check = task.isValid({ value: null })
78
+ const check = task.isValid({ name: null })
65
79
 
66
80
  expect(check).toBe(false)
67
81
  })
68
82
 
69
83
  it('Should be valid', () => {
70
- const check = task.isValid({ value: 5 })
84
+ const check = task.isValid({ name: 'John Doe' })
71
85
 
72
86
  expect(check).toBe(true)
73
87
  })
74
88
 
75
89
  it('Should validate data as part of run function', async () => {
76
90
  try {
77
- await task.run({ value: null })
91
+ await task.run({ name: null })
78
92
  // If we get here, the test should fail
79
93
  expect('no error thrown').toBeUndefined()
80
94
  } catch (e) {
81
- // Test passes if we get here
82
- expect(e).toBeDefined()
95
+ const error = e as Error
96
+ expect(error.message).toEqual('Invalid input on: name: Expected string, received null')
83
97
  }
84
98
  })
85
99
 
86
100
  it('Should work well', async () => {
87
- try {
88
- const result = await task.run({ value: 5 })
89
- expect(result.value).toBe(5)
90
- } catch (e) {
91
- expect('error thrown: ' + e).toBeUndefined()
92
- }
101
+ const result = await task.run({ name: 'John Doe' })
102
+
103
+ expect(result.name).toBe('John Doe')
93
104
  })
94
105
  })
95
106
 
@@ -115,8 +126,8 @@ describe('Validation multiple values tests', () => {
115
126
  // If we get here, the test should fail
116
127
  expect('no error thrown').toBeUndefined()
117
128
  } catch (e) {
118
- // Test passes if we get here
119
- expect(e).toBeDefined()
129
+ const error = e as Error
130
+ expect(error.message).toEqual('Invalid input on: value: Expected number, received null, increment: Required')
120
131
  }
121
132
  })
122
133
 
@@ -126,8 +137,8 @@ describe('Validation multiple values tests', () => {
126
137
  // If we get here, the test should fail
127
138
  expect('no error thrown').toBeUndefined()
128
139
  } catch (e) {
129
- // Test passes if we get here
130
- expect(e).toBeDefined()
140
+ const error = e as Error
141
+ expect(error.message).toEqual('Invalid input on: increment: Required')
131
142
  }
132
143
  })
133
144
 
@@ -192,3 +203,171 @@ describe('Set Schema', () => {
192
203
  expect(schema).toBeUndefined()
193
204
  })
194
205
  })
206
+
207
+ describe('Multiple validation errors', () => {
208
+ let task: TaskInstanceType
209
+
210
+ beforeEach(() => {
211
+ const schema = new Schema({
212
+ name: Schema.string(),
213
+ age: Schema.number(),
214
+ email: Schema.string().email()
215
+ })
216
+
217
+ task = new Task(function (argv: InferSchema<typeof schema>) {
218
+ return argv
219
+ }, {
220
+ schema
221
+ })
222
+ })
223
+
224
+ it('Should report all validation errors', async () => {
225
+ try {
226
+ await task.run({ name: 123, age: 'twenty', email: 'invalid-email' })
227
+ // If we get here, the test should fail
228
+ expect('no error thrown').toBeUndefined()
229
+ } catch (e: unknown) {
230
+ const error = e as Error
231
+ // Test that multiple errors are reported
232
+ expect(error.message).toContain('Invalid input on')
233
+ expect(error.message).toContain('name: Expected string, received number')
234
+ expect(error.message).toContain('age: Expected number, received string')
235
+ expect(error.message).toContain('email: Invalid email')
236
+ }
237
+ })
238
+ })
239
+
240
+ describe('Array validation tests', () => {
241
+ let task: TaskInstanceType
242
+
243
+ beforeEach(() => {
244
+ const schema = new Schema({
245
+ tags: Schema.array(Schema.string())
246
+ })
247
+
248
+ task = new Task(function (argv: InferSchema<typeof schema>) {
249
+ return argv
250
+ }, {
251
+ schema
252
+ })
253
+ })
254
+
255
+ it('Should be invalid with non-array value', () => {
256
+ const check = task.isValid({ tags: 'not an array' })
257
+ expect(check).toBe(false)
258
+ })
259
+
260
+ it('Should be invalid with array containing non-string items', () => {
261
+ const check = task.isValid({ tags: ['valid', 123, true] })
262
+ expect(check).toBe(false)
263
+ })
264
+
265
+ it('Should be valid with string array', () => {
266
+ const check = task.isValid({ tags: ['tag1', 'tag2', 'tag3'] })
267
+ expect(check).toBe(true)
268
+ })
269
+
270
+ it('Should validate array data on run', async () => {
271
+ try {
272
+ await task.run({ tags: 'not an array' })
273
+ // If we get here, the test should fail
274
+ expect('no error thrown').toBeUndefined()
275
+ } catch (e) {
276
+ const error = e as Error
277
+ expect(error.message).toEqual('Invalid input on: tags: Expected array, received string')
278
+ }
279
+ })
280
+
281
+ it('Should validate array items on run', async () => {
282
+ try {
283
+ await task.run({ tags: ['valid', 123, true] })
284
+ // If we get here, the test should fail
285
+ expect('no error thrown').toBeUndefined()
286
+ } catch (e) {
287
+ const error = e as Error
288
+ expect(error.message).toContain('Invalid input on')
289
+ expect(error.message).toContain('tags.1: Expected string, received number')
290
+ expect(error.message).toContain('tags.2: Expected string, received boolean')
291
+ }
292
+ })
293
+
294
+ it('Should work with valid string array', async () => {
295
+ const result = await task.run({ tags: ['tag1', 'tag2', 'tag3'] })
296
+ expect(result.tags).toEqual(['tag1', 'tag2', 'tag3'])
297
+ })
298
+ })
299
+
300
+ describe('MixedRecord validation tests', () => {
301
+ let task: TaskInstanceType
302
+
303
+ beforeEach(() => {
304
+ const schema = new Schema({
305
+ metadata: Schema.mixedRecord()
306
+ })
307
+
308
+ task = new Task(function (argv: InferSchema<typeof schema>) {
309
+ return argv
310
+ }, {
311
+ schema
312
+ })
313
+ })
314
+
315
+ it('Should be invalid with non-object value', () => {
316
+ const check = task.isValid({ metadata: 'not an object' })
317
+ expect(check).toBe(false)
318
+ })
319
+
320
+ it('Should be invalid with non-string keys', () => {
321
+ // In JavaScript, object keys are always coerced to strings, so this actually becomes a valid record
322
+ // with a string key "123"
323
+ const check = task.isValid({ metadata: { 123: 'value' }})
324
+ expect(check).toBe(true)
325
+ })
326
+
327
+ it('Should be valid with mixed value types', () => {
328
+ const check = task.isValid({
329
+ metadata: {
330
+ stringValue: 'text',
331
+ numberValue: 42,
332
+ booleanValue: true
333
+ }
334
+ })
335
+ expect(check).toBe(true)
336
+ })
337
+
338
+ it('Should validate record type on run', async () => {
339
+ try {
340
+ await task.run({ metadata: 'not an object' })
341
+ // If we get here, the test should fail
342
+ expect('no error thrown').toBeUndefined()
343
+ } catch (e) {
344
+ const error = e as Error
345
+ expect(error.message).toEqual('Invalid input on: metadata: Expected object, received string')
346
+ }
347
+ })
348
+
349
+ it('Should validate value types on run', async () => {
350
+ try {
351
+ await task.run({ metadata: { valid: 'string', invalid: { nested: 'object' } }})
352
+ // If we get here, the test should fail
353
+ expect('no error thrown').toBeUndefined()
354
+ } catch (e) {
355
+ const error = e as Error
356
+ expect(error.message).toContain('Invalid input on')
357
+ // The actual error message from Zod for this case
358
+ expect(error.message).toContain('metadata.invalid: Invalid input')
359
+ }
360
+ })
361
+
362
+ it('Should work with valid mixed record', async () => {
363
+ const validData = {
364
+ metadata: {
365
+ stringValue: 'text',
366
+ numberValue: 42,
367
+ booleanValue: true
368
+ }
369
+ }
370
+ const result = await task.run(validData)
371
+ expect(result).toEqual(validData)
372
+ })
373
+ })
@@ -0,0 +1,45 @@
1
+ import { type WrappedBoundaryFunction, type Mode, type BoundaryRecord } from './boundary'
2
+
3
+ /**
4
+ * Creates a wrapped boundary function from any function
5
+ * This is framework-agnostic and can be used with or without testing libraries like Jest
6
+ *
7
+ * @param fn The function to wrap as a boundary
8
+ * @param options Optional configuration for the mock boundary
9
+ * @returns A function wrapped as a WrappedBoundaryFunction
10
+ */
11
+ export function createMockBoundary<T extends (...args: unknown[]) => unknown>(
12
+ fn: T,
13
+ options: {
14
+ getTape?: () => Array<BoundaryRecord>,
15
+ setTape?: (tape: Array<BoundaryRecord>) => void,
16
+ getMode?: () => Mode,
17
+ setMode?: (mode: Mode) => void,
18
+ getRunData?: () => Array<BoundaryRecord>
19
+ } = {}
20
+ ): WrappedBoundaryFunction {
21
+ // Use provided functions or create simple implementations
22
+ const mockGetTape = options.getTape || ((): BoundaryRecord[] => [])
23
+ const mockSetTape = options.setTape || ((_tape: BoundaryRecord[]): void => {})
24
+ const mockGetMode = options.getMode || ((): Mode => 'proxy')
25
+ const mockSetMode = options.setMode || ((_mode: Mode): void => {})
26
+ const mockGetRunData = options.getRunData || ((): BoundaryRecord[] => [])
27
+
28
+ // Empty functions for start/stop run
29
+ const mockStartRun = (): void => {}
30
+ const mockStopRun = (): void => {}
31
+
32
+ // Cast the function to a WrappedBoundaryFunction
33
+ const wrappedFn = fn as unknown as WrappedBoundaryFunction
34
+
35
+ // Add required methods to satisfy the interface
36
+ wrappedFn.getTape = mockGetTape
37
+ wrappedFn.setTape = mockSetTape
38
+ wrappedFn.getMode = mockGetMode
39
+ wrappedFn.setMode = mockSetMode
40
+ wrappedFn.startRun = mockStartRun
41
+ wrappedFn.stopRun = mockStopRun
42
+ wrappedFn.getRunData = mockGetRunData
43
+
44
+ return wrappedFn
45
+ }