Skip to content

Commit 146fa74

Browse files
YunchuWangCopilot
andcommitted
test: port full BasicNode Functions host E2E suite (Storage backend)
Replace the thin 2-function smoke app with a faithful port of the extension repo's BasicNode app and xUnit E2E suite, wired to the published durable-functions (^3.1.0) + @azure/functions (^4.11.2) so the suite is genuinely runnable rather than blocked on an unmerged in-repo package. App (test-app/): port all orchestrators, activities, class-based entities and client-operation HTTP triggers verbatim (HelloCities, ActivityInputType, Suspend/Resume, Terminate, Timeout, Rewind, IsReplaying checks, LargeOutput, activity/entity error handling, orchestration query, purge), plus Shared/ ExceptionTypes.ts and a generic StartOrchestration starter. Harness: generalize to mirror DurableHelpers/HttpHelpers (invokeHttpTrigger, parseInstanceId/parseStatusQueryGetUri, waitForOrchestrationState, getOrchestrationDetails) and add the Node localizer strings. Keep the shared single-host lifecycle with func/Azurite preflight + clean skip. Specs: 13 area specs ported faithfully from the xUnit tests using the Node localizer strings. Node/Node-DTS skip traits translated (Storage backend keeps Node-DTS tests); Node bug annotations honored (#642, #564, #608, #645, #679, #644, and the suspend/resume/terminate-of-terminal swallow quirk). Infra: remove the unmerged-package dependency and PR#282 gating language from the workflow, script, eslint/prettier ignores; rewrite the README with the new scenarios, prerequisites, run instructions and deferred follow-ups. Verified locally: build + typecheck + eslint + prettier clean; full suite runs green against a real func host + Azurite (13 suites, 35 passed, 10 skipped). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5ff8b58 commit 146fa74

44 files changed

Lines changed: 3699 additions & 379 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/functions-e2e-tests.yaml

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ name: 🧪 Functions Host E2E Tests
22

33
# Gated Azure Functions host E2E suite. It launches a real `func start` host for
44
# test/e2e-functions/test-app (backed by Azurite / AzureStorage) and drives the
5-
# app over HTTP. It exercises the in-repo `durable-functions` package added by
6-
# PR #282 (packages/azure-functions-durable), which is NOT on `main` yet.
5+
# app over HTTP, porting the extension repo's `BasicNode` app + xUnit tests.
76
#
8-
# Every job is gated behind the `RUN_FUNCTIONS_E2E` repository variable, so this
9-
# workflow is a no-op until that variable is set to 'true' (do so only after
10-
# PR #282 has merged). Until then, existing CI stays green and this suite never
11-
# runs — mirroring durabletask-python PR #155.
7+
# The test-app depends on the PUBLISHED `durable-functions` + `@azure/functions`
8+
# packages, so it installs and runs without any in-repo package build. The suite
9+
# self-gates: each spec SKIPS cleanly when the Azure Functions Core Tools (`func`)
10+
# or the Azurite storage emulator are unavailable, and RUNS when both are present.
11+
# In CI below we install and start both, so the suite runs for real; the self-skip
12+
# is the safety net that keeps the job green if a prerequisite fails to come up.
1213

1314
on:
1415
workflow_dispatch:
@@ -24,8 +25,6 @@ permissions:
2425

2526
jobs:
2627
functions-e2e-tests:
27-
# No-op until the repo variable is set after PR #282 merges.
28-
if: ${{ vars.RUN_FUNCTIONS_E2E == 'true' }}
2928
strategy:
3029
fail-fast: false
3130
matrix:
@@ -43,14 +42,12 @@ jobs:
4342
node-version: ${{ matrix.node-version }}
4443
registry-url: "https://registry.npmjs.org"
4544

45+
# Root install provides jest + ts-jest used to run the spec files. The specs
46+
# do not depend on the core durabletask-js packages, so no workspace build is
47+
# required here.
4648
- name: ⚙️ Install dependencies
4749
run: npm ci
4850

49-
# Builds the workspace including packages/azure-functions-durable (PR #282).
50-
# If that package is absent the test-app cannot install and the suite skips.
51-
- name: 🏗️ Build workspace
52-
run: npm run build
53-
5451
- name: 🔧 Install Azurite and Azure Functions Core Tools
5552
run: npm install -g azurite azure-functions-core-tools@4
5653

@@ -68,6 +65,8 @@ jobs:
6865
sleep 1
6966
done
7067
68+
# Installs the PUBLISHED durable-functions / @azure/functions packages and
69+
# compiles the ported BasicNode app to dist/ (consumed by `func start`).
7170
- name: 📦 Install + build test-app
7271
working-directory: test/e2e-functions/test-app
7372
run: |

.prettierignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ coverage
88
# don't lint proto files and output
99
proto
1010

11-
# vendored Azure Functions sample app — kept byte-for-byte identical to
12-
# azure-functions-durable-js (its own 4-space toolchain; see PR #282)
11+
# ported Azure Functions sample app — kept close to
12+
# azure-functions-durable-js's BasicNode (its own 4-space toolchain)
1313
test/e2e-functions/test-app

eslint.config.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ export default tseslint.config(
4141
"**/jest.config.js",
4242
"jest.functions-e2e.config.js",
4343
"**/src/proto/**",
44-
// Vendored Azure Functions sample app kept byte-for-byte identical to
45-
// azure-functions-durable-js; it has its own toolchain (see PR #282).
44+
// Ported Azure Functions sample app (from azure-functions-durable-js's
45+
// `BasicNode`), kept close to source and with its own 4-space toolchain.
4646
"test/e2e-functions/test-app/**",
4747
],
4848
}

jest.functions-e2e.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ module.exports = {
2121
],
2222
},
2323
globalSetup: "<rootDir>/test/e2e-functions/global-setup.ts",
24+
globalTeardown: "<rootDir>/test/e2e-functions/global-teardown.ts",
2425
// Host cold-start (extension-bundle download + worker spin-up) dominates.
2526
testTimeout: 300_000,
2627
};

scripts/test-e2e-functions.sh

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
# Azure Functions host ("func start") for test/e2e-functions/test-app, backed by
66
# Azurite (the local Azure Storage emulator), and drives it over HTTP.
77
#
8+
# The test-app depends on the PUBLISHED durable-functions / @azure/functions
9+
# packages, so it installs and builds without any in-repo package build.
10+
#
811
# Prerequisites (the suite SKIPS cleanly if any are missing):
912
# - Azure Functions Core Tools ('func') v4 on PATH
1013
# - Azurite reachable on 127.0.0.1:10000 (started here if the 'azurite' CLI is
1114
# installed and the port is free)
12-
# - packages/azure-functions-durable present (PR #282) so the test-app's local
13-
# 'durable-functions' dependency installs
1415

1516
set -uo pipefail
1617

@@ -42,17 +43,10 @@ if ! azurite_up; then
4243
fi
4344
fi
4445

45-
# Build the workspace (incl. packages/azure-functions-durable from PR #282) so
46-
# the test-app's local 'durable-functions' dependency resolves.
47-
echo "Building workspace..."
48-
( cd "$ROOT_DIR" && npm run build )
49-
50-
if [ -f "$ROOT_DIR/packages/azure-functions-durable/package.json" ]; then
51-
echo "Installing + building test-app..."
52-
( cd "$TEST_APP_DIR" && npm install && npm run build )
53-
else
54-
echo "packages/azure-functions-durable not found (PR #282 not merged); the suite will skip."
55-
fi
46+
# Install + build the ported BasicNode app against the published durable-functions
47+
# package so 'func start' has a dist/ to serve.
48+
echo "Installing + building test-app..."
49+
( cd "$TEST_APP_DIR" && npm install && npm run build )
5650

5751
echo "Running Functions host E2E tests..."
5852
( cd "$ROOT_DIR" && npm run test:e2e:functions:internal )

test/e2e-functions/README.md

Lines changed: 92 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,95 @@
11
# Azure Functions host E2E tests (gated)
22

3-
This suite launches a **real Azure Functions host** (`func start`) for the sample
3+
This suite launches a **real Azure Functions host** (`func start`) for the ported
44
app under [`test-app/`](./test-app), backed by **Azurite** (the local Azure
5-
Storage emulator) using the Durable Task **AzureStorage** provider, and drives
6-
the app entirely over HTTP. The Node.js worker, the Functions host, and the
7-
Durable extension all cooperate exactly as they would in production.
5+
Storage emulator) using the Durable Task **AzureStorage** provider, and drives the
6+
app entirely over HTTP. The Node.js worker, the Functions host, and the Durable
7+
extension all cooperate exactly as they would in production.
88

9-
It mirrors the Python suite added in
10-
[durabletask-python#155](https://github.com/microsoft/durabletask-python/pull/155).
9+
It is a faithful port of the Azure Functions host E2E suite from the
10+
[`azure-functions-durable-extension`](https://github.com/Azure/azure-functions-durable-extension)
11+
repo — the `BasicNode` app plus its xUnit test classes — adapted to Jest. Expected
12+
strings and Node-specific bug annotations come from that repo's
13+
`NodeTestLanguageLocalizer`.
1114

12-
## ⚠️ Gated until PR #282 merges
15+
## Published `durable-functions` dependency
1316

14-
The sample app depends on the in-repo `durable-functions` package
15-
(`packages/azure-functions-durable`) added by
16-
[durabletask-js#282](https://github.com/microsoft/durabletask-js/pull/282), which
17-
is **not on `main` yet**. This suite is deliberately split out from #282 so it
18-
can land independently without touching that package.
17+
The [`test-app`](./test-app) is wired to the **published** npm packages
18+
`durable-functions` (`^3.1.0`) and `@azure/functions` (`^4.11.2`), so it installs
19+
and builds without any in-repo package build. This is what makes the suite
20+
genuinely runnable.
1921

20-
Because the package does not exist on `main`, everything here is **gated /
21-
skipped** so existing CI (`npm test`, lint, build) stays green:
22+
To run the app against an **in-repo** build of the Azure Functions durable package
23+
instead, change the `durable-functions` dependency in
24+
[`test-app/package.json`](./test-app/package.json) to a `file:` reference (e.g.
25+
`file:../../../packages/azure-functions-durable`) once that package exists on your
26+
branch, then re-run `npm install`. A note to this effect lives in that
27+
`package.json` (`comment_durable-functions`).
2228

23-
- **Prerequisite detection** — the suite skips cleanly unless all of these hold
24-
(see `harness.ts` + `global-setup.ts`):
29+
## Gating / skip model
30+
31+
The suite **skips cleanly** (it never fails) unless everything it needs is present,
32+
so it is safe to leave wired into CI and to run locally without setup:
33+
34+
- **Prerequisite detection**`global-setup.ts` only starts the shared `func`
35+
host when all of these hold (otherwise every spec skips):
2536
1. Azure Functions Core Tools (`func`) v4 is on `PATH`,
2637
2. Azurite is reachable on `127.0.0.1:10000`, and
2738
3. the test-app is installed and built (`test-app/node_modules` + `test-app/dist`).
28-
29-
On `main`, step 3 cannot be satisfied (the local `durable-functions` package is
30-
absent), so the suite is a clean no-op.
31-
39+
- **One shared host**`global-setup.ts` starts a single `func start` host for the
40+
whole run (mirroring the C# `FunctionAppFixture`) and writes its base URL to a
41+
preflight file; the specs drive it over HTTP and `global-teardown.ts` stops it.
3242
- **Isolation** — these specs are only run by the dedicated
33-
`jest.functions-e2e.config.js` via `npm run test:e2e:functions:internal`. They
34-
are **not** part of the default `npm test` / workspaces test run, nor of
43+
`jest.functions-e2e.config.js` via `npm run test:e2e:functions:internal`. They are
44+
**not** part of the default `npm test` / workspaces test run, nor of
3545
`test:e2e:internal` (which is scoped to `tests/e2e`).
36-
- **CI**`.github/workflows/functions-e2e-tests.yaml` gates every job behind the
37-
`RUN_FUNCTIONS_E2E` repository variable. It is a no-op until that variable is
38-
set to `true` (do so only after #282 has merged).
46+
- **CI**`.github/workflows/functions-e2e-tests.yaml` installs `func` + Azurite,
47+
installs/builds the test-app, and runs the suite on PRs that touch
48+
`test/e2e-functions/**`. The self-skip is the safety net if a prerequisite fails
49+
to come up.
3950

4051
## What it covers
4152

42-
Faithful to the (identical) `azure-functions-durable-js` sample app:
43-
44-
- **`hello.spec.ts`** — starts the `helloOrchestrator` (which chains three
45-
`hello` activities) and asserts the output is
46-
`["Hello, Tokyo", "Hello, Seattle", "Hello, Cairo"]`.
47-
- **`counter1.spec.ts`** — signals the `counter1` durable entity (`add`, 1) via
48-
HTTP and asserts the persisted state increments (1, then 2). `reset` is not
49-
wired to an HTTP route in the sample app, so it is not tested.
50-
51-
The only additions to the sample app are a plain `/api/ping` readiness function
52-
(for host-readiness probing) and the dependency wiring (`durable-functions`
53-
points at the workspace package). The Durable function code is kept byte-for-byte
54-
identical to `azure-functions-durable-js`.
53+
One spec per area, mirroring the extension repo's test classes. Because this is the
54+
**Storage** backend, `[Trait("Node-DTS","Skip")]` and `[Trait("DTS","Skip")]` tests
55+
are **included** (those skips apply only to the DTS backend); `[Trait("Node","Skip")]`
56+
tests are `it.skip` with a comment citing the same reason.
57+
58+
| Spec | Ported from | Notes |
59+
| ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------- |
60+
| `hello-cities.spec.ts` | `HelloCitiesTest` | activity chaining → `Hello Tokyo!` etc. |
61+
| `activity-input-type.spec.ts` | `ActivityInputTypeTests` | `byte[]`/`int[]`/string/custom-class serialization |
62+
| `error-handling.spec.ts` | `ErrorHandlingTests` | rethrow/catch/retry activity & entity; skips dotnet-only FailureDetails + custom-exception-properties tests |
63+
| `external-event.spec.ts` | `ExternalEventTests` | raise to running/completed/missing (raise-to-completed error skipped per #645) |
64+
| `suspend-resume.spec.ts` | `SuspendResumeTests` | suspend/resume running, suspended, completed |
65+
| `terminate.spec.ts` | `TerminateOrchestratorTests` | terminate running/terminated/completed/nonexistent |
66+
| `timeout.spec.ts` | `TimeoutTests` | `Task.any` activity-vs-timer race |
67+
| `rewind.spec.ts` | `RewindOrchestratorTests` | fail → rewind → complete; invocation counts; rewind-only-failed |
68+
| `is-replaying.spec.ts` | `IsReplayingTests` | `isReplaying` flags (ConditionalLog #564 and FanOutFanIn #679 skipped) |
69+
| `large-output.spec.ts` | `LargeOutputOrchestratorTests` | 65 KB via status URI + 4.5 MB via query trigger |
70+
| `orchestration-query.spec.ts` | `OrchestrationQueryTests` | `GetAllInstances` / `GetRunningInstances` |
71+
| `purge.spec.ts` | `PurgeInstancesTests` | purge by time / by entity id (purge-without-start-time #644 skipped) |
72+
| `class-based-entity.spec.ts` | `ClassBasedEntityTests` | class-based entity state |
73+
74+
### Node bug annotations honored
75+
76+
- [#642](https://github.com/Azure/azure-functions-durable-js/issues/642) — entity
77+
error text: inner "This entity failed"/"More information" assertions omitted.
78+
- [#645](https://github.com/Azure/azure-functions-durable-js/issues/645)
79+
raise-external-event to a completed instance: error assertions omitted.
80+
- [#564](https://github.com/Azure/azure-functions-durable-js/issues/564)
81+
`isReplaying` undefined before the first `yield`: `IsReplayingConditionalLog`
82+
skipped.
83+
- [#679](https://github.com/Azure/azure-functions-durable-js/issues/679)
84+
`IsReplayingFanOutFanIn` skipped.
85+
- [#644](https://github.com/Azure/azure-functions-durable-js/issues/644) — purge
86+
without a start time: those purge tests skipped.
87+
- Node swallows suspend/resume/terminate of a **terminal** instance and returns
88+
success (`200`); the specs assert that behavior.
89+
90+
The test-app additions over `BasicNode` are a plain `/api/ping` readiness function
91+
(for host-readiness probing) and the published-dependency wiring. The Durable
92+
function code is otherwise kept close to the source app.
5593

5694
## Running locally
5795

@@ -66,11 +104,10 @@ azurite --silent --location /tmp/azurite --blobPort 10000 --queuePort 10001 --ta
66104
# 2. Install the Core Tools (if needed)
67105
npm install -g azure-functions-core-tools@4
68106

69-
# 3. Build the workspace (must include packages/azure-functions-durable from #282)
107+
# 3. Install root dev deps (jest + ts-jest)
70108
npm ci
71-
npm run build
72109

73-
# 4. Install + build the test-app
110+
# 4. Install + build the test-app (against the published durable-functions)
74111
cd test/e2e-functions/test-app
75112
npm install
76113
npm run build
@@ -81,11 +118,24 @@ npm run test:e2e:functions:internal
81118
```
82119

83120
Or use the convenience wrapper, which starts Azurite (if the CLI is installed),
84-
builds the workspace + test-app, and runs the suite:
121+
installs + builds the test-app, and runs the suite:
85122

86123
```bash
87124
npm run test:e2e:functions
88125
```
89126

90127
If any prerequisite is missing, the suite skips with a `[functions-e2e]` note
91128
instead of failing.
129+
130+
## Follow-ups (deferred)
131+
132+
The following extension-repo test areas are **not** ported yet. Most need extra
133+
infrastructure (an OTLP collector, multi-version host config, scheduled starts) or
134+
an orchestration that is not part of `BasicNode`:
135+
136+
- **DistributedTracing / DistributedTracingEntities** — need an OTLP collector.
137+
- **Versioning / EntityVersioning** — need a multi-version host configuration.
138+
- **DedupeStatuses**, **GetOrchestrationHistory**, **HTTPFeature**, **Restart**,
139+
**Scheduled** — not yet ported.
140+
- **`PurgeOnlyPurgesTerminalOrchestrations`** — needs a `HelloActivityDIFailure`
141+
orchestration that is not present in `BasicNode`.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
/**
5+
* Ported from the extension e2e `ActivityInputTypeTests.DifferentActivityInputTypeTests`.
6+
*
7+
* Verifies that different input types (byte[], empty byte[], single byte, custom
8+
* class, int[], string, custom-class array) serialize correctly and are received
9+
* by activities without serialization errors.
10+
*
11+
* Gated: skips cleanly unless the shared host was started by globalSetup.
12+
*/
13+
14+
import {
15+
getOrchestrationDetails,
16+
invokeHttpTrigger,
17+
parseStatusQueryGetUri,
18+
readPreflight,
19+
waitForOrchestrationState,
20+
} from "./harness";
21+
22+
const preflight = readPreflight();
23+
const describeMaybe = preflight.ok ? describe : describe.skip;
24+
const baseUrl = preflight.baseUrl ?? "";
25+
26+
if (!preflight.ok) {
27+
console.warn(`[functions-e2e] activity-input-type.spec skipped: ${preflight.reason}`);
28+
}
29+
30+
describeMaybe("Functions host E2E — activity input types (AzureStorage)", () => {
31+
it("ActivityInputTypeOrchestrator serializes every input type without errors", async () => {
32+
const response = await invokeHttpTrigger(
33+
baseUrl,
34+
"StartOrchestration",
35+
"?orchestrationName=ActivityInputTypeOrchestrator",
36+
);
37+
expect(response.status).toBe(202); // HttpStatusCode.Accepted
38+
39+
const statusQueryGetUri = parseStatusQueryGetUri(response);
40+
await waitForOrchestrationState(statusQueryGetUri, "Completed", 30);
41+
42+
const { outputString } = await getOrchestrationDetails(statusQueryGetUri);
43+
44+
expect(outputString).toContain("Received byte[]: [1, 2, 3, 4, 5]");
45+
expect(outputString).toContain("Received byte[]: []");
46+
expect(outputString).toContain("Received byte: 42");
47+
expect(outputString).toContain("Received CustomClass: {Name: Test, Age: 25, Duration: 01:00:00, Data: [1, 2, 3]}");
48+
expect(outputString).toContain("Received int[]: [1, 2, 3, 4, 5]");
49+
expect(outputString).toContain("Received string: Test string input");
50+
expect(outputString).toContain(
51+
"Received CustomClass[]: [{Name: Test1, Age: 25, Duration: 00:30:00, Data: [1, 2, 3]}, {Name: Test2, Age: 30, Duration: 00:45:00, Data: []}]",
52+
);
53+
54+
// No serialization errors, especially for byte[] types.
55+
expect(outputString).not.toContain("Error:");
56+
}, 120_000);
57+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
/**
5+
* Ported from the extension e2e `ClassBasedEntityTests.ClassBasedEntityTest`.
6+
*
7+
* Runs an orchestration that sets and reads a class-based entity's state and
8+
* asserts the exact returned state string.
9+
*
10+
* Gated: skips cleanly unless the shared host was started by globalSetup.
11+
*/
12+
13+
import {
14+
getOrchestrationDetails,
15+
invokeHttpTrigger,
16+
parseStatusQueryGetUri,
17+
readPreflight,
18+
waitForOrchestrationState,
19+
} from "./harness";
20+
21+
const preflight = readPreflight();
22+
const describeMaybe = preflight.ok ? describe : describe.skip;
23+
const baseUrl = preflight.baseUrl ?? "";
24+
25+
if (!preflight.ok) {
26+
console.warn(`[functions-e2e] class-based-entity.spec skipped: ${preflight.reason}`);
27+
}
28+
29+
describeMaybe("Functions host E2E — class-based entity (AzureStorage)", () => {
30+
it("ClassBasedEntityOrchestration returns the entity state string", async () => {
31+
const response = await invokeHttpTrigger(
32+
baseUrl,
33+
"StartOrchestration",
34+
"?orchestrationName=ClassBasedEntityOrchestration",
35+
);
36+
expect(response.status).toBe(202); // HttpStatusCode.Accepted
37+
38+
const statusQueryGetUri = parseStatusQueryGetUri(response);
39+
await waitForOrchestrationState(statusQueryGetUri, "Completed", 30);
40+
41+
const { outputString } = await getOrchestrationDetails(statusQueryGetUri);
42+
expect(outputString).toBe("IConfiguration: yes, MyInjectedService: yes, BlobContainerClient: yes, Number: 42");
43+
}, 120_000);
44+
});

0 commit comments

Comments
 (0)