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
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:
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user