Skip to content

Commit e2b838f

Browse files
pombosilvasidharthachatterjeepenalosa
authored
[vitest-pool-workers] Workflows test support (#10494)
* Add Workflow instances DOs (Engines) to vitest-pool-workers worker * Get waitUntil working * skip sleep * skip sleep works now * InstanceModifier is now inside worflows-shared with direct access to the engine * add dynamic engine binding * add forceEventTimeout test support * Strip out engine bindings from user facing env * Prevent waitUntil from crashing tests after 5s * Add mockEvent * fix: prevent forceEventTimeot from not working if a mockEvent gets called afterwards * Add mockStepResult * Allow list of sleep steps in disableSleeps() * Add mockStepError and cleanUp. Improve waitForStepResult and waitForStatus * Add forceStepTimeout * Make mockEvent not crash if instance has not been created yet * Update abort for testing * prevent the same step from being mocked multiple times * check edge cases for wairForStatus - prevents user from waiting indefinitely * Add workflows plugin name to loopback * Fix force step timeout * Make it possible to import from workflows-shared * Allow step errors and timeouts to be set and be performed in the correct order * Add introspectWorkflow API with proxies * Expose APIs with some documentation * Add workflow tests with introspection * Fix test * Remove Vite comment * Fixing documentation * fixtures READMEs updated * Add changeset * Prettier fix * Update bundle.mjs file * Put workflows-shared as a devDependency * Change waitUntil timeouts to 1 second * Fix createBatch interception from calling this.create * Add review comment suggestions * Apply suggestions from code review - changelog file Co-authored-by: Somhairle MacLeòid <[email protected]> * Add review suggestions (miniflare do plugin, dispose introspectors) * Support isolated storage and remove Engine DO bindings to runnerWorker * Update changeset * Add team review suggestions * Update fixtures example to match a simple workflow example * Remove @ts-expect-error and add some documentation * Change cleanup to dispose. Apply suggestions. --------- Co-authored-by: Sid Chatterjee <[email protected]> Co-authored-by: Somhairle MacLeòid <[email protected]>
1 parent da24079 commit e2b838f

File tree

29 files changed

+1489
-71
lines changed

29 files changed

+1489
-71
lines changed

.changeset/nice-carrots-sink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
Include workflow bining name in workflow plugin.

.changeset/public-women-exist.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@cloudflare/vitest-pool-workers": minor
3+
---
4+
5+
Add Workflows test support to the `cloudflare:test` module.
6+
7+
The `cloudflare:test` module has two new APIs:
8+
9+
- `introspectWorkflowInstance`
10+
- `introspectWorkflow`
11+
which allow changing the behavior of one or multiple Workflow instances created during tests.

fixtures/vitest-pool-workers-examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This directory contains example projects tested with `@cloudflare/vitest-pool-wo
1010
| [📦 kv-r2-caches](kv-r2-caches) | Isolated tests using KV, R2 and the Cache API |
1111
| [📚 d1](d1) | Isolated tests using D1 with migrations |
1212
| [📌 durable-objects](durable-objects) | Isolated tests using Durable Objects with direct access |
13+
| [🔁 workflows](workflows) | Tests using Workflows |
1314
| [🚥 queues](queues) | Tests using Queue producers and consumers |
1415
| [🚰 pipelines](pipelines) | Tests using Pipelines |
1516
| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server |
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
# 🔁 workflows
22

3-
Unit testing of workflows themselves is not possible yet - but you can still
4-
trigger them in unit tests.
3+
This Worker includes a ModeratorWorkflow that serves as a template for an automated content moderation process.
4+
The testing suite uses workflow mocking to validate the logic of each step.
5+
6+
| Test | Overview |
7+
| ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
8+
| [integration.test.ts](test/integration.test.ts) | Tests on the Worker's endpoints, ensuring that workflows are created and run correctly. |
9+
| [unit.test.ts](test/unit.test.ts) | Tests on the internal logic of each workflow. It uses mocking to test steps in isolation. |
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
interface Env {
2-
TEST_WORKFLOW: Workflow;
1+
declare namespace Cloudflare {
2+
interface Env {
3+
MODERATOR: Workflow;
4+
}
35
}
6+
interface Env extends Cloudflare.Env {}
Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,89 @@
11
import {
2-
WorkerEntrypoint,
32
WorkflowEntrypoint,
43
WorkflowEvent,
54
WorkflowStep,
65
} from "cloudflare:workers";
76

8-
export class TestWorkflow extends WorkflowEntrypoint<Env> {
7+
export class ModeratorWorkflow extends WorkflowEntrypoint<Env> {
98
async run(_event: Readonly<WorkflowEvent<unknown>>, step: WorkflowStep) {
10-
console.log("ola");
11-
return "test-workflow";
9+
await step.sleep("sleep for a while", "10 seconds");
10+
11+
// Get an initial analysis from an AI model
12+
const aiResult = await step.do("AI content scan", async () => {
13+
// Call to an workers-ai to scan the text content and return a violation score
14+
15+
// Simulated score:
16+
const violationScore = Math.floor(Math.random() * 100);
17+
18+
return { violationScore: violationScore };
19+
});
20+
21+
// Triage based on the AI score
22+
if (aiResult.violationScore < 10) {
23+
await step.do("auto approve content", async () => {
24+
// API call to set app content status to "approved"
25+
return { status: "auto_approved" };
26+
});
27+
return { status: "auto_approved" };
28+
}
29+
if (aiResult.violationScore > 90) {
30+
await step.do("auto reject content", async () => {
31+
// API call to set app content status to "rejected"
32+
return { status: "auto_rejected" };
33+
});
34+
return { status: "auto_rejected" };
35+
}
36+
37+
// If the score is ambiguous, require human review
38+
type EventPayload = {
39+
moderatorAction: string;
40+
};
41+
const eventPayload = await step.waitForEvent<EventPayload>("human review", {
42+
type: "moderation-decision",
43+
timeout: "1 day",
44+
});
45+
46+
if (eventPayload) {
47+
// The moderator responded in time.
48+
const decision = eventPayload.payload.moderatorAction; // e.g., "approve" or "reject"
49+
await step.do("apply moderator decision", async () => {
50+
// API call to update content status based on the decision
51+
return { status: "moderated", decision: decision };
52+
});
53+
return { status: "moderated", decision: decision };
54+
}
1255
}
1356
}
1457

15-
export default class TestNamedEntrypoint extends WorkerEntrypoint<Env> {
16-
async fetch(request: Request) {
17-
const maybeId = new URL(request.url).searchParams.get("id");
58+
export default {
59+
async fetch(request: Request, env: Env) {
60+
const url = new URL(request.url);
61+
const maybeId = url.searchParams.get("id");
1862
if (maybeId !== null) {
19-
const instance = await this.env.TEST_WORKFLOW.get(maybeId);
63+
const instance = await env.MODERATOR.get(maybeId);
2064

2165
return Response.json(await instance.status());
2266
}
2367

24-
const workflow = await this.env.TEST_WORKFLOW.create();
68+
if (url.pathname === "/moderate") {
69+
const workflow = await env.MODERATOR.create();
70+
return Response.json({
71+
id: workflow.id,
72+
details: await workflow.status(),
73+
});
74+
}
2575

26-
return new Response(JSON.stringify({ id: workflow.id }));
27-
}
28-
}
76+
if (url.pathname === "/moderate-batch") {
77+
const workflows = await env.MODERATOR.createBatch([
78+
{},
79+
{ id: "321" },
80+
{},
81+
]);
82+
83+
const ids = workflows.map((workflow) => workflow.id);
84+
return Response.json({ ids: ids });
85+
}
86+
87+
return new Response("Not found", { status: 404 });
88+
},
89+
};
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
declare module "cloudflare:test" {
22
// Controls the type of `import("cloudflare:test").env`
3-
interface ProvidedEnv extends Env {
4-
TEST_WORKFLOW: Workflow;
5-
}
3+
interface ProvidedEnv extends Env {}
64
}

fixtures/vitest-pool-workers-examples/workflows/test/integration-self.test.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { env, introspectWorkflow, SELF } from "cloudflare:test";
2+
import { expect, it } from "vitest";
3+
4+
const STATUS_COMPLETE = "complete";
5+
const STEP_NAME = "AI content scan";
6+
const mockResult = { violationScore: 0 };
7+
8+
// This example implicitly disposes the Workflow instance
9+
it("workflow should be able to reach the end and be successful", async () => {
10+
// CONFIG with `await using` to ensure Workflow instances cleanup:
11+
await using introspector = await introspectWorkflow(env.MODERATOR);
12+
await introspector.modifyAll(async (m) => {
13+
await m.disableSleeps();
14+
await m.mockStepResult({ name: STEP_NAME }, mockResult);
15+
});
16+
17+
await SELF.fetch(`https://mock-worker.local/moderate`);
18+
19+
const instances = introspector.get();
20+
expect(instances.length).toBe(1);
21+
22+
// ASSERTIONS:
23+
const instance = instances[0];
24+
expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual(
25+
mockResult
26+
);
27+
await expect(instance.waitForStatus(STATUS_COMPLETE)).resolves.not.toThrow();
28+
29+
// DISPOSE: ensured by `await using`
30+
});
31+
32+
// This example explicitly disposes the Workflow instances
33+
it("workflow batch should be able to reach the end and be successful", async () => {
34+
// CONFIG:
35+
let introspector = await introspectWorkflow(env.MODERATOR);
36+
try {
37+
await introspector.modifyAll(async (m) => {
38+
await m.disableSleeps();
39+
await m.mockStepResult({ name: STEP_NAME }, mockResult);
40+
});
41+
42+
await SELF.fetch(`https://mock-worker.local/moderate-batch`);
43+
44+
const instances = introspector.get();
45+
expect(instances.length).toBe(3);
46+
47+
// ASSERTIONS:
48+
for (const instance of instances) {
49+
expect(await instance.waitForStepResult({ name: STEP_NAME })).toEqual(
50+
mockResult
51+
);
52+
await expect(
53+
instance.waitForStatus(STATUS_COMPLETE)
54+
).resolves.not.toThrow();
55+
}
56+
} finally {
57+
// DISPOSE:
58+
// Workflow introspector should be disposed the end of each test, if no `await using` dyntax is used
59+
// Also disposes all intercepted instances
60+
await introspector.dispose();
61+
}
62+
});

0 commit comments

Comments
 (0)