ai-workflows 2.1.3 → 2.4.0
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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +3 -3
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +132 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -169
- package/LICENSE +0 -21
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableStep Wrapper Tests (RED Phase)
|
|
3
|
+
*
|
|
4
|
+
* Tests for DurableStep - a wrapper around Cloudflare Workflows step semantics
|
|
5
|
+
* that provides durable execution, retries, sleep, and step metadata.
|
|
6
|
+
*
|
|
7
|
+
* These tests define the expected behavior for DurableStep before implementation.
|
|
8
|
+
* All tests SHOULD FAIL because DurableStep integration with real Cloudflare
|
|
9
|
+
* Workflows is not yet implemented.
|
|
10
|
+
*
|
|
11
|
+
* Uses @cloudflare/vitest-pool-workers - NO MOCKS.
|
|
12
|
+
* Tests run against real Cloudflare Workflows bindings.
|
|
13
|
+
*
|
|
14
|
+
* Bead: aip-p3m5
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
20
|
+
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// These imports will FAIL because the Cloudflare Workflows integration is not
|
|
24
|
+
// yet implemented in DurableStep. This is the RED phase of TDD.
|
|
25
|
+
// ============================================================================
|
|
26
|
+
import {
|
|
27
|
+
DurableStep,
|
|
28
|
+
StepContext,
|
|
29
|
+
type StepMetadata,
|
|
30
|
+
type StepConfig,
|
|
31
|
+
type WorkflowStep,
|
|
32
|
+
} from '../../src/worker/durable-step.js'
|
|
33
|
+
|
|
34
|
+
// Import the TestWorkflow that should be defined in worker.ts
|
|
35
|
+
// This will FAIL because TestWorkflow doesn't exist yet
|
|
36
|
+
import { TestWorkflow } from '../../src/worker.js'
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Type Definitions for Test Environment
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
interface TestEnv {
|
|
43
|
+
WORKFLOW: Workflow
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Helper Functions
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get a workflow instance from the binding.
|
|
52
|
+
* This creates a new workflow instance for testing.
|
|
53
|
+
*
|
|
54
|
+
* Note: env.WORKFLOW.create() returns a WorkflowInstance directly.
|
|
55
|
+
* The `id` option sets the instance ID for later retrieval via get().
|
|
56
|
+
*/
|
|
57
|
+
async function getWorkflowInstance(name?: string): Promise<WorkflowInstance> {
|
|
58
|
+
// Create returns the WorkflowInstance directly
|
|
59
|
+
const instance = await env.WORKFLOW.create({
|
|
60
|
+
id: name ?? crypto.randomUUID(),
|
|
61
|
+
})
|
|
62
|
+
return instance
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run a workflow and wait for it to complete.
|
|
67
|
+
*/
|
|
68
|
+
async function runWorkflow<T>(instance: WorkflowInstance, params?: unknown): Promise<T> {
|
|
69
|
+
const status = await instance.status()
|
|
70
|
+
if (status.status === 'queued' || status.status === 'running') {
|
|
71
|
+
// Wait for completion
|
|
72
|
+
let current = status
|
|
73
|
+
while (current.status !== 'complete' && current.status !== 'errored') {
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
75
|
+
current = await instance.status()
|
|
76
|
+
}
|
|
77
|
+
if (current.status === 'errored') {
|
|
78
|
+
// Extract error message from the error object
|
|
79
|
+
// Note: miniflare's workflow status API doesn't expose error details in current.error
|
|
80
|
+
// The actual error message is logged by workerd but not accessible via the status API
|
|
81
|
+
const error = current.error as unknown
|
|
82
|
+
let errorMessage: string
|
|
83
|
+
|
|
84
|
+
if (typeof error === 'string') {
|
|
85
|
+
errorMessage = error
|
|
86
|
+
} else if (error && typeof error === 'object') {
|
|
87
|
+
const err = error as Record<string, unknown>
|
|
88
|
+
// Try common error property names
|
|
89
|
+
errorMessage = (err.message ??
|
|
90
|
+
err.name ??
|
|
91
|
+
err.error ??
|
|
92
|
+
(err.cause &&
|
|
93
|
+
typeof err.cause === 'object' &&
|
|
94
|
+
(err.cause as Record<string, unknown>).message) ??
|
|
95
|
+
JSON.stringify(error)) as string
|
|
96
|
+
} else {
|
|
97
|
+
errorMessage = String(error ?? 'Unknown error')
|
|
98
|
+
}
|
|
99
|
+
throw new Error(errorMessage)
|
|
100
|
+
}
|
|
101
|
+
return current.output as T
|
|
102
|
+
}
|
|
103
|
+
return status.output as T
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// 1. DurableStep: Core Construction
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
describe('DurableStep', () => {
|
|
111
|
+
describe('construction', () => {
|
|
112
|
+
it('creates a DurableStep with a name and function', () => {
|
|
113
|
+
const step = new DurableStep('fetch-data', async (input: { url: string }) => {
|
|
114
|
+
return { status: 200 }
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(step).toBeInstanceOf(DurableStep)
|
|
118
|
+
expect(step.name).toBe('fetch-data')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('creates a DurableStep with a name, config, and function', () => {
|
|
122
|
+
const step = new DurableStep(
|
|
123
|
+
'process-payment',
|
|
124
|
+
{
|
|
125
|
+
retries: { limit: 3, delay: '1 second', backoff: 'exponential' },
|
|
126
|
+
timeout: '30 seconds',
|
|
127
|
+
},
|
|
128
|
+
async (input: { amount: number }) => {
|
|
129
|
+
return { success: true }
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(step).toBeInstanceOf(DurableStep)
|
|
134
|
+
expect(step.name).toBe('process-payment')
|
|
135
|
+
expect(step.config).toBeDefined()
|
|
136
|
+
expect(step.config?.retries?.limit).toBe(3)
|
|
137
|
+
expect(step.config?.timeout).toBe('30 seconds')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('preserves the function reference', () => {
|
|
141
|
+
const fn = async (input: { id: string }) => ({ found: true })
|
|
142
|
+
const step = new DurableStep('lookup', fn)
|
|
143
|
+
|
|
144
|
+
expect(step.fn).toBe(fn)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// 2. DurableStep.run() with Real Workflows Binding
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
describe('run() with real Workflows binding', () => {
|
|
153
|
+
it('executes the wrapped function with input via real workflow step', async () => {
|
|
154
|
+
// This test requires the TestWorkflow to be properly configured
|
|
155
|
+
// and the DurableStep to integrate with real Workflows step.do()
|
|
156
|
+
const instance = await getWorkflowInstance('exec-test-1')
|
|
157
|
+
|
|
158
|
+
// The workflow should execute a DurableStep internally
|
|
159
|
+
const result = await runWorkflow<{ value: number }>(instance)
|
|
160
|
+
|
|
161
|
+
expect(result).toBeDefined()
|
|
162
|
+
expect(result.value).toBe(42)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('wraps execution in step.do() for durability', async () => {
|
|
166
|
+
// This test verifies that step.do() is called with the step name
|
|
167
|
+
const instance = await getWorkflowInstance('durability-test-1')
|
|
168
|
+
|
|
169
|
+
const result = await runWorkflow<{ stepName: string }>(instance)
|
|
170
|
+
|
|
171
|
+
expect(result.stepName).toBe('durable-action')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('passes config to step.do() when provided', async () => {
|
|
175
|
+
// Verify that retry config is passed through to the Workflows runtime
|
|
176
|
+
const instance = await getWorkflowInstance('config-test-1')
|
|
177
|
+
|
|
178
|
+
const result = await runWorkflow<{ configApplied: boolean }>(instance)
|
|
179
|
+
|
|
180
|
+
expect(result.configApplied).toBe(true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('returns the result from the wrapped function', async () => {
|
|
184
|
+
const instance = await getWorkflowInstance('result-test-1')
|
|
185
|
+
|
|
186
|
+
const result = await runWorkflow<{ sum: number; product: number }>(instance)
|
|
187
|
+
|
|
188
|
+
expect(result).toEqual({ sum: 10, product: 21 })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('propagates errors from the wrapped function', async () => {
|
|
192
|
+
const instance = await getWorkflowInstance('error-test-1')
|
|
193
|
+
|
|
194
|
+
// Note: miniflare's workflow status API doesn't expose error details,
|
|
195
|
+
// so we check that an error is thrown (the actual error message "Step execution failed"
|
|
196
|
+
// is visible in workerd logs but not in the status object)
|
|
197
|
+
await expect(runWorkflow(instance)).rejects.toThrow()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('supports generic input and output types', async () => {
|
|
201
|
+
interface OrderInput {
|
|
202
|
+
orderId: string
|
|
203
|
+
items: string[]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface OrderResult {
|
|
207
|
+
confirmed: boolean
|
|
208
|
+
total: number
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const instance = await getWorkflowInstance('typed-test-1')
|
|
212
|
+
|
|
213
|
+
const result = await runWorkflow<OrderResult>(instance)
|
|
214
|
+
|
|
215
|
+
expect(result.confirmed).toBe(true)
|
|
216
|
+
expect(result.total).toBe(20)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('supports void input', async () => {
|
|
220
|
+
const instance = await getWorkflowInstance('void-input-test-1')
|
|
221
|
+
|
|
222
|
+
const result = await runWorkflow<string>(instance)
|
|
223
|
+
|
|
224
|
+
expect(result).toBe('hello')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// 3. DurableStep with StepContext
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
describe('run() with StepContext', () => {
|
|
233
|
+
it('provides a StepContext to the function when requested', async () => {
|
|
234
|
+
const instance = await getWorkflowInstance('ctx-test-1')
|
|
235
|
+
|
|
236
|
+
const result = await runWorkflow<{ hasContext: boolean }>(instance)
|
|
237
|
+
|
|
238
|
+
expect(result.hasContext).toBe(true)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('StepContext provides step metadata', async () => {
|
|
242
|
+
const instance = await getWorkflowInstance('metadata-test-1')
|
|
243
|
+
|
|
244
|
+
const result = await runWorkflow<StepMetadata>(instance)
|
|
245
|
+
|
|
246
|
+
expect(result).toBeDefined()
|
|
247
|
+
expect(result.id).toBe('meta-step')
|
|
248
|
+
expect(typeof result.attempt).toBe('number')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// ============================================================================
|
|
254
|
+
// 4. StepContext: step.do() for side effects
|
|
255
|
+
// ============================================================================
|
|
256
|
+
|
|
257
|
+
describe('StepContext', () => {
|
|
258
|
+
describe('do()', () => {
|
|
259
|
+
it('executes a named side effect durably via real Workflows', async () => {
|
|
260
|
+
const instance = await getWorkflowInstance('side-effect-test-1')
|
|
261
|
+
|
|
262
|
+
const result = await runWorkflow<{ sent: boolean }>(instance)
|
|
263
|
+
|
|
264
|
+
expect(result).toEqual({ sent: true })
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('executes do() with config for retries', async () => {
|
|
268
|
+
const instance = await getWorkflowInstance('retry-config-test-1')
|
|
269
|
+
|
|
270
|
+
const result = await runWorkflow<{ data: string }>(instance)
|
|
271
|
+
|
|
272
|
+
expect(result).toEqual({ data: 'response' })
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('propagates errors from do() side effects', async () => {
|
|
276
|
+
const instance = await getWorkflowInstance('side-effect-error-test-1')
|
|
277
|
+
|
|
278
|
+
// Note: miniflare's workflow status API doesn't expose error details,
|
|
279
|
+
// so we check that an error is thrown (the actual error message "Side effect failed"
|
|
280
|
+
// is visible in workerd logs but not in the status object)
|
|
281
|
+
await expect(runWorkflow(instance)).rejects.toThrow()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('supports multiple sequential do() calls', async () => {
|
|
285
|
+
const instance = await getWorkflowInstance('sequential-test-1')
|
|
286
|
+
|
|
287
|
+
const result = await runWorkflow<string[]>(instance)
|
|
288
|
+
|
|
289
|
+
expect(result).toEqual(['step-1', 'step-2', 'step-3'])
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ============================================================================
|
|
294
|
+
// 5. StepContext: sleep() and sleepUntil()
|
|
295
|
+
// ============================================================================
|
|
296
|
+
|
|
297
|
+
describe('sleep()', () => {
|
|
298
|
+
it('sleeps for a specified duration string via real Workflows', async () => {
|
|
299
|
+
const instance = await getWorkflowInstance('sleep-test-1')
|
|
300
|
+
|
|
301
|
+
// Note: In real tests, this would actually wait. For testing purposes,
|
|
302
|
+
// we verify the workflow completes successfully after the sleep.
|
|
303
|
+
const startTime = Date.now()
|
|
304
|
+
const result = await runWorkflow<{ waited: boolean }>(instance)
|
|
305
|
+
const elapsed = Date.now() - startTime
|
|
306
|
+
|
|
307
|
+
expect(result).toEqual({ waited: true })
|
|
308
|
+
// Sleep should have occurred (at least partially in miniflare)
|
|
309
|
+
// The actual duration may be simulated in test environment
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('sleeps for a duration with various units', async () => {
|
|
313
|
+
const instance = await getWorkflowInstance('multi-sleep-test-1')
|
|
314
|
+
|
|
315
|
+
const result = await runWorkflow<{ sleepCount: number }>(instance)
|
|
316
|
+
|
|
317
|
+
expect(result.sleepCount).toBe(3)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('sleepUntil()', () => {
|
|
322
|
+
it('sleeps until a specified Date via real Workflows', async () => {
|
|
323
|
+
const instance = await getWorkflowInstance('sleep-until-test-1')
|
|
324
|
+
|
|
325
|
+
const result = await runWorkflow<{ resumed: boolean }>(instance)
|
|
326
|
+
|
|
327
|
+
expect(result).toEqual({ resumed: true })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('sleeps until a specified unix timestamp (number)', async () => {
|
|
331
|
+
const instance = await getWorkflowInstance('timestamp-sleep-test-1')
|
|
332
|
+
|
|
333
|
+
const result = await runWorkflow<{ completed: boolean }>(instance)
|
|
334
|
+
|
|
335
|
+
expect(result.completed).toBe(true)
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// ============================================================================
|
|
340
|
+
// 6. StepContext: Metadata
|
|
341
|
+
// ============================================================================
|
|
342
|
+
|
|
343
|
+
describe('metadata', () => {
|
|
344
|
+
it('exposes the step id', async () => {
|
|
345
|
+
const instance = await getWorkflowInstance('step-id-test-1')
|
|
346
|
+
|
|
347
|
+
const result = await runWorkflow<{ stepId: string }>(instance)
|
|
348
|
+
|
|
349
|
+
expect(result.stepId).toBe('named-step')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('exposes the current attempt number', async () => {
|
|
353
|
+
const instance = await getWorkflowInstance('attempt-test-1')
|
|
354
|
+
|
|
355
|
+
const result = await runWorkflow<{ attempt: number }>(instance)
|
|
356
|
+
|
|
357
|
+
expect(result.attempt).toBeDefined()
|
|
358
|
+
expect(typeof result.attempt).toBe('number')
|
|
359
|
+
expect(result.attempt).toBeGreaterThanOrEqual(1)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('exposes the configured retries limit', async () => {
|
|
363
|
+
const instance = await getWorkflowInstance('retries-limit-test-1')
|
|
364
|
+
|
|
365
|
+
const result = await runWorkflow<{ retriesLimit: number }>(instance)
|
|
366
|
+
|
|
367
|
+
expect(result.retriesLimit).toBe(5)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('exposes retries as 0 when no retry config provided', async () => {
|
|
371
|
+
const instance = await getWorkflowInstance('no-retries-test-1')
|
|
372
|
+
|
|
373
|
+
const result = await runWorkflow<{ retriesLimit: number }>(instance)
|
|
374
|
+
|
|
375
|
+
expect(result.retriesLimit).toBe(0)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// ============================================================================
|
|
380
|
+
// 7. Error Handling and Retries
|
|
381
|
+
// ============================================================================
|
|
382
|
+
|
|
383
|
+
describe('error handling', () => {
|
|
384
|
+
it('retries on failure when retries are configured (real Workflows)', async () => {
|
|
385
|
+
// This test verifies that the Workflows runtime handles retries
|
|
386
|
+
const instance = await getWorkflowInstance('retry-behavior-test-1')
|
|
387
|
+
|
|
388
|
+
const result = await runWorkflow<{ attempts: number; success: boolean }>(instance)
|
|
389
|
+
|
|
390
|
+
// The workflow should retry and eventually succeed
|
|
391
|
+
expect(result.attempts).toBeGreaterThan(1)
|
|
392
|
+
expect(result.success).toBe(true)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('respects timeout configuration', async () => {
|
|
396
|
+
const instance = await getWorkflowInstance('timeout-test-1')
|
|
397
|
+
|
|
398
|
+
// A step that exceeds its timeout should error
|
|
399
|
+
await expect(runWorkflow(instance)).rejects.toThrow()
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('supports exponential backoff configuration', async () => {
|
|
403
|
+
const instance = await getWorkflowInstance('exp-backoff-test-1')
|
|
404
|
+
|
|
405
|
+
const result = await runWorkflow<{ backoffApplied: boolean }>(instance)
|
|
406
|
+
|
|
407
|
+
expect(result.backoffApplied).toBe(true)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('supports linear backoff configuration', async () => {
|
|
411
|
+
const instance = await getWorkflowInstance('linear-backoff-test-1')
|
|
412
|
+
|
|
413
|
+
const result = await runWorkflow<{ backoffType: string }>(instance)
|
|
414
|
+
|
|
415
|
+
expect(result.backoffType).toBe('linear')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('supports constant backoff configuration', async () => {
|
|
419
|
+
const instance = await getWorkflowInstance('constant-backoff-test-1')
|
|
420
|
+
|
|
421
|
+
const result = await runWorkflow<{ backoffType: string }>(instance)
|
|
422
|
+
|
|
423
|
+
expect(result.backoffType).toBe('constant')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('throws immediately without retries when no config', async () => {
|
|
427
|
+
const instance = await getWorkflowInstance('no-retry-error-test-1')
|
|
428
|
+
|
|
429
|
+
// Note: miniflare's workflow status API doesn't expose error details,
|
|
430
|
+
// so we check that an error is thrown (the actual error message "Immediate failure"
|
|
431
|
+
// is visible in workerd logs but not in the status object)
|
|
432
|
+
await expect(runWorkflow(instance)).rejects.toThrow()
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// ============================================================================
|
|
437
|
+
// 8. Composability: DurableStep chains
|
|
438
|
+
// ============================================================================
|
|
439
|
+
|
|
440
|
+
describe('composability', () => {
|
|
441
|
+
it('multiple DurableSteps can be run sequentially in a workflow', async () => {
|
|
442
|
+
const instance = await getWorkflowInstance('sequential-steps-test-1')
|
|
443
|
+
|
|
444
|
+
const result = await runWorkflow<{
|
|
445
|
+
fetchData: string
|
|
446
|
+
processed: boolean
|
|
447
|
+
}>(instance)
|
|
448
|
+
|
|
449
|
+
expect(result.fetchData).toBe('response from https://api.example.com')
|
|
450
|
+
expect(result.processed).toBe(true)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('DurableStep can be used as a factory function', async () => {
|
|
454
|
+
const instance = await getWorkflowInstance('factory-test-1')
|
|
455
|
+
|
|
456
|
+
const result = await runWorkflow<{
|
|
457
|
+
usersEndpoint: string
|
|
458
|
+
ordersEndpoint: string
|
|
459
|
+
}>(instance)
|
|
460
|
+
|
|
461
|
+
expect(result.usersEndpoint).toBe('api-users')
|
|
462
|
+
expect(result.ordersEndpoint).toBe('api-orders')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('supports parallel DurableStep execution', async () => {
|
|
466
|
+
const instance = await getWorkflowInstance('parallel-test-1')
|
|
467
|
+
|
|
468
|
+
const result = await runWorkflow<{
|
|
469
|
+
results: string[]
|
|
470
|
+
executedInParallel: boolean
|
|
471
|
+
}>(instance)
|
|
472
|
+
|
|
473
|
+
expect(result.results).toHaveLength(3)
|
|
474
|
+
expect(result.executedInParallel).toBe(true)
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// ============================================================================
|
|
480
|
+
// 9. State Persistence Across Workflow Restarts
|
|
481
|
+
// ============================================================================
|
|
482
|
+
|
|
483
|
+
describe('DurableStep: State Persistence', () => {
|
|
484
|
+
it('persists step state before execution', async () => {
|
|
485
|
+
const instance = await getWorkflowInstance('persist-before-test-1')
|
|
486
|
+
|
|
487
|
+
const result = await runWorkflow<{ statePersistedBefore: boolean }>(instance)
|
|
488
|
+
|
|
489
|
+
expect(result.statePersistedBefore).toBe(true)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('persists step state after execution', async () => {
|
|
493
|
+
const instance = await getWorkflowInstance('persist-after-test-1')
|
|
494
|
+
|
|
495
|
+
const result = await runWorkflow<{ statePersistedAfter: boolean }>(instance)
|
|
496
|
+
|
|
497
|
+
expect(result.statePersistedAfter).toBe(true)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('resumes from last successful step on workflow restart', async () => {
|
|
501
|
+
// This tests the durability guarantee - if a workflow restarts,
|
|
502
|
+
// it should not re-execute already completed steps
|
|
503
|
+
const instance = await getWorkflowInstance('resume-test-1')
|
|
504
|
+
|
|
505
|
+
const result = await runWorkflow<{
|
|
506
|
+
step1ExecutedOnce: boolean
|
|
507
|
+
step2Completed: boolean
|
|
508
|
+
}>(instance)
|
|
509
|
+
|
|
510
|
+
expect(result.step1ExecutedOnce).toBe(true)
|
|
511
|
+
expect(result.step2Completed).toBe(true)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('tracks execution history', async () => {
|
|
515
|
+
const instance = await getWorkflowInstance('history-test-1')
|
|
516
|
+
|
|
517
|
+
const result = await runWorkflow<{
|
|
518
|
+
history: Array<{ step: string; timestamp: string }>
|
|
519
|
+
}>(instance)
|
|
520
|
+
|
|
521
|
+
expect(result.history).toBeDefined()
|
|
522
|
+
expect(Array.isArray(result.history)).toBe(true)
|
|
523
|
+
expect(result.history.length).toBeGreaterThan(0)
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
// ============================================================================
|
|
528
|
+
// 10. Timeout Handling
|
|
529
|
+
// ============================================================================
|
|
530
|
+
|
|
531
|
+
describe('DurableStep: Timeout Handling', () => {
|
|
532
|
+
it('handles step timeout gracefully', async () => {
|
|
533
|
+
const instance = await getWorkflowInstance('graceful-timeout-test-1')
|
|
534
|
+
|
|
535
|
+
// The workflow should handle the timeout and return a timeout error
|
|
536
|
+
const result = await runWorkflow<{ timedOut: boolean; error: string }>(instance)
|
|
537
|
+
|
|
538
|
+
expect(result.timedOut).toBe(true)
|
|
539
|
+
expect(result.error).toContain('timeout')
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('allows subsequent steps after timeout handling', async () => {
|
|
543
|
+
const instance = await getWorkflowInstance('after-timeout-test-1')
|
|
544
|
+
|
|
545
|
+
const result = await runWorkflow<{
|
|
546
|
+
step1TimedOut: boolean
|
|
547
|
+
step2Completed: boolean
|
|
548
|
+
}>(instance)
|
|
549
|
+
|
|
550
|
+
expect(result.step1TimedOut).toBe(true)
|
|
551
|
+
expect(result.step2Completed).toBe(true)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('respects per-step timeout configuration', async () => {
|
|
555
|
+
const instance = await getWorkflowInstance('per-step-timeout-test-1')
|
|
556
|
+
|
|
557
|
+
const result = await runWorkflow<{
|
|
558
|
+
fastStepCompleted: boolean
|
|
559
|
+
slowStepTimedOut: boolean
|
|
560
|
+
}>(instance)
|
|
561
|
+
|
|
562
|
+
expect(result.fastStepCompleted).toBe(true)
|
|
563
|
+
expect(result.slowStepTimedOut).toBe(true)
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
// ============================================================================
|
|
568
|
+
// 11. Integration with WorkflowService
|
|
569
|
+
// ============================================================================
|
|
570
|
+
|
|
571
|
+
describe('DurableStep: WorkflowService Integration', () => {
|
|
572
|
+
it('DurableStep works within WorkflowService context', async () => {
|
|
573
|
+
// This verifies that DurableStep integrates properly with WorkflowService
|
|
574
|
+
const instance = await getWorkflowInstance('service-integration-test-1')
|
|
575
|
+
|
|
576
|
+
const result = await runWorkflow<{ serviceIntegrated: boolean }>(instance)
|
|
577
|
+
|
|
578
|
+
expect(result.serviceIntegrated).toBe(true)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('DurableStep can access workflow context', async () => {
|
|
582
|
+
const instance = await getWorkflowInstance('context-access-test-1')
|
|
583
|
+
|
|
584
|
+
const result = await runWorkflow<{ contextAvailable: boolean }>(instance)
|
|
585
|
+
|
|
586
|
+
expect(result.contextAvailable).toBe(true)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('DurableStep supports workflow-level state sharing', async () => {
|
|
590
|
+
const instance = await getWorkflowInstance('state-sharing-test-1')
|
|
591
|
+
|
|
592
|
+
const result = await runWorkflow<{
|
|
593
|
+
step1SetValue: string
|
|
594
|
+
step2ReadValue: string
|
|
595
|
+
}>(instance)
|
|
596
|
+
|
|
597
|
+
expect(result.step1SetValue).toBe('shared-data')
|
|
598
|
+
expect(result.step2ReadValue).toBe('shared-data')
|
|
599
|
+
})
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// ============================================================================
|
|
603
|
+
// 12. StepMetadata Type Tests
|
|
604
|
+
// ============================================================================
|
|
605
|
+
|
|
606
|
+
describe('StepMetadata', () => {
|
|
607
|
+
it('has required fields: id, attempt, retries', () => {
|
|
608
|
+
// This is a type-level test - if the import works and the type has the
|
|
609
|
+
// required fields, the DurableStep constructor and metadata access should work.
|
|
610
|
+
const metadata: StepMetadata = {
|
|
611
|
+
id: 'test-step',
|
|
612
|
+
attempt: 1,
|
|
613
|
+
retries: 3,
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
expect(metadata.id).toBe('test-step')
|
|
617
|
+
expect(metadata.attempt).toBe(1)
|
|
618
|
+
expect(metadata.retries).toBe(3)
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// 13. StepConfig Type Tests
|
|
624
|
+
// ============================================================================
|
|
625
|
+
|
|
626
|
+
describe('StepConfig', () => {
|
|
627
|
+
it('matches Cloudflare WorkflowStepConfig shape', () => {
|
|
628
|
+
const config: StepConfig = {
|
|
629
|
+
retries: {
|
|
630
|
+
limit: 5,
|
|
631
|
+
delay: '1 second',
|
|
632
|
+
backoff: 'exponential',
|
|
633
|
+
},
|
|
634
|
+
timeout: '30 seconds',
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
expect(config.retries?.limit).toBe(5)
|
|
638
|
+
expect(config.retries?.delay).toBe('1 second')
|
|
639
|
+
expect(config.retries?.backoff).toBe('exponential')
|
|
640
|
+
expect(config.timeout).toBe('30 seconds')
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it('supports numeric delay values', () => {
|
|
644
|
+
const config: StepConfig = {
|
|
645
|
+
retries: {
|
|
646
|
+
limit: 3,
|
|
647
|
+
delay: 1000,
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
expect(config.retries?.delay).toBe(1000)
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('supports numeric timeout values', () => {
|
|
655
|
+
const config: StepConfig = {
|
|
656
|
+
timeout: 30000,
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
expect(config.timeout).toBe(30000)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('allows omitting optional fields', () => {
|
|
663
|
+
const minimalConfig: StepConfig = {}
|
|
664
|
+
|
|
665
|
+
expect(minimalConfig.retries).toBeUndefined()
|
|
666
|
+
expect(minimalConfig.timeout).toBeUndefined()
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// ============================================================================
|
|
671
|
+
// 14. Edge Cases
|
|
672
|
+
// ============================================================================
|
|
673
|
+
|
|
674
|
+
describe('DurableStep: Edge Cases', () => {
|
|
675
|
+
it('handles empty input', async () => {
|
|
676
|
+
const instance = await getWorkflowInstance('empty-input-test-1')
|
|
677
|
+
|
|
678
|
+
const result = await runWorkflow<{ processed: boolean }>(instance)
|
|
679
|
+
|
|
680
|
+
expect(result.processed).toBe(true)
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('handles large input data', async () => {
|
|
684
|
+
const instance = await getWorkflowInstance('large-input-test-1')
|
|
685
|
+
|
|
686
|
+
const result = await runWorkflow<{ dataSize: number }>(instance)
|
|
687
|
+
|
|
688
|
+
expect(result.dataSize).toBeGreaterThan(1000)
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('handles large output data', async () => {
|
|
692
|
+
const instance = await getWorkflowInstance('large-output-test-1')
|
|
693
|
+
|
|
694
|
+
const result = await runWorkflow<{ items: unknown[] }>(instance)
|
|
695
|
+
|
|
696
|
+
expect(result.items.length).toBeGreaterThan(1000)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('handles nested step.do() calls', async () => {
|
|
700
|
+
const instance = await getWorkflowInstance('nested-do-test-1')
|
|
701
|
+
|
|
702
|
+
const result = await runWorkflow<{ nestedResult: string }>(instance)
|
|
703
|
+
|
|
704
|
+
expect(result.nestedResult).toBe('nested-success')
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('handles concurrent workflow instances', async () => {
|
|
708
|
+
// Create multiple workflow instances concurrently
|
|
709
|
+
const instances = await Promise.all([
|
|
710
|
+
getWorkflowInstance('concurrent-test-1'),
|
|
711
|
+
getWorkflowInstance('concurrent-test-2'),
|
|
712
|
+
getWorkflowInstance('concurrent-test-3'),
|
|
713
|
+
])
|
|
714
|
+
|
|
715
|
+
const results = await Promise.all(
|
|
716
|
+
instances.map((instance) => runWorkflow<{ instanceId: string }>(instance))
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
// Each instance should have completed with its own ID
|
|
720
|
+
const ids = results.map((r) => r.instanceId)
|
|
721
|
+
expect(new Set(ids).size).toBe(3) // All unique
|
|
722
|
+
})
|
|
723
|
+
})
|