chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:11 +08:00
commit 70bf21e064
5213 changed files with 731895 additions and 0 deletions
@@ -0,0 +1,695 @@
import { createExecutionContext, runInDurableObject } from "cloudflare:test";
import { env } from "cloudflare:workers";
import { describe, it, vi } from "vitest";
import { InstanceEvent, InstanceStatus } from "../src";
import { WorkflowBinding } from "../src/binding";
import { setTestWorkflowCallback } from "./test-entry";
import type { WorkflowHandle } from "../src/binding";
import type { Engine, EngineLogs } from "../src/engine";
import type { WorkflowEvent } from "cloudflare:workers";
let instanceCounter = 0;
function uniqueId(prefix = "instance"): string {
return `${prefix}-${++instanceCounter}`;
}
function createBinding(): WorkflowBinding {
const ctx = createExecutionContext();
return new WorkflowBinding(ctx, {
ENGINE: env.ENGINE,
BINDING_NAME: "TEST_WORKFLOW",
WORKFLOW_NAME: "test-workflow",
});
}
async function waitUntilLogEvent(
engineStub: DurableObjectStub<Engine>,
event: InstanceEvent,
timeout = 5000
): Promise<void> {
await vi.waitUntil(
async () => {
const logs = (await engineStub.readLogs()) as EngineLogs;
const hasEvent = logs.logs.some((log) => log.event === event);
return hasEvent;
},
{ timeout }
);
}
describe("WorkflowBinding", () => {
describe("create()", () => {
it("should create an instance with provided id and params", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (event) => {
return (event as WorkflowEvent<{ key: string }>).payload;
});
const params = { key: "test-value" };
const result = await binding.create({ id, params });
expect(result.id).toBe(id);
await waitUntilLogEvent(engineStub, InstanceEvent.WORKFLOW_SUCCESS);
const instance = await binding.get(id);
const status = await instance.status();
expect(status.output).toEqual(params);
});
it("should pass the workflow name in the event", async ({ expect }) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (event) => {
return (event as WorkflowEvent<unknown>).workflowName;
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WORKFLOW_SUCCESS);
const instance = await binding.get(id);
const status = await instance.status();
expect(status.output).toBe("test-workflow");
});
it("should auto-generate id when not provided", async ({ expect }) => {
const binding = createBinding();
setTestWorkflowCallback(async () => "done");
const result = await binding.create();
expect(result.id).toBeDefined();
expect(result.id.length).toBeGreaterThan(0);
// Wait for the workflow to complete before the test ends so
// the fire-and-forget init() RPC settles before teardown.
const instance = await binding.get(result.id);
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "complete";
},
{ timeout: 5000 }
);
});
it("should throw WorkflowError for invalid instance id", async ({
expect,
}) => {
const binding = createBinding();
await expect(binding.create({ id: "#invalid!" })).rejects.toThrow(
"Workflow instance has invalid id"
);
});
});
describe("get()", () => {
it("should return a WorkflowHandle for an existing instance", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async () => "done");
await binding.create({ id });
const instance = await binding.get(id);
expect(instance).toMatchObject({
id,
status: expect.any(Function),
pause: expect.any(Function),
resume: expect.any(Function),
terminate: expect.any(Function),
restart: expect.any(Function),
});
// Wait for the workflow to complete before the test ends so
// the fire-and-forget init() RPC settles before teardown.
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "complete";
},
{ timeout: 5000 }
);
});
});
describe("createBatch()", () => {
it("should create multiple instances in a batch", async ({ expect }) => {
const binding = createBinding();
const ids = ["batch-1", "batch-2", "batch-3"];
setTestWorkflowCallback(async () => "done");
const results = await binding.createBatch(ids.map((id) => ({ id })));
expect(results).toHaveLength(3);
expect(results.map((r) => r.id)).toEqual(ids);
for (const id of ids) {
const instance = await binding.get(id);
expect(instance.id).toBe(id);
}
// Wait for all batch workflows to complete before the test ends
// so the fire-and-forget init() RPCs settle before teardown.
for (const id of ids) {
const instance = await binding.get(id);
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "complete";
},
{ timeout: 5000 }
);
}
});
it("should throw error for empty batch", async ({ expect }) => {
const binding = createBinding();
await expect(binding.createBatch([])).rejects.toThrow(
"WorkflowError: batchCreate should have at least 1 instance"
);
});
});
});
describe("WorkflowBinding", () => {
it("should not call dispose when sending an event to an instance", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
const receivedEvent = await step.waitForEvent("wait-for-test-event", {
type: "test-event",
timeout: "10 seconds",
});
return receivedEvent;
});
const createdInstance = await binding.create({ id });
expect(createdInstance.id).toBe(id);
const instance = await binding.get(id);
expect(instance.id).toBe(id);
const disposeSpy = vi.fn();
await runInDurableObject<Engine, void>(engineStub, (engine) => {
const originalReceiveEvent = engine.receiveEvent.bind(engine);
engine.receiveEvent = (event) => {
const result = originalReceiveEvent(event);
return Object.assign(result, {
[Symbol.dispose]: disposeSpy,
});
};
});
using _ = (await instance.sendEvent({
type: "test-event",
payload: { test: "data" },
})) as unknown as Disposable;
await vi.waitUntil(
async () => {
const status = await instance.status();
return status.status === "complete";
},
{ timeout: 5000 }
);
expect(disposeSpy).not.toHaveBeenCalled();
});
});
describe("WorkflowHandle", () => {
describe("status()", () => {
it("should return running status for a workflow waiting for an event", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.waitForEvent("wait-for-event", {
type: "some-event",
timeout: "2 seconds",
});
return "completed";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WAIT_START);
const instance = await binding.get(id);
const status = await instance.status();
expect(status.status).toBe("running");
expect(status.output).toBeNull();
expect(status.error).toBeUndefined();
// Terminate the waiting workflow so the init() RPC settles
// before teardown (the 2-second waitForEvent timeout would
// otherwise leave the workflow running past test end).
await instance.terminate();
});
it("should return complete status and output for a successful workflow", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
const expectedOutput = { result: "success", value: 42 };
setTestWorkflowCallback(async () => expectedOutput);
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WORKFLOW_SUCCESS);
const instance = await binding.get(id);
const status = await instance.status();
expect(status.status).toBe("complete");
expect(status.output).toEqual(expectedOutput);
expect(status.error).toBeUndefined();
});
it("should return errored status and error for a failed workflow", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async () => {
throw new Error("Workflow failed intentionally");
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WORKFLOW_FAILURE);
const instance = await binding.get(id);
const status = await instance.status();
expect(status.status).toBe("errored");
expect(status.error).toBeDefined();
expect(status.error?.message).toBe("Workflow failed intentionally");
expect(status.output).toBeNull();
});
it("should return step outputs in __LOCAL_DEV_STEP_OUTPUTS", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
const step1Result = await step.do(
"step-1",
async () => "result-from-step-1"
);
const step2Result = await step.do("step-2", async () => ({
data: "result-from-step-2",
}));
const step3Result = await step.do("step-3", async () => 123);
return { step1Result, step2Result, step3Result };
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WORKFLOW_SUCCESS);
const instance = (await binding.get(id)) as WorkflowHandle;
const status = await instance.status();
expect(status.status).toBe("complete");
expect(status.__LOCAL_DEV_STEP_OUTPUTS).toHaveLength(3);
expect(status.__LOCAL_DEV_STEP_OUTPUTS[0]).toBe("result-from-step-1");
expect(status.__LOCAL_DEV_STEP_OUTPUTS[1]).toEqual({
data: "result-from-step-2",
});
expect(status.__LOCAL_DEV_STEP_OUTPUTS[2]).toBe(123);
});
it("should return terminated status for a terminated instance", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.waitForEvent("wait-for-event", {
type: "some-event",
timeout: "1 second",
});
return "completed";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WAIT_START);
const instance = await binding.get(id);
await instance.terminate();
const newInstance = await binding.get(id);
const status = await newInstance.status();
expect(status.status).toBe("terminated");
});
});
describe("sendEvent()", () => {
it("should deliver event payload to a waiting workflow", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
const receivedEvent = await step.waitForEvent("wait-for-event", {
type: "my-event-type",
timeout: "2 seconds",
});
return receivedEvent;
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WAIT_START);
const instance = await binding.get(id);
const eventPayload = { message: "hello", count: 42 };
await instance.sendEvent({
type: "my-event-type",
payload: eventPayload,
});
await vi.waitUntil(
async () => {
const status = await instance.status();
return status.status === "complete";
},
{ timeout: 5000 }
);
const status = await instance.status();
expect(status.output).toMatchObject({
payload: eventPayload,
type: "my-event-type",
});
});
it("should handle multiple sequential events", async ({ expect }) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
const event1 = await step.waitForEvent("wait-1", {
type: "event-type-1",
timeout: "10 seconds",
});
const event2 = await step.waitForEvent("wait-2", {
type: "event-type-2",
timeout: "10 seconds",
});
return { first: event1.payload, second: event2.payload };
});
await binding.create({ id });
const instance = await binding.get(id);
await waitUntilLogEvent(engineStub, InstanceEvent.WAIT_START);
await instance.sendEvent({
type: "event-type-1",
payload: { value: "first" },
});
// Wait for the second waitForEvent
await vi.waitUntil(
async () => {
const logs = (await engineStub.readLogs()) as EngineLogs;
const waitStarts = logs.logs.filter(
(log) => log.event === InstanceEvent.WAIT_START
);
return waitStarts.length === 2;
},
{ timeout: 5000 }
);
await instance.sendEvent({
type: "event-type-2",
payload: { value: "second" },
});
await vi.waitUntil(
async () => {
const status = await instance.status();
return status.status === "complete";
},
{ timeout: 5000 }
);
const status = await instance.status();
expect(status.output).toEqual({
first: { value: "first" },
second: { value: "second" },
});
});
});
describe("terminate()", () => {
it("should terminate a running workflow instance", async ({ expect }) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.waitForEvent("wait-for-event", {
type: "some-event",
timeout: "1 second",
});
await step.do("should not be called", async () => {
return "should not be called";
});
return "should never complete";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WAIT_START);
const instance = await binding.get(id);
await instance.terminate();
// Get a new stub since the engine was aborted
const newEngineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
const status = await runInDurableObject(newEngineStub, (engine) => {
return engine.getStatus();
});
expect(status).toBe(InstanceStatus.Terminated);
const logs = (await newEngineStub.readLogs()) as EngineLogs;
const hasTerminatedEvent = logs.logs.some(
(log) => log.event === InstanceEvent.WORKFLOW_TERMINATED
);
expect(hasTerminatedEvent).toBe(true);
// assert that step.do never started
const hasStepStart = logs.logs.some(
(log) => log.event === InstanceEvent.STEP_START
);
expect(hasStepStart).toBe(false);
});
});
describe("restart()", () => {
it("should restart a workflow instance", async ({ expect }) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.sleep("sleep", 250);
return "complete";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.WORKFLOW_SUCCESS);
let instance = await binding.get(id);
let status = await instance.status();
expect(status.status).toBe("complete");
// restart() aborts the old DO, gets a fresh stub, and calls attemptRestart()
// The service binding (USER_WORKFLOW) survives the abort, so no re-setup needed
await instance.restart();
const statusAfterRestart = await instance.status();
expect(statusAfterRestart.status).toBe("running");
// Wait for the restarted workflow to complete via status polling
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "complete";
},
{ timeout: 5000 }
);
// Verify second run completed
instance = await binding.get(id);
status = await instance.status();
expect(status.status).toBe("complete");
});
});
describe("pause()", () => {
it("should pause a running workflow", async ({ expect }) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.do("long-step", async () => {
await scheduler.wait(500);
return "result-1";
});
// step-2 should never run because pause takes effect after long-step
await step.do("step-2", async () => "result-2");
return "done";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.STEP_START);
const instance = await binding.get(id);
// Pause while long-step is in flight
await instance.pause();
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "paused";
},
{ timeout: 5000 }
);
const finalStatus = await instance.status();
expect(finalStatus.status).toBe("paused");
});
});
describe("resume()", () => {
it("should resume a paused workflow and complete it", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.do("long-step", async () => {
await scheduler.wait(500);
return "result-1";
});
await step.do("step-2", async () => "result-2");
return "all-done";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.STEP_START);
const instance = await binding.get(id);
// Pause while long-step is in flight
await instance.pause();
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "paused";
},
{ timeout: 5000 }
);
await instance.resume();
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "complete";
},
{ timeout: 5000 }
);
const finalStatus = await instance.status();
expect(finalStatus.status).toBe("complete");
expect(finalStatus.output).toBe("all-done");
});
it("should cancel a pending pause when resume is called before step finishes", async ({
expect,
}) => {
const id = uniqueId();
const binding = createBinding();
const engineStub = env.ENGINE.get(env.ENGINE.idFromName(id));
setTestWorkflowCallback(async (_event, step) => {
await step.do("long-step", async () => {
await scheduler.wait(1000);
return "long-result";
});
await step.do("step-after", async () => "final-result");
return "completed";
});
await binding.create({ id });
await waitUntilLogEvent(engineStub, InstanceEvent.STEP_START);
const instance = await binding.get(id);
// Pause while long-step is in flight — sets WaitingForPause
await instance.pause();
const statusAfterPause = await instance.status();
expect(statusAfterPause.status).toBe("waitingForPause");
// resume before the step finishes — this should cancel the pending pause
await instance.resume();
// status should go back to Running
const statusAfterResume = await instance.status();
expect(statusAfterResume.status).toBe("running");
await vi.waitUntil(
async () => {
const s = await instance.status();
return s.status === "complete";
},
{ timeout: 5000 }
);
const finalStatus = await instance.status();
expect(finalStatus.status).toBe("complete");
expect(finalStatus.output).toBe("completed");
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
/* eslint-disable */
declare namespace Cloudflare {
interface Env {
ENGINE: DurableObjectNamespace<import("../src/index").Engine>;
USER_WORKFLOW: import("cloudflare:workers").WorkflowEntrypoint;
}
}
declare module "workerd:unsafe" {
const unsafe: {
abortAllDurableObjects(): Promise<void>;
};
export default unsafe;
}
@@ -0,0 +1,164 @@
import { describe, it, vi } from "vitest";
import {
DEFAULT_RETRY_DELAY_MS,
invokeDelayFunction,
} from "../../src/lib/delay";
import { DelayFunctionError } from "../../src/lib/retries";
import type { WorkflowDynamicDelayContext } from "cloudflare:workers";
const input: WorkflowDynamicDelayContext = {
ctx: {
step: { name: "step", count: 1 },
attempt: 1,
config: { retries: { limit: 5, backoff: "constant" }, timeout: 0 },
},
error: new Error("boom"),
};
const makeLogger = () => ({ warn: vi.fn(), debug: vi.fn() });
const noTimeout =
(): ((ms: number, opts?: { signal?: AbortSignal }) => Promise<void>) => () =>
new Promise<void>(() => {});
describe("invokeDelayFunction", () => {
it("returns the value a synchronous delay function produces", async ({
expect,
}) => {
const logger = makeLogger();
const result = await invokeDelayFunction(() => 1234, input, {
timeoutMs: 5000,
wait: noTimeout(),
logger,
});
expect(result).toBe(1234);
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.debug).not.toHaveBeenCalled();
});
it("awaits an asynchronous delay function", async ({ expect }) => {
const logger = makeLogger();
const result = await invokeDelayFunction(async () => 1234, input, {
timeoutMs: 5000,
wait: noTimeout(),
logger,
});
expect(result).toBe(1234);
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.debug).not.toHaveBeenCalled();
});
it("throws when the delay function never resolves (timeout)", async ({
expect,
}) => {
const logger = makeLogger();
const promise = invokeDelayFunction(
() => new Promise<number>(() => {}),
input,
{
timeoutMs: 5000,
wait: () => Promise.resolve(),
logger,
}
);
await expect(promise).rejects.toThrow(
new DelayFunctionError("did not return within 5 seconds")
);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("did not resolve"),
{ step: "step", attempt: 1 }
);
expect(logger.debug).not.toHaveBeenCalled();
});
it("throws when the delay function throws synchronously", async ({
expect,
}) => {
const logger = makeLogger();
const promise = invokeDelayFunction(
() => {
throw new Error("sync boom");
},
input,
{
timeoutMs: 5000,
wait: noTimeout(),
logger,
}
);
await expect(promise).rejects.toThrow(
new DelayFunctionError("threw an error: sync boom")
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("threw"),
expect.objectContaining({ error: "sync boom" })
);
});
it("throws when the delay function rejects", async ({ expect }) => {
const logger = makeLogger();
const promise = invokeDelayFunction(
async () => {
throw new Error("async boom");
},
input,
{
timeoutMs: 5000,
wait: noTimeout(),
logger,
}
);
await expect(promise).rejects.toThrow(
new DelayFunctionError("threw an error: async boom")
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("threw"),
expect.objectContaining({ error: "async boom" })
);
});
it("logs a debug (not a warn) when the provided signal aborts before the function resolves", async ({
expect,
}) => {
const logger = makeLogger();
const abort = new AbortController();
const promise = invokeDelayFunction(
() => new Promise<number>(() => {}),
input,
{
timeoutMs: 5000,
wait: (_ms, opts) =>
new Promise<void>((resolve) => {
opts?.signal?.addEventListener("abort", () => resolve(), {
once: true,
});
}),
signal: abort.signal,
logger,
}
);
abort.abort();
await expect(promise).resolves.toBe(DEFAULT_RETRY_DELAY_MS);
expect(logger.debug).toHaveBeenCalledTimes(1);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining("engine shutdown"),
{ step: "step", attempt: 1 }
);
expect(logger.warn).not.toHaveBeenCalled();
});
it("throws without a logger", async ({ expect }) => {
const promise = invokeDelayFunction(
() => {
throw new Error("sync boom");
},
input,
{
timeoutMs: 5000,
wait: noTimeout(),
}
);
await expect(promise).rejects.toThrow(DelayFunctionError);
});
});
@@ -0,0 +1,51 @@
import { WorkerEntrypoint } from "cloudflare:workers";
import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers";
// Test entry point — re-exports everything from src/index.ts and adds
// a TestWorkflow class that can be bound as USER_WORKFLOW via serviceBindings.
// This allows the workflow entrypoint to survive DO aborts (unlike the old
// setWorkflowEntrypoint pattern which manually mutated instance.env).
//
// NOTE: We extend WorkerEntrypoint (not WorkflowEntrypoint) because workerd
// only recognises WorkerEntrypoint subclasses for service-binding RPC.
// WorkflowEntrypoint is a higher-level abstraction used by the Workflows
// platform; for our test harness the engine just needs a target with a
// callable run() method.
export * from "../src/index";
type WorkflowCallback = (
event: unknown,
step: WorkflowStep
) => Promise<unknown>;
let __testWorkflowCallback: WorkflowCallback | undefined;
/**
* Set the workflow callback that TestWorkflow.run() will delegate to.
* Call this before creating or restarting a workflow instance in tests.
*/
export function setTestWorkflowCallback(
cb: WorkflowCallback | undefined
): void {
__testWorkflowCallback = cb;
}
/**
* A WorkerEntrypoint subclass for tests that delegates run() to a
* module-level callback. Configured as the USER_WORKFLOW service binding
* in vitest.config.ts so it survives DO aborts (unlike manual env injection).
*/
export class TestWorkflow extends WorkerEntrypoint {
async run(
event: Readonly<WorkflowEvent<unknown>>,
step: WorkflowStep
): Promise<unknown> {
if (!__testWorkflowCallback) {
throw new Error(
"TestWorkflow callback not set — call setTestWorkflowCallback() before running the workflow"
);
}
return await __testWorkflowCallback(event, step);
}
}
@@ -0,0 +1,11 @@
{
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": [
"@cloudflare/workers-types",
"@cloudflare/vitest-pool-workers/types"
]
},
"include": ["./**/*.ts"]
}
+74
View File
@@ -0,0 +1,74 @@
import { env } from "cloudflare:test";
import { setTestWorkflowCallback } from "./test-entry";
import type {
DatabaseInstance,
DatabaseVersion,
DatabaseWorkflow,
Engine,
} from "../src/engine";
import type { WorkflowStep } from "cloudflare:workers";
// Track fire-and-forget init() RPC promises so they can be settled
// in afterAll hooks before vitest tears down miniflare
const pendingInits: Promise<unknown>[] = [];
/**
* Await all tracked init promises (with a timeout safety net).
* Call this in afterAll() for every test file that uses runWorkflow().
*/
export async function settlePendingWorkflows(): Promise<void> {
await Promise.allSettled(pendingInits);
pendingInits.length = 0;
}
export async function runWorkflow(
instanceId: string,
callback: (event: unknown, step: WorkflowStep) => Promise<unknown>
): Promise<DurableObjectStub<Engine>> {
const engineId = env.ENGINE.idFromName(instanceId);
const engineStub = env.ENGINE.get(engineId);
setTestWorkflowCallback(callback);
// Fire-and-forget: suppress all rejections to prevent unhandled
// rejections inside workerd (which crash the HTTP server on Windows).
// Abort errors (pause, terminate, restart) are expected; workflow
// errors (NonRetryableError, step failures) are also expected for
// tests that intentionally trigger them. Tests validate correctness
// through separate log/status assertions, not through init()'s result.
const initPromise = engineStub
.init(
12346,
{} as DatabaseWorkflow,
{} as DatabaseVersion,
{ id: instanceId } as DatabaseInstance,
{ payload: {}, timestamp: new Date(), instanceId, workflowName: "" }
)
.catch(() => {});
pendingInits.push(initPromise);
return engineStub;
}
export async function runWorkflowAndAwait(
instanceId: string,
callback: (event: unknown, step: WorkflowStep) => Promise<unknown>
): Promise<DurableObjectStub<Engine>> {
const engineId = env.ENGINE.idFromName(instanceId);
const engineStub = env.ENGINE.get(engineId);
setTestWorkflowCallback(callback);
await engineStub
.init(
12346,
{} as DatabaseWorkflow,
{} as DatabaseVersion,
{ id: instanceId } as DatabaseInstance,
{ payload: {}, timestamp: new Date(), instanceId, workflowName: "" }
)
.catch(() => {});
return engineStub;
}
@@ -0,0 +1,123 @@
import { describe, it } from "vitest";
import {
isValidStepConfig,
isValidStepName,
isValidWorkflowInstanceId,
isValidWorkflowName,
MAX_STEP_NAME_LENGTH,
MAX_WORKFLOW_INSTANCE_ID_LENGTH,
MAX_WORKFLOW_NAME_LENGTH,
} from "../src/lib/validators";
describe("Workflow name validation", () => {
it.for([
"",
NaN,
undefined,
" ",
"\n\nhello",
"w".repeat(MAX_WORKFLOW_NAME_LENGTH + 1),
"#1231231!!!!",
"-badName",
])("should reject invalid names", (value, { expect }) => {
expect(isValidWorkflowName(value as string)).toBe(false);
});
it.for([
"abc",
"NAME_123-hello",
"a-valid-string",
"w".repeat(MAX_WORKFLOW_NAME_LENGTH),
])("should accept valid names", (value, { expect }) => {
expect(isValidWorkflowName(value as string)).toBe(true);
});
});
describe("Workflow instance ID validation", () => {
it.for([
"",
NaN,
undefined,
" ",
"\n\nhello",
"w".repeat(MAX_WORKFLOW_INSTANCE_ID_LENGTH + 1),
"#1231231!!!!",
])("should reject invalid IDs", (value, { expect }) => {
expect(isValidWorkflowInstanceId(value as string)).toBe(false);
});
it.for([
"abc",
"NAME_123-hello",
"a-valid-string",
"w".repeat(MAX_WORKFLOW_INSTANCE_ID_LENGTH),
])("should accept valid IDs", (value, { expect }) => {
expect(isValidWorkflowInstanceId(value as string)).toBe(true);
});
});
describe("Workflow instance step name validation", () => {
it.for(["\x00", "w".repeat(MAX_STEP_NAME_LENGTH + 1)])(
"should reject invalid names",
(value, { expect }) => {
expect(isValidStepName(value as string)).toBe(false);
}
);
it.for([
"abc",
"NAME_123-hello",
"a-valid-string",
"w".repeat(MAX_STEP_NAME_LENGTH),
"valid step name",
])("should accept valid names", (value, { expect }) => {
expect(isValidStepName(value as string)).toBe(true);
});
});
describe("Workflow step config validation", () => {
it.for([
{ timeout: "5 years", retries: { limit: 1 } },
{ timeout: "5 years", retries: { delay: 50 } },
{ timeout: "5 years", retries: { backoff: "exponential" } },
{
timeout: "5 years",
retries: {
delay: "10 minutes",
limit: 5,
"i-like-trains": "yes".repeat(100),
},
},
{
retries: { limit: 3, delay: "i like trains", backoff: "constant" },
timeout: "10 minutes",
},
{
retries: { limit: 3, delay: 10, backoff: "constant" },
timeout: 0,
},
])("should reject invalid step configs", (value, { expect }) => {
expect(isValidStepConfig(value)).toBe(false);
});
it("should accept a dynamic delay function", ({ expect }) => {
const config = {
retries: { limit: 3, delay: () => "10 seconds", backoff: "constant" },
timeout: "10 minutes",
};
expect(isValidStepConfig(config)).toBe(true);
});
it.for([
{
retries: { limit: 0, delay: 100000, backoff: "exponential" },
timeout: "15 minutes",
},
{
retries: { limit: 5, delay: 0, backoff: "constant" },
timeout: "2 minutes",
},
])("should accept valid step configs", (value, { expect }) => {
expect(isValidStepConfig(value)).toBe(true);
});
});