Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ff1f9a3
Add Workflow instances DOs (Engines) to vitest-pool-workers worker
pombosilva Jul 30, 2025
933c7d8
Get waitUntil working
sidharthachatterjee Jul 30, 2025
6943064
skip sleep
sidharthachatterjee Jul 30, 2025
4de7468
skip sleep works now
sidharthachatterjee Jul 30, 2025
5180325
InstanceModifier is now inside worflows-shared with direct access to …
pombosilva Jul 30, 2025
9389ea2
add dynamic engine binding
pombosilva Aug 1, 2025
72b316c
add forceEventTimeout test support
pombosilva Aug 1, 2025
f592924
Strip out engine bindings from user facing env
pombosilva Aug 4, 2025
1c3b171
Prevent waitUntil from crashing tests after 5s
pombosilva Aug 5, 2025
99f92a6
Add mockEvent
pombosilva Aug 5, 2025
58dea1f
fix: prevent forceEventTimeot from not working if a mockEvent gets ca…
pombosilva Aug 5, 2025
51ca66f
Add mockStepResult
pombosilva Aug 5, 2025
b0650f5
Allow list of sleep steps in disableSleeps()
pombosilva Aug 12, 2025
4f626c0
Add mockStepError and cleanUp. Improve waitForStepResult and waitForS…
pombosilva Aug 19, 2025
3565ce8
Add forceStepTimeout
pombosilva Aug 19, 2025
7e92776
Make mockEvent not crash if instance has not been created yet
pombosilva Aug 19, 2025
83e6875
Update abort for testing
pombosilva Aug 19, 2025
4e3167c
prevent the same step from being mocked multiple times
pombosilva Aug 20, 2025
4f90105
check edge cases for wairForStatus - prevents user from waiting indef…
pombosilva Aug 20, 2025
d2f9c55
Add workflows plugin name to loopback
pombosilva Aug 28, 2025
692ef3c
Fix force step timeout
pombosilva Aug 28, 2025
c727578
Make it possible to import from workflows-shared
pombosilva Aug 28, 2025
d7ededa
Allow step errors and timeouts to be set and be performed in the corr…
pombosilva Aug 28, 2025
91505f6
Add introspectWorkflow API with proxies
pombosilva Aug 28, 2025
6bc30cc
Expose APIs with some documentation
pombosilva Aug 28, 2025
c4e4453
Add workflow tests with introspection
pombosilva Aug 28, 2025
b42a6c3
Fix test
pombosilva Aug 28, 2025
c80393b
Remove Vite comment
pombosilva Aug 28, 2025
70e16ca
Fixing documentation
pombosilva Aug 29, 2025
e46aa31
fixtures READMEs updated
pombosilva Aug 29, 2025
6e1f9b8
Add changeset
pombosilva Aug 29, 2025
5d1670f
Prettier fix
pombosilva Aug 29, 2025
d3d6507
Update bundle.mjs file
pombosilva Aug 29, 2025
37c97a6
Put workflows-shared as a devDependency
pombosilva Aug 29, 2025
f229505
Change waitUntil timeouts to 1 second
pombosilva Aug 29, 2025
ea400d8
Fix createBatch interception from calling this.create
pombosilva Aug 29, 2025
b37be40
Add review comment suggestions
pombosilva Sep 5, 2025
801edc5
Apply suggestions from code review - changelog file
pombosilva Sep 5, 2025
0f51b34
Add review suggestions (miniflare do plugin, dispose introspectors)
pombosilva Sep 5, 2025
c96da01
Support isolated storage and remove Engine DO bindings to runnerWorker
pombosilva Sep 5, 2025
040aca7
Update changeset
pombosilva Sep 5, 2025
71aeb7a
Add team review suggestions
pombosilva Sep 8, 2025
606ef2a
Update fixtures example to match a simple workflow example
pombosilva Sep 8, 2025
5012fc4
Remove @ts-expect-error and add some documentation
pombosilva Sep 9, 2025
1adf6bf
Change cleanup to dispose. Apply suggestions.
pombosilva Sep 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nice-carrots-sink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"miniflare": patch
---

Include workflow bining name in workflow plugin.
11 changes: 11 additions & 0 deletions .changeset/public-women-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cloudflare/vitest-pool-workers": minor
---

Add Workflows test support to the `cloudflare:test` module.

The `cloudflare:test` module has two new APIs:

- `introspectWorkflowInstance`
- `introspectWorkflow`
which allow changing the behavior of one or multiple Workflow instances created during tests.
1 change: 1 addition & 0 deletions fixtures/vitest-pool-workers-examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This directory contains example projects tested with `@cloudflare/vitest-pool-wo
| [📦 kv-r2-caches](kv-r2-caches) | Isolated tests using KV, R2 and the Cache API |
| [📚 d1](d1) | Isolated tests using D1 with migrations |
| [📌 durable-objects](durable-objects) | Isolated tests using Durable Objects with direct access |
| [🔁 workflows](workflows) | Tests using Workflows |
| [🚥 queues](queues) | Tests using Queue producers and consumers |
| [🚰 pipelines](pipelines) | Tests using Pipelines |
| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server |
Expand Down
9 changes: 7 additions & 2 deletions fixtures/vitest-pool-workers-examples/workflows/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# 🔁 workflows

Unit testing of workflows themselves is not possible yet - but you can still
trigger them in unit tests.
This Worker includes a ModeratorWorkflow that serves as a template for an automated content moderation process.
The testing suite uses workflow mocking to validate the logic of each step.

| Test | Overview |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
| [integration.test.ts](test/integration.test.ts) | Tests on the Worker's endpoints, ensuring that workflows are created and run correctly. |
| [unit.test.ts](test/unit.test.ts) | Tests on the internal logic of each workflow. It uses mocking to test steps in isolation. |
7 changes: 5 additions & 2 deletions fixtures/vitest-pool-workers-examples/workflows/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
interface Env {
TEST_WORKFLOW: Workflow;
declare namespace Cloudflare {
interface Env {
MODERATOR: Workflow;
}
}
interface Env extends Cloudflare.Env {}
85 changes: 73 additions & 12 deletions fixtures/vitest-pool-workers-examples/workflows/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,89 @@
import {
WorkerEntrypoint,
WorkflowEntrypoint,
WorkflowEvent,
WorkflowStep,
} from "cloudflare:workers";

export class TestWorkflow extends WorkflowEntrypoint<Env> {
export class ModeratorWorkflow extends WorkflowEntrypoint<Env> {
async run(_event: Readonly<WorkflowEvent<unknown>>, step: WorkflowStep) {
console.log("ola");
return "test-workflow";
await step.sleep("sleep for a while", "10 seconds");

// Get an initial analysis from an AI model
const aiResult = await step.do("AI content scan", async () => {
// Call to an workers-ai to scan the text content and return a violation score

// Simulated score:
const violationScore = Math.floor(Math.random() * 100);

return { violationScore: violationScore };
});

// Triage based on the AI score
if (aiResult.violationScore < 10) {
await step.do("auto approve content", async () => {
// API call to set app content status to "approved"
return { status: "auto_approved" };
});
return { status: "auto_approved" };
}
if (aiResult.violationScore > 90) {
await step.do("auto reject content", async () => {
// API call to set app content status to "rejected"
return { status: "auto_rejected" };
});
return { status: "auto_rejected" };
}

// If the score is ambiguous, require human review
type EventPayload = {
moderatorAction: string;
};
const eventPayload = await step.waitForEvent<EventPayload>("human review", {
type: "moderation-decision",
timeout: "1 day",
});

if (eventPayload) {
// The moderator responded in time.
const decision = eventPayload.payload.moderatorAction; // e.g., "approve" or "reject"
await step.do("apply moderator decision", async () => {
// API call to update content status based on the decision
return { status: "moderated", decision: decision };
});
return { status: "moderated", decision: decision };
}
}
}

export default class TestNamedEntrypoint extends WorkerEntrypoint<Env> {
async fetch(request: Request) {
const maybeId = new URL(request.url).searchParams.get("id");
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
const maybeId = url.searchParams.get("id");
if (maybeId !== null) {
const instance = await this.env.TEST_WORKFLOW.get(maybeId);
const instance = await env.MODERATOR.get(maybeId);

return Response.json(await instance.status());
}

const workflow = await this.env.TEST_WORKFLOW.create();
if (url.pathname === "/moderate") {
const workflow = await env.MODERATOR.create();
return Response.json({
id: workflow.id,
details: await workflow.status(),
});
}

return new Response(JSON.stringify({ id: workflow.id }));
}
}
if (url.pathname === "/moderate-batch") {
const workflows = await env.MODERATOR.createBatch([
{},
{ id: "321" },
{},
]);

const ids = workflows.map((workflow) => workflow.id);
return Response.json({ ids: ids });
}

return new Response("Not found", { status: 404 });
},
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
declare module "cloudflare:test" {
// Controls the type of `import("cloudflare:test").env`
interface ProvidedEnv extends Env {
TEST_WORKFLOW: Workflow;
}
interface ProvidedEnv extends Env {}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { env, introspectWorkflow, SELF } from "cloudflare:test";
import { expect, it } from "vitest";

const STATUS_COMPLETE = "complete";
const STEP_NAME = "AI content scan";
const mockResult = { violationScore: 0 };

// This example implicitly disposes the Workflow instance
it("workflow should be able to reach the end and be successful", async () => {
// CONFIG with `await using` to ensure Workflow instances cleanup:
await using introspector = await introspectWorkflow(env.MODERATOR);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: is await using needed? I guess it symbolizes Symbol.asyncDispose - but I wonder if there's a good way to doing it "synchronously" to have better syntax/DX? Maybe the runner DO can store the list of disposes and dispose it at the end of the test?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edmundhung can we do this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided on not worrying about this for now. Will be an improvement to be done later

Copy link
Contributor

@jahands jahands Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually prefer await using for stuff like this because I've found them to be more reliable than afterEach hooks (which I'm guessing would be used in the sync version proposed?)

By reliable, I mean I've had tests hang/give weird errors when doing certain things in afterEach that went away when I switched to await using

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with afterEach is that it would break with isolated storage - without isolated storage works just fine. Isolated storage makes its assertions at the end of each test and therefore we need to enforce dispose before the test ends to avoid errors

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL! Thank you for explaining

await introspector.modifyAll(async (m) => {
await m.disableSleeps();
await m.mockStepResult({ name: STEP_NAME }, mockResult);
});

await SELF.fetch(`https://mock-worker.local/moderate`);

const instances = introspector.get();
expect(instances.length).toBe(1);

// ASSERTIONS:
const instance = instances[0];
expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual(
mockResult
);
await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow();

// DISPOSE: ensured by `await using`
});

// This example explicitly disposes the Workflow instances
it("workflow batch should be able to reach the end and be successful", async () => {
// CONFIG:
let introspector = await introspectWorkflow(env.MODERATOR);
try {
await introspector.modifyAll(async (m) => {
await m.disableSleeps();
await m.mockStepResult({ name: STEP_NAME }, mockResult);
});

await SELF.fetch(`https://mock-worker.local/moderate-batch`);

const instances = introspector.get();
expect(instances.length).toBe(3);

// ASSERTIONS:
for (const instance of instances) {
expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual(
mockResult
);
await expect(
instance.waitForStatus(STATUS_COMPLETE)
).resolves.not.toThrow();
}
} finally {
// DISPOSE:
// Workflow introspector should be disposed the end of each test, if no `await using` dyntax is used
// Also disposes all intercepted instances
await introspector.dispose();
}
});
Loading
Loading