workflow 4.1.0-beta.63 → 4.2.0-beta.65
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/docs/api-reference/index.mdx +3 -0
- package/docs/api-reference/meta.json +1 -1
- package/docs/api-reference/vitest/index.mdx +150 -0
- package/docs/api-reference/workflow/create-hook.mdx +1 -1
- package/docs/api-reference/workflow/create-webhook.mdx +13 -80
- package/docs/api-reference/workflow-api/resume-webhook.mdx +9 -156
- package/docs/foundations/hooks.mdx +19 -34
- package/docs/testing/index.mdx +165 -111
- package/docs/testing/meta.json +2 -1
- package/docs/testing/server-based.mdx +183 -0
- package/package.json +11 -11
|
@@ -20,4 +20,7 @@ All the functions and primitives that come with Workflow DevKit by package.
|
|
|
20
20
|
<Card title="@workflow/ai" href="/docs/api-reference/workflow-ai">
|
|
21
21
|
Helpers for integrating AI SDK for building AI-powered workflows.
|
|
22
22
|
</Card>
|
|
23
|
+
<Card title="@workflow/vitest" href="/docs/api-reference/vitest">
|
|
24
|
+
Vitest plugin and test helpers for integration testing workflows in-process.
|
|
25
|
+
</Card>
|
|
23
26
|
</Cards>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "@workflow/vitest"
|
|
3
|
+
description: Vitest plugin and test helpers for integration testing workflows in-process.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The `@workflow/vitest` package provides a Vitest plugin and test helpers for running full workflow integration tests in-process — no server required.
|
|
7
|
+
|
|
8
|
+
## Plugin
|
|
9
|
+
|
|
10
|
+
### `workflow()`
|
|
11
|
+
|
|
12
|
+
Returns a Vite plugin array that handles SWC transforms, bundle building, and in-process handler registration automatically.
|
|
13
|
+
|
|
14
|
+
{/* @skip-typecheck - @workflow/vitest not available in docs-typecheck */}
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { defineConfig } from "vitest/config";
|
|
18
|
+
import { workflow } from "@workflow/vitest"; // [!code highlight]
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
plugins: [workflow()], // [!code highlight]
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Returns:** `Plugin[]`
|
|
26
|
+
|
|
27
|
+
## Setup Functions
|
|
28
|
+
|
|
29
|
+
### `buildWorkflowTests()`
|
|
30
|
+
|
|
31
|
+
Builds workflow and step bundles to disk. Called automatically by the `workflow()` plugin in `globalSetup`. Use directly only for [manual setup](/docs/testing#manual-setup).
|
|
32
|
+
|
|
33
|
+
{/* @skip-typecheck - @workflow/vitest not available in docs-typecheck */}
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { buildWorkflowTests } from "@workflow/vitest";
|
|
37
|
+
|
|
38
|
+
export async function setup() {
|
|
39
|
+
await buildWorkflowTests();
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Parameters:**
|
|
44
|
+
|
|
45
|
+
| Parameter | Type | Description |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `options?` | `WorkflowTestOptions` | Optional configuration |
|
|
48
|
+
|
|
49
|
+
### `setupWorkflowTests()`
|
|
50
|
+
|
|
51
|
+
Sets up an in-process workflow runtime in each test worker. Imports pre-built bundles, creates a [Local World](/docs/worlds/local) instance with direct handlers, and sets it as the global world. Clears all workflow data on each invocation for full test isolation.
|
|
52
|
+
|
|
53
|
+
Called automatically by the `workflow()` plugin in `setupFiles`. Use directly only for [manual setup](/docs/testing#manual-setup).
|
|
54
|
+
|
|
55
|
+
{/* @skip-typecheck - @workflow/vitest not available in docs-typecheck */}
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { beforeAll, afterAll } from "vitest";
|
|
59
|
+
import { setupWorkflowTests, teardownWorkflowTests } from "@workflow/vitest";
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
await setupWorkflowTests();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await teardownWorkflowTests();
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Parameters:**
|
|
71
|
+
|
|
72
|
+
| Parameter | Type | Description |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `options?` | `WorkflowTestOptions` | Optional configuration |
|
|
75
|
+
|
|
76
|
+
### `teardownWorkflowTests()`
|
|
77
|
+
|
|
78
|
+
Tears down the workflow test world. Clears the global world and closes the Local World instance. Called automatically by the `workflow()` plugin.
|
|
79
|
+
|
|
80
|
+
**Returns:** `Promise<void>`
|
|
81
|
+
|
|
82
|
+
### `WorkflowTestOptions`
|
|
83
|
+
|
|
84
|
+
| Option | Type | Default | Description |
|
|
85
|
+
| --- | --- | --- | --- |
|
|
86
|
+
| `cwd` | `string` | `process.cwd()` | The working directory of the project (where `workflows/` lives) |
|
|
87
|
+
|
|
88
|
+
## Test Helpers
|
|
89
|
+
|
|
90
|
+
### `waitForSleep()`
|
|
91
|
+
|
|
92
|
+
Polls the event log until the workflow has a pending `sleep()` call — one with a `wait_created` event but no corresponding `wait_completed` event. Returns the correlation ID of the pending sleep, which can be passed to [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to target a specific sleep.
|
|
93
|
+
|
|
94
|
+
{/* @skip-typecheck - @workflow/vitest not available in docs-typecheck */}
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { waitForSleep } from "@workflow/vitest"; // [!code highlight]
|
|
98
|
+
import { start, getRun } from "workflow/api";
|
|
99
|
+
|
|
100
|
+
const run = await start(myWorkflow, []);
|
|
101
|
+
const sleepId = await waitForSleep(run); // [!code highlight]
|
|
102
|
+
await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlight]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Parameters:**
|
|
106
|
+
|
|
107
|
+
| Parameter | Type | Description |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `run` | `Run<any>` | The workflow run to monitor |
|
|
110
|
+
| `options?` | `WaitOptions` | Polling and timeout configuration |
|
|
111
|
+
|
|
112
|
+
**Returns:** `Promise<string>` — The correlation ID of the first pending sleep. Pass this to `wakeUp({ correlationIds: [id] })` to target a specific sleep.
|
|
113
|
+
|
|
114
|
+
#### Behavior with Multiple Sleeps
|
|
115
|
+
|
|
116
|
+
- **Sequential sleeps**: `waitForSleep()` returns each sleep as the workflow reaches it. After waking one, call `waitForSleep()` again for the next.
|
|
117
|
+
- **Parallel sleeps**: `waitForSleep()` returns whichever pending sleep is found first. After waking it, call `waitForSleep()` again to get the next one.
|
|
118
|
+
|
|
119
|
+
### `waitForHook()`
|
|
120
|
+
|
|
121
|
+
Polls the hook list and event log until a hook matching the optional `token` filter exists that hasn't been received yet. Returns the matching hook object.
|
|
122
|
+
|
|
123
|
+
{/* @skip-typecheck - @workflow/vitest not available in docs-typecheck */}
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { waitForHook } from "@workflow/vitest"; // [!code highlight]
|
|
127
|
+
import { start, resumeHook } from "workflow/api";
|
|
128
|
+
|
|
129
|
+
const run = await start(myWorkflow, ["doc-1"]);
|
|
130
|
+
const hook = await waitForHook(run, { token: "approval:doc-1" }); // [!code highlight]
|
|
131
|
+
await resumeHook(hook.token, { approved: true }); // [!code highlight]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Parameters:**
|
|
135
|
+
|
|
136
|
+
| Parameter | Type | Description |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `run` | `Run<any>` | The workflow run to monitor |
|
|
139
|
+
| `options?` | `WaitOptions & { token?: string }` | Polling, timeout, and optional token filter |
|
|
140
|
+
|
|
141
|
+
**Returns:** `Promise<Hook>` — The first pending hook matching the filter. The hook object includes `token`, `hookId`, and `runId`.
|
|
142
|
+
|
|
143
|
+
### `WaitOptions`
|
|
144
|
+
|
|
145
|
+
Both `waitForSleep()` and `waitForHook()` accept options for controlling polling behavior:
|
|
146
|
+
|
|
147
|
+
| Option | Type | Default | Description |
|
|
148
|
+
| --- | --- | --- | --- |
|
|
149
|
+
| `timeout` | `number` | `30000` | Maximum time to wait in milliseconds |
|
|
150
|
+
| `pollInterval` | `number` | `100` | Polling interval in milliseconds |
|
|
@@ -100,7 +100,7 @@ export async function slackBotWorkflow(channelId: string) {
|
|
|
100
100
|
|
|
101
101
|
// Token constructed from channel ID
|
|
102
102
|
using hook = createHook<SlackMessage>({ // [!code highlight]
|
|
103
|
-
token: `
|
|
103
|
+
token: `slack_messages:${channelId}`, // [!code highlight]
|
|
104
104
|
}); // [!code highlight]
|
|
105
105
|
|
|
106
106
|
for await (const message of hook) {
|
|
@@ -120,46 +120,6 @@ async function processData(data: any) {
|
|
|
120
120
|
}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
### Customizing Tokens
|
|
124
|
-
|
|
125
|
-
Tokens are used to identify a specific webhook. You can customize the token to be more specific to a use case.
|
|
126
|
-
|
|
127
|
-
```typescript lineNumbers
|
|
128
|
-
import { createWebhook, type RequestWithResponse } from "workflow"
|
|
129
|
-
|
|
130
|
-
async function sendAck(request: RequestWithResponse) {
|
|
131
|
-
"use step";
|
|
132
|
-
await request.respondWith(
|
|
133
|
-
new Response(JSON.stringify({ received: true }), {
|
|
134
|
-
headers: { "Content-Type": "application/json" }
|
|
135
|
-
})
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function githubWebhookWorkflow(repoName: string) {
|
|
140
|
-
"use workflow";
|
|
141
|
-
|
|
142
|
-
// Use a deterministic token based on the repository
|
|
143
|
-
using webhook = createWebhook({ // [!code highlight]
|
|
144
|
-
token: `github_webhook:${repoName}`, // [!code highlight]
|
|
145
|
-
}); // [!code highlight]
|
|
146
|
-
|
|
147
|
-
console.log("Configure GitHub webhook:", webhook.url);
|
|
148
|
-
|
|
149
|
-
const request = await webhook;
|
|
150
|
-
const event = await request.json();
|
|
151
|
-
|
|
152
|
-
await sendAck(request);
|
|
153
|
-
|
|
154
|
-
await deployCommit(event);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function deployCommit(event: any) {
|
|
158
|
-
"use step";
|
|
159
|
-
// Deploy logic here
|
|
160
|
-
}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
123
|
### Waiting for Multiple Requests
|
|
164
124
|
|
|
165
125
|
You can also wait for multiple requests by using the `for await...of` syntax.
|
|
@@ -167,62 +127,35 @@ You can also wait for multiple requests by using the `for await...of` syntax.
|
|
|
167
127
|
```typescript lineNumbers
|
|
168
128
|
import { createWebhook, type RequestWithResponse } from "workflow"
|
|
169
129
|
|
|
170
|
-
async function
|
|
130
|
+
async function sendAck(request: RequestWithResponse, message: string) {
|
|
171
131
|
"use step";
|
|
172
132
|
await request.respondWith(
|
|
173
|
-
|
|
174
|
-
JSON.stringify({
|
|
175
|
-
response_type: "in_channel",
|
|
176
|
-
text: message
|
|
177
|
-
}),
|
|
178
|
-
{ headers: { "Content-Type": "application/json" } }
|
|
179
|
-
)
|
|
133
|
+
Response.json({ received: true, message })
|
|
180
134
|
);
|
|
181
135
|
}
|
|
182
136
|
|
|
183
|
-
async function
|
|
137
|
+
async function processEvent(data: any) {
|
|
184
138
|
"use step";
|
|
185
|
-
|
|
186
|
-
new Response("Stopping workflow...")
|
|
187
|
-
);
|
|
139
|
+
console.log("Processing event:", data);
|
|
188
140
|
}
|
|
189
141
|
|
|
190
|
-
export async function
|
|
142
|
+
export async function eventCollectorWorkflow() {
|
|
191
143
|
"use workflow";
|
|
192
144
|
|
|
193
|
-
using webhook = createWebhook({
|
|
194
|
-
|
|
195
|
-
});
|
|
145
|
+
using webhook = createWebhook({ respondWith: "manual" });
|
|
146
|
+
console.log("Send events to:", webhook.url);
|
|
196
147
|
|
|
197
148
|
for await (const request of webhook) { // [!code highlight]
|
|
198
|
-
const
|
|
199
|
-
const command = formData.get("command");
|
|
200
|
-
const text = formData.get("text");
|
|
149
|
+
const data = await request.json();
|
|
201
150
|
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
await sendSlackResponse(request, "Checking status...");
|
|
205
|
-
|
|
206
|
-
// Process the command
|
|
207
|
-
const status = await checkSystemStatus();
|
|
208
|
-
await postToSlack(channelId, `Status: ${status}`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (text === "stop") {
|
|
212
|
-
await sendStopResponse(request);
|
|
151
|
+
if (data.type === "done") {
|
|
152
|
+
await sendAck(request, "Workflow complete");
|
|
213
153
|
break;
|
|
214
154
|
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async function checkSystemStatus() {
|
|
219
|
-
"use step";
|
|
220
|
-
return "All systems operational";
|
|
221
|
-
}
|
|
222
155
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
156
|
+
await sendAck(request, "Event received");
|
|
157
|
+
await processEvent(data);
|
|
158
|
+
}
|
|
226
159
|
}
|
|
227
160
|
```
|
|
228
161
|
|
|
@@ -56,11 +56,17 @@ Returns a `Promise<Response>` that resolves to:
|
|
|
56
56
|
|
|
57
57
|
Throws an error if the webhook token is not found or invalid.
|
|
58
58
|
|
|
59
|
-
##
|
|
59
|
+
## Usage Note
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
<Callout type="warn">
|
|
62
|
+
In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL.
|
|
63
|
+
|
|
64
|
+
For server-side hook resumption with deterministic tokens, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) with [`createHook()`](/docs/api-reference/workflow/create-hook) instead.
|
|
65
|
+
</Callout>
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
## Example
|
|
68
|
+
|
|
69
|
+
Forward an incoming HTTP request to a webhook by token:
|
|
64
70
|
|
|
65
71
|
```typescript lineNumbers
|
|
66
72
|
import { resumeWebhook } from "workflow/api";
|
|
@@ -82,159 +88,6 @@ export async function POST(request: Request) {
|
|
|
82
88
|
}
|
|
83
89
|
```
|
|
84
90
|
|
|
85
|
-
### GitHub Webhook Handler
|
|
86
|
-
|
|
87
|
-
Handle GitHub webhook events and forward them to workflows:
|
|
88
|
-
|
|
89
|
-
```typescript lineNumbers
|
|
90
|
-
import { resumeWebhook } from "workflow/api";
|
|
91
|
-
import { verifyGitHubSignature } from "@/lib/github";
|
|
92
|
-
|
|
93
|
-
export async function POST(request: Request) {
|
|
94
|
-
// Extract repository name from URL
|
|
95
|
-
const url = new URL(request.url);
|
|
96
|
-
const repo = url.pathname.split("/").pop();
|
|
97
|
-
|
|
98
|
-
// Verify GitHub signature
|
|
99
|
-
const signature = request.headers.get("x-hub-signature-256");
|
|
100
|
-
const isValid = await verifyGitHubSignature(request, signature);
|
|
101
|
-
|
|
102
|
-
if (!isValid) {
|
|
103
|
-
return new Response("Invalid signature", { status: 401 });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Construct deterministic token
|
|
107
|
-
const token = `github_webhook:${repo}`;
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const response = await resumeWebhook(token, request); // [!code highlight]
|
|
111
|
-
return response;
|
|
112
|
-
} catch (error) {
|
|
113
|
-
return new Response("Workflow not found", { status: 404 });
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Slack Slash Command Handler
|
|
119
|
-
|
|
120
|
-
Process Slack slash commands and route them to workflow webhooks:
|
|
121
|
-
|
|
122
|
-
```typescript lineNumbers
|
|
123
|
-
import { resumeWebhook } from "workflow/api";
|
|
124
|
-
|
|
125
|
-
export async function POST(request: Request) {
|
|
126
|
-
const formData = await request.formData();
|
|
127
|
-
const channelId = formData.get("channel_id") as string;
|
|
128
|
-
const command = formData.get("command") as string;
|
|
129
|
-
|
|
130
|
-
// Verify Slack request signature
|
|
131
|
-
const slackSignature = request.headers.get("x-slack-signature");
|
|
132
|
-
if (!slackSignature) {
|
|
133
|
-
return new Response("Unauthorized", { status: 401 });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Construct token from channel ID
|
|
137
|
-
const token = `slack_command:${channelId}`;
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
const response = await resumeWebhook(token, request); // [!code highlight]
|
|
141
|
-
return response;
|
|
142
|
-
} catch (error) {
|
|
143
|
-
// If no workflow is listening, return a default response
|
|
144
|
-
return new Response(
|
|
145
|
-
JSON.stringify({
|
|
146
|
-
response_type: "ephemeral",
|
|
147
|
-
text: "No active workflow for this channel"
|
|
148
|
-
}),
|
|
149
|
-
{
|
|
150
|
-
headers: { "Content-Type": "application/json" }
|
|
151
|
-
}
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Multi-Tenant Webhook Router
|
|
158
|
-
|
|
159
|
-
Route webhooks to different workflows based on tenant/organization:
|
|
160
|
-
|
|
161
|
-
```typescript lineNumbers
|
|
162
|
-
import { resumeWebhook } from "workflow/api";
|
|
163
|
-
|
|
164
|
-
export async function POST(request: Request) {
|
|
165
|
-
const url = new URL(request.url);
|
|
166
|
-
|
|
167
|
-
// Extract tenant and webhook ID from path
|
|
168
|
-
// e.g., /api/webhooks/tenant-123/webhook-abc
|
|
169
|
-
const [, , , tenantId, webhookId] = url.pathname.split("/");
|
|
170
|
-
|
|
171
|
-
if (!tenantId || !webhookId) {
|
|
172
|
-
return new Response("Invalid webhook URL", { status: 400 });
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Verify API key for tenant
|
|
176
|
-
const apiKey = request.headers.get("authorization");
|
|
177
|
-
const isAuthorized = await verifyTenantApiKey(tenantId, apiKey);
|
|
178
|
-
|
|
179
|
-
if (!isAuthorized) {
|
|
180
|
-
return new Response("Unauthorized", { status: 401 });
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Construct namespaced token
|
|
184
|
-
const token = `tenant:${tenantId}:webhook:${webhookId}`;
|
|
185
|
-
|
|
186
|
-
try {
|
|
187
|
-
const response = await resumeWebhook(token, request); // [!code highlight]
|
|
188
|
-
return response;
|
|
189
|
-
} catch (error) {
|
|
190
|
-
return new Response("Webhook not found or expired", { status: 404 });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function verifyTenantApiKey(tenantId: string, apiKey: string | null) {
|
|
195
|
-
// Verify API key logic
|
|
196
|
-
return apiKey === process.env[`TENANT_${tenantId}_API_KEY`];
|
|
197
|
-
}
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
### Server Action (Next.js)
|
|
201
|
-
|
|
202
|
-
Use `resumeWebhook` in a Next.js server action:
|
|
203
|
-
|
|
204
|
-
```typescript lineNumbers
|
|
205
|
-
"use server";
|
|
206
|
-
|
|
207
|
-
import { resumeWebhook } from "workflow/api";
|
|
208
|
-
|
|
209
|
-
export async function triggerWebhook(
|
|
210
|
-
token: string,
|
|
211
|
-
payload: Record<string, any>
|
|
212
|
-
) {
|
|
213
|
-
// Create a Request object from the payload
|
|
214
|
-
const request = new Request("http://localhost/webhook", {
|
|
215
|
-
method: "POST",
|
|
216
|
-
headers: {
|
|
217
|
-
"Content-Type": "application/json",
|
|
218
|
-
},
|
|
219
|
-
body: JSON.stringify(payload),
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
const response = await resumeWebhook(token, request);
|
|
224
|
-
|
|
225
|
-
// Parse and return the response
|
|
226
|
-
const contentType = response.headers.get("content-type");
|
|
227
|
-
if (contentType?.includes("application/json")) {
|
|
228
|
-
return await response.json();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return await response.text();
|
|
232
|
-
} catch (error) {
|
|
233
|
-
throw new Error("Webhook not found");
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
```
|
|
237
|
-
|
|
238
91
|
## Related Functions
|
|
239
92
|
|
|
240
93
|
- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Create a webhook in a workflow
|
|
@@ -339,53 +339,36 @@ Like hooks, webhooks support iteration:
|
|
|
339
339
|
```typescript lineNumbers
|
|
340
340
|
import { createWebhook, type RequestWithResponse } from "workflow";
|
|
341
341
|
|
|
342
|
-
async function
|
|
342
|
+
async function sendAck(request: RequestWithResponse, message: string) {
|
|
343
343
|
"use step";
|
|
344
344
|
|
|
345
345
|
await request.respondWith(
|
|
346
|
-
|
|
347
|
-
JSON.stringify({ response_type: "in_channel", text }),
|
|
348
|
-
{ headers: { "Content-Type": "application/json" } }
|
|
349
|
-
)
|
|
346
|
+
Response.json({ received: true, message })
|
|
350
347
|
);
|
|
351
348
|
}
|
|
352
349
|
|
|
353
|
-
|
|
354
|
-
"use
|
|
350
|
+
async function processEvent(data: any) {
|
|
351
|
+
"use step";
|
|
352
|
+
console.log("Processing event:", data);
|
|
353
|
+
}
|
|
355
354
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
respondWith: "manual"
|
|
359
|
-
});
|
|
355
|
+
export async function eventCollectorWorkflow() {
|
|
356
|
+
"use workflow";
|
|
360
357
|
|
|
361
|
-
|
|
358
|
+
using webhook = createWebhook({ respondWith: "manual" });
|
|
359
|
+
console.log("Send events to:", webhook.url);
|
|
362
360
|
|
|
363
361
|
for await (const request of webhook) {
|
|
364
|
-
const
|
|
365
|
-
const command = formData.get("command");
|
|
366
|
-
const text = formData.get("text");
|
|
367
|
-
|
|
368
|
-
if (command === "/status") {
|
|
369
|
-
await respondToSlack(request, "Checking status...");
|
|
370
|
-
const status = await checkSystemStatus();
|
|
371
|
-
await postToSlack(channelId, `Status: ${status}`);
|
|
372
|
-
}
|
|
362
|
+
const data = await request.json();
|
|
373
363
|
|
|
374
|
-
if (
|
|
375
|
-
await
|
|
364
|
+
if (data.type === "done") {
|
|
365
|
+
await sendAck(request, "Workflow complete");
|
|
376
366
|
break;
|
|
377
367
|
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
368
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async function postToSlack(channelId: string, message: string) {
|
|
387
|
-
"use step";
|
|
388
|
-
// Post message to Slack
|
|
369
|
+
await sendAck(request, "Event received");
|
|
370
|
+
await processEvent(data);
|
|
371
|
+
}
|
|
389
372
|
}
|
|
390
373
|
```
|
|
391
374
|
|
|
@@ -464,7 +447,9 @@ This pattern is especially valuable in larger applications where the workflow an
|
|
|
464
447
|
|
|
465
448
|
### Token Design
|
|
466
449
|
|
|
467
|
-
|
|
450
|
+
Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always use randomly generated tokens to prevent unauthorized access to public webhook endpoints.
|
|
451
|
+
|
|
452
|
+
When using custom tokens with `createHook()`:
|
|
468
453
|
|
|
469
454
|
- **Make them deterministic**: Base them on data the external system can reconstruct (like channel IDs, user IDs, etc.)
|
|
470
455
|
- **Use namespacing**: Prefix tokens to avoid conflicts (e.g., `slack:${channelId}`, `github:${repoId}`)
|
package/docs/testing/index.mdx
CHANGED
|
@@ -1,25 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Testing
|
|
3
|
-
description: Unit test individual steps and integration test entire workflows using Vitest
|
|
4
|
-
type: conceptual
|
|
5
|
-
summary: Learn how to unit test steps and integration test workflows using Vitest.
|
|
6
|
-
prerequisites:
|
|
7
|
-
- /docs/foundations/workflows-and-steps
|
|
8
|
-
- /docs/getting-started/vite
|
|
9
|
-
related:
|
|
10
|
-
- /docs/foundations/hooks
|
|
11
|
-
- /docs/api-reference/workflow-api/start
|
|
12
|
-
- /docs/api-reference/workflow-api/resume-hook
|
|
13
|
-
- /docs/api-reference/workflow-api/get-run
|
|
14
|
-
- /docs/observability
|
|
3
|
+
description: Unit test individual steps and integration test entire workflows using Vitest.
|
|
15
4
|
---
|
|
16
5
|
|
|
17
|
-
Testing is a critical part of building reliable workflows. Because steps are just functions annotated with directives, they can be unit tested like any other JavaScript function. Workflow DevKit also provides a
|
|
6
|
+
Testing is a critical part of building reliable workflows. Because steps are just functions annotated with directives, they can be unit tested like any other JavaScript function. Workflow DevKit also provides a Vitest plugin that runs full workflows in-process — no running server required.
|
|
18
7
|
|
|
19
8
|
This guide covers two approaches:
|
|
20
9
|
|
|
21
10
|
1. **Unit testing** - Test individual steps as plain functions, without the workflow runtime.
|
|
22
|
-
2. **Integration testing** - Test entire workflows
|
|
11
|
+
2. **Integration testing** - Test entire workflows in-process using the `workflow()` Vitest plugin. Required when you want to test workflow specific code paths, like those using [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), retries, etc.
|
|
23
12
|
|
|
24
13
|
## Unit Testing Steps
|
|
25
14
|
|
|
@@ -88,125 +77,71 @@ describe("sendWelcomeEmail step", () => {
|
|
|
88
77
|
This approach is ideal for verifying the business logic inside individual steps in isolation.
|
|
89
78
|
|
|
90
79
|
<Callout type="info">
|
|
91
|
-
Unit testing works well for individual steps. A simple workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-
|
|
80
|
+
Unit testing works well for individual steps. A simple workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features.
|
|
92
81
|
</Callout>
|
|
93
82
|
|
|
94
|
-
## Integration Testing with the
|
|
83
|
+
## Integration Testing with the Vitest Plugin
|
|
95
84
|
|
|
96
|
-
For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The
|
|
85
|
+
For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically — it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required.
|
|
86
|
+
|
|
87
|
+
<Callout type="warn">
|
|
88
|
+
Inside integration tests, which run the full workflow runtime, `vi.mock()` and related calls do not work — neither for your own modules nor for third-party npm packages. All step dependencies are inlined into the compiled bundle by esbuild, bypassing Vitest's module system entirely. To test steps with mocked dependencies, use [unit tests](#unit-testing-steps) instead. Consider dependency injection or environment variable-based conditional logic for controlling behavior in integration tests.
|
|
89
|
+
</Callout>
|
|
97
90
|
|
|
98
91
|
### Vitest Configuration
|
|
99
92
|
|
|
100
|
-
Create a separate Vitest config for integration tests that includes the `workflow()` plugin
|
|
93
|
+
Create a separate Vitest config for integration tests that includes the `workflow()` plugin:
|
|
101
94
|
|
|
102
95
|
```typescript title="vitest.integration.config.ts" lineNumbers
|
|
103
96
|
import { defineConfig } from "vitest/config";
|
|
104
|
-
import { workflow } from "workflow/
|
|
97
|
+
import { workflow } from "@workflow/vitest"; // [!code highlight]
|
|
105
98
|
|
|
106
99
|
export default defineConfig({
|
|
107
100
|
plugins: [workflow()], // [!code highlight]
|
|
108
101
|
test: {
|
|
109
102
|
include: ["**/*.integration.test.ts"],
|
|
110
103
|
testTimeout: 60_000, // Workflows may take longer than default timeout
|
|
111
|
-
globalSetup: "./vitest.integration.setup.ts", // [!code highlight]
|
|
112
104
|
},
|
|
113
105
|
});
|
|
114
106
|
```
|
|
115
107
|
|
|
108
|
+
That's it. The plugin automatically:
|
|
109
|
+
|
|
110
|
+
1. Transforms `"use workflow"` and `"use step"` directives via SWC
|
|
111
|
+
2. Builds workflow and step bundles before tests run
|
|
112
|
+
3. Sets up an in-process workflow runtime using a fresh [Local World](/docs/worlds/local) instance in each test worker — all workflow data is cleared automatically between test files for full isolation
|
|
113
|
+
|
|
116
114
|
<Callout type="info">
|
|
117
115
|
Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above.
|
|
118
116
|
</Callout>
|
|
119
117
|
|
|
120
|
-
###
|
|
121
|
-
|
|
122
|
-
Integration tests need a running server to execute workflow steps. The `globalSetup` script starts a [Nitro](https://v3.nitro.build) server as a sidecar process before tests run, and tears it down afterwards:
|
|
123
|
-
|
|
124
|
-
```typescript title="vitest.integration.setup.ts" lineNumbers
|
|
125
|
-
import { spawn } from "node:child_process";
|
|
126
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
127
|
-
import type { ChildProcess } from "node:child_process";
|
|
118
|
+
### Writing Integration Tests
|
|
128
119
|
|
|
129
|
-
|
|
130
|
-
const PORT = "4000";
|
|
120
|
+
Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returnvalue) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails):
|
|
131
121
|
|
|
132
|
-
|
|
133
|
-
|
|
122
|
+
```typescript title="workflows/calculate.integration.test.ts" lineNumbers
|
|
123
|
+
import { describe, it, expect } from "vitest";
|
|
124
|
+
import { start } from "workflow/api"; // [!code highlight]
|
|
125
|
+
import { calculateWorkflow } from "./calculate";
|
|
134
126
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
env: process.env,
|
|
139
|
-
});
|
|
127
|
+
describe("calculateWorkflow", () => {
|
|
128
|
+
it("should compute the correct result", async () => {
|
|
129
|
+
const run = await start(calculateWorkflow, [2, 7]); // [!code highlight]
|
|
140
130
|
|
|
141
|
-
|
|
142
|
-
const ready = await new Promise<boolean>((resolve) => {
|
|
143
|
-
const timeout = setTimeout(() => resolve(false), 15_000);
|
|
144
|
-
|
|
145
|
-
server?.stdout?.on("data", (data) => {
|
|
146
|
-
const output = data.toString();
|
|
147
|
-
console.log("[server]", output);
|
|
148
|
-
if (output.includes("listening") || output.includes("ready")) {
|
|
149
|
-
clearTimeout(timeout);
|
|
150
|
-
resolve(true);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
131
|
+
expect(run.runId).toMatch(/^wrun_/);
|
|
153
132
|
|
|
154
|
-
|
|
155
|
-
|
|
133
|
+
// Blocks until the workflow completes or fails
|
|
134
|
+
const result = await run.returnValue; // [!code highlight]
|
|
135
|
+
expect(result).toEqual({
|
|
136
|
+
sum: 9,
|
|
137
|
+
product: 14,
|
|
138
|
+
combined: 23,
|
|
156
139
|
});
|
|
157
140
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
clearTimeout(timeout);
|
|
161
|
-
resolve(false);
|
|
162
|
-
});
|
|
141
|
+
const status = await run.status; // [!code highlight]
|
|
142
|
+
expect(status).toEqual("completed");
|
|
163
143
|
});
|
|
164
|
-
|
|
165
|
-
if (!ready) {
|
|
166
|
-
throw new Error("Server failed to start within 15 seconds");
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
await delay(2_000); // Allow full initialization
|
|
170
|
-
|
|
171
|
-
// Point the workflow runtime at the local server
|
|
172
|
-
process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${PORT}`; // [!code highlight]
|
|
173
|
-
process.env.WORKFLOW_LOCAL_DATA_DIR = "./.workflow-data"; // [!code highlight]
|
|
174
|
-
|
|
175
|
-
console.log("Server ready for workflow execution");
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export async function teardown() { // [!code highlight]
|
|
179
|
-
if (server) {
|
|
180
|
-
console.log("Stopping server...");
|
|
181
|
-
server.kill("SIGTERM");
|
|
182
|
-
await delay(1_000);
|
|
183
|
-
if (!server.killed) {
|
|
184
|
-
server.kill("SIGKILL");
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
The setup script sets two environment variables that the workflow runtime reads:
|
|
191
|
-
|
|
192
|
-
- `WORKFLOW_LOCAL_BASE_URL` tells the runtime where to send step execution requests
|
|
193
|
-
- `WORKFLOW_LOCAL_DATA_DIR` tells the runtime where to persist workflow state locally
|
|
194
|
-
|
|
195
|
-
<Callout type="info">
|
|
196
|
-
You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use a [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server.
|
|
197
|
-
</Callout>
|
|
198
|
-
|
|
199
|
-
### Running Integration Tests
|
|
200
|
-
|
|
201
|
-
Add a script to your `package.json`:
|
|
202
|
-
|
|
203
|
-
```json title="package.json"
|
|
204
|
-
{
|
|
205
|
-
"scripts": {
|
|
206
|
-
"test": "vitest",
|
|
207
|
-
"test:integration": "vitest --config vitest.integration.config.ts"
|
|
208
|
-
}
|
|
209
|
-
}
|
|
144
|
+
});
|
|
210
145
|
```
|
|
211
146
|
|
|
212
147
|
### Testing Hooks and Waits
|
|
@@ -250,29 +185,32 @@ async function publishDocument(doc: { id: string; content: string }) {
|
|
|
250
185
|
}
|
|
251
186
|
```
|
|
252
187
|
|
|
253
|
-
You can write an integration test that starts the workflow,
|
|
188
|
+
You can write an integration test that starts the workflow, waits for the hook and sleep to be reached, then resumes them programmatically:
|
|
254
189
|
|
|
255
190
|
```typescript title="workflows/approval.integration.test.ts" lineNumbers
|
|
256
191
|
import { describe, it, expect } from "vitest";
|
|
257
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
258
192
|
import { start, getRun, resumeHook } from "workflow/api"; // [!code highlight]
|
|
193
|
+
import { waitForHook, waitForSleep } from "@workflow/vitest"; // [!code highlight]
|
|
259
194
|
import { approvalWorkflow } from "./approval";
|
|
260
195
|
|
|
261
196
|
describe("approvalWorkflow", () => {
|
|
262
197
|
it("should publish when approved", async () => {
|
|
263
198
|
const run = await start(approvalWorkflow, ["doc-123"]); // [!code highlight]
|
|
264
199
|
|
|
265
|
-
//
|
|
200
|
+
// Wait for the hook to be created, then resume it
|
|
201
|
+
await waitForHook(run, { token: "approval:doc-123" }); // [!code highlight]
|
|
266
202
|
await resumeHook("approval:doc-123", { // [!code highlight]
|
|
267
203
|
approved: true, // [!code highlight]
|
|
268
204
|
reviewer: "alice", // [!code highlight]
|
|
269
205
|
}); // [!code highlight]
|
|
270
206
|
|
|
271
|
-
// Wait for the
|
|
272
|
-
|
|
207
|
+
// Wait for the first pending sleep to be reached.
|
|
208
|
+
// waitForSleep() returns the sleep's correlation ID, which can be
|
|
209
|
+
// passed to wakeUp() later to target a specific sleep in the workflow.
|
|
210
|
+
const sleepId = await waitForSleep(run); // [!code highlight]
|
|
273
211
|
|
|
274
|
-
//
|
|
275
|
-
await getRun(run.runId).wakeUp(); // [!code highlight]
|
|
212
|
+
// Calling wakeUp() without correlationIds would resume all active sleeps
|
|
213
|
+
await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlight]
|
|
276
214
|
|
|
277
215
|
const result = await run.returnValue;
|
|
278
216
|
expect(result).toEqual({
|
|
@@ -284,6 +222,7 @@ describe("approvalWorkflow", () => {
|
|
|
284
222
|
it("should reject when not approved", async () => {
|
|
285
223
|
const run = await start(approvalWorkflow, ["doc-456"]);
|
|
286
224
|
|
|
225
|
+
await waitForHook(run, { token: "approval:doc-456" });
|
|
287
226
|
await resumeHook("approval:doc-456", {
|
|
288
227
|
approved: false,
|
|
289
228
|
reviewer: "bob",
|
|
@@ -300,12 +239,124 @@ describe("approvalWorkflow", () => {
|
|
|
300
239
|
```
|
|
301
240
|
|
|
302
241
|
<Callout type="info">
|
|
303
|
-
[`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) are the key API functions for integration testing. Use `start()` to trigger a workflow, `resumeHook()` to simulate external events, and `wakeUp()` to skip `sleep()` calls so tests run instantly. See the [API Reference](/docs/api-reference/workflow-api) for the full list of available functions.
|
|
242
|
+
[`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) are the key API functions for integration testing. Use `start()` to trigger a workflow, `resumeHook()` to simulate external events, and `wakeUp()` to skip `sleep()` calls so tests run instantly. [`waitForSleep()`](/docs/api-reference/vitest#waitforsleep) and [`waitForHook()`](/docs/api-reference/vitest#waitforhook) from `@workflow/vitest` let you wait for the workflow to reach a specific point before resuming. See the [API Reference](/docs/api-reference/workflow-api) for the full list of available functions.
|
|
243
|
+
</Callout>
|
|
244
|
+
|
|
245
|
+
<Callout type="info">
|
|
246
|
+
`waitForSleep()` returns the first **pending** sleep — one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it.
|
|
247
|
+
</Callout>
|
|
248
|
+
|
|
249
|
+
### Testing Webhooks
|
|
250
|
+
|
|
251
|
+
Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload — no HTTP server needed:
|
|
252
|
+
|
|
253
|
+
```typescript title="workflows/ingest.ts" lineNumbers
|
|
254
|
+
import { createWebhook } from "workflow";
|
|
255
|
+
|
|
256
|
+
export async function ingestWorkflow(endpointId: string) {
|
|
257
|
+
"use workflow";
|
|
258
|
+
|
|
259
|
+
// Webhook tokens are always randomly generated
|
|
260
|
+
using webhook = createWebhook(); // [!code highlight]
|
|
261
|
+
|
|
262
|
+
const request = await webhook; // [!code highlight]
|
|
263
|
+
const body = await request.text();
|
|
264
|
+
const data = await parsePayload(body);
|
|
265
|
+
|
|
266
|
+
return { endpointId, received: data };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function parsePayload(body: string) {
|
|
270
|
+
"use step";
|
|
271
|
+
return JSON.parse(body);
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```typescript title="workflows/ingest.integration.test.ts" lineNumbers
|
|
276
|
+
import { describe, it, expect } from "vitest";
|
|
277
|
+
import { start, resumeWebhook } from "workflow/api"; // [!code highlight]
|
|
278
|
+
import { waitForHook } from "@workflow/vitest"; // [!code highlight]
|
|
279
|
+
import { ingestWorkflow } from "./ingest";
|
|
280
|
+
|
|
281
|
+
describe("ingestWorkflow", () => {
|
|
282
|
+
it("should process webhook data", async () => {
|
|
283
|
+
const run = await start(ingestWorkflow, ["ep-1"]);
|
|
284
|
+
|
|
285
|
+
// Discover the randomly generated webhook token
|
|
286
|
+
const hook = await waitForHook(run); // [!code highlight]
|
|
287
|
+
|
|
288
|
+
// Resume the webhook with a Request object
|
|
289
|
+
await resumeWebhook( // [!code highlight]
|
|
290
|
+
hook.token, // [!code highlight]
|
|
291
|
+
new Request("https://example.com/webhook", { // [!code highlight]
|
|
292
|
+
method: "POST", // [!code highlight]
|
|
293
|
+
body: JSON.stringify({ event: "order.created", orderId: "123" }), // [!code highlight]
|
|
294
|
+
}) // [!code highlight]
|
|
295
|
+
); // [!code highlight]
|
|
296
|
+
|
|
297
|
+
const result = await run.returnValue;
|
|
298
|
+
expect(result).toEqual({
|
|
299
|
+
endpointId: "ep-1",
|
|
300
|
+
received: { event: "order.created", orderId: "123" },
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Manual Setup
|
|
307
|
+
|
|
308
|
+
If you need more control over the test lifecycle, the plugin also exports the individual setup functions:
|
|
309
|
+
|
|
310
|
+
```typescript title="vitest.integration.config.ts" lineNumbers
|
|
311
|
+
import { defineConfig } from "vitest/config";
|
|
312
|
+
import { workflowTransformPlugin } from "@workflow/rollup";
|
|
313
|
+
|
|
314
|
+
export default defineConfig({
|
|
315
|
+
plugins: [workflowTransformPlugin()],
|
|
316
|
+
test: {
|
|
317
|
+
include: ["**/*.integration.test.ts"],
|
|
318
|
+
testTimeout: 60_000,
|
|
319
|
+
globalSetup: "./vitest.integration.setup.ts",
|
|
320
|
+
setupFiles: ["./vitest.integration.env.ts"],
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```typescript title="vitest.integration.setup.ts"
|
|
326
|
+
import { buildWorkflowTests } from "@workflow/vitest";
|
|
327
|
+
|
|
328
|
+
export async function setup() {
|
|
329
|
+
await buildWorkflowTests();
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
```typescript title="vitest.integration.env.ts"
|
|
334
|
+
import { beforeAll, afterAll } from "vitest";
|
|
335
|
+
import {
|
|
336
|
+
setupWorkflowTests,
|
|
337
|
+
teardownWorkflowTests,
|
|
338
|
+
} from "@workflow/vitest";
|
|
339
|
+
|
|
340
|
+
beforeAll(async () => {
|
|
341
|
+
await setupWorkflowTests();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
afterAll(async () => {
|
|
345
|
+
await teardownWorkflowTests();
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
<Callout type="info">
|
|
350
|
+
`setupWorkflowTests()` automatically clears all workflow data (runs, events, hooks) on each invocation, ensuring full test isolation between test files.
|
|
351
|
+
</Callout>
|
|
352
|
+
|
|
353
|
+
<Callout type="info">
|
|
354
|
+
For advanced setups that require a running server (e.g. testing against your actual framework's HTTP layer), see [Server-based integration testing](/docs/testing/server-based).
|
|
304
355
|
</Callout>
|
|
305
356
|
|
|
306
357
|
## Debugging Test Runs
|
|
307
358
|
|
|
308
|
-
When integration tests fail, the [Workflow DevKit CLI and Web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state
|
|
359
|
+
When integration tests fail, the [Workflow DevKit CLI and Web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you would use in development.
|
|
309
360
|
|
|
310
361
|
Launch the Web UI to visually explore your test workflow runs:
|
|
311
362
|
|
|
@@ -357,9 +408,12 @@ Integration tests are the right place to verify that your workflows handle error
|
|
|
357
408
|
- [Hooks & Webhooks](/docs/foundations/hooks) - Pausing and resuming workflows with external data
|
|
358
409
|
- [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows programmatically
|
|
359
410
|
- [`resumeHook()` API Reference](/docs/api-reference/workflow-api/resume-hook) - Resume hooks with data
|
|
411
|
+
- [`resumeWebhook()` API Reference](/docs/api-reference/workflow-api/resume-webhook) - Resume webhooks with Request objects
|
|
360
412
|
- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Check workflow run status and wake up sleeping runs
|
|
413
|
+
- [`@workflow/vitest` API Reference](/docs/api-reference/vitest) - Test helpers: `waitForSleep()`, `waitForHook()`, and plugin setup
|
|
361
414
|
- [Vite Integration](/docs/getting-started/vite) - Set up the Vite plugin
|
|
362
415
|
- [Observability](/docs/observability) - Inspect and debug workflow runs with the CLI and Web UI
|
|
416
|
+
- [Server-based testing](/docs/testing/server-based) - Integration testing with a running server
|
|
363
417
|
|
|
364
418
|
---
|
|
365
419
|
|
package/docs/testing/meta.json
CHANGED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Server-Based Testing
|
|
3
|
+
description: Integration test workflows against a running server when you need to test the full HTTP layer.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The [Vitest plugin](/docs/testing#integration-testing-with-the-vitest-plugin) runs workflows entirely in-process and is the recommended approach for most testing scenarios. However, there are cases where you may want to test against a running server:
|
|
7
|
+
|
|
8
|
+
- Testing the full HTTP layer (middleware, authentication, request handling)
|
|
9
|
+
- Reproducing behavior that only occurs in a specific framework's runtime (e.g. Next.js, Nitro)
|
|
10
|
+
- Testing webhook endpoints that receive real HTTP requests
|
|
11
|
+
|
|
12
|
+
This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point — customize the server setup to match your own deployment environment.
|
|
13
|
+
|
|
14
|
+
## Vitest Configuration
|
|
15
|
+
|
|
16
|
+
Create a Vitest config with the `workflow()` Vite plugin for code transforms and a `globalSetup` script that manages the server lifecycle:
|
|
17
|
+
|
|
18
|
+
```typescript title="vitest.server.config.ts" lineNumbers
|
|
19
|
+
import { defineConfig } from "vitest/config";
|
|
20
|
+
import { workflow } from "workflow/vite"; // [!code highlight]
|
|
21
|
+
|
|
22
|
+
export default defineConfig({
|
|
23
|
+
plugins: [workflow()], // [!code highlight]
|
|
24
|
+
test: {
|
|
25
|
+
include: ["**/*.server.test.ts"],
|
|
26
|
+
testTimeout: 60_000,
|
|
27
|
+
globalSetup: "./vitest.server.setup.ts", // [!code highlight]
|
|
28
|
+
env: {
|
|
29
|
+
WORKFLOW_LOCAL_BASE_URL: "http://localhost:4000", // [!code highlight]
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
<Callout type="info">
|
|
36
|
+
Note the import path: `workflow/vite` (not `@workflow/vitest`). The Vite plugin handles code transforms but does not set up in-process execution. The server handles workflow execution instead.
|
|
37
|
+
</Callout>
|
|
38
|
+
|
|
39
|
+
## Global Setup Script
|
|
40
|
+
|
|
41
|
+
The `globalSetup` script starts a dev server before tests run and tears it down afterwards. This example uses [Nitro](https://v3.nitro.build), but you can use any server framework that supports the workflow runtime.
|
|
42
|
+
|
|
43
|
+
```typescript title="vitest.server.setup.ts" lineNumbers
|
|
44
|
+
import { spawn } from "node:child_process";
|
|
45
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
46
|
+
import type { ChildProcess } from "node:child_process";
|
|
47
|
+
|
|
48
|
+
let server: ChildProcess | null = null;
|
|
49
|
+
const PORT = "4000";
|
|
50
|
+
|
|
51
|
+
export async function setup() { // [!code highlight]
|
|
52
|
+
console.log("Starting server for workflow execution...");
|
|
53
|
+
|
|
54
|
+
server = spawn("npx", ["nitro", "dev", "--port", PORT], {
|
|
55
|
+
stdio: "pipe",
|
|
56
|
+
detached: false,
|
|
57
|
+
env: process.env,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Wait for the server to be ready
|
|
61
|
+
const ready = await new Promise<boolean>((resolve) => {
|
|
62
|
+
const timeout = setTimeout(() => resolve(false), 15_000);
|
|
63
|
+
|
|
64
|
+
server?.stdout?.on("data", (data) => {
|
|
65
|
+
const output = data.toString();
|
|
66
|
+
console.log("[server]", output);
|
|
67
|
+
if (output.includes("listening") || output.includes("ready")) {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
resolve(true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server?.stderr?.on("data", (data) => {
|
|
74
|
+
console.error("[server]", data.toString());
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
server?.on("error", (error) => {
|
|
78
|
+
console.error("Failed to start server:", error);
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
resolve(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!ready) {
|
|
85
|
+
throw new Error("Server failed to start within 15 seconds");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await delay(2_000); // Allow full initialization
|
|
89
|
+
|
|
90
|
+
// Point the workflow runtime at the local server
|
|
91
|
+
process.env.WORKFLOW_LOCAL_BASE_URL = `http://localhost:${PORT}`; // [!code highlight]
|
|
92
|
+
|
|
93
|
+
console.log("Server ready for workflow execution");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function teardown() { // [!code highlight]
|
|
97
|
+
if (server) {
|
|
98
|
+
console.log("Stopping server...");
|
|
99
|
+
server.kill("SIGTERM");
|
|
100
|
+
await delay(1_000);
|
|
101
|
+
if (!server.killed) {
|
|
102
|
+
server.kill("SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends step execution requests to the running server.
|
|
109
|
+
|
|
110
|
+
<Callout type="info">
|
|
111
|
+
You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server.
|
|
112
|
+
</Callout>
|
|
113
|
+
|
|
114
|
+
## Writing Tests
|
|
115
|
+
|
|
116
|
+
Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs — [`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) — to control workflow execution:
|
|
117
|
+
|
|
118
|
+
```typescript title="workflows/calculate.server.test.ts" lineNumbers
|
|
119
|
+
import { describe, it, expect } from "vitest";
|
|
120
|
+
import { start, getRun, resumeHook } from "workflow/api";
|
|
121
|
+
import { calculateWorkflow } from "./calculate";
|
|
122
|
+
import { approvalWorkflow } from "./approval";
|
|
123
|
+
|
|
124
|
+
describe("calculateWorkflow", () => {
|
|
125
|
+
it("should compute the correct result", async () => {
|
|
126
|
+
const run = await start(calculateWorkflow, [2, 7]);
|
|
127
|
+
const result = await run.returnValue;
|
|
128
|
+
|
|
129
|
+
expect(result).toEqual({
|
|
130
|
+
sum: 9,
|
|
131
|
+
product: 14,
|
|
132
|
+
combined: 23,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("approvalWorkflow", () => {
|
|
138
|
+
it("should publish when approved", async () => {
|
|
139
|
+
const run = await start(approvalWorkflow, ["doc-1"]);
|
|
140
|
+
|
|
141
|
+
// Use resumeHook and wakeUp to control workflow execution
|
|
142
|
+
await resumeHook("approval:doc-1", {
|
|
143
|
+
approved: true,
|
|
144
|
+
reviewer: "alice",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await getRun(run.runId).wakeUp();
|
|
148
|
+
|
|
149
|
+
const result = await run.returnValue;
|
|
150
|
+
expect(result).toEqual({
|
|
151
|
+
status: "published",
|
|
152
|
+
reviewer: "alice",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
<Callout type="info">
|
|
159
|
+
In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly — you may need to add short delays or polling to ensure the workflow has reached the desired state before resuming.
|
|
160
|
+
</Callout>
|
|
161
|
+
|
|
162
|
+
## Running Tests
|
|
163
|
+
|
|
164
|
+
Add a script to your `package.json`:
|
|
165
|
+
|
|
166
|
+
```json title="package.json"
|
|
167
|
+
{
|
|
168
|
+
"scripts": {
|
|
169
|
+
"test": "vitest",
|
|
170
|
+
"test:server": "vitest --config vitest.server.config.ts"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## When to Use This Approach
|
|
176
|
+
|
|
177
|
+
| Scenario | Recommended approach |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| Testing workflow logic, steps, hooks, retries | [In-process plugin](/docs/testing) |
|
|
180
|
+
| Testing HTTP middleware or authentication | Server-based |
|
|
181
|
+
| Testing webhook endpoints with real HTTP | Server-based |
|
|
182
|
+
| CI/CD pipeline testing | [In-process plugin](/docs/testing) |
|
|
183
|
+
| Reproducing framework-specific behavior | Server-based |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workflow",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0-beta.65",
|
|
4
4
|
"description": "Workflow DevKit - Build durable, resilient, and observable workflows",
|
|
5
5
|
"main": "dist/typescript-plugin.cjs",
|
|
6
6
|
"type": "module",
|
|
@@ -52,17 +52,17 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"ms": "2.1.3",
|
|
55
|
-
"@workflow/astro": "4.0.0-beta.
|
|
56
|
-
"@workflow/cli": "4.
|
|
57
|
-
"@workflow/core": "4.
|
|
58
|
-
"@workflow/errors": "4.1.0-beta.
|
|
55
|
+
"@workflow/astro": "4.0.0-beta.39",
|
|
56
|
+
"@workflow/cli": "4.2.0-beta.65",
|
|
57
|
+
"@workflow/core": "4.2.0-beta.65",
|
|
58
|
+
"@workflow/errors": "4.1.0-beta.18",
|
|
59
59
|
"@workflow/typescript-plugin": "4.0.1-beta.5",
|
|
60
|
-
"@workflow/next": "4.0.1-beta.
|
|
61
|
-
"@workflow/nest": "0.0.0-beta.
|
|
62
|
-
"@workflow/nitro": "4.0.1-beta.
|
|
63
|
-
"@workflow/nuxt": "4.0.1-beta.
|
|
64
|
-
"@workflow/sveltekit": "4.0.0-beta.
|
|
65
|
-
"@workflow/rollup": "4.0.0-beta.
|
|
60
|
+
"@workflow/next": "4.0.1-beta.61",
|
|
61
|
+
"@workflow/nest": "0.0.0-beta.14",
|
|
62
|
+
"@workflow/nitro": "4.0.1-beta.60",
|
|
63
|
+
"@workflow/nuxt": "4.0.1-beta.49",
|
|
64
|
+
"@workflow/sveltekit": "4.0.0-beta.54",
|
|
65
|
+
"@workflow/rollup": "4.0.0-beta.22"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/ms": "2.1.0",
|