workflow 4.2.0-beta.64 → 4.2.0-beta.66

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/ai/index.mdx CHANGED
@@ -151,7 +151,7 @@ import { z } from "zod";
151
151
  export const tools = {
152
152
  searchFlights: tool({
153
153
  description: "Search for flights",
154
- inputSchema: z.object({ query: z.string() }),
154
+ inputSchema: z.object({ from: z.string(), to: z.string(), date: z.string() }),
155
155
  execute: searchFlights,
156
156
  }),
157
157
  };
@@ -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>
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "title": "API Reference",
3
- "pages": ["...", "workflow-ai"]
3
+ "pages": ["...", "workflow-ai", "vitest"]
4
4
  }
@@ -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 |
@@ -209,7 +209,7 @@ Here's a more complex example showing how you might stream AI chat responses:
209
209
  import { getWritable } from "workflow";
210
210
  import { generateId, streamText, type UIMessageChunk } from "ai";
211
211
 
212
- export async function chat(messages: UIMessage[]) {
212
+ export async function chat(messages: ModelMessage[]) {
213
213
  "use workflow";
214
214
 
215
215
  // Get typed writable stream for UI message chunks
@@ -223,7 +223,7 @@ export async function chat(messages: UIMessage[]) {
223
223
  // Process messages in steps
224
224
  for (let i = 0; i < MAX_STEPS; i++) {
225
225
  const result = await streamTextStep(currentMessages, writable);
226
- currentMessages.push(result.messages);
226
+ currentMessages.push(...result.messages);
227
227
 
228
228
  if (result.finishReason !== "tool-calls") {
229
229
  break;
@@ -252,7 +252,7 @@ async function startStream(writable: WritableStream<UIMessageChunk>) {
252
252
  }
253
253
 
254
254
  async function streamTextStep(
255
- messages: UIMessage[],
255
+ messages: ModelMessage[],
256
256
  writable: WritableStream<UIMessageChunk>
257
257
  ) {
258
258
  "use step";
@@ -261,9 +261,8 @@ async function streamTextStep(
261
261
 
262
262
  // Call streamText from the AI SDK
263
263
  const result = streamText({
264
- model: "gpt-4",
264
+ model: myModel,
265
265
  messages,
266
- /* other options */
267
266
  });
268
267
 
269
268
  // Pipe the AI stream into the writable stream
@@ -89,9 +89,10 @@ export async function POST(request: Request) {
89
89
 
90
90
  try {
91
91
  const hook = await getHookByToken(token); // [!code highlight]
92
+ const metadata = hook.metadata as { allowedUserId?: string } | undefined;
92
93
 
93
94
  // Validate that the hook metadata matches the user
94
- if (hook.metadata?.allowedUserId !== userId) {
95
+ if (metadata?.allowedUserId !== userId) {
95
96
  return Response.json(
96
97
  { error: "Unauthorized to resume this hook" },
97
98
  { status: 403 }
@@ -85,7 +85,7 @@ export async function processPayment(orderId: string) {
85
85
  const payment = await hook; // [!code highlight]
86
86
  return { success: true, payment };
87
87
  } catch (error) {
88
- if (error instanceof WorkflowRuntimeError && error.slug === "hook-conflict") { // [!code highlight]
88
+ if (error instanceof WorkflowRuntimeError && error.message.includes("hook-conflict")) { // [!code highlight]
89
89
  // Another workflow is already processing this order
90
90
  return { success: false, reason: "duplicate-processing" };
91
91
  }
@@ -120,7 +120,7 @@ import { streamProcessingWorkflow } from "./workflows/streaming";
120
120
  export async function POST(request: Request) {
121
121
  // Streams can be passed as workflow arguments
122
122
  const run = await start(streamProcessingWorkflow, [request.body]); // [!code highlight]
123
- await run.result();
123
+ await run.returnValue;
124
124
 
125
125
  return Response.json({ status: "complete" });
126
126
  }
@@ -136,7 +136,7 @@ import { sleep, createWebhook } from "workflow";
136
136
  export async function documentReviewProcess(userId: string) {
137
137
  "use workflow";
138
138
 
139
- await sleep("1 month"); // Sleep will suspend without consuming any resources [!code highlight]
139
+ await sleep("30d"); // Sleep will suspend without consuming any resources [!code highlight]
140
140
 
141
141
  // Create a webhook for external workflow resumption
142
142
  const webhook = createWebhook();
@@ -1,25 +1,14 @@
1
1
  ---
2
2
  title: Testing
3
- description: Unit test individual steps and integration test entire workflows using Vitest and the Vite plugin.
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 [Vite plugin](/docs/getting-started/vite) that integrates with [Vitest](https://vitest.dev), enabling full integration tests against a real workflow runtime.
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 against a real workflow setup using the Vite plugin. Required for workflows that use [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), or retries.
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-vite-plugin) for testing entire workflows, especially those that depend on workflow-only features.
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 Vite Plugin
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 `workflow/vite` plugin integrates directly with Vitest, compiling your `"use workflow"` and `"use step"` directives so the full workflow runtime is active during tests.
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 and a `globalSetup` script:
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/vite"; // [!code highlight]
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
- ### Global Setup Script
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
- let server: ChildProcess | null = null;
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
- export async function setup() { // [!code highlight]
133
- console.log("Starting server for workflow execution...");
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
- server = spawn("npx", ["nitro", "dev", "--port", PORT], {
136
- stdio: "pipe",
137
- detached: false,
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
- // Wait for the server to be ready
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
- server?.stderr?.on("data", (data) => {
155
- console.error("[server]", data.toString());
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
- server?.on("error", (error) => {
159
- console.error("Failed to start server:", error);
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, resumes the hook, and uses [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to skip the sleep so your tests don't have to wait for the full duration:
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
- // Resume the hook programmatically, simulating an external approval
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 workflow to replay and reach the sleep() call
272
- await delay(5_000); // [!code highlight]
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
- // Skip the 24-hour sleep so the test completes immediately
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 to `WORKFLOW_LOCAL_DATA_DIR`, you can use the same observability tools you would use in development.
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
 
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "title": "Testing",
3
- "pages": ["testing"]
3
+ "pages": ["index", "server-based"],
4
+ "defaultOpen": false
4
5
  }
@@ -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.2.0-beta.64",
3
+ "version": "4.2.0-beta.66",
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.38",
56
- "@workflow/cli": "4.2.0-beta.64",
57
- "@workflow/core": "4.2.0-beta.64",
55
+ "@workflow/astro": "4.0.0-beta.40",
56
+ "@workflow/core": "4.2.0-beta.66",
58
57
  "@workflow/errors": "4.1.0-beta.18",
58
+ "@workflow/cli": "4.2.0-beta.66",
59
59
  "@workflow/typescript-plugin": "4.0.1-beta.5",
60
- "@workflow/next": "4.0.1-beta.60",
61
- "@workflow/nest": "0.0.0-beta.13",
62
- "@workflow/nitro": "4.0.1-beta.59",
63
- "@workflow/nuxt": "4.0.1-beta.48",
64
- "@workflow/sveltekit": "4.0.0-beta.53",
65
- "@workflow/rollup": "4.0.0-beta.21"
60
+ "@workflow/next": "4.0.1-beta.62",
61
+ "@workflow/nest": "0.0.0-beta.15",
62
+ "@workflow/nitro": "4.0.1-beta.61",
63
+ "@workflow/nuxt": "4.0.1-beta.50",
64
+ "@workflow/sveltekit": "4.0.0-beta.55",
65
+ "@workflow/rollup": "4.0.0-beta.23"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/ms": "2.1.0",