From 5ff8b580012d3b2e928948219ef11ac8db3cf1fb Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 15 Jul 2026 11:43:59 -0700 Subject: [PATCH 1/5] test: add gated Azure Functions host E2E suite (Storage backend) Add an Azure Functions host end-to-end test suite that launches a real `func start` host for a sample app (backed by Azurite / AzureStorage) and drives it over HTTP, mirroring durabletask-python #155. It exercises the in-repo `durable-functions` package added by #282 (packages/azure-functions-durable), which is not on main yet. Everything is gated/skipped so existing CI stays green on main until #282 merges: - The suite runs only via a dedicated jest config (jest.functions-e2e.config.js) + `npm run test:e2e:functions:internal`; it is not part of `npm test` / workspaces / `test:e2e:internal`. - Specs skip cleanly unless `func`, Azurite (127.0.0.1:10000) and a built/installed test-app are all present (jest globalSetup preflight). - The new CI workflow gates every job behind the RUN_FUNCTIONS_E2E repo variable, so it is a no-op until set to 'true' after #282 lands. The sample app functions are kept byte-for-byte identical to azure-functions-durable-js; only the `durable-functions` dependency is rewired to the workspace package, plus a plain /api/ping readiness function. The vendored test-app is excluded from root eslint + prettier to preserve it verbatim while keeping lint/format green. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/functions-e2e-tests.yaml | 79 +++ .prettierignore | 4 + eslint.config.mjs | 4 + jest.functions-e2e.config.js | 26 + package.json | 2 + scripts/test-e2e-functions.sh | 66 +++ test/e2e-functions/README.md | 91 +++ test/e2e-functions/counter1.spec.ts | 51 ++ test/e2e-functions/global-setup.ts | 24 + test/e2e-functions/harness.ts | 534 ++++++++++++++++++ test/e2e-functions/hello.spec.ts | 44 ++ test/e2e-functions/test-app/.funcignore | 10 + test/e2e-functions/test-app/.gitignore | 22 + test/e2e-functions/test-app/host.json | 22 + .../test-app/local.settings.json | 7 + test/e2e-functions/test-app/package.json | 21 + .../test-app/src/functions/counter1.ts | 47 ++ .../test-app/src/functions/hello.ts | 41 ++ .../test-app/src/functions/ping.ts | 14 + test/e2e-functions/test-app/tsconfig.json | 10 + 20 files changed, 1119 insertions(+) create mode 100644 .github/workflows/functions-e2e-tests.yaml create mode 100644 jest.functions-e2e.config.js create mode 100755 scripts/test-e2e-functions.sh create mode 100644 test/e2e-functions/README.md create mode 100644 test/e2e-functions/counter1.spec.ts create mode 100644 test/e2e-functions/global-setup.ts create mode 100644 test/e2e-functions/harness.ts create mode 100644 test/e2e-functions/hello.spec.ts create mode 100644 test/e2e-functions/test-app/.funcignore create mode 100644 test/e2e-functions/test-app/.gitignore create mode 100644 test/e2e-functions/test-app/host.json create mode 100644 test/e2e-functions/test-app/local.settings.json create mode 100644 test/e2e-functions/test-app/package.json create mode 100644 test/e2e-functions/test-app/src/functions/counter1.ts create mode 100644 test/e2e-functions/test-app/src/functions/hello.ts create mode 100644 test/e2e-functions/test-app/src/functions/ping.ts create mode 100644 test/e2e-functions/test-app/tsconfig.json diff --git a/.github/workflows/functions-e2e-tests.yaml b/.github/workflows/functions-e2e-tests.yaml new file mode 100644 index 0000000..763e7eb --- /dev/null +++ b/.github/workflows/functions-e2e-tests.yaml @@ -0,0 +1,79 @@ +name: ๐Ÿงช Functions Host E2E Tests + +# Gated Azure Functions host E2E suite. It launches a real `func start` host for +# test/e2e-functions/test-app (backed by Azurite / AzureStorage) and drives the +# app over HTTP. It exercises the in-repo `durable-functions` package added by +# PR #282 (packages/azure-functions-durable), which is NOT on `main` yet. +# +# Every job is gated behind the `RUN_FUNCTIONS_E2E` repository variable, so this +# workflow is a no-op until that variable is set to 'true' (do so only after +# PR #282 has merged). Until then, existing CI stays green and this suite never +# runs โ€” mirroring durabletask-python PR #155. + +on: + workflow_dispatch: + pull_request: + branches: + - main + paths: + - "test/e2e-functions/**" + - ".github/workflows/functions-e2e-tests.yaml" + +permissions: + contents: read + +jobs: + functions-e2e-tests: + # No-op until the repo variable is set after PR #282 merges. + if: ${{ vars.RUN_FUNCTIONS_E2E == 'true' }} + strategy: + fail-fast: false + matrix: + node-version: ["22.x"] + name: "functions-e2e (node ${{ matrix.node-version }})" + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ“ฅ Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: โš™๏ธ NodeJS - Install + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + registry-url: "https://registry.npmjs.org" + + - name: โš™๏ธ Install dependencies + run: npm ci + + # Builds the workspace including packages/azure-functions-durable (PR #282). + # If that package is absent the test-app cannot install and the suite skips. + - name: ๐Ÿ—๏ธ Build workspace + run: npm run build + + - name: ๐Ÿ”ง Install Azurite and Azure Functions Core Tools + run: npm install -g azurite azure-functions-core-tools@4 + + - name: ๐Ÿ—„๏ธ Start Azurite + run: | + mkdir -p /tmp/azurite + azurite --silent --location /tmp/azurite \ + --blobPort 10000 --queuePort 10001 --tablePort 10002 & + echo "Waiting for Azurite blob endpoint..." + for i in $(seq 1 30); do + if (echo > /dev/tcp/127.0.0.1/10000) >/dev/null 2>&1; then + echo "Azurite is up." + break + fi + sleep 1 + done + + - name: ๐Ÿ“ฆ Install + build test-app + working-directory: test/e2e-functions/test-app + run: | + npm install + npm run build + + - name: โœ… Run Functions host E2E tests + run: npm run test:e2e:functions:internal + timeout-minutes: 20 diff --git a/.prettierignore b/.prettierignore index 0cfea3a..c54b38f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,7 @@ dist coverage # don't lint proto files and output proto + +# vendored Azure Functions sample app โ€” kept byte-for-byte identical to +# azure-functions-durable-js (its own 4-space toolchain; see PR #282) +test/e2e-functions/test-app diff --git a/eslint.config.mjs b/eslint.config.mjs index f01f4e9..0541679 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,7 +39,11 @@ export default tseslint.config( "**/node_modules/**", "**/src/version.ts", "**/jest.config.js", + "jest.functions-e2e.config.js", "**/src/proto/**", + // Vendored Azure Functions sample app kept byte-for-byte identical to + // azure-functions-durable-js; it has its own toolchain (see PR #282). + "test/e2e-functions/test-app/**", ], } ); diff --git a/jest.functions-e2e.config.js b/jest.functions-e2e.config.js new file mode 100644 index 0000000..4705cf5 --- /dev/null +++ b/jest.functions-e2e.config.js @@ -0,0 +1,26 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// +// Dedicated jest config for the gated Azure Functions host e2e suite. It is +// intentionally NOT part of the default `npm test` / workspaces test run and is +// only executed via `npm run test:e2e:functions:internal`. These specs drive a +// real `func start` host over HTTP and skip cleanly without the local toolchain. +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/test/e2e-functions"], + testMatch: ["**/test/e2e-functions/**/*.spec.ts"], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: "tsconfig.base.json", + }, + ], + }, + globalSetup: "/test/e2e-functions/global-setup.ts", + // Host cold-start (extension-bundle download + worker spin-up) dominates. + testTimeout: 300_000, +}; diff --git a/package.json b/package.json index c27cfcb..ff9422c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "test:e2e:one": "jest tests/e2e --runInBand --detectOpenHandles --testNamePattern", "test:e2e:azuremanaged:internal": "jest test/e2e-azuremanaged --detectOpenHandles", "test:e2e:azuremanaged": "./scripts/test-e2e-azuremanaged.sh", + "test:e2e:functions:internal": "jest -c jest.functions-e2e.config.js --runInBand --detectOpenHandles", + "test:e2e:functions": "./scripts/test-e2e-functions.sh", "lint": "eslint .", "pretty": "prettier --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"", "pretty-fix": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", diff --git a/scripts/test-e2e-functions.sh b/scripts/test-e2e-functions.sh new file mode 100755 index 0000000..618201c --- /dev/null +++ b/scripts/test-e2e-functions.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Script to run the gated Azure Functions host E2E tests. +# +# This mirrors scripts/test-e2e-azuremanaged.sh. The suite launches a real +# Azure Functions host ("func start") for test/e2e-functions/test-app, backed by +# Azurite (the local Azure Storage emulator), and drives it over HTTP. +# +# Prerequisites (the suite SKIPS cleanly if any are missing): +# - Azure Functions Core Tools ('func') v4 on PATH +# - Azurite reachable on 127.0.0.1:10000 (started here if the 'azurite' CLI is +# installed and the port is free) +# - packages/azure-functions-durable present (PR #282) so the test-app's local +# 'durable-functions' dependency installs + +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_APP_DIR="$ROOT_DIR/test/e2e-functions/test-app" + +AZURITE_HOST="127.0.0.1" +AZURITE_BLOB_PORT="10000" + +started_azurite="" + +azurite_up() { + (exec 3<>"/dev/tcp/$AZURITE_HOST/$AZURITE_BLOB_PORT") 2>/dev/null && exec 3>&- 3<&- +} + +if ! azurite_up; then + if command -v azurite >/dev/null 2>&1; then + echo "Starting Azurite..." + AZURITE_DATA="$(mktemp -d)" + azurite --silent --location "$AZURITE_DATA" \ + --blobPort 10000 --queuePort 10001 --tablePort 10002 & + started_azurite="$!" + for _ in $(seq 1 30); do + azurite_up && break + sleep 1 + done + else + echo "Azurite is not running and 'azurite' is not installed; the suite will skip." + fi +fi + +# Build the workspace (incl. packages/azure-functions-durable from PR #282) so +# the test-app's local 'durable-functions' dependency resolves. +echo "Building workspace..." +( cd "$ROOT_DIR" && npm run build ) + +if [ -f "$ROOT_DIR/packages/azure-functions-durable/package.json" ]; then + echo "Installing + building test-app..." + ( cd "$TEST_APP_DIR" && npm install && npm run build ) +else + echo "packages/azure-functions-durable not found (PR #282 not merged); the suite will skip." +fi + +echo "Running Functions host E2E tests..." +( cd "$ROOT_DIR" && npm run test:e2e:functions:internal ) +status=$? + +if [ -n "$started_azurite" ]; then + echo "Stopping Azurite..." + kill "$started_azurite" 2>/dev/null || true +fi + +exit $status diff --git a/test/e2e-functions/README.md b/test/e2e-functions/README.md new file mode 100644 index 0000000..9af974c --- /dev/null +++ b/test/e2e-functions/README.md @@ -0,0 +1,91 @@ +# Azure Functions host E2E tests (gated) + +This suite launches a **real Azure Functions host** (`func start`) for the sample +app under [`test-app/`](./test-app), backed by **Azurite** (the local Azure +Storage emulator) using the Durable Task **AzureStorage** provider, and drives +the app entirely over HTTP. The Node.js worker, the Functions host, and the +Durable extension all cooperate exactly as they would in production. + +It mirrors the Python suite added in +[durabletask-python#155](https://github.com/microsoft/durabletask-python/pull/155). + +## โš ๏ธ Gated until PR #282 merges + +The sample app depends on the in-repo `durable-functions` package +(`packages/azure-functions-durable`) added by +[durabletask-js#282](https://github.com/microsoft/durabletask-js/pull/282), which +is **not on `main` yet**. This suite is deliberately split out from #282 so it +can land independently without touching that package. + +Because the package does not exist on `main`, everything here is **gated / +skipped** so existing CI (`npm test`, lint, build) stays green: + +- **Prerequisite detection** โ€” the suite skips cleanly unless all of these hold + (see `harness.ts` + `global-setup.ts`): + 1. Azure Functions Core Tools (`func`) v4 is on `PATH`, + 2. Azurite is reachable on `127.0.0.1:10000`, and + 3. the test-app is installed and built (`test-app/node_modules` + `test-app/dist`). + + On `main`, step 3 cannot be satisfied (the local `durable-functions` package is + absent), so the suite is a clean no-op. + +- **Isolation** โ€” these specs are only run by the dedicated + `jest.functions-e2e.config.js` via `npm run test:e2e:functions:internal`. They + are **not** part of the default `npm test` / workspaces test run, nor of + `test:e2e:internal` (which is scoped to `tests/e2e`). +- **CI** โ€” `.github/workflows/functions-e2e-tests.yaml` gates every job behind the + `RUN_FUNCTIONS_E2E` repository variable. It is a no-op until that variable is + set to `true` (do so only after #282 has merged). + +## What it covers + +Faithful to the (identical) `azure-functions-durable-js` sample app: + +- **`hello.spec.ts`** โ€” starts the `helloOrchestrator` (which chains three + `hello` activities) and asserts the output is + `["Hello, Tokyo", "Hello, Seattle", "Hello, Cairo"]`. +- **`counter1.spec.ts`** โ€” signals the `counter1` durable entity (`add`, 1) via + HTTP and asserts the persisted state increments (1, then 2). `reset` is not + wired to an HTTP route in the sample app, so it is not tested. + +The only additions to the sample app are a plain `/api/ping` readiness function +(for host-readiness probing) and the dependency wiring (`durable-functions` +points at the workspace package). The Durable function code is kept byte-for-byte +identical to `azure-functions-durable-js`. + +## Running locally + +You need the [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) +and [Azurite](https://learn.microsoft.com/azure/storage/common/storage-use-azurite). + +```bash +# 1. Start Azurite (blob/queue/table on 10000/10001/10002) +npm install -g azurite +azurite --silent --location /tmp/azurite --blobPort 10000 --queuePort 10001 --tablePort 10002 & + +# 2. Install the Core Tools (if needed) +npm install -g azure-functions-core-tools@4 + +# 3. Build the workspace (must include packages/azure-functions-durable from #282) +npm ci +npm run build + +# 4. Install + build the test-app +cd test/e2e-functions/test-app +npm install +npm run build +cd - + +# 5. Run the suite +npm run test:e2e:functions:internal +``` + +Or use the convenience wrapper, which starts Azurite (if the CLI is installed), +builds the workspace + test-app, and runs the suite: + +```bash +npm run test:e2e:functions +``` + +If any prerequisite is missing, the suite skips with a `[functions-e2e]` note +instead of failing. diff --git a/test/e2e-functions/counter1.spec.ts b/test/e2e-functions/counter1.spec.ts new file mode 100644 index 0000000..14256b4 --- /dev/null +++ b/test/e2e-functions/counter1.spec.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * E2E test for the `counter1` durable entity, driven over HTTP against a real + * Azure Functions host backed by Azurite (AzureStorage provider). + * + * The sample app only wires the entity's `add` (POST) and read (GET) operations + * to HTTP, so the test asserts that repeated signals increment the persisted + * state. `reset` is not HTTP-exposed and is intentionally not tested. + * + * Gated: skips cleanly unless `func`, Azurite, and a built/installed test-app + * are all present (see globalSetup + readPreflight). + */ + +import { FunctionApp, HOST_STARTUP_TIMEOUT_MS, readPreflight, TEST_APP_DIR } from "./harness"; + +const preflight = readPreflight(TEST_APP_DIR); +const describeMaybe = preflight.ok ? describe : describe.skip; + +if (!preflight.ok) { + console.warn(`[functions-e2e] counter1.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” counter1 entity (AzureStorage)", () => { + let app: FunctionApp; + + beforeAll(async () => { + app = new FunctionApp(TEST_APP_DIR); + await app.start(); + }, HOST_STARTUP_TIMEOUT_MS + 30_000); + + afterAll(async () => { + if (app) { + await app.stop(); + } + }); + + it("increments entity state on each signal", async () => { + // Use a unique key so repeated runs (state persists in Azurite) start clean. + const key = `it-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; + + await app.signalCounter(key); + const afterFirst = await app.waitForCounter(key, (value) => value === 1); + expect(afterFirst).toBe(1); + + await app.signalCounter(key); + const afterSecond = await app.waitForCounter(key, (value) => value === 2); + expect(afterSecond).toBe(2); + }, 90_000); +}); diff --git a/test/e2e-functions/global-setup.ts b/test/e2e-functions/global-setup.ts new file mode 100644 index 0000000..b7edd72 --- /dev/null +++ b/test/e2e-functions/global-setup.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Jest globalSetup for the Azure Functions Durable e2e suite. + * + * Runs the async prerequisite preflight once (func on PATH + Azurite reachable + + * test-app built/installed) and records the result to a temp file. Each spec + * reads that file synchronously to decide `describe` vs `describe.skip`, so the + * whole suite is a clean no-op when the local toolchain is not set up โ€” and, in + * particular, on `main` where `packages/azure-functions-durable` (PR #282) does + * not yet exist and the test-app therefore cannot be installed. + */ + +import * as fs from "fs"; +import { checkPrerequisites, preflightFilePath, TEST_APP_DIR } from "./harness"; + +export default async function globalSetup(): Promise { + const result = await checkPrerequisites(TEST_APP_DIR); + fs.writeFileSync(preflightFilePath(), JSON.stringify(result), "utf-8"); + if (!result.ok) { + console.warn(`[functions-e2e] Skipping suite: ${result.reason}`); + } +} diff --git a/test/e2e-functions/harness.ts b/test/e2e-functions/harness.ts new file mode 100644 index 0000000..69d8186 --- /dev/null +++ b/test/e2e-functions/harness.ts @@ -0,0 +1,534 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Shared helpers for the Azure Functions Durable end-to-end tests. + * + * These tests launch a *real* Azure Functions host (`func start`) for the sample + * function app under `test-app/`, backed by Azurite (the local Azure Storage + * emulator) and the public Durable Task extension bundle. The Node.js worker, + * the host, and the Durable extension all cooperate exactly as they would in + * production, and the suite drives the app purely over HTTP. + * + * Everything here is Node built-in only (`child_process`, `http`, `net`, `fs`, + * `os`, `path`) so the harness adds no test dependencies of its own. + * + * This mirrors the Python harness in durabletask-python PR #155, adapted to the + * routes exposed by the (identical) azure-functions-durable-js sample app: + * - POST /api/orchestrators/{name} -> starts an orchestration, returns the + * durable `createCheckStatusResponse` payload ({ id, statusQueryGetUri, ... }). + * - GET /api/counter1/{id} -> reads the `counter1` entity state. + * - POST /api/counter1/{id} -> signals the `counter1` entity ("add", 1). + * - GET /api/ping -> plain readiness probe (added for tests). + */ + +import { ChildProcess, spawn, spawnSync } from "child_process"; +import * as fs from "fs"; +import * as http from "http"; +import * as net from "net"; +import * as os from "os"; +import * as path from "path"; + +/** Directory of the sample function app driven by these tests. */ +export const TEST_APP_DIR = path.join(__dirname, "test-app"); + +// Azurite's well-known blob endpoint. The Durable extension's default Azure +// Storage provider also needs queue/table, but a reachable blob port is a good +// proxy for "Azurite is up" and matches the other e2e suites in this repo. +export const AZURITE_HOST = "127.0.0.1"; +export const AZURITE_BLOB_PORT = 10000; + +// How long to wait for the Functions host to become ready, and for an +// orchestration to reach a terminal state. The host cold-start (extension +// bundle download on first run + worker spin-up) dominates the former. +export const HOST_STARTUP_TIMEOUT_MS = 180_000; +export const ORCHESTRATION_TIMEOUT_MS = 60_000; + +/** Result of a preflight prerequisite check. */ +export interface PrerequisiteCheck { + ok: boolean; + reason?: string; +} + +/** Loose shape of a durable orchestration status-query response. */ +export interface OrchestrationStatus { + name?: string; + instanceId?: string; + runtimeStatus?: string; + input?: unknown; + output?: unknown; + customStatus?: unknown; + createdTime?: string; + lastUpdatedTime?: string; + [key: string]: unknown; +} + +/** Payload returned by durable `createCheckStatusResponse`. */ +interface CheckStatusPayload { + id: string; + statusQueryGetUri: string; + [key: string]: unknown; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Return an OS-assigned free TCP port. */ +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") { + const { port } = address; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Failed to acquire a free TCP port"))); + } + }); + }); +} + +/** Return the path to the Azure Functions Core Tools (`func`), if installed. */ +export function funcExecutable(): string | undefined { + const isWindows = process.platform === "win32"; + const candidates = isWindows ? ["func.cmd", "func.exe", "func.bat", "func"] : ["func"]; + const pathDirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); + for (const dir of pathDirs) { + for (const candidate of candidates) { + const full = path.join(dir, candidate); + try { + if (fs.statSync(full).isFile()) { + return full; + } + } catch { + // Not in this directory; keep looking. + } + } + } + return undefined; +} + +/** Return true if Azurite's blob endpoint accepts TCP connections. */ +export function azuriteRunning(timeoutMs = 2000): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + let settled = false; + const done = (result: boolean): void => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + resolve(result); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + socket.connect(AZURITE_BLOB_PORT, AZURITE_HOST); + }); +} + +/** + * `func start` for a Node app serves the compiled output in `dist/` and needs + * the `durable-functions` dependency installed under `node_modules/`. Both are + * produced by `npm install && npm run build` inside the test-app (which only + * works once PR #282's `packages/azure-functions-durable` exists). + */ +export function testAppBuilt(appDir: string = TEST_APP_DIR): boolean { + return fs.existsSync(path.join(appDir, "dist")) && fs.existsSync(path.join(appDir, "node_modules")); +} + +/** Run the full async preflight (func + Azurite + built test-app). */ +export async function checkPrerequisites(appDir: string = TEST_APP_DIR): Promise { + if (!funcExecutable()) { + return { ok: false, reason: "Azure Functions Core Tools ('func') is not installed." }; + } + if (!(await azuriteRunning())) { + return { ok: false, reason: `Azurite is not running on ${AZURITE_HOST}:${AZURITE_BLOB_PORT}.` }; + } + if (!testAppBuilt(appDir)) { + return { + ok: false, + reason: + `test-app is not built/installed at ${appDir} (missing dist/ or node_modules/). ` + + "Build packages/azure-functions-durable (PR #282), then install + build the test-app.", + }; + } + return { ok: true }; +} + +/** Path of the preflight result file written by jest globalSetup. */ +export function preflightFilePath(): string { + return path.join(os.tmpdir(), "durabletask-js-functions-e2e-preflight.json"); +} + +/** + * Read the preflight result written by globalSetup. When the file is absent + * (e.g. a spec is run without the dedicated jest config) fall back to the + * synchronous checks; the async Azurite probe is then skipped, so the suite + * still fails safe by attempting to start the host. + */ +export function readPreflight(appDir: string = TEST_APP_DIR): PrerequisiteCheck { + try { + return JSON.parse(fs.readFileSync(preflightFilePath(), "utf-8")) as PrerequisiteCheck; + } catch { + if (!funcExecutable()) { + return { ok: false, reason: "Azure Functions Core Tools ('func') is not installed." }; + } + if (!testAppBuilt(appDir)) { + return { ok: false, reason: `test-app is not built/installed at ${appDir}.` }; + } + return { ok: true }; + } +} + +/** Result of an HTTP request performed by the harness. */ +export interface HttpResult { + status: number; + body: string; + json(): T; +} + +/** + * Perform an HTTP request, returning status and body. HTTP error responses + * (4xx/5xx) are returned rather than thrown, so callers can assert on status. + */ +export function httpRequest(method: string, url: string, data?: unknown, timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + let payload: string | undefined; + const headers: Record = {}; + if (data !== undefined) { + const isString = typeof data === "string"; + payload = isString ? (data as string) : JSON.stringify(data); + headers["Content-Type"] = isString ? "text/plain" : "application/json"; + headers["Content-Length"] = Buffer.byteLength(payload).toString(); + } + const req = http.request( + { + method, + hostname: parsed.hostname, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + headers, + timeout: timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf-8"); + resolve({ + status: res.statusCode ?? 0, + body, + json(): T { + return (body ? JSON.parse(body) : null) as T; + }, + }); + }); + }, + ); + req.once("timeout", () => { + req.destroy(new Error(`HTTP ${method} ${url} timed out after ${timeoutMs}ms`)); + }); + req.once("error", reject); + if (payload !== undefined) { + req.write(payload); + } + req.end(); + }); +} + +/** Raised when the host aborts startup for a non-transient reason. */ +class FatalStartupError extends Error {} + +// Markers that indicate the host aborted startup (e.g. the app failed to load). +// Detecting these lets us fail fast with the log rather than blocking for the +// full startup timeout. +const FATAL_LOG_MARKERS = [ + "Host startup operation has been canceled", + "Worker failed to load", + "Worker was unable to load", +]; + +/** + * Manages the lifecycle of a `func start` host for the sample app. + * + * `start()` launches the host and blocks until `/api/ping` responds; `stop()` + * terminates the whole process tree and surfaces the captured host log if + * startup failed. + */ +export class FunctionApp { + readonly appDir: string; + port: number; + baseUrl: string; + + private _process: ChildProcess | undefined; + private readonly _logPath: string; + private _logStream: fs.WriteStream | undefined; + private _log = ""; + private _exited = false; + + // `func start` binds the HTTP port itself, some time after we pick a free one. + // Another process can claim that port in the interim, so a transient startup + // failure is retried on a freshly chosen free port a few times. + private static readonly STARTUP_MAX_ATTEMPTS = 3; + + constructor(appDir: string = TEST_APP_DIR, port?: number) { + this.appDir = appDir; + if (!fs.existsSync(this.appDir) || !fs.statSync(this.appDir).isDirectory()) { + throw new Error(`Sample app not found: ${this.appDir}`); + } + this.port = port ?? 0; + this.baseUrl = ""; + this._logPath = path.join(this.appDir, "_func_host.log"); + } + + async start(): Promise { + if (!funcExecutable()) { + throw new Error("Azure Functions Core Tools ('func') is not installed."); + } + + let lastError: unknown; + for (let attempt = 1; attempt <= FunctionApp.STARTUP_MAX_ATTEMPTS; attempt++) { + if (!this.port) { + this.port = await findFreePort(); + } + this.baseUrl = `http://127.0.0.1:${this.port}`; + this._launch(); + try { + await this._waitUntilReady(); + return; + } catch (err) { + await this.stop(); + if (err instanceof FatalStartupError) { + // The app itself failed to load; a different port won't help. + throw err; + } + lastError = err; + this.port = 0; // pick a fresh port on the next attempt + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + private _launch(): void { + this._exited = false; + this._log = ""; + this._logStream = fs.createWriteStream(this._logPath, { flags: "w" }); + + const isWindows = process.platform === "win32"; + // On Windows `func` is a `.cmd`, so run it through the shell (which also + // resolves it from PATH). On POSIX, `detached` puts the host in its own + // process group so we can terminate it and its worker children together. + const child = spawn("func", ["start", "--port", String(this.port)], { + cwd: this.appDir, + env: process.env, + shell: isWindows, + windowsHide: true, + detached: !isWindows, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout?.on("data", (chunk: Buffer) => this._appendLog(chunk)); + child.stderr?.on("data", (chunk: Buffer) => this._appendLog(chunk)); + child.once("exit", () => { + this._exited = true; + }); + this._process = child; + } + + private _appendLog(chunk: Buffer): void { + const text = chunk.toString("utf-8"); + this._log += text; + this._logStream?.write(text); + } + + private async _waitUntilReady(): Promise { + const deadline = Date.now() + HOST_STARTUP_TIMEOUT_MS; + const pingUrl = `${this.baseUrl}/api/ping`; + while (Date.now() < deadline) { + if (this._exited) { + throw new Error(`Functions host exited early.\n${this._readLog()}`); + } + this._checkLogForFatalErrors(); + try { + const result = await httpRequest("GET", pingUrl, undefined, 5000); + if (result.status === 200) { + return; + } + } catch { + // Host is not accepting connections yet. + } + await sleep(1000); + } + throw new Error(`Functions host did not become ready within ${HOST_STARTUP_TIMEOUT_MS}ms.\n${this._readLog()}`); + } + + private _checkLogForFatalErrors(): void { + for (const marker of FATAL_LOG_MARKERS) { + if (this._log.includes(marker)) { + throw new FatalStartupError(`Functions host failed to start (matched '${marker}').\n${this._readLog()}`); + } + } + } + + private _readLog(): string { + return `----- func host log -----\n${this._log || "(no host log captured)"}`; + } + + async stop(): Promise { + const proc = this._process; + this._process = undefined; + if (proc && proc.pid !== undefined && !this._exited) { + await FunctionApp._terminateProcessTree(proc); + } + if (this._logStream) { + this._logStream.end(); + this._logStream = undefined; + } + } + + /** + * Terminate the `func` host *and* its child worker processes. `func start` + * spawns children (the .NET host and the Node language worker); terminating + * only the top process can orphan them and block later runs on a fixed port. + */ + private static _terminateProcessTree(proc: ChildProcess): Promise { + return new Promise((resolve) => { + const pid = proc.pid; + if (pid === undefined) { + resolve(); + return; + } + + let settled = false; + const finish = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + proc.once("exit", finish); + + if (process.platform === "win32") { + spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { windowsHide: true }); + } else { + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + proc.kill("SIGTERM"); + } catch { + // Already exited. + } + } + // Force-kill the whole group if it has not exited gracefully. + setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + // Already exited. + } + }, 10_000).unref(); + } + + // Safety net so teardown never hangs the test run. + setTimeout(finish, 20_000).unref(); + }); + } + + // -- orchestration helpers --------------------------------------------- + + /** + * Start an orchestration via `POST /api/orchestrators/{name}`. + * + * Returns the new instance id and the durable status-query URI (which embeds + * the task hub, connection, and system key needed to poll status locally). + */ + async startHelloOrchestration( + orchestratorName = "helloOrchestrator", + body?: string, + ): Promise<{ id: string; statusQueryGetUri: string }> { + const result = await httpRequest("POST", `${this.baseUrl}/api/orchestrators/${orchestratorName}`, body); + if (result.status !== 200 && result.status !== 202) { + throw new Error(`start failed: ${result.status} ${result.body}`); + } + const payload = result.json(); + return { id: payload.id, statusQueryGetUri: payload.statusQueryGetUri }; + } + + /** Fetch orchestration status by following the durable status-query URI. */ + async getStatus(statusQueryGetUri: string): Promise { + const result = await httpRequest("GET", statusQueryGetUri); + // Durable returns 202 while the instance is running and 200 once terminal; + // both carry the status body. + if (result.status !== 200 && result.status !== 202) { + throw new Error(`status failed: ${result.status} ${result.body}`); + } + return result.json(); + } + + /** Poll status until the orchestration reaches a terminal state. */ + async waitForCompletion( + statusQueryGetUri: string, + timeoutMs = ORCHESTRATION_TIMEOUT_MS, + ): Promise { + const terminal = new Set(["completed", "failed", "terminated", "canceled"]); + const deadline = Date.now() + timeoutMs; + let status: OrchestrationStatus = {}; + while (Date.now() < deadline) { + status = await this.getStatus(statusQueryGetUri); + if (terminal.has(String(status.runtimeStatus ?? "").toLowerCase())) { + return status; + } + await sleep(500); + } + throw new Error(`Orchestration did not complete within ${timeoutMs}ms; last status: ${JSON.stringify(status)}`); + } + + // -- entity helpers ---------------------------------------------------- + + /** Signal the `counter1` entity to add 1 via `POST /api/counter1/{id}`. */ + async signalCounter(id: string): Promise { + const result = await httpRequest("POST", `${this.baseUrl}/api/counter1/${id}`); + if (result.status < 200 || result.status >= 300) { + throw new Error(`counter signal failed: ${result.status} ${result.body}`); + } + } + + /** Read the `counter1` entity state via `GET /api/counter1/{id}`. */ + async readCounter(id: string): Promise { + const result = await httpRequest("GET", `${this.baseUrl}/api/counter1/${id}`); + if (result.status < 200 || result.status >= 300) { + throw new Error(`counter read failed: ${result.status} ${result.body}`); + } + if (!result.body) { + return null; + } + return result.json(); + } + + /** Poll the entity read route until `predicate(state)` is true. */ + async waitForCounter( + id: string, + predicate: (value: number | null) => boolean, + timeoutMs = 30_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + let value: number | null = null; + while (Date.now() < deadline) { + value = await this.readCounter(id); + if (predicate(value)) { + return value; + } + await sleep(500); + } + throw new Error(`counter ${id} predicate not met within ${timeoutMs}ms; last value: ${JSON.stringify(value)}`); + } +} diff --git a/test/e2e-functions/hello.spec.ts b/test/e2e-functions/hello.spec.ts new file mode 100644 index 0000000..7f10ec9 --- /dev/null +++ b/test/e2e-functions/hello.spec.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * E2E test for the `helloOrchestrator` fan-in/sequence sample, driven over HTTP + * against a real Azure Functions host backed by Azurite (AzureStorage provider). + * + * Gated: skips cleanly unless `func`, Azurite, and a built/installed test-app + * are all present (see globalSetup + readPreflight). + */ + +import { FunctionApp, HOST_STARTUP_TIMEOUT_MS, readPreflight, TEST_APP_DIR } from "./harness"; + +const preflight = readPreflight(TEST_APP_DIR); +const describeMaybe = preflight.ok ? describe : describe.skip; + +if (!preflight.ok) { + console.warn(`[functions-e2e] hello.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” hello orchestration (AzureStorage)", () => { + let app: FunctionApp; + + beforeAll(async () => { + app = new FunctionApp(TEST_APP_DIR); + await app.start(); + }, HOST_STARTUP_TIMEOUT_MS + 30_000); + + afterAll(async () => { + if (app) { + await app.stop(); + } + }); + + it("runs helloOrchestrator and returns the three greetings", async () => { + const { id, statusQueryGetUri } = await app.startHelloOrchestration(); + expect(id).toBeTruthy(); + + const status = await app.waitForCompletion(statusQueryGetUri); + + expect(String(status.runtimeStatus).toLowerCase()).toBe("completed"); + expect(status.output).toEqual(["Hello, Tokyo", "Hello, Seattle", "Hello, Cairo"]); + }, 90_000); +}); diff --git a/test/e2e-functions/test-app/.funcignore b/test/e2e-functions/test-app/.funcignore new file mode 100644 index 0000000..d72240f --- /dev/null +++ b/test/e2e-functions/test-app/.funcignore @@ -0,0 +1,10 @@ +*.js.map +*.ts +.git* +.vscode +__azurite_db*__.json +__blobstorage__ +__queuestorage__ +local.settings.json +test +tsconfig.json diff --git a/test/e2e-functions/test-app/.gitignore b/test/e2e-functions/test-app/.gitignore new file mode 100644 index 0000000..bb6734e --- /dev/null +++ b/test/e2e-functions/test-app/.gitignore @@ -0,0 +1,22 @@ +# TypeScript build output +dist +out + +# Dependencies +node_modules/ + +# Azure Functions artifacts +bin +obj +appsettings.json + +# Azurite artifacts +__blobstorage__ +__queuestorage__ +__azurite_db*__.json + +# Host log captured by the e2e harness +_func_host.log + +# Logs +*.log diff --git a/test/e2e-functions/test-app/host.json b/test/e2e-functions/test-app/host.json new file mode 100644 index 0000000..2c991d9 --- /dev/null +++ b/test/e2e-functions/test-app/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[3.15.0, 4.0.0)" + }, + "extensions": { + "durableTask": { + "storageProvider": { + "type": "AzureStorage" + } + } + } +} diff --git a/test/e2e-functions/test-app/local.settings.json b/test/e2e-functions/test-app/local.settings.json new file mode 100644 index 0000000..da0efa9 --- /dev/null +++ b/test/e2e-functions/test-app/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node" + } +} diff --git a/test/e2e-functions/test-app/package.json b/test/e2e-functions/test-app/package.json new file mode 100644 index 0000000..68e05f3 --- /dev/null +++ b/test/e2e-functions/test-app/package.json @@ -0,0 +1,21 @@ +{ + "name": "test-app", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "prestart": "npm run build", + "start": "func start", + "test": "echo \"No tests yet...\"" + }, + "dependencies": { + "@azure/functions": "^4.14.0", + "durable-functions": "file:../../../packages/azure-functions-durable" + }, + "devDependencies": { + "@types/node": "^18.x", + "typescript": "^4.7.2" + }, + "main": "dist/src/functions/*.js" +} diff --git a/test/e2e-functions/test-app/src/functions/counter1.ts b/test/e2e-functions/test-app/src/functions/counter1.ts new file mode 100644 index 0000000..6c1f7c9 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/counter1.ts @@ -0,0 +1,47 @@ +import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from "@azure/functions"; +import * as df from "durable-functions"; +import { EntityContext, EntityHandler } from "durable-functions"; + +const entityName = "counter1"; + +const counter1: EntityHandler = (context: EntityContext) => { + const currentValue: number = context.df.getState(() => 0); + switch (context.df.operationName) { + case "add": + const amount: number = context.df.getInput(); + context.df.setState(currentValue + amount); + break; + case "reset": + context.df.setState(0); + break; + case "get": + context.df.return(currentValue); + break; + } +}; +df.app.entity(entityName, counter1); + +const counter1HttpStart: HttpHandler = async ( + req: HttpRequest, + context: InvocationContext +): Promise => { + const id: string = req.params.id; + const entityId = new df.EntityId(entityName, id); + const client = df.getClient(context); + + if (req.method === "POST") { + // increment value + await client.signalEntity(entityId, "add", 1); + } else { + // read current state of entity + const stateResponse = await client.readEntityState(entityId); + return new HttpResponse({ + jsonBody: stateResponse.entityState, + }); + } +}; +app.http("counter1HttpStart", { + route: `${entityName}/{id}`, + extraInputs: [df.input.durableClient()], + handler: counter1HttpStart, +}); diff --git a/test/e2e-functions/test-app/src/functions/hello.ts b/test/e2e-functions/test-app/src/functions/hello.ts new file mode 100644 index 0000000..41ef917 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/hello.ts @@ -0,0 +1,41 @@ +import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from "@azure/functions"; +import * as df from "durable-functions"; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from "durable-functions"; + +const activityName = "hello"; + +const helloOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + const outputs = []; + outputs.push(yield context.df.callActivity(activityName, "Tokyo")); + outputs.push(yield context.df.callActivity(activityName, "Seattle")); + outputs.push(yield context.df.callActivity(activityName, "Cairo")); + + return outputs; +}; +df.app.orchestration("helloOrchestrator", helloOrchestrator); + +const hello: ActivityHandler = (input: string): string => { + return `Hello, ${input}`; +}; +df.app.activity(activityName, { handler: hello }); + +const helloHttpStart: HttpHandler = async ( + request: HttpRequest, + context: InvocationContext +): Promise => { + const client = df.getClient(context); + const body: unknown = await request.text(); + const instanceId: string = await client.startNew(request.params.orchestratorName, { + input: body, + }); + + context.log(`Started orchestration with ID = '${instanceId}'.`); + + return client.createCheckStatusResponse(request, instanceId); +}; + +app.http("helloHttpStart", { + route: "orchestrators/{orchestratorName}", + extraInputs: [df.input.durableClient()], + handler: helloHttpStart, +}); diff --git a/test/e2e-functions/test-app/src/functions/ping.ts b/test/e2e-functions/test-app/src/functions/ping.ts new file mode 100644 index 0000000..88acd7a --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/ping.ts @@ -0,0 +1,14 @@ +import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from "@azure/functions"; + +// Plain (non-durable) HTTP endpoint used by the e2e harness to detect when the +// Functions host has finished cold-starting and is ready to serve requests. +const ping: HttpHandler = async (_request: HttpRequest, _context: InvocationContext): Promise => { + return new HttpResponse({ status: 200, body: "pong" }); +}; + +app.http("ping", { + methods: ["GET"], + authLevel: "anonymous", + route: "ping", + handler: ping, +}); diff --git a/test/e2e-functions/test-app/tsconfig.json b/test/e2e-functions/test-app/tsconfig.json new file mode 100644 index 0000000..f56851b --- /dev/null +++ b/test/e2e-functions/test-app/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "outDir": "dist", + "rootDir": ".", + "sourceMap": true, + "strict": false + } +} From 146fa7426f261a484458be5e6f85fcbedcc8a099 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 15 Jul 2026 17:36:52 -0700 Subject: [PATCH 2/5] test: port full BasicNode Functions host E2E suite (Storage backend) Replace the thin 2-function smoke app with a faithful port of the extension repo's BasicNode app and xUnit E2E suite, wired to the published durable-functions (^3.1.0) + @azure/functions (^4.11.2) so the suite is genuinely runnable rather than blocked on an unmerged in-repo package. App (test-app/): port all orchestrators, activities, class-based entities and client-operation HTTP triggers verbatim (HelloCities, ActivityInputType, Suspend/Resume, Terminate, Timeout, Rewind, IsReplaying checks, LargeOutput, activity/entity error handling, orchestration query, purge), plus Shared/ ExceptionTypes.ts and a generic StartOrchestration starter. Harness: generalize to mirror DurableHelpers/HttpHelpers (invokeHttpTrigger, parseInstanceId/parseStatusQueryGetUri, waitForOrchestrationState, getOrchestrationDetails) and add the Node localizer strings. Keep the shared single-host lifecycle with func/Azurite preflight + clean skip. Specs: 13 area specs ported faithfully from the xUnit tests using the Node localizer strings. Node/Node-DTS skip traits translated (Storage backend keeps Node-DTS tests); Node bug annotations honored (#642, #564, #608, #645, #679, #644, and the suspend/resume/terminate-of-terminal swallow quirk). Infra: remove the unmerged-package dependency and PR#282 gating language from the workflow, script, eslint/prettier ignores; rewrite the README with the new scenarios, prerequisites, run instructions and deferred follow-ups. Verified locally: build + typecheck + eslint + prettier clean; full suite runs green against a real func host + Azurite (13 suites, 35 passed, 10 skipped). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/functions-e2e-tests.yaml | 25 +- .prettierignore | 4 +- eslint.config.mjs | 4 +- jest.functions-e2e.config.js | 1 + scripts/test-e2e-functions.sh | 20 +- test/e2e-functions/README.md | 134 ++- .../e2e-functions/activity-input-type.spec.ts | 57 + test/e2e-functions/class-based-entity.spec.ts | 44 + test/e2e-functions/counter1.spec.ts | 51 - test/e2e-functions/error-handling.spec.ts | 103 ++ test/e2e-functions/external-event.spec.ts | 76 ++ test/e2e-functions/global-setup.ts | 44 +- test/e2e-functions/global-teardown.ts | 27 + test/e2e-functions/harness.ts | 326 +++-- test/e2e-functions/hello-cities.spec.ts | 46 + test/e2e-functions/hello.spec.ts | 44 - test/e2e-functions/is-replaying.spec.ts | 82 ++ test/e2e-functions/large-output.spec.ts | 75 ++ .../e2e-functions/orchestration-query.spec.ts | 69 ++ test/e2e-functions/purge.spec.ts | 92 ++ test/e2e-functions/rewind.spec.ts | 117 ++ test/e2e-functions/suspend-resume.spec.ts | 115 ++ test/e2e-functions/terminate.spec.ts | 102 ++ test/e2e-functions/test-app/package-lock.json | 1048 +++++++++++++++++ test/e2e-functions/test-app/package.json | 19 +- .../test-app/src/Shared/ExceptionTypes.ts | 43 + .../src/functions/ActivityErrorHandling.ts | 64 + .../src/functions/ActivityInputType.ts | 160 +++ .../src/functions/ClassBasedEntities.ts | 32 + .../src/functions/EntityErrorHandling.ts | 62 + .../functions/ExternalEventOrchestration.ts | 43 + .../test-app/src/functions/HelloCities.ts | 66 ++ .../src/functions/IsReplayingChecks.ts | 149 +++ .../src/functions/LargeOutputOrchestrator.ts | 80 ++ .../src/functions/OrchestrationQuery.ts | 69 ++ .../functions/PurgeOrchestrationHistory.ts | 85 ++ .../src/functions/RewindOrchestration.ts | 175 +++ .../functions/SuspendResumeOrchestration.ts | 60 + .../src/functions/TerminateOrchestration.ts | 51 + .../src/functions/TimeoutOrchestration.ts | 65 + .../test-app/src/functions/counter1.ts | 47 - .../test-app/src/functions/hello.ts | 41 - test/e2e-functions/test-app/src/index.ts | 10 + test/e2e-functions/timeout.spec.ts | 51 + 44 files changed, 3699 insertions(+), 379 deletions(-) create mode 100644 test/e2e-functions/activity-input-type.spec.ts create mode 100644 test/e2e-functions/class-based-entity.spec.ts delete mode 100644 test/e2e-functions/counter1.spec.ts create mode 100644 test/e2e-functions/error-handling.spec.ts create mode 100644 test/e2e-functions/external-event.spec.ts create mode 100644 test/e2e-functions/global-teardown.ts create mode 100644 test/e2e-functions/hello-cities.spec.ts delete mode 100644 test/e2e-functions/hello.spec.ts create mode 100644 test/e2e-functions/is-replaying.spec.ts create mode 100644 test/e2e-functions/large-output.spec.ts create mode 100644 test/e2e-functions/orchestration-query.spec.ts create mode 100644 test/e2e-functions/purge.spec.ts create mode 100644 test/e2e-functions/rewind.spec.ts create mode 100644 test/e2e-functions/suspend-resume.spec.ts create mode 100644 test/e2e-functions/terminate.spec.ts create mode 100644 test/e2e-functions/test-app/package-lock.json create mode 100644 test/e2e-functions/test-app/src/Shared/ExceptionTypes.ts create mode 100644 test/e2e-functions/test-app/src/functions/ActivityErrorHandling.ts create mode 100644 test/e2e-functions/test-app/src/functions/ActivityInputType.ts create mode 100644 test/e2e-functions/test-app/src/functions/ClassBasedEntities.ts create mode 100644 test/e2e-functions/test-app/src/functions/EntityErrorHandling.ts create mode 100644 test/e2e-functions/test-app/src/functions/ExternalEventOrchestration.ts create mode 100644 test/e2e-functions/test-app/src/functions/HelloCities.ts create mode 100644 test/e2e-functions/test-app/src/functions/IsReplayingChecks.ts create mode 100644 test/e2e-functions/test-app/src/functions/LargeOutputOrchestrator.ts create mode 100644 test/e2e-functions/test-app/src/functions/OrchestrationQuery.ts create mode 100644 test/e2e-functions/test-app/src/functions/PurgeOrchestrationHistory.ts create mode 100644 test/e2e-functions/test-app/src/functions/RewindOrchestration.ts create mode 100644 test/e2e-functions/test-app/src/functions/SuspendResumeOrchestration.ts create mode 100644 test/e2e-functions/test-app/src/functions/TerminateOrchestration.ts create mode 100644 test/e2e-functions/test-app/src/functions/TimeoutOrchestration.ts delete mode 100644 test/e2e-functions/test-app/src/functions/counter1.ts delete mode 100644 test/e2e-functions/test-app/src/functions/hello.ts create mode 100644 test/e2e-functions/test-app/src/index.ts create mode 100644 test/e2e-functions/timeout.spec.ts diff --git a/.github/workflows/functions-e2e-tests.yaml b/.github/workflows/functions-e2e-tests.yaml index 763e7eb..e9fb2c2 100644 --- a/.github/workflows/functions-e2e-tests.yaml +++ b/.github/workflows/functions-e2e-tests.yaml @@ -2,13 +2,14 @@ name: ๐Ÿงช Functions Host E2E Tests # Gated Azure Functions host E2E suite. It launches a real `func start` host for # test/e2e-functions/test-app (backed by Azurite / AzureStorage) and drives the -# app over HTTP. It exercises the in-repo `durable-functions` package added by -# PR #282 (packages/azure-functions-durable), which is NOT on `main` yet. +# app over HTTP, porting the extension repo's `BasicNode` app + xUnit tests. # -# Every job is gated behind the `RUN_FUNCTIONS_E2E` repository variable, so this -# workflow is a no-op until that variable is set to 'true' (do so only after -# PR #282 has merged). Until then, existing CI stays green and this suite never -# runs โ€” mirroring durabletask-python PR #155. +# The test-app depends on the PUBLISHED `durable-functions` + `@azure/functions` +# packages, so it installs and runs without any in-repo package build. The suite +# self-gates: each spec SKIPS cleanly when the Azure Functions Core Tools (`func`) +# or the Azurite storage emulator are unavailable, and RUNS when both are present. +# In CI below we install and start both, so the suite runs for real; the self-skip +# is the safety net that keeps the job green if a prerequisite fails to come up. on: workflow_dispatch: @@ -24,8 +25,6 @@ permissions: jobs: functions-e2e-tests: - # No-op until the repo variable is set after PR #282 merges. - if: ${{ vars.RUN_FUNCTIONS_E2E == 'true' }} strategy: fail-fast: false matrix: @@ -43,14 +42,12 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: "https://registry.npmjs.org" + # Root install provides jest + ts-jest used to run the spec files. The specs + # do not depend on the core durabletask-js packages, so no workspace build is + # required here. - name: โš™๏ธ Install dependencies run: npm ci - # Builds the workspace including packages/azure-functions-durable (PR #282). - # If that package is absent the test-app cannot install and the suite skips. - - name: ๐Ÿ—๏ธ Build workspace - run: npm run build - - name: ๐Ÿ”ง Install Azurite and Azure Functions Core Tools run: npm install -g azurite azure-functions-core-tools@4 @@ -68,6 +65,8 @@ jobs: sleep 1 done + # Installs the PUBLISHED durable-functions / @azure/functions packages and + # compiles the ported BasicNode app to dist/ (consumed by `func start`). - name: ๐Ÿ“ฆ Install + build test-app working-directory: test/e2e-functions/test-app run: | diff --git a/.prettierignore b/.prettierignore index c54b38f..842ff97 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,6 @@ coverage # don't lint proto files and output proto -# vendored Azure Functions sample app โ€” kept byte-for-byte identical to -# azure-functions-durable-js (its own 4-space toolchain; see PR #282) +# ported Azure Functions sample app โ€” kept close to +# azure-functions-durable-js's BasicNode (its own 4-space toolchain) test/e2e-functions/test-app diff --git a/eslint.config.mjs b/eslint.config.mjs index 0541679..542df54 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,8 +41,8 @@ export default tseslint.config( "**/jest.config.js", "jest.functions-e2e.config.js", "**/src/proto/**", - // Vendored Azure Functions sample app kept byte-for-byte identical to - // azure-functions-durable-js; it has its own toolchain (see PR #282). + // Ported Azure Functions sample app (from azure-functions-durable-js's + // `BasicNode`), kept close to source and with its own 4-space toolchain. "test/e2e-functions/test-app/**", ], } diff --git a/jest.functions-e2e.config.js b/jest.functions-e2e.config.js index 4705cf5..152b94e 100644 --- a/jest.functions-e2e.config.js +++ b/jest.functions-e2e.config.js @@ -21,6 +21,7 @@ module.exports = { ], }, globalSetup: "/test/e2e-functions/global-setup.ts", + globalTeardown: "/test/e2e-functions/global-teardown.ts", // Host cold-start (extension-bundle download + worker spin-up) dominates. testTimeout: 300_000, }; diff --git a/scripts/test-e2e-functions.sh b/scripts/test-e2e-functions.sh index 618201c..a12c400 100755 --- a/scripts/test-e2e-functions.sh +++ b/scripts/test-e2e-functions.sh @@ -5,12 +5,13 @@ # Azure Functions host ("func start") for test/e2e-functions/test-app, backed by # Azurite (the local Azure Storage emulator), and drives it over HTTP. # +# The test-app depends on the PUBLISHED durable-functions / @azure/functions +# packages, so it installs and builds without any in-repo package build. +# # Prerequisites (the suite SKIPS cleanly if any are missing): # - Azure Functions Core Tools ('func') v4 on PATH # - Azurite reachable on 127.0.0.1:10000 (started here if the 'azurite' CLI is # installed and the port is free) -# - packages/azure-functions-durable present (PR #282) so the test-app's local -# 'durable-functions' dependency installs set -uo pipefail @@ -42,17 +43,10 @@ if ! azurite_up; then fi fi -# Build the workspace (incl. packages/azure-functions-durable from PR #282) so -# the test-app's local 'durable-functions' dependency resolves. -echo "Building workspace..." -( cd "$ROOT_DIR" && npm run build ) - -if [ -f "$ROOT_DIR/packages/azure-functions-durable/package.json" ]; then - echo "Installing + building test-app..." - ( cd "$TEST_APP_DIR" && npm install && npm run build ) -else - echo "packages/azure-functions-durable not found (PR #282 not merged); the suite will skip." -fi +# Install + build the ported BasicNode app against the published durable-functions +# package so 'func start' has a dist/ to serve. +echo "Installing + building test-app..." +( cd "$TEST_APP_DIR" && npm install && npm run build ) echo "Running Functions host E2E tests..." ( cd "$ROOT_DIR" && npm run test:e2e:functions:internal ) diff --git a/test/e2e-functions/README.md b/test/e2e-functions/README.md index 9af974c..2b33565 100644 --- a/test/e2e-functions/README.md +++ b/test/e2e-functions/README.md @@ -1,57 +1,95 @@ # Azure Functions host E2E tests (gated) -This suite launches a **real Azure Functions host** (`func start`) for the sample +This suite launches a **real Azure Functions host** (`func start`) for the ported app under [`test-app/`](./test-app), backed by **Azurite** (the local Azure -Storage emulator) using the Durable Task **AzureStorage** provider, and drives -the app entirely over HTTP. The Node.js worker, the Functions host, and the -Durable extension all cooperate exactly as they would in production. +Storage emulator) using the Durable Task **AzureStorage** provider, and drives the +app entirely over HTTP. The Node.js worker, the Functions host, and the Durable +extension all cooperate exactly as they would in production. -It mirrors the Python suite added in -[durabletask-python#155](https://github.com/microsoft/durabletask-python/pull/155). +It is a faithful port of the Azure Functions host E2E suite from the +[`azure-functions-durable-extension`](https://github.com/Azure/azure-functions-durable-extension) +repo โ€” the `BasicNode` app plus its xUnit test classes โ€” adapted to Jest. Expected +strings and Node-specific bug annotations come from that repo's +`NodeTestLanguageLocalizer`. -## โš ๏ธ Gated until PR #282 merges +## Published `durable-functions` dependency -The sample app depends on the in-repo `durable-functions` package -(`packages/azure-functions-durable`) added by -[durabletask-js#282](https://github.com/microsoft/durabletask-js/pull/282), which -is **not on `main` yet**. This suite is deliberately split out from #282 so it -can land independently without touching that package. +The [`test-app`](./test-app) is wired to the **published** npm packages +`durable-functions` (`^3.1.0`) and `@azure/functions` (`^4.11.2`), so it installs +and builds without any in-repo package build. This is what makes the suite +genuinely runnable. -Because the package does not exist on `main`, everything here is **gated / -skipped** so existing CI (`npm test`, lint, build) stays green: +To run the app against an **in-repo** build of the Azure Functions durable package +instead, change the `durable-functions` dependency in +[`test-app/package.json`](./test-app/package.json) to a `file:` reference (e.g. +`file:../../../packages/azure-functions-durable`) once that package exists on your +branch, then re-run `npm install`. A note to this effect lives in that +`package.json` (`comment_durable-functions`). -- **Prerequisite detection** โ€” the suite skips cleanly unless all of these hold - (see `harness.ts` + `global-setup.ts`): +## Gating / skip model + +The suite **skips cleanly** (it never fails) unless everything it needs is present, +so it is safe to leave wired into CI and to run locally without setup: + +- **Prerequisite detection** โ€” `global-setup.ts` only starts the shared `func` + host when all of these hold (otherwise every spec skips): 1. Azure Functions Core Tools (`func`) v4 is on `PATH`, 2. Azurite is reachable on `127.0.0.1:10000`, and 3. the test-app is installed and built (`test-app/node_modules` + `test-app/dist`). - - On `main`, step 3 cannot be satisfied (the local `durable-functions` package is - absent), so the suite is a clean no-op. - +- **One shared host** โ€” `global-setup.ts` starts a single `func start` host for the + whole run (mirroring the C# `FunctionAppFixture`) and writes its base URL to a + preflight file; the specs drive it over HTTP and `global-teardown.ts` stops it. - **Isolation** โ€” these specs are only run by the dedicated - `jest.functions-e2e.config.js` via `npm run test:e2e:functions:internal`. They - are **not** part of the default `npm test` / workspaces test run, nor of + `jest.functions-e2e.config.js` via `npm run test:e2e:functions:internal`. They are + **not** part of the default `npm test` / workspaces test run, nor of `test:e2e:internal` (which is scoped to `tests/e2e`). -- **CI** โ€” `.github/workflows/functions-e2e-tests.yaml` gates every job behind the - `RUN_FUNCTIONS_E2E` repository variable. It is a no-op until that variable is - set to `true` (do so only after #282 has merged). +- **CI** โ€” `.github/workflows/functions-e2e-tests.yaml` installs `func` + Azurite, + installs/builds the test-app, and runs the suite on PRs that touch + `test/e2e-functions/**`. The self-skip is the safety net if a prerequisite fails + to come up. ## What it covers -Faithful to the (identical) `azure-functions-durable-js` sample app: - -- **`hello.spec.ts`** โ€” starts the `helloOrchestrator` (which chains three - `hello` activities) and asserts the output is - `["Hello, Tokyo", "Hello, Seattle", "Hello, Cairo"]`. -- **`counter1.spec.ts`** โ€” signals the `counter1` durable entity (`add`, 1) via - HTTP and asserts the persisted state increments (1, then 2). `reset` is not - wired to an HTTP route in the sample app, so it is not tested. - -The only additions to the sample app are a plain `/api/ping` readiness function -(for host-readiness probing) and the dependency wiring (`durable-functions` -points at the workspace package). The Durable function code is kept byte-for-byte -identical to `azure-functions-durable-js`. +One spec per area, mirroring the extension repo's test classes. Because this is the +**Storage** backend, `[Trait("Node-DTS","Skip")]` and `[Trait("DTS","Skip")]` tests +are **included** (those skips apply only to the DTS backend); `[Trait("Node","Skip")]` +tests are `it.skip` with a comment citing the same reason. + +| Spec | Ported from | Notes | +| ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `hello-cities.spec.ts` | `HelloCitiesTest` | activity chaining โ†’ `Hello Tokyo!` etc. | +| `activity-input-type.spec.ts` | `ActivityInputTypeTests` | `byte[]`/`int[]`/string/custom-class serialization | +| `error-handling.spec.ts` | `ErrorHandlingTests` | rethrow/catch/retry activity & entity; skips dotnet-only FailureDetails + custom-exception-properties tests | +| `external-event.spec.ts` | `ExternalEventTests` | raise to running/completed/missing (raise-to-completed error skipped per #645) | +| `suspend-resume.spec.ts` | `SuspendResumeTests` | suspend/resume running, suspended, completed | +| `terminate.spec.ts` | `TerminateOrchestratorTests` | terminate running/terminated/completed/nonexistent | +| `timeout.spec.ts` | `TimeoutTests` | `Task.any` activity-vs-timer race | +| `rewind.spec.ts` | `RewindOrchestratorTests` | fail โ†’ rewind โ†’ complete; invocation counts; rewind-only-failed | +| `is-replaying.spec.ts` | `IsReplayingTests` | `isReplaying` flags (ConditionalLog #564 and FanOutFanIn #679 skipped) | +| `large-output.spec.ts` | `LargeOutputOrchestratorTests` | 65 KB via status URI + 4.5 MB via query trigger | +| `orchestration-query.spec.ts` | `OrchestrationQueryTests` | `GetAllInstances` / `GetRunningInstances` | +| `purge.spec.ts` | `PurgeInstancesTests` | purge by time / by entity id (purge-without-start-time #644 skipped) | +| `class-based-entity.spec.ts` | `ClassBasedEntityTests` | class-based entity state | + +### Node bug annotations honored + +- [#642](https://github.com/Azure/azure-functions-durable-js/issues/642) โ€” entity + error text: inner "This entity failed"/"More information" assertions omitted. +- [#645](https://github.com/Azure/azure-functions-durable-js/issues/645) โ€” + raise-external-event to a completed instance: error assertions omitted. +- [#564](https://github.com/Azure/azure-functions-durable-js/issues/564) โ€” + `isReplaying` undefined before the first `yield`: `IsReplayingConditionalLog` + skipped. +- [#679](https://github.com/Azure/azure-functions-durable-js/issues/679) โ€” + `IsReplayingFanOutFanIn` skipped. +- [#644](https://github.com/Azure/azure-functions-durable-js/issues/644) โ€” purge + without a start time: those purge tests skipped. +- Node swallows suspend/resume/terminate of a **terminal** instance and returns + success (`200`); the specs assert that behavior. + +The test-app additions over `BasicNode` are a plain `/api/ping` readiness function +(for host-readiness probing) and the published-dependency wiring. The Durable +function code is otherwise kept close to the source app. ## Running locally @@ -66,11 +104,10 @@ azurite --silent --location /tmp/azurite --blobPort 10000 --queuePort 10001 --ta # 2. Install the Core Tools (if needed) npm install -g azure-functions-core-tools@4 -# 3. Build the workspace (must include packages/azure-functions-durable from #282) +# 3. Install root dev deps (jest + ts-jest) npm ci -npm run build -# 4. Install + build the test-app +# 4. Install + build the test-app (against the published durable-functions) cd test/e2e-functions/test-app npm install npm run build @@ -81,7 +118,7 @@ npm run test:e2e:functions:internal ``` Or use the convenience wrapper, which starts Azurite (if the CLI is installed), -builds the workspace + test-app, and runs the suite: +installs + builds the test-app, and runs the suite: ```bash npm run test:e2e:functions @@ -89,3 +126,16 @@ npm run test:e2e:functions If any prerequisite is missing, the suite skips with a `[functions-e2e]` note instead of failing. + +## Follow-ups (deferred) + +The following extension-repo test areas are **not** ported yet. Most need extra +infrastructure (an OTLP collector, multi-version host config, scheduled starts) or +an orchestration that is not part of `BasicNode`: + +- **DistributedTracing / DistributedTracingEntities** โ€” need an OTLP collector. +- **Versioning / EntityVersioning** โ€” need a multi-version host configuration. +- **DedupeStatuses**, **GetOrchestrationHistory**, **HTTPFeature**, **Restart**, + **Scheduled** โ€” not yet ported. +- **`PurgeOnlyPurgesTerminalOrchestrations`** โ€” needs a `HelloActivityDIFailure` + orchestration that is not present in `BasicNode`. diff --git a/test/e2e-functions/activity-input-type.spec.ts b/test/e2e-functions/activity-input-type.spec.ts new file mode 100644 index 0000000..04d550a --- /dev/null +++ b/test/e2e-functions/activity-input-type.spec.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `ActivityInputTypeTests.DifferentActivityInputTypeTests`. + * + * Verifies that different input types (byte[], empty byte[], single byte, custom + * class, int[], string, custom-class array) serialize correctly and are received + * by activities without serialization errors. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + getOrchestrationDetails, + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] activity-input-type.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” activity input types (AzureStorage)", () => { + it("ActivityInputTypeOrchestrator serializes every input type without errors", async () => { + const response = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=ActivityInputTypeOrchestrator", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + const { outputString } = await getOrchestrationDetails(statusQueryGetUri); + + expect(outputString).toContain("Received byte[]: [1, 2, 3, 4, 5]"); + expect(outputString).toContain("Received byte[]: []"); + expect(outputString).toContain("Received byte: 42"); + expect(outputString).toContain("Received CustomClass: {Name: Test, Age: 25, Duration: 01:00:00, Data: [1, 2, 3]}"); + expect(outputString).toContain("Received int[]: [1, 2, 3, 4, 5]"); + expect(outputString).toContain("Received string: Test string input"); + expect(outputString).toContain( + "Received CustomClass[]: [{Name: Test1, Age: 25, Duration: 00:30:00, Data: [1, 2, 3]}, {Name: Test2, Age: 30, Duration: 00:45:00, Data: []}]", + ); + + // No serialization errors, especially for byte[] types. + expect(outputString).not.toContain("Error:"); + }, 120_000); +}); diff --git a/test/e2e-functions/class-based-entity.spec.ts b/test/e2e-functions/class-based-entity.spec.ts new file mode 100644 index 0000000..867081a --- /dev/null +++ b/test/e2e-functions/class-based-entity.spec.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `ClassBasedEntityTests.ClassBasedEntityTest`. + * + * Runs an orchestration that sets and reads a class-based entity's state and + * asserts the exact returned state string. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + getOrchestrationDetails, + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] class-based-entity.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” class-based entity (AzureStorage)", () => { + it("ClassBasedEntityOrchestration returns the entity state string", async () => { + const response = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=ClassBasedEntityOrchestration", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + const { outputString } = await getOrchestrationDetails(statusQueryGetUri); + expect(outputString).toBe("IConfiguration: yes, MyInjectedService: yes, BlobContainerClient: yes, Number: 42"); + }, 120_000); +}); diff --git a/test/e2e-functions/counter1.spec.ts b/test/e2e-functions/counter1.spec.ts deleted file mode 100644 index 14256b4..0000000 --- a/test/e2e-functions/counter1.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -/** - * E2E test for the `counter1` durable entity, driven over HTTP against a real - * Azure Functions host backed by Azurite (AzureStorage provider). - * - * The sample app only wires the entity's `add` (POST) and read (GET) operations - * to HTTP, so the test asserts that repeated signals increment the persisted - * state. `reset` is not HTTP-exposed and is intentionally not tested. - * - * Gated: skips cleanly unless `func`, Azurite, and a built/installed test-app - * are all present (see globalSetup + readPreflight). - */ - -import { FunctionApp, HOST_STARTUP_TIMEOUT_MS, readPreflight, TEST_APP_DIR } from "./harness"; - -const preflight = readPreflight(TEST_APP_DIR); -const describeMaybe = preflight.ok ? describe : describe.skip; - -if (!preflight.ok) { - console.warn(`[functions-e2e] counter1.spec skipped: ${preflight.reason}`); -} - -describeMaybe("Functions host E2E โ€” counter1 entity (AzureStorage)", () => { - let app: FunctionApp; - - beforeAll(async () => { - app = new FunctionApp(TEST_APP_DIR); - await app.start(); - }, HOST_STARTUP_TIMEOUT_MS + 30_000); - - afterAll(async () => { - if (app) { - await app.stop(); - } - }); - - it("increments entity state on each signal", async () => { - // Use a unique key so repeated runs (state persists in Azurite) start clean. - const key = `it-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; - - await app.signalCounter(key); - const afterFirst = await app.waitForCounter(key, (value) => value === 1); - expect(afterFirst).toBe(1); - - await app.signalCounter(key); - const afterSecond = await app.waitForCounter(key, (value) => value === 2); - expect(afterSecond).toBe(2); - }, 90_000); -}); diff --git a/test/e2e-functions/error-handling.spec.ts b/test/e2e-functions/error-handling.spec.ts new file mode 100644 index 0000000..7b677d8 --- /dev/null +++ b/test/e2e-functions/error-handling.spec.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `ErrorHandlingTests`. + * + * Covers uncaught/caught activity and entity exceptions plus retried activity / + * entity / custom-retry orchestrations. Assertion text comes from the Node + * localizer. Backend = Storage, so `[Trait("Node-DTS","Skip")]` and + * `[Trait("DTS","Skip")]` tests are INCLUDED (those skips apply to the DTS + * backend only); `[Trait("Node","Skip")]` tests are `it.skip`. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + NODE_LOCALIZED_STRINGS, + getOrchestrationDetails, + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] error-handling.spec skipped: ${preflight.reason}`); +} + +async function runToState(orchestrationName: string, desiredState: string) { + const response = await invokeHttpTrigger(baseUrl, "StartOrchestration", `?orchestrationName=${orchestrationName}`); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, desiredState, 30); + return getOrchestrationDetails(statusQueryGetUri); +} + +describeMaybe("Functions host E2E โ€” error handling (AzureStorage)", () => { + // DTS-skipped in C#; included for the Storage backend. + it("RethrowActivityException fails with the rethrown activity error", async () => { + const { outputString } = await runToState("RethrowActivityException", "Failed"); + expect(outputString.startsWith(NODE_LOCALIZED_STRINGS["RethrownActivityException.ErrorMessage"])).toBe(true); + expect(outputString).toContain("This activity failed"); + }, 120_000); + + // DTS-skipped in C#; included for the Storage backend. + it("ThrowEntityOrchestration fails with the rethrown entity error", async () => { + const { outputString } = await runToState("ThrowEntityOrchestration", "Failed"); + expect(outputString.startsWith(NODE_LOCALIZED_STRINGS["RethrownEntityException.ErrorMessage"])).toBe(true); + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/642 โ€” Node does + // not surface "This entity failed" in the output, so that assertion is omitted. + }, 120_000); + + // Node-DTS-skipped in C#; included for the Storage backend. + it("CatchActivityException completes after catching the activity error", async () => { + const { outputString } = await runToState("CatchActivityException", "Completed"); + expect(outputString.startsWith(NODE_LOCALIZED_STRINGS["CaughtActivityException.ErrorMessage"])).toBe(true); + expect(outputString).toContain("This activity failed"); + }, 120_000); + + // FailureDetails is a dotnet-isolated implementation detail โ€” [Trait("Node","Skip")]. + it.skip("CatchActivityExceptionFailureDetails (Node skip: FailureDetails is dotnet-isolated-only)", () => { + // Not applicable to the Node SDK. + }); + + it("CatchEntityOrchestration completes after catching the entity error", async () => { + const { outputString } = await runToState("CatchEntityOrchestration", "Completed"); + expect(outputString.startsWith(NODE_LOCALIZED_STRINGS["CaughtEntityException.ErrorMessage"])).toBe(true); + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/642 โ€” Node does not + // surface "This entity failed"/"More information about the failure", so the inner-detail + // assertions are omitted for Node. + }, 120_000); + + // Node-DTS-skipped in C#; included for the Storage backend. + it("RetryActivityFunction eventually succeeds", async () => { + const { outputString } = await runToState("RetryActivityFunction", "Completed"); + expect(outputString).toBe("Success"); + // The C# test additionally scrapes Core Tools logs for the retried failure; that + // exercises the extension's logging, not the JS SDK, so it is omitted here. + }, 120_000); + + // DTS + Node-DTS-skipped in C#; included for the Storage backend. + it("RetryEntityOrchestration eventually succeeds", async () => { + const { outputString } = await runToState("RetryEntityOrchestration", "Completed"); + expect(outputString).toBe("Success"); + // Log-scraping assertions omitted (extension logging, not JS SDK behavior). + }, 120_000); + + // Node-DTS-skipped in C#; included for the Storage backend. + it("CustomRetryActivityFunction eventually succeeds", async () => { + const { outputString } = await runToState("CustomRetryActivityFunction", "Completed"); + expect(outputString).toBe("Success"); + // Log-scraping assertions omitted (extension logging, not JS SDK behavior). + }, 120_000); + + // FailureDetails custom properties are a dotnet-isolated implementation detail โ€” [Trait("Node","Skip")]. + it.skip("CustomExceptionPropertiesInFailureDetails (Node skip: FailureDetails is dotnet-isolated-only)", () => { + // Not applicable to the Node SDK. + }); +}); diff --git a/test/e2e-functions/external-event.spec.ts b/test/e2e-functions/external-event.spec.ts new file mode 100644 index 0000000..f7734e5 --- /dev/null +++ b/test/e2e-functions/external-event.spec.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `ExternalEventTests`. + * + * Raises an external event to a running orchestration (completes it), then to a + * completed instance and to a non-existent instance. Assertion text comes from + * the Node localizer. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + NODE_LOCALIZED_STRINGS, + formatLocalized, + invokeHttpTrigger, + parseInstanceId, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] external-event.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” external events (AzureStorage)", () => { + it("RaiseExternalEvent delivers to a running instance and completes it", async () => { + const response = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=ExternalEventOrchestrator", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const instanceId = parseInstanceId(response); + const jsonContent = JSON.stringify(instanceId); + const statusQueryGetUri = parseStatusQueryGetUri(response); + + // Send the event the orchestration is waiting for. + await invokeHttpTrigger(baseUrl, "SendExternalEvent_HttpStart", "", jsonContent, "application/json"); + + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + // ClientOperationReceived log assertions from the C# test are omitted (they + // exercise the extension's Core Tools logging, not the JS SDK). + + // Resend to the now-completed instance. + await invokeHttpTrigger(baseUrl, "SendExternalEvent_HttpStart", "", jsonContent, "application/json"); + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/645 โ€” Node does + // not surface an error when raising to a completed instance, so the C# error + // assertions (guarded by `LanguageType != Node`) are intentionally omitted. + }, 120_000); + + it("RaiseExternalEvent to a non-existent instance returns the not-found error", async () => { + const testInstanceId = "instance-does-not-exist-test"; + const jsonContent = JSON.stringify(testInstanceId); + + const response = await invokeHttpTrigger( + baseUrl, + "SendExternalEvent_HttpStart", + "", + jsonContent, + "application/json", + ); + const responseContent = response.body; + + expect(responseContent).toContain(NODE_LOCALIZED_STRINGS["ExternalEvent.InvalidInstance.ErrorName"]); + expect(responseContent).toContain(formatLocalized("ExternalEvent.InvalidInstance.ErrorMessage", testInstanceId)); + }, 120_000); +}); diff --git a/test/e2e-functions/global-setup.ts b/test/e2e-functions/global-setup.ts index b7edd72..4b01c3c 100644 --- a/test/e2e-functions/global-setup.ts +++ b/test/e2e-functions/global-setup.ts @@ -5,20 +5,44 @@ * Jest globalSetup for the Azure Functions Durable e2e suite. * * Runs the async prerequisite preflight once (func on PATH + Azurite reachable + - * test-app built/installed) and records the result to a temp file. Each spec - * reads that file synchronously to decide `describe` vs `describe.skip`, so the - * whole suite is a clean no-op when the local toolchain is not set up โ€” and, in - * particular, on `main` where `packages/azure-functions-durable` (PR #282) does - * not yet exist and the test-app therefore cannot be installed. + * test-app built/installed). When the prerequisites are met it starts a single + * shared `func start` host (mirroring the C# `FunctionAppFixture`, which shares + * one host across the whole test collection) and records the host `baseUrl` to a + * temp file. Each spec reads that file synchronously to decide `describe` vs + * `describe.skip` and to obtain the shared host URL, so the whole suite is a + * clean no-op when the local toolchain is not set up (no `func`, or Azurite not + * running, or the test-app has not been installed/built). + * + * The started host is stashed on `globalThis` so global-teardown can stop it. */ import * as fs from "fs"; -import { checkPrerequisites, preflightFilePath, TEST_APP_DIR } from "./harness"; +import { checkPrerequisites, FunctionApp, preflightFilePath, PrerequisiteCheck, TEST_APP_DIR } from "./harness"; + +export const GLOBAL_APP_KEY = "__DURABLETASK_FUNCTIONS_E2E_APP__"; export default async function globalSetup(): Promise { - const result = await checkPrerequisites(TEST_APP_DIR); - fs.writeFileSync(preflightFilePath(), JSON.stringify(result), "utf-8"); - if (!result.ok) { - console.warn(`[functions-e2e] Skipping suite: ${result.reason}`); + const preflight: PrerequisiteCheck = await checkPrerequisites(TEST_APP_DIR); + + if (!preflight.ok) { + fs.writeFileSync(preflightFilePath(), JSON.stringify(preflight), "utf-8"); + console.warn(`[functions-e2e] Skipping suite: ${preflight.reason}`); + return; } + + // Prerequisites are present: start the single shared host for the whole run. + const app = new FunctionApp(TEST_APP_DIR); + try { + await app.start(); + } catch (err) { + await app.stop(); + const reason = `Failed to start the shared func host: ${err instanceof Error ? err.message : String(err)}`; + fs.writeFileSync(preflightFilePath(), JSON.stringify({ ok: false, reason }), "utf-8"); + console.warn(`[functions-e2e] Skipping suite: ${reason}`); + return; + } + + (globalThis as Record)[GLOBAL_APP_KEY] = app; + fs.writeFileSync(preflightFilePath(), JSON.stringify({ ok: true, baseUrl: app.baseUrl }), "utf-8"); + console.log(`[functions-e2e] Shared func host ready at ${app.baseUrl}`); } diff --git a/test/e2e-functions/global-teardown.ts b/test/e2e-functions/global-teardown.ts new file mode 100644 index 0000000..ef81f01 --- /dev/null +++ b/test/e2e-functions/global-teardown.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Jest globalTeardown for the Azure Functions Durable e2e suite. + * + * Stops the single shared `func start` host started by globalSetup (if any) and + * removes the preflight file. Runs in the same process as globalSetup, so it + * retrieves the host handle from `globalThis`. + */ + +import * as fs from "fs"; +import { FunctionApp, preflightFilePath } from "./harness"; +import { GLOBAL_APP_KEY } from "./global-setup"; + +export default async function globalTeardown(): Promise { + const app = (globalThis as Record)[GLOBAL_APP_KEY] as FunctionApp | undefined; + if (app) { + await app.stop(); + delete (globalThis as Record)[GLOBAL_APP_KEY]; + } + try { + fs.unlinkSync(preflightFilePath()); + } catch { + // Nothing to clean up. + } +} diff --git a/test/e2e-functions/harness.ts b/test/e2e-functions/harness.ts index 69d8186..f7b234d 100644 --- a/test/e2e-functions/harness.ts +++ b/test/e2e-functions/harness.ts @@ -13,13 +13,14 @@ * Everything here is Node built-in only (`child_process`, `http`, `net`, `fs`, * `os`, `path`) so the harness adds no test dependencies of its own. * - * This mirrors the Python harness in durabletask-python PR #155, adapted to the - * routes exposed by the (identical) azure-functions-durable-js sample app: - * - POST /api/orchestrators/{name} -> starts an orchestration, returns the - * durable `createCheckStatusResponse` payload ({ id, statusQueryGetUri, ... }). - * - GET /api/counter1/{id} -> reads the `counter1` entity state. - * - POST /api/counter1/{id} -> signals the `counter1` entity ("add", 1). - * - GET /api/ping -> plain readiness probe (added for tests). + * The harness mirrors the C# `DurableHelpers`/`HttpHelpers` used by the + * extension's own e2e suite (which drive the identical `BasicNode` app): + * - POST /api/{functionName}{queryString} -> invoke an HTTP trigger. Durable + * starters return the `createCheckStatusResponse` payload + * ({ id, statusQueryGetUri, ... }); client-operation triggers (RaiseEvent, + * SuspendInstance, TerminateInstance, ...) return their own status/body. + * - GET {statusQueryGetUri} -> poll orchestration status. + * - GET /api/ping -> plain readiness probe. */ import { ChildProcess, spawn, spawnSync } from "child_process"; @@ -48,6 +49,8 @@ export const ORCHESTRATION_TIMEOUT_MS = 60_000; export interface PrerequisiteCheck { ok: boolean; reason?: string; + /** Base URL of the shared `func` host, set by globalSetup once it is ready. */ + baseUrl?: string; } /** Loose shape of a durable orchestration status-query response. */ @@ -63,13 +66,6 @@ export interface OrchestrationStatus { [key: string]: unknown; } -/** Payload returned by durable `createCheckStatusResponse`. */ -interface CheckStatusPayload { - id: string; - statusQueryGetUri: string; - [key: string]: unknown; -} - function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -136,8 +132,9 @@ export function azuriteRunning(timeoutMs = 2000): Promise { /** * `func start` for a Node app serves the compiled output in `dist/` and needs * the `durable-functions` dependency installed under `node_modules/`. Both are - * produced by `npm install && npm run build` inside the test-app (which only - * works once PR #282's `packages/azure-functions-durable` exists). + * produced by `npm install && npm run build` inside the test-app. The app is + * wired to the published `durable-functions` package, so this works out of the + * box with no in-repo package build required. */ export function testAppBuilt(appDir: string = TEST_APP_DIR): boolean { return fs.existsSync(path.join(appDir, "dist")) && fs.existsSync(path.join(appDir, "node_modules")); @@ -156,7 +153,7 @@ export async function checkPrerequisites(appDir: string = TEST_APP_DIR): Promise ok: false, reason: `test-app is not built/installed at ${appDir} (missing dist/ or node_modules/). ` + - "Build packages/azure-functions-durable (PR #282), then install + build the test-app.", + "Run `npm install && npm run build` inside the test-app.", }; } return { ok: true }; @@ -168,23 +165,29 @@ export function preflightFilePath(): string { } /** - * Read the preflight result written by globalSetup. When the file is absent - * (e.g. a spec is run without the dedicated jest config) fall back to the - * synchronous checks; the async Azurite probe is then skipped, so the suite - * still fails safe by attempting to start the host. + * Read the preflight result written by globalSetup. Specs use this to decide + * `describe` vs `describe.skip` and to obtain the shared host `baseUrl`. + * + * When the file is absent (e.g. a spec is run without the dedicated jest config + * that provides globalSetup) the suite cannot reach a shared host, so this + * returns a skip result rather than attempting anything. */ -export function readPreflight(appDir: string = TEST_APP_DIR): PrerequisiteCheck { +export function readPreflight(): PrerequisiteCheck { + let result: PrerequisiteCheck; try { - return JSON.parse(fs.readFileSync(preflightFilePath(), "utf-8")) as PrerequisiteCheck; + result = JSON.parse(fs.readFileSync(preflightFilePath(), "utf-8")) as PrerequisiteCheck; } catch { - if (!funcExecutable()) { - return { ok: false, reason: "Azure Functions Core Tools ('func') is not installed." }; - } - if (!testAppBuilt(appDir)) { - return { ok: false, reason: `test-app is not built/installed at ${appDir}.` }; - } - return { ok: true }; + return { + ok: false, + reason: + "No preflight result found. Run the suite via `npm run test:e2e:functions:internal` " + + "(the dedicated jest config provides the globalSetup that starts the shared host).", + }; + } + if (result.ok && !result.baseUrl) { + return { ok: false, reason: "Preflight reported OK but no shared host baseUrl was recorded." }; } + return result; } /** Result of an HTTP request performed by the harness. */ @@ -198,7 +201,13 @@ export interface HttpResult { * Perform an HTTP request, returning status and body. HTTP error responses * (4xx/5xx) are returned rather than thrown, so callers can assert on status. */ -export function httpRequest(method: string, url: string, data?: unknown, timeoutMs = 30_000): Promise { +export function httpRequest( + method: string, + url: string, + data?: unknown, + timeoutMs = 30_000, + contentType?: string, +): Promise { return new Promise((resolve, reject) => { const parsed = new URL(url); let payload: string | undefined; @@ -206,7 +215,7 @@ export function httpRequest(method: string, url: string, data?: unknown, timeout if (data !== undefined) { const isString = typeof data === "string"; payload = isString ? (data as string) : JSON.stringify(data); - headers["Content-Type"] = isString ? "text/plain" : "application/json"; + headers["Content-Type"] = contentType ?? (isString ? "text/plain" : "application/json"); headers["Content-Length"] = Buffer.byteLength(payload).toString(); } const req = http.request( @@ -442,93 +451,194 @@ export class FunctionApp { setTimeout(finish, 20_000).unref(); }); } +} - // -- orchestration helpers --------------------------------------------- +// -- HTTP trigger + durable status helpers ------------------------------- +// +// These mirror the C# `HttpHelpers`/`DurableHelpers` used by the extension's +// own e2e suite so the ported specs read the same way. They are standalone +// functions (not methods) because the suite drives a single shared `func` host +// started once in globalSetup; specs receive its `baseUrl` from the preflight. - /** - * Start an orchestration via `POST /api/orchestrators/{name}`. - * - * Returns the new instance id and the durable status-query URI (which embeds - * the task hub, connection, and system key needed to poll status locally). - */ - async startHelloOrchestration( - orchestratorName = "helloOrchestrator", - body?: string, - ): Promise<{ id: string; statusQueryGetUri: string }> { - const result = await httpRequest("POST", `${this.baseUrl}/api/orchestrators/${orchestratorName}`, body); - if (result.status !== 200 && result.status !== 202) { - throw new Error(`start failed: ${result.status} ${result.body}`); - } - const payload = result.json(); - return { id: payload.id, statusQueryGetUri: payload.statusQueryGetUri }; - } +/** + * Invoke an HTTP trigger via `POST {baseUrl}/api/{functionName}{queryString}`. + * + * Mirrors `HttpHelpers.InvokeHttpTrigger` (and, when `body` is supplied, + * `InvokeHttpTriggerWithBody`). HTTP error responses are returned rather than + * thrown so specs can assert on status/body. `queryString` must include its + * leading `?` when present. + */ +export async function invokeHttpTrigger( + baseUrl: string, + functionName: string, + queryString = "", + body?: string, + mediaType = "text/plain", +): Promise { + const url = `${baseUrl}/api/${functionName}${queryString}`; + return httpRequest("POST", url, body, 60_000, body !== undefined ? mediaType : undefined); +} - /** Fetch orchestration status by following the durable status-query URI. */ - async getStatus(statusQueryGetUri: string): Promise { - const result = await httpRequest("GET", statusQueryGetUri); - // Durable returns 202 while the instance is running and 200 once terminal; - // both carry the status body. - if (result.status !== 200 && result.status !== 202) { - throw new Error(`status failed: ${result.status} ${result.body}`); - } - return result.json(); +/** Fetch the raw orchestration status by following the durable status-query URI. */ +export async function getStatus(statusQueryGetUri: string): Promise { + const result = await httpRequest("GET", statusQueryGetUri); + // Durable returns 202 while the instance is running and 200 once terminal; + // both carry the status body. + if (result.status !== 200 && result.status !== 202) { + throw new Error(`status failed: ${result.status} ${result.body}`); } + return result.json(); +} - /** Poll status until the orchestration reaches a terminal state. */ - async waitForCompletion( - statusQueryGetUri: string, - timeoutMs = ORCHESTRATION_TIMEOUT_MS, - ): Promise { - const terminal = new Set(["completed", "failed", "terminated", "canceled"]); - const deadline = Date.now() + timeoutMs; - let status: OrchestrationStatus = {}; - while (Date.now() < deadline) { - status = await this.getStatus(statusQueryGetUri); - if (terminal.has(String(status.runtimeStatus ?? "").toLowerCase())) { - return status; - } - await sleep(500); +/** + * Read orchestration details from the durable status-query URI, mirroring C# + * `DurableHelpers.GetRunningOrchestrationDetailsAsync`. + * + * `output`/`input` are the raw parsed JSON values; `outputString`/`inputString` + * mirror C#'s `JsonNode.ToString()` semantics used throughout the suite: a JSON + * string value becomes the unquoted string, while an object/array/number + * becomes its JSON text. + */ +export async function getOrchestrationDetails(statusQueryGetUri: string): Promise { + const status = await getStatus(statusQueryGetUri); + return { + instanceId: String(status.instanceId ?? ""), + runtimeStatus: String(status.runtimeStatus ?? ""), + input: status.input, + inputString: jsonNodeToString(status.input), + output: status.output, + outputString: jsonNodeToString(status.output), + }; +} + +/** + * Poll status until the orchestration reaches `desiredState`, mirroring C# + * `DurableHelpers.WaitForOrchestrationStateAsync` (200ms backoff doubling to + * 2s). Fails fast if the instance reaches an unexpected terminal state. + */ +export async function waitForOrchestrationState( + statusQueryGetUri: string, + desiredState: string, + maxTimeoutSeconds = ORCHESTRATION_TIMEOUT_MS / 1000, +): Promise { + const finalStates = new Set(["Completed", "Terminated", "Failed"]); + const deadline = Date.now() + maxTimeoutSeconds * 1000; + let delay = 200; + let current: OrchestrationDetails = { + instanceId: "", + runtimeStatus: "", + input: undefined, + inputString: "", + output: undefined, + outputString: "", + }; + while (Date.now() < deadline) { + current = await getOrchestrationDetails(statusQueryGetUri); + if (current.runtimeStatus === desiredState) { + return current; + } + if (finalStates.has(current.runtimeStatus)) { + throw new Error(`Orchestration reached ${current.runtimeStatus} state when test was expecting ${desiredState}`); } - throw new Error(`Orchestration did not complete within ${timeoutMs}ms; last status: ${JSON.stringify(status)}`); + await sleep(delay); + delay = Math.min(delay * 2, 2000); } + throw new Error( + `Orchestration did not reach ${desiredState} status within ${maxTimeoutSeconds} seconds; last status: ${current.runtimeStatus}`, + ); +} - // -- entity helpers ---------------------------------------------------- +/** Orchestration details parsed from a durable status-query response. */ +export interface OrchestrationDetails { + instanceId: string; + runtimeStatus: string; + input: unknown; + inputString: string; + output: unknown; + outputString: string; +} - /** Signal the `counter1` entity to add 1 via `POST /api/counter1/{id}`. */ - async signalCounter(id: string): Promise { - const result = await httpRequest("POST", `${this.baseUrl}/api/counter1/${id}`); - if (result.status < 200 || result.status >= 300) { - throw new Error(`counter signal failed: ${result.status} ${result.body}`); - } +/** + * Mirror C#'s `JsonNode.ToString()`: string values become the unquoted string, + * objects/arrays/numbers become their JSON text, null/undefined become "". + */ +export function jsonNodeToString(value: unknown): string { + if (value === null || value === undefined) { + return ""; } - - /** Read the `counter1` entity state via `GET /api/counter1/{id}`. */ - async readCounter(id: string): Promise { - const result = await httpRequest("GET", `${this.baseUrl}/api/counter1/${id}`); - if (result.status < 200 || result.status >= 300) { - throw new Error(`counter read failed: ${result.status} ${result.body}`); - } - if (!result.body) { - return null; - } - return result.json(); + if (typeof value === "string") { + return value; } + return JSON.stringify(value); +} - /** Poll the entity read route until `predicate(state)` is true. */ - async waitForCounter( - id: string, - predicate: (value: number | null) => boolean, - timeoutMs = 30_000, - ): Promise { - const deadline = Date.now() + timeoutMs; - let value: number | null = null; - while (Date.now() < deadline) { - value = await this.readCounter(id); - if (predicate(value)) { - return value; - } - await sleep(500); - } - throw new Error(`counter ${id} predicate not met within ${timeoutMs}ms; last value: ${JSON.stringify(value)}`); +/** + * Parse the durable instance id from a check-status response body, mirroring + * C# `DurableHelpers.ParseInstanceIdAsync` (key `Id`, falling back to `id`). + */ +export function parseInstanceId(response: HttpResult): string { + return tokenizeAndGetValue(response.body, "Id"); +} + +/** + * Parse `statusQueryGetUri` from a check-status response body, mirroring C# + * `DurableHelpers.ParseStatusQueryGetUriAsync` (key `StatusQueryGetUri`, + * falling back to `statusQueryGetUri`). + */ +export function parseStatusQueryGetUri(response: HttpResult): string { + return tokenizeAndGetValue(response.body, "StatusQueryGetUri"); +} + +function tokenizeAndGetValue(json: string, key: string): string { + if (!json) { + return ""; } + let parsed: Record; + try { + parsed = JSON.parse(json) as Record; + } catch { + return ""; + } + const lowerKey = key.charAt(0).toLowerCase() + key.slice(1); + const value = parsed[key] ?? parsed[lowerKey]; + return value === undefined || value === null ? "" : String(value); +} + +/** + * Node-specific expected strings and bug annotations, ported verbatim from the + * extension's `NodeTestLanguageLocalizer`. `{0}`-style placeholders are filled + * by `formatLocalized`. C# `{{`/`}}` escapes are already unescaped here. + */ +export const NODE_LOCALIZED_STRINGS: Record = { + "CaughtActivityException.ErrorMessage": "Caught exception: Error: Activity function 'raise_exception' failed:", + "RethrownActivityException.ErrorMessage": + "Orchestrator function 'RethrowActivityException' failed: Activity function 'raise_exception' failed: ", + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/642 + "CaughtEntityException.ErrorMessage": "Error: [object Object]", + "RethrownEntityException.ErrorMessage": "Orchestrator function 'ThrowEntityOrchestration' failed:", + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/645 + "ExternalEvent.CompletedInstance.ErrorName": "N/A", + "ExternalEvent.CompletedInstance.ErrorMessage": "N/A", + "ExternalEvent.InvalidInstance.ErrorName": "Error", + "ExternalEvent.InvalidInstance.ErrorMessage": "No instance with ID '{0}' found", + // Empty: Node's unique behavior causes suspend/resume/terminate of terminal + // instances to succeed rather than fail. + "SuspendCompletedInstance.FailureMessage": "", + "ResumeCompletedInstance.FailureMessage": "", + "SuspendSuspendedInstance.FailureMessage": + 'Error: The operation failed with an unexpected status code: 500. Details: {"Message":"Something went wrong while processing your request', + "ResumeRunningInstance.FailureMessage": + 'Error: The operation failed with an unexpected status code: 500. Details: {"Message":"Something went wrong while processing your request', + "TerminateCompletedInstance.FailureMessage": "", + "TerminateTerminatedInstance.FailureMessage": "", + "TerminateInvalidInstance.FailureMessage": "No instance with ID '{0}' found.", +}; + +/** Look up a Node localized string and substitute `{0}`, `{1}`, ... args. */ +export function formatLocalized(key: string, ...args: unknown[]): string { + const template = NODE_LOCALIZED_STRINGS[key] ?? ""; + return template.replace(/\{(\d+)\}/g, (match, index) => { + const arg = args[Number(index)]; + return arg === undefined ? match : String(arg); + }); } diff --git a/test/e2e-functions/hello-cities.spec.ts b/test/e2e-functions/hello-cities.spec.ts new file mode 100644 index 0000000..f956146 --- /dev/null +++ b/test/e2e-functions/hello-cities.spec.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `HelloCitiesTest` (HttpEndToEndTests). + * + * Drives the generic `StartOrchestration` starter over HTTP against the shared + * `func` host and asserts the `HelloCities` activity-chaining orchestration + * completes with the expected greeting in its output. + * + * Gated: skips cleanly unless the shared host was started by globalSetup (func + + * Azurite + a built/installed test-app all present). + */ + +import { + getOrchestrationDetails, + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] hello-cities.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” HelloCities (AzureStorage)", () => { + it("StartOrchestration(HelloCities) completes and returns the greeting", async () => { + const response = await invokeHttpTrigger(baseUrl, "StartOrchestration", "?orchestrationName=HelloCities"); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + const details = await getOrchestrationDetails(statusQueryGetUri); + expect(details.outputString).toContain("Hello Tokyo!"); + + // The C# test additionally asserts a `ClientOperationReceived` log carrying a + // FunctionInvocationId. That exercises the extension's Core Tools logging, not + // the JS SDK behavior under test, so it is intentionally not ported here. + }, 120_000); +}); diff --git a/test/e2e-functions/hello.spec.ts b/test/e2e-functions/hello.spec.ts deleted file mode 100644 index 7f10ec9..0000000 --- a/test/e2e-functions/hello.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -/** - * E2E test for the `helloOrchestrator` fan-in/sequence sample, driven over HTTP - * against a real Azure Functions host backed by Azurite (AzureStorage provider). - * - * Gated: skips cleanly unless `func`, Azurite, and a built/installed test-app - * are all present (see globalSetup + readPreflight). - */ - -import { FunctionApp, HOST_STARTUP_TIMEOUT_MS, readPreflight, TEST_APP_DIR } from "./harness"; - -const preflight = readPreflight(TEST_APP_DIR); -const describeMaybe = preflight.ok ? describe : describe.skip; - -if (!preflight.ok) { - console.warn(`[functions-e2e] hello.spec skipped: ${preflight.reason}`); -} - -describeMaybe("Functions host E2E โ€” hello orchestration (AzureStorage)", () => { - let app: FunctionApp; - - beforeAll(async () => { - app = new FunctionApp(TEST_APP_DIR); - await app.start(); - }, HOST_STARTUP_TIMEOUT_MS + 30_000); - - afterAll(async () => { - if (app) { - await app.stop(); - } - }); - - it("runs helloOrchestrator and returns the three greetings", async () => { - const { id, statusQueryGetUri } = await app.startHelloOrchestration(); - expect(id).toBeTruthy(); - - const status = await app.waitForCompletion(statusQueryGetUri); - - expect(String(status.runtimeStatus).toLowerCase()).toBe("completed"); - expect(status.output).toEqual(["Hello, Tokyo", "Hello, Seattle", "Hello, Cairo"]); - }, 90_000); -}); diff --git a/test/e2e-functions/is-replaying.spec.ts b/test/e2e-functions/is-replaying.spec.ts new file mode 100644 index 0000000..ca4bc37 --- /dev/null +++ b/test/e2e-functions/is-replaying.spec.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `IsReplayingTests`. + * + * Verifies the `isReplaying` flag across single-activity, multi-activity, and + * counter orchestrations. Backend = Storage, so `[Trait("Node-DTS","Skip")]` + * tests are INCLUDED; the two `[Trait("Node","Skip")]` tests are `it.skip` + * (bugs #564 and #679). + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + getOrchestrationDetails, + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] is-replaying.spec skipped: ${preflight.reason}`); +} + +async function runAndGetOutput(orchestrationName: string): Promise> { + const response = await invokeHttpTrigger(baseUrl, "StartOrchestration", `?orchestrationName=${orchestrationName}`); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + const details = await getOrchestrationDetails(statusQueryGetUri); + return details.output as Record; +} + +describeMaybe("Functions host E2E โ€” isReplaying (AzureStorage)", () => { + // Node-DTS-skipped in C#; included for the Storage backend. + it("IsReplayingBasic reports replay flags around a single activity", async () => { + const output = await runAndGetOutput("IsReplayingBasic"); + expect(output["before_activity"]).toBe(true); + expect(output["after_activity"]).toBe(false); + expect(output["activity_result"]).toBe("hello"); + }, 120_000); + + // Node-DTS-skipped in C#; included for the Storage backend. + it("IsReplayingMultiActivity shows replay progression across snapshots", async () => { + const output = await runAndGetOutput("IsReplayingMultiActivity"); + + const activities = output["activities"] as string[]; + expect(activities).toEqual(["one", "two", "three"]); + + const snapshots = output["snapshots"] as Array<{ is_replaying: boolean; label: string }>; + expect(snapshots).toHaveLength(4); + for (let i = 0; i < 3; i++) { + expect(snapshots[i].is_replaying).toBe(true); + } + expect(snapshots[3].is_replaying).toBe(false); + }, 120_000); + + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/564 + it.skip("IsReplayingConditionalLog (Node skip: isReplaying undefined before first yield, #564)", () => { + // Not portable until #564 is fixed. + }); + + // Node-DTS-skipped in C#; included for the Storage backend. + it("IsReplayingCounter tracks replay and live checkpoints", async () => { + const output = await runAndGetOutput("IsReplayingCounter"); + expect(output["total_checkpoints"]).toBe(4); + expect(output["non_replay_count"]).toBe(1); + expect(output["replay_count"]).toBe(3); + expect(output["activities"] as string[]).toEqual(["a", "b", "c"]); + }, 120_000); + + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/679 + it.skip("IsReplayingFanOutFanIn (Node skip: replay flags around parallel tasks, #679)", () => { + // Not portable until #679 is fixed. + }); +}); diff --git a/test/e2e-functions/large-output.spec.ts b/test/e2e-functions/large-output.spec.ts new file mode 100644 index 0000000..5b79032 --- /dev/null +++ b/test/e2e-functions/large-output.spec.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `LargeOutputOrchestratorTests`. + * + * - LargeOutputStatusQueryTests(65): outputs slightly above the 64 KB queue limit + * are stored in blob storage and returned via the status-query URI. + * - DurableTaskClientWriteOutputTests(4608): larger-than-4 MB outputs are read + * back via a query trigger. The C# test is DTS/Java-skipped; since this is the + * Storage backend, it is included here. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + invokeHttpTrigger, + parseInstanceId, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] large-output.spec skipped: ${preflight.reason}`); +} + +function generateLargeString(sizeInKB: number): string { + return "A".repeat(sizeInKB * 1024); +} + +describeMaybe("Functions host E2E โ€” large output (AzureStorage)", () => { + it("LargeOutputStatusQueryTests(65): returns 65 KB output via the status-query URI", async () => { + const sizeInKB = 65; + const response = await invokeHttpTrigger( + baseUrl, + "LargeOutputOrchestrator_HttpStart", + "", + String(sizeInKB), + "application/json", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const statusQueryGetUri = parseStatusQueryGetUri(response); + const details = await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + expect(details.outputString).toContain(generateLargeString(sizeInKB)); + }, 180_000); + + // The C# test is [Trait("DTS","Skip")] + [Trait("Java","Skip")]; those skips do + // not apply to the Storage backend, so it is included here. + it("DurableTaskClientWriteOutputTests(4608): returns >4 MB output via a query trigger", async () => { + const sizeInKB = 4608; + const response = await invokeHttpTrigger( + baseUrl, + "LargeOutputOrchestrator_HttpStart", + "", + String(sizeInKB), + "application/json", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + const result = await invokeHttpTrigger(baseUrl, "LargeOutputOrchestrator_Query_Output", `?id=${instanceId}`); + expect(result.status).toBe(200); // HttpStatusCode.OK + expect(result.body).toContain(generateLargeString(sizeInKB)); + }, 180_000); +}); diff --git a/test/e2e-functions/orchestration-query.spec.ts b/test/e2e-functions/orchestration-query.spec.ts new file mode 100644 index 0000000..14bb5b8 --- /dev/null +++ b/test/e2e-functions/orchestration-query.spec.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `OrchestrationQueryTests`. + * + * Lists all instances and running instances via the query HTTP triggers. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + invokeHttpTrigger, + parseInstanceId, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] orchestration-query.spec skipped: ${preflight.reason}`); +} + +interface QueriedInstance { + InstanceId?: string; + instanceId?: string; +} + +describeMaybe("Functions host E2E โ€” orchestration query (AzureStorage)", () => { + it("GetAllInstances returns a JSON array", async () => { + const statusResponse = await invokeHttpTrigger(baseUrl, "GetAllInstances", ""); + expect(statusResponse.status).toBe(200); // HttpStatusCode.OK + + const parsed = JSON.parse(statusResponse.body); + expect(parsed).not.toBeNull(); + expect(Array.isArray(parsed)).toBe(true); + }, 120_000); + + it("GetRunningInstances contains the running instance", async () => { + const response = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=LongRunningOrchestrator", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + try { + const statusResponse = await invokeHttpTrigger(baseUrl, "GetRunningInstances", ""); + expect(statusResponse.status).toBe(200); // HttpStatusCode.OK + + const parsed = JSON.parse(statusResponse.body) as QueriedInstance[]; + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.some((x) => x.InstanceId === instanceId || x.instanceId === instanceId)).toBe(true); + } finally { + try { + await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`); + } catch { + // best-effort cleanup + } + } + }, 120_000); +}); diff --git a/test/e2e-functions/purge.spec.ts b/test/e2e-functions/purge.spec.ts new file mode 100644 index 0000000..b7d17ec --- /dev/null +++ b/test/e2e-functions/purge.spec.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `PurgeInstancesTests`. + * + * Purges instance history by time range and by entity instance id. Backend = + * Storage, so `[Trait("Node-DTS","Skip")]` tests are INCLUDED. The + * "purge without start time" tests are `[Trait("Node","Skip")]` (bug #644) and + * are `it.skip`. `PurgeOnlyPurgesTerminalOrchestrations` requires a + * `HelloActivityDIFailure` orchestration that is not part of BasicNode, so it is + * deferred (see README follow-ups). + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { invokeHttpTrigger, parseStatusQueryGetUri, readPreflight, waitForOrchestrationState } from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] purge.spec skipped: ${preflight.reason}`); +} + +const PURGED_RECORDS = /^Purged \d+ records$/; + +describeMaybe("Functions host E2E โ€” purge (AzureStorage)", () => { + // Node-DTS-skipped in C#; included for the Storage backend. + it("PurgeOrchestrationHistory with start and end time succeeds", async () => { + const purgeStartTime = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const purgeEndTime = new Date().toISOString(); + const response = await invokeHttpTrigger( + baseUrl, + "PurgeOrchestrationHistory", + `?purgeStartTime=${purgeStartTime}&purgeEndTime=${purgeEndTime}`, + ); + expect(response.status).toBe(200); // HttpStatusCode.OK + expect(response.body).toMatch(PURGED_RECORDS); + }, 120_000); + + // Node-DTS-skipped in C#; included for the Storage backend. + it("PurgeOrchestrationHistory with start time only succeeds", async () => { + const purgeStartTime = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const response = await invokeHttpTrigger(baseUrl, "PurgeOrchestrationHistory", `?purgeStartTime=${purgeStartTime}`); + expect(response.status).toBe(200); // HttpStatusCode.OK + expect(response.body).toMatch(PURGED_RECORDS); + }, 120_000); + + // Bug: https://github.com/Azure/azure-functions-durable-js/issues/644 (purge + // without a start time). [Trait("Node","Skip")]. + it.skip("PurgeOrchestrationHistory with end time only (Node skip: purge without start time, #644)", () => {}); + it.skip("PurgeOrchestrationHistory with no boundaries (Node skip: purge without start time, #644)", () => {}); + it.skip("PurgeOrchestrationHistoryAfterInvocation (Node skip: purge without start time, #644)", () => {}); + it.skip("PurgeAfterPurge_ZeroRows (Node skip: purge without start time, #644)", () => {}); + + // Deferred: requires a `HelloActivityDIFailure` orchestration that is not part + // of the BasicNode app. Tracked as a follow-up in the README. + it.skip("PurgeOnlyPurgesTerminalOrchestrations (deferred: needs HelloActivityDIFailure)", () => {}); + + it("PurgeEntity purges an existing entity instance and reports zero for a missing one", async () => { + const orchestrationResponse = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=InvokeDummyEntityOrchestration", + ); + expect(orchestrationResponse.status).toBe(202); // HttpStatusCode.Accepted + + const statusQueryGetUri = parseStatusQueryGetUri(orchestrationResponse); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + // EntityInstanceId("DummyEntity","myEntity") => "@dummyentity@myEntity" (name lowercased). + const existingEntityId = "@dummyentity@myEntity"; + const purgeExistent = await invokeHttpTrigger( + baseUrl, + "PurgeOrchestrationHistory", + `?instanceId=${existingEntityId}`, + ); + expect(purgeExistent.status).toBe(200); // HttpStatusCode.OK + expect(purgeExistent.body).toBe("Purged 1 records"); + + const missingEntityId = "@dummyentity3@myEntity"; + const purgeNonExistent = await invokeHttpTrigger( + baseUrl, + "PurgeOrchestrationHistory", + `?instanceId=${missingEntityId}`, + ); + expect(purgeNonExistent.status).toBe(200); // HttpStatusCode.OK + expect(purgeNonExistent.body).toBe("Purged 0 records"); + }, 120_000); +}); diff --git a/test/e2e-functions/rewind.spec.ts b/test/e2e-functions/rewind.spec.ts new file mode 100644 index 0000000..1d5f4c9 --- /dev/null +++ b/test/e2e-functions/rewind.spec.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `RewindOrchestratorTests`. + * + * Drives `HttpStart_RewindOrchestration` to fail N times, rewinds after each + * failure, and asserts per-activity invocation counts (failed activity runs + * 1+numFailures times, others once). Also verifies that Node returns 400 when + * attempting to rewind non-failed instances. + * + * Backend = Storage, so `[Trait("DTS","Skip")]` is INCLUDED (that skip applies to + * the DTS backend only, awaiting an emulator with the new rewind implementation). + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { randomUUID } from "crypto"; +import { + getOrchestrationDetails, + invokeHttpTrigger, + parseInstanceId, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] rewind.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” rewind (AzureStorage)", () => { + // DTS-skipped in C#; included for the Storage backend. + it.each([1, 2])( + "RewindFailedOrchestration(numFailures=%i) rewinds to completion", + async (numFailures) => { + // callEntities is true for non-MSSQL durability providers (Storage here). + const callEntities = true; + const response = await invokeHttpTrigger( + baseUrl, + "HttpStart_RewindOrchestration", + `?input=fail&numFailures=${numFailures}&callEntities=${callEntities}`, + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + + for (let i = 0; i < numFailures; i++) { + await waitForOrchestrationState(statusQueryGetUri, "Failed", 30); + const rewindResponse = await invokeHttpTrigger(baseUrl, "RewindInstance", `?instanceId=${instanceId}`); + expect(rewindResponse.status).toBe(200); // HttpStatusCode.OK + } + + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + const details = await getOrchestrationDetails(statusQueryGetUri); + const output = details.output as Record; + expect(output).toBeTruthy(); + + // Each successful activity/entity runs once; the failed activity runs on the + // first attempt plus once per rewind (numFailures + 1). + for (const [key, value] of Object.entries(output)) { + if (key.includes("fail_activity")) { + expect(value).toBe(1 + numFailures); + } else { + expect(value).toBe(1); + } + } + }, + 180_000, + ); + + it("only rewinds failed orchestrations (Node returns 400 otherwise)", async () => { + // Completed orchestration โ€” rewind should fail. + let response = await invokeHttpTrigger( + baseUrl, + "HttpStart_RewindOrchestration", + "?input=complete&numFailures=0&callEntities=false", + ); + expect(response.status).toBe(202); + let instanceId = parseInstanceId(response); + let statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + let rewindResponse = await invokeHttpTrigger(baseUrl, "RewindInstance", `?instanceId=${instanceId}`); + expect(rewindResponse.status).toBe(400); // Node: BadRequest + + // Running orchestration โ€” rewind should fail. + response = await invokeHttpTrigger( + baseUrl, + "HttpStart_RewindOrchestration", + "?input=run&numFailures=0&callEntities=false", + ); + expect(response.status).toBe(202); + instanceId = parseInstanceId(response); + statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + rewindResponse = await invokeHttpTrigger(baseUrl, "RewindInstance", `?instanceId=${instanceId}`); + expect(rewindResponse.status).toBe(400); // Node: BadRequest + + // Terminated orchestration โ€” rewind should fail. + const terminateResponse = await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`); + expect(terminateResponse.status).toBe(200); + await waitForOrchestrationState(statusQueryGetUri, "Terminated", 30); + rewindResponse = await invokeHttpTrigger(baseUrl, "RewindInstance", `?instanceId=${instanceId}`); + expect(rewindResponse.status).toBe(400); // Node: BadRequest + + // The C# test also rewinds a Pending (scheduled-start) instance, but scheduled + // starts are dotnet-isolated-only, so that branch is not ported for Node. + + // Non-existent instance โ€” rewind should fail. + rewindResponse = await invokeHttpTrigger(baseUrl, "RewindInstance", `?instanceId=${randomUUID()}`); + expect(rewindResponse.status).toBe(400); // Node: BadRequest + }, 180_000); +}); diff --git a/test/e2e-functions/suspend-resume.spec.ts b/test/e2e-functions/suspend-resume.spec.ts new file mode 100644 index 0000000..5dd41b7 --- /dev/null +++ b/test/e2e-functions/suspend-resume.spec.ts @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `SuspendResumeTests`. + * + * Suspends/resumes a running orchestration, and asserts the failure behavior for + * suspend-of-suspended, resume-of-running, and suspend/resume-of-completed. Node + * swallows suspend/resume of a terminal instance and returns success (200 empty), + * so that branch is asserted here. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + HttpResult, + NODE_LOCALIZED_STRINGS, + invokeHttpTrigger, + parseInstanceId, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] suspend-resume.spec skipped: ${preflight.reason}`); +} + +function assertRequestSucceeds(response: HttpResult): void { + expect(response.status).toBe(200); // HttpStatusCode.OK + expect(response.body).toBe(""); +} + +function assertRequestFails(response: HttpResult, expectedErrorMessage: string): void { + expect(response.status).toBe(400); // HttpStatusCode.BadRequest + expect(response.body.startsWith(expectedErrorMessage)).toBe(true); +} + +async function tryTerminate(instanceId: string): Promise { + try { + await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`); + } catch { + // best-effort cleanup + } +} + +async function startLongRunning(): Promise<{ instanceId: string; statusQueryGetUri: string }> { + const response = await invokeHttpTrigger(baseUrl, "StartOrchestration", "?orchestrationName=LongRunningOrchestrator"); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + return { + instanceId: parseInstanceId(response), + statusQueryGetUri: parseStatusQueryGetUri(response), + }; +} + +describeMaybe("Functions host E2E โ€” suspend/resume (AzureStorage)", () => { + it("suspends and resumes a running orchestration", async () => { + const { instanceId, statusQueryGetUri } = await startLongRunning(); + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + try { + assertRequestSucceeds(await invokeHttpTrigger(baseUrl, "SuspendInstance", `?instanceId=${instanceId}`)); + await waitForOrchestrationState(statusQueryGetUri, "Suspended", 30); + + assertRequestSucceeds(await invokeHttpTrigger(baseUrl, "ResumeInstance", `?instanceId=${instanceId}`)); + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + } finally { + await tryTerminate(instanceId); + } + }, 120_000); + + it("fails to suspend an already-suspended orchestration", async () => { + const { instanceId, statusQueryGetUri } = await startLongRunning(); + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + try { + assertRequestSucceeds(await invokeHttpTrigger(baseUrl, "SuspendInstance", `?instanceId=${instanceId}`)); + await waitForOrchestrationState(statusQueryGetUri, "Suspended", 30); + + const resuspend = await invokeHttpTrigger(baseUrl, "SuspendInstance", `?instanceId=${instanceId}`); + assertRequestFails(resuspend, NODE_LOCALIZED_STRINGS["SuspendSuspendedInstance.FailureMessage"]); + } finally { + await tryTerminate(instanceId); + } + }, 120_000); + + it("fails to resume a running orchestration", async () => { + const { instanceId, statusQueryGetUri } = await startLongRunning(); + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + try { + const resume = await invokeHttpTrigger(baseUrl, "ResumeInstance", `?instanceId=${instanceId}`); + assertRequestFails(resume, NODE_LOCALIZED_STRINGS["ResumeRunningInstance.FailureMessage"]); + } finally { + await tryTerminate(instanceId); + } + }, 120_000); + + it("swallows suspend/resume of a completed orchestration (Node behavior)", async () => { + const response = await invokeHttpTrigger(baseUrl, "StartOrchestration", "?orchestrationName=HelloCities"); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + try { + // Node swallows suspend/resume of a terminal instance and returns 200 empty. + assertRequestSucceeds(await invokeHttpTrigger(baseUrl, "SuspendInstance", `?instanceId=${instanceId}`)); + assertRequestSucceeds(await invokeHttpTrigger(baseUrl, "ResumeInstance", `?instanceId=${instanceId}`)); + } finally { + await tryTerminate(instanceId); + } + }, 120_000); +}); diff --git a/test/e2e-functions/terminate.spec.ts b/test/e2e-functions/terminate.spec.ts new file mode 100644 index 0000000..74b9ac5 --- /dev/null +++ b/test/e2e-functions/terminate.spec.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `TerminateOrchestratorTests`. + * + * Terminates a running orchestration, then exercises terminate-of-terminated, + * terminate-of-completed, and terminate-of-nonexistent. Node swallows terminate + * of a terminal instance and returns 200; a non-existent instance returns 400 + * with the localized not-found message. + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { randomUUID } from "crypto"; +import { + HttpResult, + formatLocalized, + invokeHttpTrigger, + parseInstanceId, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] terminate.spec skipped: ${preflight.reason}`); +} + +function assertTerminateSucceeds(response: HttpResult): void { + expect(response.status).toBe(200); // HttpStatusCode.OK + expect(response.body).toBe(""); +} + +describeMaybe("Functions host E2E โ€” terminate (AzureStorage)", () => { + it("terminates a running orchestration", async () => { + const response = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=LongRunningOrchestrator", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + + assertTerminateSucceeds(await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`)); + await waitForOrchestrationState(statusQueryGetUri, "Terminated", 30); + }, 120_000); + + // Scheduled orchestrations are not implemented in Node โ€” [Trait("Node","Skip")]. + it.skip("TerminateScheduledOrchestration (Node skip: scheduled starts not implemented)", () => { + // Not applicable to the Node SDK. + }); + + it("swallows terminate of an already-terminated orchestration (Node behavior)", async () => { + const response = await invokeHttpTrigger( + baseUrl, + "StartOrchestration", + "?orchestrationName=LongRunningOrchestrator", + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + + await waitForOrchestrationState(statusQueryGetUri, "Running", 30); + assertTerminateSucceeds(await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`)); + await waitForOrchestrationState(statusQueryGetUri, "Terminated", 30); + + const terminateAgain = await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`); + // Node swallows the failure and returns 200. + expect(terminateAgain.status).toBe(200); + // Node's localized message for this case is empty (Contains("") is trivially satisfied). + expect(terminateAgain.body).toContain(formatLocalized("TerminateTerminatedInstance.FailureMessage", instanceId)); + }, 120_000); + + it("swallows terminate of a completed orchestration (Node behavior)", async () => { + const response = await invokeHttpTrigger(baseUrl, "StartOrchestration", "?orchestrationName=HelloCities"); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + const instanceId = parseInstanceId(response); + const statusQueryGetUri = parseStatusQueryGetUri(response); + + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + const terminateResponse = await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`); + // Node swallows the failure and returns 200. + expect(terminateResponse.status).toBe(200); + expect(terminateResponse.body).toContain(formatLocalized("TerminateCompletedInstance.FailureMessage", instanceId)); + }, 120_000); + + it("fails to terminate a non-existent orchestration", async () => { + const instanceId = randomUUID(); + const terminateResponse = await invokeHttpTrigger(baseUrl, "TerminateInstance", `?instanceId=${instanceId}`); + expect(terminateResponse.status).toBe(400); // HttpStatusCode.BadRequest + expect(terminateResponse.body).toContain(formatLocalized("TerminateInvalidInstance.FailureMessage", instanceId)); + }, 120_000); +}); diff --git a/test/e2e-functions/test-app/package-lock.json b/test/e2e-functions/test-app/package-lock.json new file mode 100644 index 0000000..f919244 --- /dev/null +++ b/test/e2e-functions/test-app/package-lock.json @@ -0,0 +1,1048 @@ +{ + "name": "test-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-app", + "version": "1.0.0", + "dependencies": { + "@azure/functions": "^4.11.2", + "durable-functions": "^3.1.0" + }, + "devDependencies": { + "@types/node": "^20.x", + "rimraf": "^5.0.0", + "typescript": "^4.7.2" + } + }, + "node_modules/@azure/functions": { + "version": "4.16.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/functions/-/functions-4.16.2.tgz", + "integrity": "sha1-e/GOBuS1+Ss2i0I8xX/pu2WI788=", + "license": "MIT", + "dependencies": { + "@azure/functions-extensions-base": "0.3.0", + "cookie": "^0.7.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@azure/functions-extensions-base": { + "version": "0.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/functions-extensions-base/-/functions-extensions-base-0.3.0.tgz", + "integrity": "sha1-cGLy8GmLtkdwN4+eev+fd8Ukn04=", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha1-s3Znt7wYHBaHgiWbq0JHT79StVA=", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha1-wbA0beM2ulWvLVp5cIggN7rt7AU=", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-20.19.43.tgz", + "integrity": "sha1-/Oz1gLpCoNtVz0BMNyyXlzw3bJc=", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha1-Sf/1hXfP7j83F2/qtMIuAPhtf3c=", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha1-YCFu6kZNhkWXzigyAAc4oFiWUME=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/axios/-/axios-1.18.1.tgz", + "integrity": "sha1-1j+YY7zYk4gVyG+eKr04AYnZbf4=", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha1-C7oicf631Fiw0xrRNiWqpHVEMeI=", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha1-w9RaizT9cwYxoRCoolIGgrMdWn8=", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha1-VWNpxHKiupEPKXmJG1JrNDYjftc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-2.6.9.tgz", + "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/durable-functions": { + "version": "3.4.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/durable-functions/-/durable-functions-3.4.0.tgz", + "integrity": "sha1-hxLn7GSnc7wtG/9V4OFnnZI7puU=", + "license": "MIT", + "dependencies": { + "@azure/functions": "^4.14.0", + "@opentelemetry/api": "^1.9.0", + "axios": "^1.16.1", + "debug": "~2.6.9", + "lodash": "^4.18.1", + "moment": "^2.29.2", + "uuid": "^9.0.1", + "validator": "~13.15.20" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha1-aWzi7Aqg5uqTo5f/zySqeEDIJ8s=", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha1-hAyIA7DYBH9P8M+WMXazLU7z7XI=", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha1-8x274MGDsAptJutjJcgQwP0YvU0=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha1-KEdKFZ07nRHvYgUKFO1g5N9tYbw=", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha1-Mujp7Rtoo0l777msK2rfkqY4V28=", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha1-KOhk4beG2+u2jbH0UvljUnhmWCc=", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-10.5.0.tgz", + "integrity": "sha1-jsA1WRnNMzjChCiiPU8k7MX+c4w=", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha1-LNxC1AvvLltO6rfAGnPFTOerWrw=", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha1-xZ7yJKBP6LdU89sAY6Jeow0ABdY=", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha1-iDOp2Jq0rN5hiJQr0cU7Y5DtWoo=", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha1-/ytmwfYybVlRPeJAe/iBQ5gSdxw=", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha1-u6vNwChZ9JhzAchW4zh85exDv3A=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha1-OBqHG2KnNEUGYK497uRIE/cNlZo=", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha1-mwy5/LeAh/b9fqur4lEcTT1gV04=", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/moment/-/moment-2.30.1.tgz", + "integrity": "sha1-+MkcB7enhuMMWZJt9TC06slpdK4=", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha1-TxRxoBCCeob5TP2bByfjbSZ95QU=", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha1-eWCmaIiFlKByCxKpEdGnQqufEdI=", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha1-p0h1aK2tV3z6qn6IxJyrOrMIGro=", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha1-I7mEPT3JLbcfluGizpLjn9KoIhw=", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha1-lSGIwcvVRgcOLdIND0HArgUwywQ=", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha1-FPja7G2B5yIdKjV+Zoyrc728p5Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha1-CVl5+bzA0J2jJNWNA86Pg3TL5lo=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "6.23.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.23.0.tgz", + "integrity": "sha1-UpEUTImcnIaJaPuoD93ZxI3vdfk=", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha1-4YjUyIU8xyIiA5LEJM1jfzIpPzA=", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.22", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/validator/-/validator-13.15.22.tgz", + "integrity": "sha1-X4R89KeZEH5XFvyH5c8qM3px6xQ=", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", + "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha1-VtwiNo7lcPrOG0mBmXXZuaXq0hQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/test/e2e-functions/test-app/package.json b/test/e2e-functions/test-app/package.json index 68e05f3..e0b81c6 100644 --- a/test/e2e-functions/test-app/package.json +++ b/test/e2e-functions/test-app/package.json @@ -2,20 +2,27 @@ "name": "test-app", "version": "1.0.0", "description": "", + "comment_durable-functions": "This app is wired to the PUBLISHED durable-functions package so the suite is runnable without an in-repo build. To test against an in-repo build of the Azure Functions durable package instead, change the durable-functions dependency below to a file: reference (e.g. file:../../../packages/azure-functions-durable) once that package exists on your branch, then re-run npm install.", "scripts": { "build": "tsc", "watch": "tsc -w", - "prestart": "npm run build", + "clean": "rimraf dist", + "prestart": "npm run clean && npm run build", "start": "func start", "test": "echo \"No tests yet...\"" }, "dependencies": { - "@azure/functions": "^4.14.0", - "durable-functions": "file:../../../packages/azure-functions-durable" + "@azure/functions": "^4.11.2", + "durable-functions": "^3.1.0" }, "devDependencies": { - "@types/node": "^18.x", + "@types/node": "^20.x", + "rimraf": "^5.0.0", "typescript": "^4.7.2" }, - "main": "dist/src/functions/*.js" -} + "overrides": { + "validator": "13.15.22", + "undici-types": "6.23.0" + }, + "main": "dist/src/{index.js,functions/*.js}" +} \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/Shared/ExceptionTypes.ts b/test/e2e-functions/test-app/src/Shared/ExceptionTypes.ts new file mode 100644 index 0000000..e22ecf4 --- /dev/null +++ b/test/e2e-functions/test-app/src/Shared/ExceptionTypes.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +// Custom exception types +class ErrorWithCause extends Error { + constructor(message: string) { + super(message); + delete (this as any).message; // Ensure our getter overrides it + + this._message = message; // Store the original message + + // Fix the prototype chain (necessary when extending built-ins in TypeScript) + Object.setPrototypeOf(this, new.target.prototype); + } + + cause: Error | undefined; + _message: string; + + get message(): string { + let msg = `${this.name}: ${this._message}`; + if (this.cause) { + msg += `\nCaused by: ${this.cause.message}`; + } + return msg; + } +} + + +export class InvalidOperationException extends ErrorWithCause { + constructor(message: string) { + super(message); + this.name = "InvalidOperationException"; + } +} + +export class OverflowException extends ErrorWithCause { + constructor(message: string) { + super(message); + this.name = "OverflowException"; + } +} diff --git a/test/e2e-functions/test-app/src/functions/ActivityErrorHandling.ts b/test/e2e-functions/test-app/src/functions/ActivityErrorHandling.ts new file mode 100644 index 0000000..d277d61 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/ActivityErrorHandling.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler, RetryOptions } from 'durable-functions'; +import { InvalidOperationException, OverflowException } from '../Shared/ExceptionTypes'; + +const attemptCount: Record = {}; + +// Orchestration: RethrowActivityException +const RethrowActivityException: OrchestrationHandler = function* (context: OrchestrationContext) { + yield context.df.callActivity("raise_exception", context.df.instanceId); +}; +df.app.orchestration("RethrowActivityException", RethrowActivityException); + +// Orchestration: CatchActivityException +const CatchActivityException: OrchestrationHandler = function* (context: OrchestrationContext) { + try { + yield context.df.callActivity("raise_exception", context.df.instanceId); + } catch (e: any) { + context.error(`Caught exception: ${e}`); + return `Caught exception: ${e}`; + } +}; +df.app.orchestration("CatchActivityException", CatchActivityException); + +// Orchestration: RetryActivityFunction +const RetryActivityFunction: OrchestrationHandler = function* (context: OrchestrationContext) { + yield context.df.callActivityWithRetry("raise_exception", new RetryOptions(5000, 3), context.df.instanceId); + return "Success"; +}; +df.app.orchestration("RetryActivityFunction", RetryActivityFunction); + +// Orchestration: CustomRetryActivityFunction +const CustomRetryActivityFunction: OrchestrationHandler = function* (context: OrchestrationContext) { + yield context.df.callActivityWithRetry("raise_complex_exception", new RetryOptions(5000, 3), context.df.instanceId); + return "Success"; +}; +df.app.orchestration("CustomRetryActivityFunction", CustomRetryActivityFunction); + +// Activity: raise_exception +const RaiseExceptionActivity: ActivityHandler = (instance: string) => { + if (!(instance in attemptCount)) { + attemptCount[instance] = 1; + throw new InvalidOperationException("This activity failed"); + } + return "This activity succeeded"; +}; +df.app.activity("raise_exception", { handler: RaiseExceptionActivity }); + +// Activity: raise_complex_exception +const RaiseComplexExceptionActivity: ActivityHandler = (instance2: string) => { + if (!(instance2 in attemptCount)) { + attemptCount[instance2] = 1; + const overflow = new OverflowException("Inner exception message"); + const error = new InvalidOperationException("This activity failed\nMore information about the failure"); + error.cause = overflow; + throw error; + } + return "This activity succeeded"; +}; +df.app.activity("raise_complex_exception", { handler: RaiseComplexExceptionActivity }); diff --git a/test/e2e-functions/test-app/src/functions/ActivityInputType.ts b/test/e2e-functions/test-app/src/functions/ActivityInputType.ts new file mode 100644 index 0000000..15d92bc --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/ActivityInputType.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import * as df from 'durable-functions'; +import { OrchestrationHandler, OrchestrationContext, ActivityHandler } from "durable-functions"; + +// Helper for durations +function parseDuration(duration: string): number { + // Parses "HH:MM:SS" or "0HH:MM:SS" to milliseconds + const match = duration.match(/(\d+):(\d+):(\d+)/); + if (!match) return 0; + const [, h, m, s] = match.map(Number); + return ((h || 0) * 3600 + (m || 0) * 60 + (s || 0)) * 1000; +} + +class MyCustomClass { + name?: string; + age: number; + data: number[]; + duration: number; // milliseconds + + constructor(name: string | undefined, age: number, data: number[], duration: number) { + this.name = name; + this.age = age; + this.data = data; + this.duration = duration; + } + + toString(): string { + // Leading 0 before duration to match expected output + const durationStr = `${new Date(this.duration).toISOString().substring(11, 19)}`; + return `{Name: ${this.name}, Age: ${this.age}, Duration: ${durationStr}, Data: [${Array.from(this.data).join(", ")}]}`; + } + + // toJSON(): object { + // return { + // Name: this.name, + // Age: this.age, + // Data: Array.from(this.data), + // Duration: `0${new Date(this.duration).toISOString().substr(11, 8)}` + // }; + // } + + static fromJSON(data: any): MyCustomClass { + return new MyCustomClass( + data.name, + data.age, + Array.from(data.data), + data.duration + ); + } +} + +// Orchestrator +const ActivityInputTypeOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + const output: string[] = []; + + // Test byte array input + const byte_array_input = [1, 2, 3, 4, 5]; + const r_1 = yield context.df.callActivity("byte_array_input", byte_array_input); + output.push(r_1); + + // Test empty byte array input + const empty_byte_array: number[] = []; + const r_2 = yield context.df.callActivity("byte_array_input", empty_byte_array); + output.push(r_2); + + // Test single byte input + const single_byte_input = 42; + const r_3 = yield context.df.callActivity("single_byte_input", single_byte_input); + output.push(r_3); + + // Test custom class input + const custom_class_input = new MyCustomClass("Test", 25, [1, 2, 3], 3600000); // 1 hour in ms + const r_4 = yield context.df.callActivity("custom_class_input", custom_class_input); + output.push(r_4); + + // Test int array input + const int_array_input = [1, 2, 3, 4, 5]; + const r_5 = yield context.df.callActivity("int_array_input", int_array_input); + output.push(r_5); + + // Test string input + const string_input = "Test string input"; + const r_6 = yield context.df.callActivity("string_input", string_input); + output.push(r_6); + + // Test array of custom class input + const complex_input = [ + new MyCustomClass("Test1", 25, [1, 2, 3], 30 * 60 * 1000), // 30 min + new MyCustomClass("Test2", 30, [], 45 * 60 * 1000) // 45 min + ]; + const r_7 = yield context.df.callActivity("custom_class_array_input", complex_input); + output.push(r_7); + + return output; +}; +df.app.orchestration("ActivityInputTypeOrchestrator", ActivityInputTypeOrchestrator); + +// Activities + +const byte_array_input: ActivityHandler = (input: Uint8Array): string => { + if (!Array.isArray(input) || !input.every(x => typeof x === "number")) { + return `Error: Expected byte[] but got ${typeof input} ${input}`; + } + return `Received byte[]: [${Array.from(new Uint8Array(input)).join(", ")}]`; +}; +df.app.activity("byte_array_input", { handler: byte_array_input }); + +const single_byte_input: ActivityHandler = (input: number): string => { + if (typeof input !== "number") { + return `Error: Expected byte but got ${typeof input}`; + } + return `Received byte: ${input}`; +}; +df.app.activity("single_byte_input", { handler: single_byte_input }); + +const custom_class_input: ActivityHandler = (input: MyCustomClass): string => { + const obj = MyCustomClass.fromJSON(input); + const data = obj.data; + if (!(Array.isArray(data) && data.every(x => typeof x === "number"))) { + return `Error: Expected Data to be byte[] but got ${typeof data}`; + } + return `Received CustomClass: ${obj.toString()}`; +}; +df.app.activity("custom_class_input", { handler: custom_class_input }); + +const int_array_input: ActivityHandler = (input: number[]): string => { + if (!Array.isArray(input) || !input.every(x => typeof x === "number")) { + return `Error: Expected int[] but got ${typeof input}`; + } + return `Received int[]: [${input.join(", ")}]`; +}; +df.app.activity("int_array_input", { handler: int_array_input }); + +const string_input: ActivityHandler = (input: string): string => { + if (typeof input !== "string") { + return `Error: Expected string but got ${typeof input}`; + } + return `Received string: ${input}`; +}; +df.app.activity("string_input", { handler: string_input }); + +const custom_class_array_input: ActivityHandler = (input: any): string => { + let parsedInput: MyCustomClass[] = []; + if (!Array.isArray(input)) { + return `Error: Expected MyCustomClass[] but got ${typeof input}`; + } + for (const item of input) { + if (!(item instanceof MyCustomClass) && + !(item && typeof item === "object" && "name" in item && "age" in item && "data" in item && "duration" in item)) { + return `Error: Expected MyCustomClass but got ${typeof item}`; + } + parsedInput.push(item instanceof MyCustomClass ? item : MyCustomClass.fromJSON(item)); + } + return `Received CustomClass[]: [${Array.from(parsedInput).map(x => x.toString()).join(", ")}]`; +}; +df.app.activity("custom_class_array_input", { handler: custom_class_array_input }); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/ClassBasedEntities.ts b/test/e2e-functions/test-app/src/functions/ClassBasedEntities.ts new file mode 100644 index 0000000..c0aa827 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/ClassBasedEntities.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import * as df from "durable-functions"; +import { OrchestrationContext, OrchestrationHandler, EntityContext, EntityHandler } from "durable-functions"; + +// Orchestration +const ClassBasedEntityOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const entityId = new df.EntityId("TestEntity", "singleton"); + yield context.df.callEntity(entityId, "SetState", 42); + const result = yield context.df.callEntity(entityId, "GetState"); + return result; +}; +df.app.orchestration("ClassBasedEntityOrchestration", ClassBasedEntityOrchestration); + +// Entity +const TestEntity: EntityHandler<{state: string}> = (context: EntityContext<{state: string}>) => { + if (context.df.operationName === "SetState") { + const currentState = context.df.getState(() => ({ state: "" })); + currentState.state = `IConfiguration: yes, MyInjectedService: yes, BlobContainerClient: yes, Number: ${context.df.getInput()}`; + context.df.setState(currentState); + } else if (context.df.operationName === "GetState") { + const currentState = context.df.getState(); + if (!currentState) { + throw new Error("State not set"); + } + context.df.return(currentState.state); + } +}; +df.app.entity("TestEntity", TestEntity); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/EntityErrorHandling.ts b/test/e2e-functions/test-app/src/functions/EntityErrorHandling.ts new file mode 100644 index 0000000..cf97751 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/EntityErrorHandling.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import * as df from "durable-functions"; +import { OrchestrationContext, OrchestrationHandler, EntityContext, EntityHandler } from "durable-functions"; +import { InvalidOperationException, OverflowException } from "../Shared/ExceptionTypes"; + +// In-memory attempt count (not durable, but matches Python's global dict for test purposes) +const attemptCount: Record = {}; + +// Orchestration: ThrowEntityOrchestration +const ThrowEntityOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const entityId = new df.EntityId("Counter", "myCounter"); + yield context.df.callEntity(entityId, "get", context.df.instanceId); + return "Success"; +}; +df.app.orchestration("ThrowEntityOrchestration", ThrowEntityOrchestration); + +// Orchestration: CatchEntityOrchestration +const CatchEntityOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const entityId = new df.EntityId("Counter", "myCounter"); + try { + yield context.df.callEntity(entityId, "get", context.df.instanceId); + return "Success"; + } catch (e: any) { + return String(e); + } +}; +df.app.orchestration("CatchEntityOrchestration", CatchEntityOrchestration); + +// Orchestration: RetryEntityOrchestration +const RetryEntityOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const entityId = new df.EntityId("Counter", "myCounter"); + try { + yield context.df.callEntity(entityId, "get", context.df.instanceId); + return "Success"; + } catch (e) { + yield context.df.callEntity(entityId, "get", context.df.instanceId); + return "Success"; + } +}; +df.app.orchestration("RetryEntityOrchestration", RetryEntityOrchestration); + +// Entity: Counter +const Counter: EntityHandler = (context: EntityContext) => { + const instanceId = context.df.getInput(); + if (!instanceId) { + throw new Error("Did not get a valid instanceId as input to the entity"); + } + if (!(instanceId in attemptCount)) { + attemptCount[instanceId] = 1; + const inner = new OverflowException("Inner exception message"); + const err = new InvalidOperationException("This entity failed\r\nMore information about the failure"); + err.cause = inner; + throw err; + } + attemptCount[instanceId] += 1; + context.df.return(0); +}; +df.app.entity("Counter", Counter); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/ExternalEventOrchestration.ts b/test/e2e-functions/test-app/src/functions/ExternalEventOrchestration.ts new file mode 100644 index 0000000..c0a7e7f --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/ExternalEventOrchestration.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import * as df from "durable-functions"; +import { app, HttpHandler, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { OrchestrationContext, OrchestrationHandler } from "durable-functions"; + +// Orchestration +const ExternalEventOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext): Generator { + yield context.df.waitForExternalEvent("Approval"); + return "Orchestrator Finished!"; +}; +df.app.orchestration("ExternalEventOrchestrator", ExternalEventOrchestrator); + +// HTTP Trigger to send external event +const SendExternalEvent_HttpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + try { + let body = await request.json().catch(() => ({})); + let instanceId = body.toString() || request.query.get("instanceId") || request.params["instanceId"]; + if (typeof instanceId === "object" && instanceId !== null) { + instanceId = instanceId; + } + await client.raiseEvent(instanceId, "Approval", true); + return { + status: 200, + body: `External event sent to ${instanceId}.` + }; + } catch (ex: any) { + return { + status: 400, + body: `${ex.constructor.name}: ${ex.message}` + }; + } +}; +app.http("SendExternalEvent_HttpStart", { + route: "SendExternalEvent_HttpStart", + extraInputs: [df.input.durableClient()], + methods: ["GET", "POST"], + handler: SendExternalEvent_HttpStart +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/HelloCities.ts b/test/e2e-functions/test-app/src/functions/HelloCities.ts new file mode 100644 index 0000000..ce52cd1 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/HelloCities.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +const activityName = 'HelloCitiesActivity'; + +const HelloCities: OrchestrationHandler = function* (context: OrchestrationContext) { + const scheduled_start_time = context.df.getInput(); + if (scheduled_start_time) { + let scheduled_start_time_date = new Date(scheduled_start_time); + yield context.df.createTimer(scheduled_start_time_date); + } + + const outputs = []; + outputs.push(yield context.df.callActivity(activityName, 'Tokyo')); + outputs.push(yield context.df.callActivity(activityName, 'Seattle')); + outputs.push(yield context.df.callActivity(activityName, 'London')); + + return outputs; +}; +df.app.orchestration('HelloCities', HelloCities); + +const HelloCitiesActivity: ActivityHandler = (input: string): string => { + return `Hello ${input}!`; +}; +df.app.activity(activityName, { handler: HelloCitiesActivity }); + + +const HelloCitiesHttpStartScheduled: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + const body: unknown = await request.text(); + + const instanceId: string = await client.startNew("HelloCities", { input: request.params.ScheduledStartTime }); + + context.log(`Started orchestration with ID = '${instanceId}'.`); + + return client.createCheckStatusResponse(request, instanceId); +}; + +app.http('HelloCities_HttpStart_Scheduled', { + route: 'HelloCities_HttpStart_Scheduled', + extraInputs: [df.input.durableClient()], + handler: HelloCitiesHttpStartScheduled, +}); + + +const StartOrchestration: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + + const instanceId = await client.startNew(request.params.orchestrationName, { instanceId: request.params.instanceId }); + + context.log(`Started orchestration with ID = '${instanceId}'.`); + + return client.createCheckStatusResponse(request, instanceId); +}; + +app.http('StartOrchestration', { + route: 'StartOrchestration', + extraInputs: [df.input.durableClient()], + handler: StartOrchestration, +}); diff --git a/test/e2e-functions/test-app/src/functions/IsReplayingChecks.ts b/test/e2e-functions/test-app/src/functions/IsReplayingChecks.ts new file mode 100644 index 0000000..fa3a2f4 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/IsReplayingChecks.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +// --------------------------------------------------------------------------- +// Activity used by the is_replaying orchestrators +// --------------------------------------------------------------------------- + +const IsReplayingEcho: ActivityHandler = (input: string): string => { + return input; +}; +df.app.activity('IsReplayingEcho', { handler: IsReplayingEcho }); + +// --------------------------------------------------------------------------- +// 1. IsReplayingBasic +// --------------------------------------------------------------------------- + +const IsReplayingBasic: OrchestrationHandler = function* (context: OrchestrationContext) { + // Workaround: context.df.isReplaying is undefined before the first yield. + // See https://github.com/Azure/azure-functions-durable-js/issues/564 + const before: boolean = context.df.isReplaying ?? true; + const result: string = yield context.df.callActivity('IsReplayingEcho', 'hello'); + const after: boolean = context.df.isReplaying; + return { + before_activity: before, + after_activity: after, + activity_result: result, + }; +}; +df.app.orchestration('IsReplayingBasic', IsReplayingBasic); + +// --------------------------------------------------------------------------- +// 2. IsReplayingMultiActivity +// --------------------------------------------------------------------------- + +const IsReplayingMultiActivity: OrchestrationHandler = function* (context: OrchestrationContext) { + // Workaround: context.df.isReplaying is undefined before the first yield. + // See https://github.com/Azure/azure-functions-durable-js/issues/564 + const snapshots: object[] = []; + + snapshots.push({ step: 0, label: 'start', is_replaying: context.df.isReplaying ?? true }); + + const r1: string = yield context.df.callActivity('IsReplayingEcho', 'one'); + snapshots.push({ step: 1, label: 'after_first', is_replaying: context.df.isReplaying }); + + const r2: string = yield context.df.callActivity('IsReplayingEcho', 'two'); + snapshots.push({ step: 2, label: 'after_second', is_replaying: context.df.isReplaying }); + + const r3: string = yield context.df.callActivity('IsReplayingEcho', 'three'); + snapshots.push({ step: 3, label: 'after_third', is_replaying: context.df.isReplaying }); + + return { + snapshots, + activities: [r1, r2, r3], + }; +}; +df.app.orchestration('IsReplayingMultiActivity', IsReplayingMultiActivity); + +// --------------------------------------------------------------------------- +// 3. IsReplayingConditionalLog +// --------------------------------------------------------------------------- + +const IsReplayingConditionalLog: OrchestrationHandler = function* (context: OrchestrationContext) { + // Workaround: context.df.isReplaying is undefined before the first yield. + // See https://github.com/Azure/azure-functions-durable-js/issues/564 + let liveLogCount = 0; + + if (!(context.df.isReplaying ?? true)) { + console.log('IsReplayingConditionalLog: LIVE before activity'); + liveLogCount++; + } else { + console.log('IsReplayingConditionalLog: REPLAY before activity'); + } + + const result: string = yield context.df.callActivity('IsReplayingEcho', 'logged'); + + if (!context.df.isReplaying) { + console.log('IsReplayingConditionalLog: LIVE after activity'); + liveLogCount++; + } else { + console.log('IsReplayingConditionalLog: REPLAY after activity'); + } + + return { + live_log_count: liveLogCount, + activity_result: result, + }; +}; +df.app.orchestration('IsReplayingConditionalLog', IsReplayingConditionalLog); + +// --------------------------------------------------------------------------- +// 4. IsReplayingCounter +// --------------------------------------------------------------------------- + +const IsReplayingCounter: OrchestrationHandler = function* (context: OrchestrationContext) { + // Workaround: context.df.isReplaying is undefined before the first yield. + // See https://github.com/Azure/azure-functions-durable-js/issues/564 + let nonReplayCount = 0; + let replayCount = 0; + + if (context.df.isReplaying ?? true) { replayCount++; } else { nonReplayCount++; } + + const r1: string = yield context.df.callActivity('IsReplayingEcho', 'a'); + if (context.df.isReplaying) { replayCount++; } else { nonReplayCount++; } + + const r2: string = yield context.df.callActivity('IsReplayingEcho', 'b'); + if (context.df.isReplaying) { replayCount++; } else { nonReplayCount++; } + + const r3: string = yield context.df.callActivity('IsReplayingEcho', 'c'); + if (context.df.isReplaying) { replayCount++; } else { nonReplayCount++; } + + return { + non_replay_count: nonReplayCount, + replay_count: replayCount, + total_checkpoints: nonReplayCount + replayCount, + activities: [r1, r2, r3], + }; +}; +df.app.orchestration('IsReplayingCounter', IsReplayingCounter); + +// --------------------------------------------------------------------------- +// 5. IsReplayingFanOutFanIn +// --------------------------------------------------------------------------- + +const IsReplayingFanOutFanIn: OrchestrationHandler = function* (context: OrchestrationContext) { + // Workaround: context.df.isReplaying is undefined before the first yield. + // See https://github.com/Azure/azure-functions-durable-js/issues/564 + const before: boolean = context.df.isReplaying ?? true; + + const tasks = [ + context.df.callActivity('IsReplayingEcho', 'alpha'), + context.df.callActivity('IsReplayingEcho', 'beta'), + context.df.callActivity('IsReplayingEcho', 'gamma'), + ]; + const results: string[] = yield context.df.Task.all(tasks); + + const after: boolean = context.df.isReplaying; + + return { + before_fan_out: before, + after_fan_in: after, + activities: results, + }; +}; +df.app.orchestration('IsReplayingFanOutFanIn', IsReplayingFanOutFanIn); diff --git a/test/e2e-functions/test-app/src/functions/LargeOutputOrchestrator.ts b/test/e2e-functions/test-app/src/functions/LargeOutputOrchestrator.ts new file mode 100644 index 0000000..ffd0681 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/LargeOutputOrchestrator.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponse, HttpResponseInit, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +// Helper function +function generateLargeString(sizeInKB: number): string { + return 'A'.repeat(sizeInKB * 1024); +} + +// Orchestration +const LargeOutputOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + const sizeInKB = context.df.getInput(); + if (sizeInKB == null || sizeInKB <= 0) { + throw new Error('sizeInKB must be a positive integer.'); + } + context.log('Saying hello.'); + const outputs: any[] = []; + const r_1 = yield context.df.callActivity('large_output_say_hello', 'Tokyo'); + outputs.push(r_1); + outputs.push(generateLargeString(sizeInKB)); + return outputs; +}; +df.app.orchestration('LargeOutputOrchestrator', LargeOutputOrchestrator); + +// Activity +const large_output_say_hello: ActivityHandler = (name: string): string => { + return `Hello ${name}!`; +}; +df.app.activity('large_output_say_hello', { handler: large_output_say_hello }); + +// HTTP starter +const LargeOutputOrchestrator_HttpStart: HttpHandler = async (req: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + let sizeInKB: number = 0; + try { + const body = await req.json(); + sizeInKB = parseInt(body as any, 10); + } catch { + sizeInKB = parseInt(req.query.get('sizeInKB') ?? '0', 10); + } + const instanceId = await client.startNew('LargeOutputOrchestrator', {input: sizeInKB}); + context.log(`Started orchestration with ID = '${instanceId}'.`); + return client.createCheckStatusResponse(req, instanceId); +}; + +app.http('LargeOutputOrchestrator_HttpStart', { + route: 'LargeOutputOrchestrator_HttpStart', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: LargeOutputOrchestrator_HttpStart, +}); + +// HTTP query output +const LargeOutputOrchestrator_Query_Output: HttpHandler = async (req: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + const id = req.params["id"] ?? req.query.get('id'); + const metadata = await client.getStatus(id, { showInput: true }); + if (!metadata) { + return { status: 404, + body: 'Orchestration metadata not found.' }; + } + const output = metadata.output; + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(output) + }; +}; + +app.http('LargeOutputOrchestrator_Query_Output', { + route: 'LargeOutputOrchestrator_Query_Output', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: LargeOutputOrchestrator_Query_Output, +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/OrchestrationQuery.ts b/test/e2e-functions/test-app/src/functions/OrchestrationQuery.ts new file mode 100644 index 0000000..98d4c3c --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/OrchestrationQuery.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; + +// GetAllInstances +const GetAllInstances: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + try { + const instances = await client.getStatusAll(); + // This would not be necessary if we implemented toJSON for DurableOrchestrationStatus + const result = JSON.stringify(instances); + context.log(result); + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: result + }; + } catch (ex: any) { + context.log(`Error: ${ex}`); + return{ + status: 400, + headers: { 'Content-Type': 'text/plain' }, + body: String(ex) + }; + } +}; + +app.http('GetAllInstances', { + route: 'GetAllInstances', + extraInputs: [df.input.durableClient()], + handler: GetAllInstances, +}); + +// GetRunningInstances +const GetRunningInstances: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + try { + const filterStatuses = [ + df.OrchestrationRuntimeStatus.Running, + df.OrchestrationRuntimeStatus.Pending, + df.OrchestrationRuntimeStatus.ContinuedAsNew + ]; + const instances = await client.getStatusBy({ runtimeStatus: filterStatuses }); + const result = JSON.stringify(instances); + context.log(result); + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: result + }; + } catch (ex: any) { + context.log(`Error: ${ex}`); + return { + status: 400, + headers: { 'Content-Type': 'text/plain' }, + body: String(ex) + }; + } +}; + +app.http('GetRunningInstances', { + route: 'GetRunningInstances', + extraInputs: [df.input.durableClient()], + handler: GetRunningInstances, +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/PurgeOrchestrationHistory.ts b/test/e2e-functions/test-app/src/functions/PurgeOrchestrationHistory.ts new file mode 100644 index 0000000..ed52670 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/PurgeOrchestrationHistory.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import { OrchestrationContext, OrchestrationHandler, EntityContext, EntityHandler } from "durable-functions"; +import * as df from 'durable-functions'; + +// HTTP handler for PurgeOrchestrationHistory +const PurgeOrchestrationHistory: HttpHandler = async (req: HttpRequest, context: InvocationContext): Promise => { + context.log('Starting to purge instance histories'); + try { + const client = df.getClient(context); + let result: df.PurgeHistoryResult; + + const instanceId = req.query.get('instanceId'); + if (instanceId) + { + result = await client.purgeInstanceHistory(instanceId); + context.log(`Finished purging history for instance ${instanceId}`); + } + else + { + // Parse optional query parameters for purgeStartTime and purgeEndTime + let purgeStartTime: Date | undefined = undefined; + let purgeEndTime: Date | undefined = undefined; + + const purgeStartTimeParam = req.query.get('purgeStartTime'); + const purgeEndTimeParam = req.query.get('purgeEndTime'); + + if (purgeStartTimeParam) { + purgeStartTime = new Date(purgeStartTimeParam); + } + if (purgeEndTimeParam) { + purgeEndTime = new Date(purgeEndTimeParam); + } + + // Purge orchestration history + result = await client.purgeInstanceHistoryBy({ + createdTimeFrom: purgeStartTime, + createdTimeTo: purgeEndTime, + runtimeStatus: [ + df.OrchestrationRuntimeStatus.Completed, + df.OrchestrationRuntimeStatus.Failed, + df.OrchestrationRuntimeStatus.Terminated + ] + }); + + context.log('Finished purge all instance history'); + } + return { + status: 200, + body: `Purged ${result.instancesDeleted} records`, + headers: { 'Content-Type': 'text/plain' } + }; + } catch (ex: any) { + context.error('Failed to purge all instance history', ex); + return { + status: 500, + body: `Failed to purge all instance history: ${ex?.message ?? ex}`, + headers: { 'Content-Type': 'text/plain' } + }; + } +}; + +const InvokeDummyEntityOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const entityId = new df.EntityId("DummyEntity", "myEntity"); + yield context.df.callEntity(entityId, "get"); + return "Success"; +}; +df.app.orchestration("InvokeDummyEntityOrchestration", InvokeDummyEntityOrchestration); + +const DummyEntity: EntityHandler = (context: EntityContext) => { + context.df.setState("state"); + context.df.return(0); +}; +df.app.entity("DummyEntity", DummyEntity); + +app.http('PurgeOrchestrationHistory', { + route: 'PurgeOrchestrationHistory', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: PurgeOrchestrationHistory, +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/RewindOrchestration.ts b/test/e2e-functions/test-app/src/functions/RewindOrchestration.ts new file mode 100644 index 0000000..e978f34 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/RewindOrchestration.ts @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler, EntityContext, EntityHandler } from 'durable-functions'; + +const invocationCounts: Map = new Map(); +const entityId = new df.EntityId('InvocationCounterEntity', 'entity'); + +// Orchestration +const RewindParentOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const input = context.df.getInput(); + + if (input.name == 'run') + { + // Create a timer for 10 minutes + yield context.df.createTimer(new Date(Date.now() + 60 * 10 * 1000)); + } + else if (input.name == 'complete') + { + return {}; + } + else if (input.name == 'fail') + { + const subOrchestrationTasks = [ + context.df.callSubOrchestrator('SucceedSubOrchestration', 'succeed_sub_1'), + context.df.callSubOrchestrator( + 'FailParentSubOrchestration', + new OrchestrationInput('fail_parent_sub_1', input.numFailures, input.callEntities)), + context.df.callSubOrchestrator( + 'FailParentSubOrchestration', + new OrchestrationInput('fail_parent_sub_2', input.numFailures, input.callEntities)), + context.df.callSubOrchestrator('SucceedSubOrchestration', 'succeed_sub_2') + ]; + + yield context.df.Task.all(subOrchestrationTasks); + return invocationCounts; + } + else + { + throw new Error('Invalid input'); + } +}; +df.app.orchestration('RewindParentOrchestration', RewindParentOrchestration); + +// Suborchestrations +const FailParentSubOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const input = context.df.getInput(); + yield context.df.callActivity('SucceedActivity', input.name + '_succeed_activity'); + if (input.callEntities) + { + yield context.df.callEntity(entityId, input.name + '_call_entity'); + } + yield context.df.callSubOrchestrator( + 'FailChildSubOrchestration', + new OrchestrationInput(input.name + '_child', input.numFailures, input.callEntities)); +} +df.app.orchestration('FailParentSubOrchestration', FailParentSubOrchestration); + +const FailChildSubOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + const input = context.df.getInput(); + + if (input.callEntities) + { + context.df.signalEntity(entityId, input.name + '_signal_entity'); + } + + const activityAndEntityTasks = [ + context.df.callActivity('SucceedActivity', input.name + '_succeed_activity'), + context.df.callActivity('FailActivity', new OrchestrationInput(input.name + '_fail_activity_1', input.numFailures, input.callEntities)), + context.df.callActivity('FailActivity', new OrchestrationInput(input.name + '_fail_activity_2', input.numFailures, input.callEntities)), + ]; + + if (input.callEntities) + { + activityAndEntityTasks.push(context.df.callEntity(entityId, input.name + '_call_entity')); + } + + yield context.df.Task.all(activityAndEntityTasks); +} +df.app.orchestration('FailChildSubOrchestration', FailChildSubOrchestration); + +const SucceedSubOrchestration: OrchestrationHandler = function* (context: OrchestrationContext) { + yield context.df.callActivity('SucceedActivity', context.df.getInput() + '_succeed_activity'); +} +df.app.orchestration('SucceedSubOrchestration', SucceedSubOrchestration); + +// Activities +const SucceedActivity: ActivityHandler = (input: string): string => { + UpdateInvocationCount(input); + return 'Hello ' + input; +}; +df.app.activity('SucceedActivity', { handler: SucceedActivity }); + +const FailActivity: ActivityHandler = (input: OrchestrationInput): string => { + if (UpdateInvocationCount(input.name) <= input.numFailures) + { + throw new Error('Failure!'); + } + return 'Success!'; +}; +df.app.activity('FailActivity', { handler: FailActivity }); + +// HTTP Rewind Instance +const RewindInstance: HttpHandler = async (req: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + let body = await req.json().catch(() => ({})); + let instanceId = await body['instanceId'] || req.query.get('instanceId') || req.params['instanceId']; + const reason = 'Rewinding the instance for testing.'; + try { + await client.rewind(instanceId, reason); + return new HttpResponse({ status: 200 }); + } catch (ex: any) { + return new HttpResponse({ body: String(ex), status: 400, headers: { 'Content-Type': 'text/plain' } }); + } +}; + +app.http('RewindInstance', { + route: 'RewindInstance', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: RewindInstance, +}); + +const HttpStart_RewindOrchestration: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + invocationCounts.clear(); + + const instanceId: string = await client.startNew( + 'RewindParentOrchestration', + { input: new OrchestrationInput( + request.params.input, + Number.parseInt(request.params.numFailures), + request.params.callEntities == 'true' + )}); + + context.log(`Started orchestration with ID = '${instanceId}'.`); + + return client.createCheckStatusResponse(request, instanceId); +}; + +app.http('HttpStart_RewindOrchestration', { + route: 'HttpStart_RewindOrchestration', + extraInputs: [df.input.durableClient()], + handler: HttpStart_RewindOrchestration, +}); + +function UpdateInvocationCount(key) { + let invocationCount = invocationCounts.get(key) || 0; // get existing count or 0 + invocationCounts.set(key, ++invocationCount); + return invocationCount; +} + +// Entity +const InvocationCounterEntity: EntityHandler<{state: string}> = (context: EntityContext<{state: string}>) => { + UpdateInvocationCount(context.df.operationName); +}; +df.app.entity('InvocationCounterEntity', InvocationCounterEntity); + +class OrchestrationInput +{ + name: string; + numFailures: number; + callEntities: boolean; + + constructor(name: string, numFailures: number, callEntities: boolean) + { + this.name = name; + this.numFailures = numFailures; + this.callEntities = callEntities; + } +} \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/SuspendResumeOrchestration.ts b/test/e2e-functions/test-app/src/functions/SuspendResumeOrchestration.ts new file mode 100644 index 0000000..8fa31c5 --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/SuspendResumeOrchestration.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { DurableClient } from 'durable-functions'; + +// SuspendInstance HTTP trigger +const SuspendInstance: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client: DurableClient = df.getClient(context); + const instanceId = request.params.instanceId; + const suspendReason = "Suspending the instance for test."; + try { + // Reason for the cast - Bug: https://github.com/Azure/azure-functions-durable-js/issues/608 + await (client as any).suspend(instanceId, suspendReason); + return { status: 200 }; + } catch (ex: any) { + context.log(`Error suspending instance: ${ex}`); + return { + status: 400, + body: String(ex), + headers: { "Content-Type": "text/plain" } + }; + } +}; + +app.http('SuspendInstance', { + route: 'SuspendInstance', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: SuspendInstance, +}); + +// ResumeInstance HTTP trigger +const ResumeInstance: HttpHandler = async (request: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + const instanceId = request.params.instanceId; + const resumeReason = "Resuming the instance for test."; + try { + // Reason for the cast - Bug: https://github.com/Azure/azure-functions-durable-js/issues/608 + await (client as any).resume(instanceId, resumeReason); + return { status: 200 }; + } catch (ex: any) { + context.log(`Error resuming instance: ${ex}`); + return { + status: 400, + body: String(ex), + headers: { "Content-Type": "text/plain" } + }; + } +}; + +app.http('ResumeInstance', { + route: 'ResumeInstance', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: ResumeInstance, +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/TerminateOrchestration.ts b/test/e2e-functions/test-app/src/functions/TerminateOrchestration.ts new file mode 100644 index 0000000..0e49f5d --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/TerminateOrchestration.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +// Orchestration +const LongRunningOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + context.log('Starting long-running orchestration.'); + const outputs: string[] = []; + for (let i = 0; i < 100000; i++) { + const res: string = yield context.df.callActivity('simulated_work_activity', 100); + outputs.push(res); + } + return outputs; +}; +df.app.orchestration('LongRunningOrchestrator', LongRunningOrchestrator); + +// Activity +const simulated_work_activity: ActivityHandler = (sleepms: number): string => { + console.log(`Sleeping for ${sleepms}ms.`); + // Simulate sleep (busy wait, not recommended for production) + const start = Date.now(); + while (Date.now() - start < sleepms) { /* busy wait */ } + return `Slept for ${sleepms}ms.`; +}; +df.app.activity('simulated_work_activity', { handler: simulated_work_activity }); + +// HTTP Terminate Instance +const TerminateInstance: HttpHandler = async (req: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + let body = await req.json().catch(() => ({})); + let instanceId = await body["instanceId"] || req.query.get("instanceId") || req.params["instanceId"]; + const reason = 'Long-running orchestration was terminated early.'; + try { + await client.terminate(instanceId, reason); + return new HttpResponse({ status: 200 }); + } catch (ex: any) { + return new HttpResponse({ body: String(ex), status: 400, headers: { 'Content-Type': 'text/plain' } }); + } +}; + +app.http('TerminateInstance', { + route: 'TerminateInstance', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: TerminateInstance, +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/TimeoutOrchestration.ts b/test/e2e-functions/test-app/src/functions/TimeoutOrchestration.ts new file mode 100644 index 0000000..8d82cac --- /dev/null +++ b/test/e2e-functions/test-app/src/functions/TimeoutOrchestration.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app, HttpHandler, HttpRequest, HttpResponse, HttpResponseInit, InvocationContext } from '@azure/functions'; +import * as df from 'durable-functions'; +import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from 'durable-functions'; + +// Orchestration +const TimeoutOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { + const timeoutSeconds = context.df.getInput(); + if (!timeoutSeconds || typeof timeoutSeconds !== 'number') { + throw new Error("Timeout value is required for this orchestration."); + } + const timeout = timeoutSeconds * 1000; + const deadline = context.df.currentUtcDateTime.getTime() + timeout; + + const activityTask = context.df.callActivity('long_activity', context.df.instanceId); + const timeoutTask = context.df.createTimer(new Date(deadline)); + + const winner = yield context.df.Task.any([activityTask, timeoutTask]); + if (winner === activityTask) { + timeoutTask.cancel(); + return activityTask.result; + } else { + return "The activity function timed out"; + } +}; +df.app.orchestration('TimeoutOrchestrator', TimeoutOrchestrator); + +// Activity +const long_activity: ActivityHandler = async (instanceid: string): Promise => { + // The duration of 5 seconds for this activity was chosen because + // it is long enough to demonstrate both the activity timeout and the + // activity success case in the tests for activity timeout. + await new Promise(resolve => setTimeout(resolve, 5000)); + return "The activity function completed successfully"; +}; +df.app.activity('long_activity', { handler: long_activity }); + +// HTTP starter +const timer_http_start: HttpHandler = async (req: HttpRequest, context: InvocationContext): Promise => { + const client = df.getClient(context); + const timeoutSecondsStr = req.query.get("timeoutSeconds") ?? (await req.text()); + const timeoutSeconds = Number(timeoutSecondsStr); + + if (!timeoutSecondsStr || isNaN(timeoutSeconds)) { + return { + status: 400, + body: "Please pass a valid timeoutSeconds value in the query string or in the request body." + }; + } + + const instanceId: string = await client.startNew("TimeoutOrchestrator", {input: timeoutSeconds}); + context.log(`Started orchestration with ID = '${instanceId}'.`); + return client.createCheckStatusResponse(req, instanceId); +}; + +app.http('TimeoutOrchestrator_HttpStart', { + route: 'TimeoutOrchestrator_HttpStart', + methods: ['GET', 'POST'], + extraInputs: [df.input.durableClient()], + handler: timer_http_start, +}); \ No newline at end of file diff --git a/test/e2e-functions/test-app/src/functions/counter1.ts b/test/e2e-functions/test-app/src/functions/counter1.ts deleted file mode 100644 index 6c1f7c9..0000000 --- a/test/e2e-functions/test-app/src/functions/counter1.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from "@azure/functions"; -import * as df from "durable-functions"; -import { EntityContext, EntityHandler } from "durable-functions"; - -const entityName = "counter1"; - -const counter1: EntityHandler = (context: EntityContext) => { - const currentValue: number = context.df.getState(() => 0); - switch (context.df.operationName) { - case "add": - const amount: number = context.df.getInput(); - context.df.setState(currentValue + amount); - break; - case "reset": - context.df.setState(0); - break; - case "get": - context.df.return(currentValue); - break; - } -}; -df.app.entity(entityName, counter1); - -const counter1HttpStart: HttpHandler = async ( - req: HttpRequest, - context: InvocationContext -): Promise => { - const id: string = req.params.id; - const entityId = new df.EntityId(entityName, id); - const client = df.getClient(context); - - if (req.method === "POST") { - // increment value - await client.signalEntity(entityId, "add", 1); - } else { - // read current state of entity - const stateResponse = await client.readEntityState(entityId); - return new HttpResponse({ - jsonBody: stateResponse.entityState, - }); - } -}; -app.http("counter1HttpStart", { - route: `${entityName}/{id}`, - extraInputs: [df.input.durableClient()], - handler: counter1HttpStart, -}); diff --git a/test/e2e-functions/test-app/src/functions/hello.ts b/test/e2e-functions/test-app/src/functions/hello.ts deleted file mode 100644 index 41ef917..0000000 --- a/test/e2e-functions/test-app/src/functions/hello.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from "@azure/functions"; -import * as df from "durable-functions"; -import { ActivityHandler, OrchestrationContext, OrchestrationHandler } from "durable-functions"; - -const activityName = "hello"; - -const helloOrchestrator: OrchestrationHandler = function* (context: OrchestrationContext) { - const outputs = []; - outputs.push(yield context.df.callActivity(activityName, "Tokyo")); - outputs.push(yield context.df.callActivity(activityName, "Seattle")); - outputs.push(yield context.df.callActivity(activityName, "Cairo")); - - return outputs; -}; -df.app.orchestration("helloOrchestrator", helloOrchestrator); - -const hello: ActivityHandler = (input: string): string => { - return `Hello, ${input}`; -}; -df.app.activity(activityName, { handler: hello }); - -const helloHttpStart: HttpHandler = async ( - request: HttpRequest, - context: InvocationContext -): Promise => { - const client = df.getClient(context); - const body: unknown = await request.text(); - const instanceId: string = await client.startNew(request.params.orchestratorName, { - input: body, - }); - - context.log(`Started orchestration with ID = '${instanceId}'.`); - - return client.createCheckStatusResponse(request, instanceId); -}; - -app.http("helloHttpStart", { - route: "orchestrators/{orchestratorName}", - extraInputs: [df.input.durableClient()], - handler: helloHttpStart, -}); diff --git a/test/e2e-functions/test-app/src/index.ts b/test/e2e-functions/test-app/src/index.ts new file mode 100644 index 0000000..13d0466 --- /dev/null +++ b/test/e2e-functions/test-app/src/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Ported from the azure-functions-durable-extension e2e `BasicNode` app. + +import { app } from '@azure/functions'; + +app.setup({ + enableHttpStream: true, +}); diff --git a/test/e2e-functions/timeout.spec.ts b/test/e2e-functions/timeout.spec.ts new file mode 100644 index 0000000..f73606e --- /dev/null +++ b/test/e2e-functions/timeout.spec.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Ported from the extension e2e `TimeoutTests.TimeoutFunction_ShouldTimeoutWhenAppropriate`. + * + * The TimeoutOrchestrator races a ~5s activity against a timer. A 2s timeout lets + * the timer win ("timed out"); a 10s timeout lets the activity win ("completed"). + * + * Gated: skips cleanly unless the shared host was started by globalSetup. + */ + +import { + getOrchestrationDetails, + invokeHttpTrigger, + parseStatusQueryGetUri, + readPreflight, + waitForOrchestrationState, +} from "./harness"; + +const preflight = readPreflight(); +const describeMaybe = preflight.ok ? describe : describe.skip; +const baseUrl = preflight.baseUrl ?? ""; + +if (!preflight.ok) { + console.warn(`[functions-e2e] timeout.spec skipped: ${preflight.reason}`); +} + +describeMaybe("Functions host E2E โ€” activity timeout (AzureStorage)", () => { + it.each([ + [2, "The activity function timed out"], + [10, "The activity function completed successfully"], + ])( + "TimeoutOrchestrator(timeoutSeconds=%i) => %s", + async (timeoutSeconds, expectedOutput) => { + const response = await invokeHttpTrigger( + baseUrl, + "TimeoutOrchestrator_HttpStart", + `?timeoutSeconds=${timeoutSeconds}`, + ); + expect(response.status).toBe(202); // HttpStatusCode.Accepted + + const statusQueryGetUri = parseStatusQueryGetUri(response); + await waitForOrchestrationState(statusQueryGetUri, "Completed", 30); + + const { outputString } = await getOrchestrationDetails(statusQueryGetUri); + expect(outputString).toBe(expectedOutput); + }, + 120_000, + ); +}); From 2963134ff9cbda3951a99f0c6c033c6f8ec3589a Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 16 Jul 2026 10:29:28 -0700 Subject: [PATCH 3/5] ci: make Functions host E2E workflow manual (ad hoc) only Drop the automatic pull_request trigger so the gated Functions host E2E suite runs on demand via workflow_dispatch while it beds in, rather than gating day-to-day PRs. Document how to re-enable the pull_request trigger later. Job steps (install func + Azurite, build test-app, run suite) are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/functions-e2e-tests.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/functions-e2e-tests.yaml b/.github/workflows/functions-e2e-tests.yaml index e9fb2c2..f9eec23 100644 --- a/.github/workflows/functions-e2e-tests.yaml +++ b/.github/workflows/functions-e2e-tests.yaml @@ -10,15 +10,16 @@ name: ๐Ÿงช Functions Host E2E Tests # or the Azurite storage emulator are unavailable, and RUNS when both are present. # In CI below we install and start both, so the suite runs for real; the self-skip # is the safety net that keeps the job green if a prerequisite fails to come up. +# +# Triggering: this suite is run ad hoc (manual dispatch) for now while it beds in, +# so it does not gate day-to-day PRs. Start it from the Actions tab via "Run +# workflow" (workflow_dispatch). To re-enable automatic runs on pull requests +# later, add a `pull_request` trigger scoped to `test/e2e-functions/**` and this +# workflow file. NOTE: workflow_dispatch only appears in the Actions UI once this +# file exists on the repository's default branch. on: workflow_dispatch: - pull_request: - branches: - - main - paths: - - "test/e2e-functions/**" - - ".github/workflows/functions-e2e-tests.yaml" permissions: contents: read From 43c5a2b2490df4589ac2fe6b0bb970d947db1a88 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 16 Jul 2026 10:52:23 -0700 Subject: [PATCH 4/5] test(functions-e2e): drop ping.ts, use /admin/host/status readiness probe The ping.ts HTTP trigger was not part of the extension's BasicNode app; it only backed the harness readiness probe. Switch the harness to poll the func host's /admin/host/status endpoint for state == "Running" (matching the extension's C# FunctionAppProcess fixture) and remove ping.ts so the test-app is a faithful BasicNode port. Addresses PR review feedback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e718bd1c-581f-4a9a-995a-62d4a38bbc86 --- test/e2e-functions/README.md | 8 +++++--- test/e2e-functions/harness.ts | 18 ++++++++++++------ .../test-app/src/functions/ping.ts | 14 -------------- 3 files changed, 17 insertions(+), 23 deletions(-) delete mode 100644 test/e2e-functions/test-app/src/functions/ping.ts diff --git a/test/e2e-functions/README.md b/test/e2e-functions/README.md index 2b33565..6ad5bc1 100644 --- a/test/e2e-functions/README.md +++ b/test/e2e-functions/README.md @@ -87,9 +87,11 @@ tests are `it.skip` with a comment citing the same reason. - Node swallows suspend/resume/terminate of a **terminal** instance and returns success (`200`); the specs assert that behavior. -The test-app additions over `BasicNode` are a plain `/api/ping` readiness function -(for host-readiness probing) and the published-dependency wiring. The Durable -function code is otherwise kept close to the source app. +The only test-app deviation from `BasicNode` is the published-dependency wiring +(see `test-app/package.json`); the Durable function code is otherwise kept close +to the source app. Host readiness is detected by polling `/admin/host/status` +for `state == "Running"`, the same way the extension's C# `FunctionAppProcess` +fixture does. ## Running locally diff --git a/test/e2e-functions/harness.ts b/test/e2e-functions/harness.ts index f7b234d..d87dcd1 100644 --- a/test/e2e-functions/harness.ts +++ b/test/e2e-functions/harness.ts @@ -20,7 +20,7 @@ * ({ id, statusQueryGetUri, ... }); client-operation triggers (RaiseEvent, * SuspendInstance, TerminateInstance, ...) return their own status/body. * - GET {statusQueryGetUri} -> poll orchestration status. - * - GET /api/ping -> plain readiness probe. + * - GET /admin/host/status -> host readiness probe. */ import { ChildProcess, spawn, spawnSync } from "child_process"; @@ -268,7 +268,8 @@ const FATAL_LOG_MARKERS = [ /** * Manages the lifecycle of a `func start` host for the sample app. * - * `start()` launches the host and blocks until `/api/ping` responds; `stop()` + * `start()` launches the host and blocks until `/admin/host/status` reports + * `Running`; `stop()` * terminates the whole process tree and surfaces the captured host log if * startup failed. */ @@ -360,19 +361,24 @@ export class FunctionApp { private async _waitUntilReady(): Promise { const deadline = Date.now() + HOST_STARTUP_TIMEOUT_MS; - const pingUrl = `${this.baseUrl}/api/ping`; + // Mirrors the extension's C# `FunctionAppProcess`: poll the host's admin + // status endpoint until it reports `state == "Running"`. + const statusUrl = `${this.baseUrl}/admin/host/status`; while (Date.now() < deadline) { if (this._exited) { throw new Error(`Functions host exited early.\n${this._readLog()}`); } this._checkLogForFatalErrors(); try { - const result = await httpRequest("GET", pingUrl, undefined, 5000); + const result = await httpRequest("GET", statusUrl, undefined, 5000); if (result.status === 200) { - return; + const state = (JSON.parse(result.body) as { state?: string }).state; + if (state === "Running") { + return; + } } } catch { - // Host is not accepting connections yet. + // Host is not accepting connections / not fully started yet. } await sleep(1000); } diff --git a/test/e2e-functions/test-app/src/functions/ping.ts b/test/e2e-functions/test-app/src/functions/ping.ts deleted file mode 100644 index 88acd7a..0000000 --- a/test/e2e-functions/test-app/src/functions/ping.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { app, HttpHandler, HttpRequest, HttpResponse, InvocationContext } from "@azure/functions"; - -// Plain (non-durable) HTTP endpoint used by the e2e harness to detect when the -// Functions host has finished cold-starting and is ready to serve requests. -const ping: HttpHandler = async (_request: HttpRequest, _context: InvocationContext): Promise => { - return new HttpResponse({ status: 200, body: "pong" }); -}; - -app.http("ping", { - methods: ["GET"], - authLevel: "anonymous", - route: "ping", - handler: ping, -}); From e6331ffd402bf7bc7caa850c388bc85c978862f9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 16 Jul 2026 11:37:23 -0700 Subject: [PATCH 5/5] test(functions-e2e): fail wrapper script fast on test-app build failure The wrapper used set -uo pipefail (no -e) and did not check the test-app install/build subshell result, so a failed npm install/build would fall through to a skipped Jest run and exit 0, masking the failure. Guard the install/build and propagate a non-zero exit (stopping Azurite first). Addresses PR review feedback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e718bd1c-581f-4a9a-995a-62d4a38bbc86 --- scripts/test-e2e-functions.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/test-e2e-functions.sh b/scripts/test-e2e-functions.sh index a12c400..6056fdc 100755 --- a/scripts/test-e2e-functions.sh +++ b/scripts/test-e2e-functions.sh @@ -44,9 +44,17 @@ if ! azurite_up; then fi # Install + build the ported BasicNode app against the published durable-functions -# package so 'func start' has a dist/ to serve. +# package so 'func start' has a dist/ to serve. If this fails there is no app to +# run, so fail fast instead of letting the suite skip and masking the error. echo "Installing + building test-app..." -( cd "$TEST_APP_DIR" && npm install && npm run build ) +if ! ( cd "$TEST_APP_DIR" && npm install && npm run build ); then + echo "test-app install/build failed." >&2 + if [ -n "$started_azurite" ]; then + echo "Stopping Azurite..." + kill "$started_azurite" 2>/dev/null || true + fi + exit 1 +fi echo "Running Functions host E2E tests..." ( cd "$ROOT_DIR" && npm run test:e2e:functions:internal )