diff --git a/.github/workflows/functions-e2e-tests.yaml b/.github/workflows/functions-e2e-tests.yaml new file mode 100644 index 0000000..f9eec23 --- /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, porting the extension repo's `BasicNode` app + xUnit tests. +# +# 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. +# +# 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: + +permissions: + contents: read + +jobs: + functions-e2e-tests: + 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" + + # 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 + + - 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 + + # 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: | + 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..842ff97 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,7 @@ dist coverage # don't lint proto files and output proto + +# 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 f01f4e9..542df54 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/**", + // 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 new file mode 100644 index 0000000..152b94e --- /dev/null +++ b/jest.functions-e2e.config.js @@ -0,0 +1,27 @@ +/** @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", + globalTeardown: "/test/e2e-functions/global-teardown.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..6056fdc --- /dev/null +++ b/scripts/test-e2e-functions.sh @@ -0,0 +1,68 @@ +#!/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. +# +# 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) + +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 + +# Install + build the ported BasicNode app against the published durable-functions +# 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..." +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 ) +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..6ad5bc1 --- /dev/null +++ b/test/e2e-functions/README.md @@ -0,0 +1,143 @@ +# Azure Functions host E2E tests (gated) + +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. + +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`. + +## Published `durable-functions` dependency + +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. + +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`). + +## 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`). +- **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 + `test:e2e:internal` (which is scoped to `tests/e2e`). +- **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 + +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 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 + +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. Install root dev deps (jest + ts-jest) +npm ci + +# 4. Install + build the test-app (against the published durable-functions) +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), +installs + builds the 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. + +## 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/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 new file mode 100644 index 0000000..4b01c3c --- /dev/null +++ b/test/e2e-functions/global-setup.ts @@ -0,0 +1,48 @@ +// 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). 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, FunctionApp, preflightFilePath, PrerequisiteCheck, TEST_APP_DIR } from "./harness"; + +export const GLOBAL_APP_KEY = "__DURABLETASK_FUNCTIONS_E2E_APP__"; + +export default async function globalSetup(): Promise { + 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 new file mode 100644 index 0000000..d87dcd1 --- /dev/null +++ b/test/e2e-functions/harness.ts @@ -0,0 +1,650 @@ +// 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. + * + * 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 /admin/host/status -> host readiness probe. + */ + +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; + /** 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. */ +export interface OrchestrationStatus { + name?: string; + instanceId?: string; + runtimeStatus?: string; + input?: unknown; + output?: unknown; + customStatus?: unknown; + createdTime?: string; + lastUpdatedTime?: 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. 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")); +} + +/** 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/). ` + + "Run `npm install && npm run build` inside 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. 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(): PrerequisiteCheck { + let result: PrerequisiteCheck; + try { + result = JSON.parse(fs.readFileSync(preflightFilePath(), "utf-8")) as PrerequisiteCheck; + } catch { + 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. */ +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, + contentType?: string, +): 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"] = contentType ?? (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 `/admin/host/status` reports + * `Running`; `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; + // 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", statusUrl, undefined, 5000); + if (result.status === 200) { + const state = (JSON.parse(result.body) as { state?: string }).state; + if (state === "Running") { + return; + } + } + } catch { + // Host is not accepting connections / not fully started 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(); + }); + } +} + +// -- 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. + +/** + * 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 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(); +} + +/** + * 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}`); + } + 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}`, + ); +} + +/** Orchestration details parsed from a durable status-query response. */ +export interface OrchestrationDetails { + instanceId: string; + runtimeStatus: string; + input: unknown; + inputString: string; + output: unknown; + outputString: string; +} + +/** + * 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 ""; + } + if (typeof value === "string") { + return value; + } + return 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/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/.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-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 new file mode 100644 index 0000000..e0b81c6 --- /dev/null +++ b/test/e2e-functions/test-app/package.json @@ -0,0 +1,28 @@ +{ + "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", + "clean": "rimraf dist", + "prestart": "npm run clean && npm run build", + "start": "func start", + "test": "echo \"No tests yet...\"" + }, + "dependencies": { + "@azure/functions": "^4.11.2", + "durable-functions": "^3.1.0" + }, + "devDependencies": { + "@types/node": "^20.x", + "rimraf": "^5.0.0", + "typescript": "^4.7.2" + }, + "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/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/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 + } +} 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, + ); +});