diff --git a/packages/azure-functions-durable/CHANGELOG.md b/packages/azure-functions-durable/CHANGELOG.md index f2fc94e..05fc47f 100644 --- a/packages/azure-functions-durable/CHANGELOG.md +++ b/packages/azure-functions-durable/CHANGELOG.md @@ -2,8 +2,14 @@ ### New -### Fixes +- Added a `durable-functions/testing` entry point with `runOrchestrator`, which runs an orchestrator + to a terminal state against inline activity implementations on the in-memory backend and always + releases its worker, and `createActivityContext` for invoking activity handlers directly. + Interactive scenarios (external events, termination, suspend/resume) and entity batches are + covered by driving the `@microsoft/durabletask-js` in-memory test stack with `wrapOrchestrator` / + `wrapEntity`; see the README. +### Fixes ## v4.0.0-beta.1 (2026-07-31) diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 801cf01..1969407 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -96,12 +96,13 @@ changed: **not** available — existing `response.getHeader(...)` calls **fail at runtime** and must be rewritten to index `response.headers[...]` by lower-cased key (response header names are lower-cased by `fetch`). -- **Some v3 top-level exports were removed** — `DummyOrchestrationContext` / `DummyEntityContext` - (testing utilities) and the entity-lock types above. `TaskFailedError` - is re-exported from the core SDK (aggregate failures surface as JS-native `AggregateError`); use the - core `TestOrchestrationWorker` / `TestOrchestrationClient` for orchestration unit tests. +- **The v3 dummy contexts were replaced by `durable-functions/testing`.** The new helpers run + orchestrators through the real in-memory replay engine and run entity batches directly, without a + Functions host or imports from `@microsoft/durabletask-js`. The entity-lock types above remain + removed. `TaskFailedError` is re-exported from the core SDK (aggregate failures surface as + JS-native `AggregateError`). - **A plain non-generator classic orchestrator is no longer supported.** A classic v3 orchestrator - written as a *synchronous, single-argument, non-generator* function `(context) => context.df.*` + written as a _synchronous, single-argument, non-generator_ function `(context) => context.df.*` (one that never `yield`s) is now treated as a **core-native** orchestrator and receives the core `OrchestrationContext`, which has no `.df`. This resolves [#321](https://github.com/microsoft/durabletask-js/issues/321), where a core-native @@ -149,6 +150,104 @@ app.http("startHello", { }); ``` +## Testing + +`durable-functions/testing` provides one helper for the common case — running an orchestrator to +completion against fake activities — plus a factory for the activity invocation context. Everything +else is already covered by the in-memory test stack in `@microsoft/durabletask-js`. + +### Activities + +Activity handlers are ordinary Azure Functions handlers with no durable state, so call them directly +and pass a context: + +```typescript +import type { InvocationContext } from "@azure/functions"; +import { createActivityContext } from "durable-functions/testing"; + +const sayHello = (name: string, context: InvocationContext) => `${context.functionName}: Hello, ${name}!`; + +expect(await sayHello("World", createActivityContext("sayHello"))).toBe("sayHello: Hello, World!"); +``` + +### Orchestrations + +`runOrchestrator` registers the orchestrator and the supplied activities on an in-memory worker, runs +the instance to a terminal state, and stops the worker before returning: + +```typescript +import type { OrchestrationContext } from "durable-functions"; +import { OrchestrationRuntimeStatus } from "durable-functions"; +import { runOrchestrator } from "durable-functions/testing"; + +const helloOrchestrator = function* (context: OrchestrationContext) { + const name = context.df.getInput(); + return yield context.df.callActivity("sayHello", name); +}; + +const result = await runOrchestrator(helloOrchestrator, { + input: "World", + activities: { + sayHello: (name: unknown) => `Hello, ${String(name)}!`, + }, +}); + +expect(result.runtimeStatus).toBe(OrchestrationRuntimeStatus.Completed); +expect(result.output).toBe("Hello, World!"); +``` + +`runtimeStatus`, `output`, and `customStatus` are produced by the same mapping `client.getStatus()` +applies at runtime, so a test asserts on the values a deployed client would observe. A failed run +returns `OrchestrationRuntimeStatus.Failed` together with `failure` (`errorType`, `message`, +`stackTrace`). + +`runOrchestrator` intentionally has no forced timeout. It returns only after the orchestration is +terminal and the worker has drained, so activity code cannot keep mutating test state afterwards. +Arbitrary JavaScript promises cannot be cancelled: if a handler never settles, the helper stays +pending and the test runner's own timeout applies. + +Durable timers run on **real wall-clock delays** — the in-memory backend has no virtual clock, so +keep timer delays short in tests. + +### Interactive scenarios and entities + +External events, termination, suspend/resume, and entity batches are not wrapped. Drive the core +in-memory stack directly and register Durable Functions handlers with `wrapOrchestrator` (or +`wrapEntity`): + +```typescript +import { + InMemoryOrchestrationBackend, + TestOrchestrationClient, + TestOrchestrationWorker, +} from "@microsoft/durabletask-js"; +import { toDurableOrchestrationStatus, wrapOrchestrator } from "durable-functions"; + +const backend = new InMemoryOrchestrationBackend(); +const worker = new TestOrchestrationWorker(backend); +const client = new TestOrchestrationClient(backend); + +worker.addNamedOrchestrator( + "approval", + wrapOrchestrator(function* (context) { + return { approved: yield context.df.waitForExternalEvent("approved") }; + }), +); +await worker.start(); + +try { + const instanceId = await client.scheduleNewOrchestration("approval", undefined, "approval-1"); + await client.waitForOrchestrationStart(instanceId, true, 10); + await client.raiseOrchestrationEvent(instanceId, "approved", true); + + const state = await client.waitForOrchestrationCompletion(instanceId, true, 10); + expect(toDurableOrchestrationStatus(state!).output).toEqual({ approved: true }); +} finally { + await worker.stop(); + backend.reset(); +} +``` + ### Client (starter) functions `app.client.*` is sugar for the client-starter above — it adds the `durableClient` input binding and diff --git a/packages/azure-functions-durable/package.json b/packages/azure-functions-durable/package.json index 2fd4a59..614016a 100644 --- a/packages/azure-functions-durable/package.json +++ b/packages/azure-functions-durable/package.json @@ -7,11 +7,23 @@ "description": "Azure Functions Durable provider for the Durable Task JavaScript SDK", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "typesVersions": { + "*": { + "testing": [ + "./dist/testing/index.d.ts" + ] + } + }, "exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.js", "import": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "require": "./dist/testing/index.js", + "import": "./dist/testing/index.js" } }, "files": [ diff --git a/packages/azure-functions-durable/src/testing/index.ts b/packages/azure-functions-durable/src/testing/index.ts new file mode 100644 index 0000000..6111564 --- /dev/null +++ b/packages/azure-functions-durable/src/testing/index.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { InvocationContext } from "@azure/functions"; +import { + InMemoryOrchestrationBackend, + TestOrchestrationClient, + TestOrchestrationWorker, +} from "@microsoft/durabletask-js"; +import type { TaskFailureDetails } from "@microsoft/durabletask-js"; +import type { ActivityHandler, OrchestrationHandler } from "../app"; +import { wrapOrchestrator } from "../orchestration-context"; +import { OrchestrationRuntimeStatus, toDurableOrchestrationStatus } from "../orchestration-status"; + +const ORCHESTRATOR_NAME = "orchestrator"; +const DEFAULT_ACTIVITY_NAME = "activity"; + +/** + * Creates the {@link InvocationContext} an activity handler receives at runtime. + * + * @remarks Activities are ordinary Azure Functions handlers with no durable state, so they are + * tested by calling them directly: `await sayHello("World", createActivityContext("sayHello"))`. + */ +export function createActivityContext(functionName: string = DEFAULT_ACTIVITY_NAME): InvocationContext { + return new InvocationContext({ functionName }); +} + +/** Options for {@link runOrchestrator}. */ +export interface OrchestratorTestOptions { + /** Input passed to the orchestrator. */ + input?: TInput; + /** Instance id to schedule under. Defaults to a generated id. */ + instanceId?: string; + /** Activity implementations the orchestrator may call, keyed by activity name. */ + activities?: Readonly>; +} + +/** + * The terminal state of an orchestration run. + * + * @remarks `runtimeStatus`, `output`, and `customStatus` are produced by the same mapping + * `DurableFunctionsClient.getStatus` applies at runtime, so a test asserts on exactly the values a + * deployed client would observe. + */ +export interface OrchestrationTestResult { + instanceId: string; + runtimeStatus: OrchestrationRuntimeStatus; + output?: TOutput; + customStatus?: unknown; + /** Populated when the orchestration failed. */ + failure?: TaskFailureDetails; +} + +/** + * Runs one orchestrator to a terminal state on the in-memory backend and always releases its worker. + * + * @remarks Durable timers use real wall-clock time, because the in-memory backend has no virtual + * clock; tests should schedule short delays. There is no forced timeout: the helper returns only + * once the orchestration is terminal and the worker has drained, so activity code cannot keep + * mutating test state after it returns. Arbitrary JavaScript promises cannot be cancelled, so a + * handler that never settles leaves this helper pending and the test runner's timeout applies. + * + * Interactive scenarios (external events, terminate, suspend/resume) are covered by driving + * `TestOrchestrationWorker` and `TestOrchestrationClient` from `@microsoft/durabletask-js` directly + * and registering handlers with {@link wrapOrchestrator}; see the package README. + */ +export async function runOrchestrator( + handler: OrchestrationHandler, + options: OrchestratorTestOptions = {}, +): Promise> { + const backend = new InMemoryOrchestrationBackend(); + const worker = new TestOrchestrationWorker(backend); + const client = new TestOrchestrationClient(backend); + + worker.addNamedOrchestrator(ORCHESTRATOR_NAME, wrapOrchestrator(handler)); + for (const [name, activity] of Object.entries(options.activities ?? {})) { + worker.addNamedActivity(name, async (_context, input) => activity(input, createActivityContext(name))); + } + + await worker.start(); + try { + const instanceId = await client.scheduleNewOrchestration(ORCHESTRATOR_NAME, options.input, options.instanceId); + // A zero timeout disables the backend's wait timer, per the no-forced-timeout remark above. + const state = await client.waitForOrchestrationCompletion(instanceId, true, 0); + if (!state) { + throw new Error(`Orchestration '${instanceId}' was not found.`); + } + + const status = toDurableOrchestrationStatus(state); + return { + instanceId: status.instanceId, + runtimeStatus: status.runtimeStatus, + output: status.output as TOutput | undefined, + customStatus: status.customStatus, + failure: state.failureDetails + ? { + errorType: state.failureDetails.errorType, + message: state.failureDetails.message, + stackTrace: state.failureDetails.stackTrace, + } + : undefined, + }; + } finally { + // Stopping the worker drains the in-flight orchestrator/activity handler; resetting the backend + // clears durable timers that would otherwise keep the process alive after the test. + await worker.stop(); + backend.reset(); + } +} diff --git a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts index 039667e..2e382ba 100644 --- a/packages/azure-functions-durable/test/unit/compat-exports.spec.ts +++ b/packages/azure-functions-durable/test/unit/compat-exports.spec.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import ts from "typescript"; import type { ActivityHandler, DurableClient, @@ -10,6 +14,7 @@ import type { OrchestrationHandler, } from "../../src"; import { TaskFailedError } from "../../src"; +import packageJson from "../../package.json"; describe("v3 compatibility type aliases", () => { it("exposes ActivityHandler / OrchestrationHandler / OrchestrationContext", () => { @@ -22,6 +27,47 @@ describe("v3 compatibility type aliases", () => { expect(typeof orchestrator).toBe("function"); }); + describe("package exports", () => { + it("publishes the testing subpath independently from the runtime entry point", () => { + const exports = packageJson.exports as Record; + expect(exports["./testing"]).toEqual({ + types: "./dist/testing/index.d.ts", + require: "./dist/testing/index.js", + import: "./dist/testing/index.js", + }); + expect(packageJson.typesVersions).toEqual({ + "*": { + testing: ["./dist/testing/index.d.ts"], + }, + }); + }); + + it("resolves testing declarations with classic Node module resolution", () => { + const consumerRoot = mkdtempSync(join(tmpdir(), "durable-functions-types-")); + const packageRoot = join(consumerRoot, "node_modules", "durable-functions"); + const declarationPath = join(packageRoot, "dist", "testing", "index.d.ts"); + const consumerPath = join(consumerRoot, "consumer.ts"); + + try { + mkdirSync(join(packageRoot, "dist", "testing"), { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify(packageJson)); + writeFileSync(declarationPath, "export declare function runOrchestrator(): Promise;"); + writeFileSync(consumerPath, 'import { runOrchestrator } from "durable-functions/testing";'); + + const resolved = ts.resolveModuleName( + "durable-functions/testing", + consumerPath, + { moduleResolution: ts.ModuleResolutionKind.Node10 }, + ts.sys, + ).resolvedModule; + + expect(resolved?.resolvedFileName.replace(/\\/g, "/")).toBe(declarationPath.replace(/\\/g, "/")); + } finally { + rmSync(consumerRoot, { recursive: true, force: true }); + } + }); + }); + it("exposes generic EntityContext / EntityHandler and DurableClient", () => { // Compile-guard: the generic aliases must accept a type argument (the legacy v3 surface uses // e.g. EntityHandler), even though our underlying types are non-generic. diff --git a/packages/azure-functions-durable/test/unit/testing.spec.ts b/packages/azure-functions-durable/test/unit/testing.spec.ts new file mode 100644 index 0000000..d234fb5 --- /dev/null +++ b/packages/azure-functions-durable/test/unit/testing.spec.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { InvocationContext } from "@azure/functions"; +import { + InMemoryOrchestrationBackend, + TestOrchestrationClient, + TestOrchestrationWorker, +} from "@microsoft/durabletask-js"; +import { OrchestrationRuntimeStatus, toDurableOrchestrationStatus, wrapOrchestrator } from "../../src"; +import type { OrchestrationContext, OrchestrationHandler } from "../../src"; +import { createActivityContext, runOrchestrator } from "../../src/testing"; + +describe("durable-functions/testing", () => { + describe("createActivityContext", () => { + it("builds the invocation context an activity handler receives", async () => { + const sayHello = (name: string, context: InvocationContext) => `${context.functionName}: Hello, ${name}!`; + + expect(await sayHello("World", createActivityContext("sayHello"))).toBe("sayHello: Hello, World!"); + expect(createActivityContext().functionName).toBe("activity"); + }); + }); + + describe("runOrchestrator", () => { + it("runs a classic orchestrator against inline activities", async () => { + const orchestrator: OrchestrationHandler = function* ( + context: OrchestrationContext, + ): Generator { + const name = context.df.getInput(); + return yield context.df.callActivity("sayHello", name); + }; + + const result = await runOrchestrator(orchestrator, { + input: "World", + instanceId: "one-shot", + activities: { + sayHello: (name: unknown) => `Hello, ${String(name)}!`, + }, + }); + + expect(result).toEqual({ + instanceId: "one-shot", + runtimeStatus: OrchestrationRuntimeStatus.Completed, + output: "Hello, World!", + customStatus: undefined, + failure: undefined, + }); + }); + + it("names each activity's invocation context after the registered activity", async () => { + const orchestrator: OrchestrationHandler = function* ( + context: OrchestrationContext, + ): Generator { + return yield context.df.callActivity("whoAmI"); + }; + + const result = await runOrchestrator(orchestrator, { + activities: { + whoAmI: (_input: unknown, context: InvocationContext) => context.functionName, + }, + }); + + expect(result.output).toBe("whoAmI"); + }); + + it("reports failures as a terminal status with custom status and failure details", async () => { + const orchestrator: OrchestrationHandler = function* ( + context: OrchestrationContext, + ): Generator { + context.df.setCustomStatus({ phase: "starting" }); + yield context.df.callActivity("fail"); + }; + + const result = await runOrchestrator(orchestrator, { + activities: { + fail: () => { + throw new TypeError("activity failed"); + }, + }, + }); + + expect(result.runtimeStatus).toBe(OrchestrationRuntimeStatus.Failed); + expect(result.customStatus).toEqual({ phase: "starting" }); + expect(result.failure).toMatchObject({ + errorType: expect.any(String), + message: expect.stringContaining("activity failed"), + }); + }); + + it("does not return before a delayed activity settles", async () => { + let activityStarted!: () => void; + const started = new Promise((resolve) => { + activityStarted = resolve; + }); + const mutations: string[] = []; + const orchestrator: OrchestrationHandler = function* ( + context: OrchestrationContext, + ): Generator { + yield context.df.callActivity("slow"); + }; + + let executionSettled = false; + const execution = runOrchestrator(orchestrator, { + activities: { + slow: async () => { + activityStarted(); + await new Promise((resolve) => setTimeout(resolve, 100)); + mutations.push("activity"); + }, + }, + }).finally(() => { + executionSettled = true; + }); + + await started; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(executionSettled).toBe(false); + + await expect(execution).resolves.toMatchObject({ runtimeStatus: OrchestrationRuntimeStatus.Completed }); + expect(mutations).toEqual(["activity"]); + + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(mutations).toEqual(["activity"]); + }); + + it("does not return before a delayed async orchestrator settles", async () => { + let settled = false; + const execution = runOrchestrator(async () => { + await new Promise((resolve) => setTimeout(resolve, 75)); + settled = true; + return "done"; + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(settled).toBe(false); + + await expect(execution).resolves.toMatchObject({ + runtimeStatus: OrchestrationRuntimeStatus.Completed, + output: "done", + }); + expect(settled).toBe(true); + }); + + it("clears durable timers so a completed run leaves nothing pending", async () => { + const orchestrator: OrchestrationHandler = function* ( + context: OrchestrationContext, + ): Generator { + yield context.df.createTimer(new Date(context.df.currentUtcDateTime.getTime() + 10)); + return "timer fired"; + }; + + await expect(runOrchestrator(orchestrator)).resolves.toMatchObject({ + runtimeStatus: OrchestrationRuntimeStatus.Completed, + output: "timer fired", + }); + }); + }); + + // Interactive scenarios intentionally have no dedicated wrapper: the core in-memory stack already + // exposes them. This pins the pattern the README documents. + it("drives external events through the core in-memory stack", async () => { + const backend = new InMemoryOrchestrationBackend(); + const worker = new TestOrchestrationWorker(backend); + const client = new TestOrchestrationClient(backend); + + worker.addNamedOrchestrator( + "approval", + wrapOrchestrator(function* (context: OrchestrationContext): Generator { + const approved = yield context.df.waitForExternalEvent("approved"); + return { approved }; + }), + ); + await worker.start(); + + try { + const instanceId = await client.scheduleNewOrchestration("approval", undefined, "approval-1"); + await client.waitForOrchestrationStart(instanceId, true, 10); + await client.raiseOrchestrationEvent(instanceId, "approved", true); + const state = await client.waitForOrchestrationCompletion(instanceId, true, 10); + + expect(state).toBeDefined(); + expect(toDurableOrchestrationStatus(state!)).toMatchObject({ + runtimeStatus: OrchestrationRuntimeStatus.Completed, + output: { approved: true }, + }); + } finally { + await worker.stop(); + backend.reset(); + } + }); +});