@bratsos/workflow-engine 0.0.11 → 0.2.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/README.md +270 -513
- package/dist/chunk-D7RVRRM2.js +3 -0
- package/dist/chunk-D7RVRRM2.js.map +1 -0
- package/dist/chunk-HL3OJG7W.js +1033 -0
- package/dist/chunk-HL3OJG7W.js.map +1 -0
- package/dist/chunk-MUWP5SF2.js +33 -0
- package/dist/chunk-MUWP5SF2.js.map +1 -0
- package/dist/chunk-NYKMT46J.js +1143 -0
- package/dist/chunk-NYKMT46J.js.map +1 -0
- package/dist/chunk-P4KMGCT3.js +2292 -0
- package/dist/chunk-P4KMGCT3.js.map +1 -0
- package/dist/chunk-SPXBCZLB.js +17 -0
- package/dist/chunk-SPXBCZLB.js.map +1 -0
- package/dist/cli/sync-models.d.ts +1 -0
- package/dist/cli/sync-models.js +210 -0
- package/dist/cli/sync-models.js.map +1 -0
- package/dist/client-D4PoxADF.d.ts +798 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +4 -0
- package/dist/client.js.map +1 -0
- package/dist/index-DAzCfO1R.d.ts +217 -0
- package/dist/index.d.ts +569 -0
- package/dist/index.js +399 -0
- package/dist/index.js.map +1 -0
- package/dist/interface-MMqhfQQK.d.ts +411 -0
- package/dist/kernel/index.d.ts +26 -0
- package/dist/kernel/index.js +3 -0
- package/dist/kernel/index.js.map +1 -0
- package/dist/kernel/testing/index.d.ts +44 -0
- package/dist/kernel/testing/index.js +85 -0
- package/dist/kernel/testing/index.js.map +1 -0
- package/dist/persistence/index.d.ts +2 -0
- package/dist/persistence/index.js +6 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/prisma/index.d.ts +37 -0
- package/dist/persistence/prisma/index.js +5 -0
- package/dist/persistence/prisma/index.js.map +1 -0
- package/dist/plugins-BCnDUwIc.d.ts +415 -0
- package/dist/ports-tU3rzPXJ.d.ts +245 -0
- package/dist/stage-BPw7m9Wx.d.ts +144 -0
- package/dist/testing/index.d.ts +264 -0
- package/dist/testing/index.js +920 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +11 -1
- package/skills/workflow-engine/SKILL.md +234 -348
- package/skills/workflow-engine/references/03-runtime-setup.md +111 -426
- package/skills/workflow-engine/references/05-persistence-setup.md +32 -0
- package/skills/workflow-engine/references/07-testing-patterns.md +141 -474
- package/skills/workflow-engine/references/08-common-patterns.md +118 -431
|
@@ -1,546 +1,213 @@
|
|
|
1
1
|
# Testing Patterns
|
|
2
2
|
|
|
3
|
-
Complete guide for testing
|
|
3
|
+
Complete guide for testing with the command kernel using in-memory adapters.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## In-Memory Adapters
|
|
6
6
|
|
|
7
|
-
The
|
|
7
|
+
The engine provides in-memory implementations for all ports:
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
|
+
// Persistence and job queue
|
|
10
11
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
InMemoryWorkflowPersistence,
|
|
13
|
+
InMemoryJobQueue,
|
|
14
|
+
InMemoryAICallLogger,
|
|
14
15
|
} from "@bratsos/workflow-engine/testing";
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## TestWorkflowPersistence
|
|
18
|
-
|
|
19
|
-
In-memory implementation of `WorkflowPersistence` for testing:
|
|
20
|
-
|
|
21
|
-
```typescript
|
|
22
|
-
const persistence = new TestWorkflowPersistence();
|
|
23
|
-
|
|
24
|
-
// Create a run
|
|
25
|
-
const run = await persistence.createRun({
|
|
26
|
-
workflowId: "test-workflow",
|
|
27
|
-
workflowName: "Test Workflow",
|
|
28
|
-
workflowType: "test",
|
|
29
|
-
input: { data: "test" },
|
|
30
|
-
});
|
|
31
16
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
17
|
+
// Kernel-specific test adapters
|
|
18
|
+
import {
|
|
19
|
+
FakeClock,
|
|
20
|
+
InMemoryBlobStore,
|
|
21
|
+
CollectingEventSink,
|
|
22
|
+
NoopScheduler,
|
|
23
|
+
} from "@bratsos/workflow-engine/kernel/testing";
|
|
38
24
|
```
|
|
39
25
|
|
|
40
|
-
##
|
|
26
|
+
## Test Kernel Setup
|
|
41
27
|
|
|
42
|
-
|
|
28
|
+
Create a fully in-memory kernel for testing:
|
|
43
29
|
|
|
44
30
|
```typescript
|
|
45
|
-
|
|
31
|
+
import { createKernel } from "@bratsos/workflow-engine/kernel";
|
|
46
32
|
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// Dequeue
|
|
55
|
-
const job = await jobQueue.dequeue();
|
|
33
|
+
function createTestKernel(workflows: Map<string, Workflow>) {
|
|
34
|
+
const persistence = new InMemoryWorkflowPersistence();
|
|
35
|
+
const jobQueue = new InMemoryJobQueue();
|
|
36
|
+
const blobStore = new InMemoryBlobStore();
|
|
37
|
+
const eventSink = new CollectingEventSink();
|
|
38
|
+
const scheduler = new NoopScheduler();
|
|
39
|
+
const clock = new FakeClock();
|
|
56
40
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
41
|
+
const kernel = createKernel({
|
|
42
|
+
persistence,
|
|
43
|
+
blobStore,
|
|
44
|
+
jobTransport: jobQueue,
|
|
45
|
+
eventSink,
|
|
46
|
+
scheduler,
|
|
47
|
+
clock,
|
|
48
|
+
registry: { getWorkflow: (id) => workflows.get(id) },
|
|
49
|
+
});
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
return { kernel, persistence, jobQueue, blobStore, eventSink, scheduler, clock };
|
|
52
|
+
}
|
|
63
53
|
```
|
|
64
54
|
|
|
65
|
-
##
|
|
66
|
-
|
|
67
|
-
Mock implementation of `AIHelper` for predictable test results:
|
|
55
|
+
## Full Workflow Lifecycle Test
|
|
68
56
|
|
|
69
57
|
```typescript
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
58
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
59
|
+
import { defineStage, WorkflowBuilder } from "@bratsos/workflow-engine";
|
|
60
|
+
import { z } from "zod";
|
|
61
|
+
|
|
62
|
+
const echoStage = defineStage({
|
|
63
|
+
id: "echo",
|
|
64
|
+
name: "Echo",
|
|
65
|
+
schemas: {
|
|
66
|
+
input: z.object({ message: z.string() }),
|
|
67
|
+
output: z.object({ echoed: z.string() }),
|
|
68
|
+
config: z.object({}),
|
|
69
|
+
},
|
|
70
|
+
async execute(ctx) {
|
|
71
|
+
return { output: { echoed: ctx.input.message } };
|
|
72
|
+
},
|
|
80
73
|
});
|
|
81
74
|
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
const workflow = new WorkflowBuilder(
|
|
76
|
+
"echo-wf", "Echo WF", "Test",
|
|
77
|
+
z.object({ message: z.string() }),
|
|
78
|
+
z.object({ echoed: z.string() }),
|
|
79
|
+
)
|
|
80
|
+
.pipe(echoStage)
|
|
81
|
+
.build();
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// Use in tests
|
|
89
|
-
const result = await mockAI.generateText("gemini-2.5-flash", "Any prompt");
|
|
90
|
-
// Returns "Expected text response"
|
|
91
|
-
|
|
92
|
-
// Check calls
|
|
93
|
-
console.log(mockAI.calls.length); // Number of calls
|
|
94
|
-
console.log(mockAI.calls[0].modelKey); // Model used
|
|
95
|
-
console.log(mockAI.calls[0].prompt); // Prompt sent
|
|
96
|
-
|
|
97
|
-
// Reset between tests
|
|
98
|
-
mockAI.reset();
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## Testing Individual Stages
|
|
102
|
-
|
|
103
|
-
### Unit Testing a Sync Stage
|
|
104
|
-
|
|
105
|
-
```typescript
|
|
106
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
107
|
-
import { TestWorkflowPersistence, MockAIHelper } from "@bratsos/workflow-engine/testing";
|
|
108
|
-
import { myStage } from "./my-stage";
|
|
109
|
-
|
|
110
|
-
describe("myStage", () => {
|
|
111
|
-
let persistence: TestWorkflowPersistence;
|
|
112
|
-
let mockAI: MockAIHelper;
|
|
83
|
+
describe("echo workflow", () => {
|
|
84
|
+
let kernel, persistence, jobQueue;
|
|
113
85
|
|
|
114
86
|
beforeEach(() => {
|
|
115
|
-
|
|
116
|
-
|
|
87
|
+
const t = createTestKernel(new Map([["echo-wf", workflow]]));
|
|
88
|
+
kernel = t.kernel;
|
|
89
|
+
persistence = t.persistence;
|
|
90
|
+
jobQueue = t.jobQueue;
|
|
117
91
|
});
|
|
118
92
|
|
|
119
|
-
it("
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
config,
|
|
128
|
-
workflowContext: {},
|
|
129
|
-
workflowRunId: "test-run",
|
|
130
|
-
stageId: "my-stage",
|
|
131
|
-
log: async () => {},
|
|
132
|
-
storage: persistence.createStorage("test-run"),
|
|
133
|
-
require: (id: string) => {
|
|
134
|
-
throw new Error(`Stage ${id} not found`);
|
|
135
|
-
},
|
|
136
|
-
optional: (id: string) => undefined,
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
// Act
|
|
140
|
-
const result = await myStage.execute(ctx);
|
|
141
|
-
|
|
142
|
-
// Assert
|
|
143
|
-
expect(result.output).toBeDefined();
|
|
144
|
-
expect(result.output.processedData).toBe("TEST INPUT");
|
|
145
|
-
});
|
|
93
|
+
it("completes a single-stage workflow", async () => {
|
|
94
|
+
// 1. Create the run
|
|
95
|
+
const { workflowRunId } = await kernel.dispatch({
|
|
96
|
+
type: "run.create",
|
|
97
|
+
idempotencyKey: "test-1",
|
|
98
|
+
workflowId: "echo-wf",
|
|
99
|
+
input: { message: "hello" },
|
|
100
|
+
});
|
|
146
101
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
102
|
+
// 2. Claim pending runs (enqueues first-stage job)
|
|
103
|
+
await kernel.dispatch({
|
|
104
|
+
type: "run.claimPending",
|
|
105
|
+
workerId: "test-worker",
|
|
106
|
+
});
|
|
150
107
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
workflowContext: {},
|
|
155
|
-
workflowRunId: "test-run",
|
|
156
|
-
stageId: "ai-stage",
|
|
157
|
-
log: async () => {},
|
|
158
|
-
storage: persistence.createStorage("test-run"),
|
|
159
|
-
require: () => ({}),
|
|
160
|
-
optional: () => undefined,
|
|
161
|
-
// Inject mock AI helper
|
|
162
|
-
ai: mockAI,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const result = await aiStage.execute(ctx);
|
|
166
|
-
|
|
167
|
-
// Verify AI was called
|
|
168
|
-
expect(mockAI.calls.length).toBe(1);
|
|
169
|
-
expect(mockAI.calls[0].modelKey).toBe("gemini-2.5-flash");
|
|
170
|
-
|
|
171
|
-
// Verify result
|
|
172
|
-
expect(result.output.classification).toBe("tech");
|
|
173
|
-
});
|
|
108
|
+
// 3. Dequeue and execute the job
|
|
109
|
+
const job = await jobQueue.dequeue();
|
|
110
|
+
expect(job).not.toBeNull();
|
|
174
111
|
|
|
175
|
-
|
|
176
|
-
|
|
112
|
+
await kernel.dispatch({
|
|
113
|
+
type: "job.execute",
|
|
114
|
+
workflowRunId: job.workflowRunId,
|
|
115
|
+
workflowId: job.workflowId,
|
|
116
|
+
stageId: job.stageId,
|
|
117
|
+
config: {},
|
|
118
|
+
});
|
|
119
|
+
await jobQueue.complete(job.jobId);
|
|
177
120
|
|
|
178
|
-
|
|
121
|
+
// 4. Transition the workflow
|
|
122
|
+
const { action } = await kernel.dispatch({
|
|
123
|
+
type: "run.transition",
|
|
124
|
+
workflowRunId,
|
|
125
|
+
});
|
|
126
|
+
expect(action).toBe("completed");
|
|
179
127
|
|
|
180
|
-
|
|
128
|
+
// 5. Verify final state
|
|
129
|
+
const run = await persistence.getRun(workflowRunId);
|
|
130
|
+
expect(run.status).toBe("COMPLETED");
|
|
181
131
|
});
|
|
182
132
|
});
|
|
183
133
|
```
|
|
184
134
|
|
|
185
|
-
|
|
135
|
+
## FakeClock
|
|
186
136
|
|
|
187
|
-
|
|
188
|
-
it("should access previous stage output", async () => {
|
|
189
|
-
// Setup workflow context with previous stage output
|
|
190
|
-
const workflowContext = {
|
|
191
|
-
"data-extraction": {
|
|
192
|
-
title: "Test Document",
|
|
193
|
-
sections: [{ heading: "Intro", content: "..." }],
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const ctx = {
|
|
198
|
-
input: {},
|
|
199
|
-
config: {},
|
|
200
|
-
workflowContext,
|
|
201
|
-
workflowRunId: "test-run",
|
|
202
|
-
stageId: "processing-stage",
|
|
203
|
-
log: async () => {},
|
|
204
|
-
storage: persistence.createStorage("test-run"),
|
|
205
|
-
require: (id: string) => {
|
|
206
|
-
const output = workflowContext[id];
|
|
207
|
-
if (!output) throw new Error(`Missing ${id}`);
|
|
208
|
-
return output;
|
|
209
|
-
},
|
|
210
|
-
optional: (id: string) => workflowContext[id],
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const result = await processingStage.execute(ctx);
|
|
214
|
-
|
|
215
|
-
expect(result.output.processedTitle).toBe("Test Document");
|
|
216
|
-
});
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
## Testing Async Batch Stages
|
|
220
|
-
|
|
221
|
-
### Testing Execute (Suspension)
|
|
137
|
+
Injectable time source for deterministic testing:
|
|
222
138
|
|
|
223
139
|
```typescript
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
workflowContext: {
|
|
227
|
-
"data-extraction": { items: ["a", "b", "c"] },
|
|
228
|
-
},
|
|
229
|
-
});
|
|
140
|
+
const clock = new FakeClock();
|
|
141
|
+
clock.now(); // Returns frozen time
|
|
230
142
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// Verify suspended result
|
|
234
|
-
expect(result.suspended).toBe(true);
|
|
235
|
-
expect(result.state.batchId).toBeDefined();
|
|
236
|
-
expect(result.state.pollInterval).toBe(60000);
|
|
237
|
-
expect(result.pollConfig.nextPollAt).toBeInstanceOf(Date);
|
|
238
|
-
});
|
|
143
|
+
// Advance time for testing stale leases, poll intervals, etc.
|
|
144
|
+
clock.advance(60_000); // Advance 60 seconds
|
|
239
145
|
```
|
|
240
146
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
```typescript
|
|
244
|
-
it("should return cached results on resume", async () => {
|
|
245
|
-
// Setup storage with cached results
|
|
246
|
-
await persistence.saveArtifact({
|
|
247
|
-
workflowRunId: "test-run",
|
|
248
|
-
key: "batch-result",
|
|
249
|
-
type: "ARTIFACT",
|
|
250
|
-
data: { items: ["processed-a", "processed-b"] },
|
|
251
|
-
size: 100,
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
const ctx = createTestContext({
|
|
255
|
-
resumeState: {
|
|
256
|
-
batchId: "batch-123",
|
|
257
|
-
submittedAt: new Date().toISOString(),
|
|
258
|
-
pollInterval: 60000,
|
|
259
|
-
maxWaitTime: 3600000,
|
|
260
|
-
},
|
|
261
|
-
});
|
|
147
|
+
## CollectingEventSink
|
|
262
148
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
expect(result.suspended).toBeUndefined();
|
|
266
|
-
expect(result.output.items).toEqual(["processed-a", "processed-b"]);
|
|
267
|
-
});
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
### Testing checkCompletion
|
|
149
|
+
Captures events for assertions:
|
|
271
150
|
|
|
272
151
|
```typescript
|
|
273
|
-
|
|
152
|
+
const eventSink = new CollectingEventSink();
|
|
274
153
|
|
|
275
|
-
|
|
276
|
-
it("should return not ready when batch is processing", async () => {
|
|
277
|
-
// Mock batch API
|
|
278
|
-
vi.spyOn(batchProvider, "getStatus").mockResolvedValue({
|
|
279
|
-
status: "processing",
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
const result = await batchStage.checkCompletion(
|
|
283
|
-
{ batchId: "batch-123", submittedAt: "...", pollInterval: 60000, maxWaitTime: 3600000 },
|
|
284
|
-
{ workflowRunId: "test-run", stageId: "batch-stage", config: {}, log: async () => {}, storage: persistence.createStorage("test-run") }
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
expect(result.ready).toBe(false);
|
|
288
|
-
expect(result.nextCheckIn).toBe(60000);
|
|
289
|
-
});
|
|
154
|
+
// ... run workflow ...
|
|
290
155
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
vi.spyOn(batchProvider, "getResults").mockResolvedValue([
|
|
294
|
-
{ id: "req-1", result: "processed", inputTokens: 100, outputTokens: 50, status: "succeeded" },
|
|
295
|
-
]);
|
|
156
|
+
// Flush outbox to publish events
|
|
157
|
+
await kernel.dispatch({ type: "outbox.flush" });
|
|
296
158
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
expect(result.ready).toBe(true);
|
|
303
|
-
expect(result.output.items).toHaveLength(1);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("should return error when batch fails", async () => {
|
|
307
|
-
vi.spyOn(batchProvider, "getStatus").mockResolvedValue({ status: "failed" });
|
|
308
|
-
|
|
309
|
-
const result = await batchStage.checkCompletion(
|
|
310
|
-
{ batchId: "batch-123", submittedAt: "...", pollInterval: 60000, maxWaitTime: 3600000 },
|
|
311
|
-
createCheckContext()
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
expect(result.ready).toBe(false);
|
|
315
|
-
expect(result.error).toBeDefined();
|
|
316
|
-
});
|
|
317
|
-
});
|
|
159
|
+
// Assert on collected events
|
|
160
|
+
expect(eventSink.events).toContainEqual(
|
|
161
|
+
expect.objectContaining({ type: "workflow:completed" })
|
|
162
|
+
);
|
|
318
163
|
```
|
|
319
164
|
|
|
320
|
-
##
|
|
321
|
-
|
|
322
|
-
### Testing Complete Workflow
|
|
165
|
+
## Testing Idempotency
|
|
323
166
|
|
|
324
167
|
```typescript
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
persistence = new TestWorkflowPersistence();
|
|
333
|
-
mockAI = new MockAIHelper();
|
|
334
|
-
|
|
335
|
-
// Setup mock responses for all AI calls
|
|
336
|
-
mockAI.mockGenerateObject({ title: "Test", sections: [] });
|
|
337
|
-
mockAI.mockGenerateObject({ categories: ["tech"] });
|
|
338
|
-
mockAI.mockGenerateText("This is a summary.");
|
|
339
|
-
});
|
|
168
|
+
it("deduplicates run.create with same key", async () => {
|
|
169
|
+
const cmd = {
|
|
170
|
+
type: "run.create" as const,
|
|
171
|
+
idempotencyKey: "same-key",
|
|
172
|
+
workflowId: "echo-wf",
|
|
173
|
+
input: { message: "hello" },
|
|
174
|
+
};
|
|
340
175
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const run = await persistence.createRun({
|
|
344
|
-
workflowId: "document-analysis",
|
|
345
|
-
workflowName: "Document Analysis",
|
|
346
|
-
workflowType: "document-analysis",
|
|
347
|
-
input: { documentUrl: "https://example.com/doc.pdf" },
|
|
348
|
-
});
|
|
176
|
+
const first = await kernel.dispatch(cmd);
|
|
177
|
+
const second = await kernel.dispatch(cmd);
|
|
349
178
|
|
|
350
|
-
|
|
351
|
-
const executor = new WorkflowExecutor(
|
|
352
|
-
documentWorkflow,
|
|
353
|
-
run.id,
|
|
354
|
-
"document-analysis",
|
|
355
|
-
{
|
|
356
|
-
persistence,
|
|
357
|
-
aiLogger: createMockAILogger(),
|
|
358
|
-
}
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
// Execute
|
|
362
|
-
const result = await executor.execute(
|
|
363
|
-
{ documentUrl: "https://example.com/doc.pdf" },
|
|
364
|
-
documentWorkflow.getDefaultConfig()
|
|
365
|
-
);
|
|
366
|
-
|
|
367
|
-
// Verify
|
|
368
|
-
expect(result.status).toBe("COMPLETED");
|
|
369
|
-
|
|
370
|
-
// Check stages were created
|
|
371
|
-
const stages = await persistence.getStagesByRun(run.id);
|
|
372
|
-
expect(stages.length).toBe(4);
|
|
373
|
-
expect(stages.every(s => s.status === "COMPLETED")).toBe(true);
|
|
374
|
-
});
|
|
179
|
+
expect(first.workflowRunId).toBe(second.workflowRunId);
|
|
375
180
|
});
|
|
376
181
|
```
|
|
377
182
|
|
|
378
|
-
|
|
183
|
+
## Testing Cancellation
|
|
379
184
|
|
|
380
185
|
```typescript
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
persistence = new TestWorkflowPersistence();
|
|
388
|
-
jobQueue = new TestJobQueue();
|
|
389
|
-
|
|
390
|
-
runtime = createWorkflowRuntime({
|
|
391
|
-
persistence,
|
|
392
|
-
jobQueue,
|
|
393
|
-
registry: { getWorkflow: (id) => workflows[id] ?? null },
|
|
394
|
-
pollIntervalMs: 100,
|
|
395
|
-
jobPollIntervalMs: 50,
|
|
396
|
-
});
|
|
186
|
+
it("cancels a running workflow", async () => {
|
|
187
|
+
const { workflowRunId } = await kernel.dispatch({
|
|
188
|
+
type: "run.create",
|
|
189
|
+
idempotencyKey: "cancel-test",
|
|
190
|
+
workflowId: "echo-wf",
|
|
191
|
+
input: { message: "hello" },
|
|
397
192
|
});
|
|
398
193
|
|
|
399
|
-
|
|
400
|
-
runtime.stop();
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("should process workflow through runtime", async () => {
|
|
404
|
-
// Create run
|
|
405
|
-
const { workflowRunId } = await runtime.createRun({
|
|
406
|
-
workflowId: "simple-workflow",
|
|
407
|
-
input: { data: "test" },
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// Start runtime (in test mode)
|
|
411
|
-
await runtime.start();
|
|
194
|
+
await kernel.dispatch({ type: "run.claimPending", workerId: "w1" });
|
|
412
195
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}, { timeout: 5000 });
|
|
418
|
-
|
|
419
|
-
// Verify
|
|
420
|
-
const run = await persistence.getRun(workflowRunId);
|
|
421
|
-
expect(run?.status).toBe("COMPLETED");
|
|
196
|
+
const { cancelled } = await kernel.dispatch({
|
|
197
|
+
type: "run.cancel",
|
|
198
|
+
workflowRunId,
|
|
199
|
+
reason: "User cancelled",
|
|
422
200
|
});
|
|
201
|
+
expect(cancelled).toBe(true);
|
|
423
202
|
});
|
|
424
203
|
```
|
|
425
204
|
|
|
426
|
-
##
|
|
427
|
-
|
|
428
|
-
### Creating Test Context
|
|
429
|
-
|
|
430
|
-
```typescript
|
|
431
|
-
function createTestContext<TInput, TConfig>(
|
|
432
|
-
overrides: Partial<{
|
|
433
|
-
input: TInput;
|
|
434
|
-
config: TConfig;
|
|
435
|
-
workflowContext: Record<string, unknown>;
|
|
436
|
-
resumeState: unknown;
|
|
437
|
-
ai: MockAIHelper;
|
|
438
|
-
}> = {}
|
|
439
|
-
) {
|
|
440
|
-
const persistence = new TestWorkflowPersistence();
|
|
441
|
-
|
|
442
|
-
return {
|
|
443
|
-
input: overrides.input ?? {},
|
|
444
|
-
config: overrides.config ?? {},
|
|
445
|
-
workflowContext: overrides.workflowContext ?? {},
|
|
446
|
-
workflowRunId: "test-run",
|
|
447
|
-
stageId: "test-stage",
|
|
448
|
-
resumeState: overrides.resumeState,
|
|
449
|
-
log: async (level: string, message: string) => {
|
|
450
|
-
console.log(`[${level}] ${message}`);
|
|
451
|
-
},
|
|
452
|
-
storage: persistence.createStorage("test-run"),
|
|
453
|
-
require: (id: string) => {
|
|
454
|
-
const output = overrides.workflowContext?.[id];
|
|
455
|
-
if (!output) throw new Error(`Missing required stage: ${id}`);
|
|
456
|
-
return output;
|
|
457
|
-
},
|
|
458
|
-
optional: (id: string) => overrides.workflowContext?.[id],
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
### Wait Helper
|
|
464
|
-
|
|
465
|
-
```typescript
|
|
466
|
-
async function waitFor(
|
|
467
|
-
condition: () => Promise<boolean>,
|
|
468
|
-
options: { timeout?: number; interval?: number } = {}
|
|
469
|
-
): Promise<void> {
|
|
470
|
-
const { timeout = 5000, interval = 100 } = options;
|
|
471
|
-
const start = Date.now();
|
|
472
|
-
|
|
473
|
-
while (Date.now() - start < timeout) {
|
|
474
|
-
if (await condition()) return;
|
|
475
|
-
await new Promise(r => setTimeout(r, interval));
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
throw new Error(`Condition not met within ${timeout}ms`);
|
|
479
|
-
}
|
|
480
|
-
```
|
|
481
|
-
|
|
482
|
-
### Mock AI Logger
|
|
483
|
-
|
|
484
|
-
```typescript
|
|
485
|
-
function createMockAILogger(): AICallLogger {
|
|
486
|
-
const calls: CreateAICallInput[] = [];
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
logCall: (call) => { calls.push(call); },
|
|
490
|
-
logBatchResults: async (batchId, results) => { calls.push(...results); },
|
|
491
|
-
getStats: async (topic) => ({
|
|
492
|
-
totalCalls: calls.filter(c => c.topic.startsWith(topic)).length,
|
|
493
|
-
totalInputTokens: 0,
|
|
494
|
-
totalOutputTokens: 0,
|
|
495
|
-
totalCost: 0,
|
|
496
|
-
perModel: {},
|
|
497
|
-
}),
|
|
498
|
-
isRecorded: async (batchId) => calls.some(c => c.metadata?.batchId === batchId),
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
## Best Practices
|
|
504
|
-
|
|
505
|
-
### 1. Isolate Tests
|
|
205
|
+
## Reset Between Tests
|
|
506
206
|
|
|
507
207
|
```typescript
|
|
508
208
|
beforeEach(() => {
|
|
509
|
-
persistence.
|
|
510
|
-
jobQueue.
|
|
511
|
-
|
|
512
|
-
});
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
### 2. Test Edge Cases
|
|
516
|
-
|
|
517
|
-
```typescript
|
|
518
|
-
it("should handle empty input", async () => { ... });
|
|
519
|
-
it("should handle missing dependencies", async () => { ... });
|
|
520
|
-
it("should handle AI errors", async () => { ... });
|
|
521
|
-
it("should handle timeout", async () => { ... });
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
### 3. Use Type-Safe Mocks
|
|
525
|
-
|
|
526
|
-
```typescript
|
|
527
|
-
// Bad: loosely typed
|
|
528
|
-
const ctx = { input: {}, config: {} } as any;
|
|
529
|
-
|
|
530
|
-
// Good: properly typed
|
|
531
|
-
const ctx = createTestContext<InputType, ConfigType>({
|
|
532
|
-
input: { data: "test" },
|
|
533
|
-
config: { verbose: true },
|
|
534
|
-
});
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
### 4. Test Metrics and Artifacts
|
|
538
|
-
|
|
539
|
-
```typescript
|
|
540
|
-
it("should record metrics", async () => {
|
|
541
|
-
const result = await stage.execute(ctx);
|
|
542
|
-
|
|
543
|
-
expect(result.customMetrics?.itemsProcessed).toBe(10);
|
|
544
|
-
expect(result.artifacts?.rawData).toBeDefined();
|
|
209
|
+
persistence.clear();
|
|
210
|
+
jobQueue.clear();
|
|
211
|
+
eventSink.events = [];
|
|
545
212
|
});
|
|
546
213
|
```
|