Skip to content

Commit 940b4a0

Browse files
YunchuWangCopilot
andcommitted
Merge remote-tracking branch 'origin/main' into yunchuwang/functions-grpc-support
Brings in #290 (failure-details spans) and #303 (Functions host E2E suite + functions-e2e-tests workflow) so the E2E surface exists on this branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e5be22b-b727-4ccf-8668-60397bb403a0
2 parents 868f3ef + f8c460c commit 940b4a0

48 files changed

Lines changed: 4526 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: 🧪 Functions Host E2E Tests
2+
3+
# Gated Azure Functions host E2E suite. It launches a real `func start` host for
4+
# test/e2e-functions/test-app (backed by Azurite / AzureStorage) and drives the
5+
# app over HTTP, porting the extension repo's `BasicNode` app + xUnit tests.
6+
#
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.
13+
#
14+
# Triggering: this suite is run ad hoc (manual dispatch) for now while it beds in,
15+
# so it does not gate day-to-day PRs. Start it from the Actions tab via "Run
16+
# workflow" (workflow_dispatch). To re-enable automatic runs on pull requests
17+
# later, add a `pull_request` trigger scoped to `test/e2e-functions/**` and this
18+
# workflow file. NOTE: workflow_dispatch only appears in the Actions UI once this
19+
# file exists on the repository's default branch.
20+
21+
on:
22+
workflow_dispatch:
23+
24+
permissions:
25+
contents: read
26+
27+
jobs:
28+
functions-e2e-tests:
29+
strategy:
30+
fail-fast: false
31+
matrix:
32+
node-version: ["22.x"]
33+
name: "functions-e2e (node ${{ matrix.node-version }})"
34+
runs-on: ubuntu-latest
35+
36+
steps:
37+
- name: 📥 Checkout code
38+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
39+
40+
- name: ⚙️ NodeJS - Install
41+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
42+
with:
43+
node-version: ${{ matrix.node-version }}
44+
registry-url: "https://registry.npmjs.org"
45+
46+
# Root install provides jest + ts-jest used to run the spec files. The specs
47+
# do not depend on the core durabletask-js packages, so no workspace build is
48+
# required here.
49+
- name: ⚙️ Install dependencies
50+
run: npm ci
51+
52+
- name: 🔧 Install Azurite and Azure Functions Core Tools
53+
run: npm install -g azurite azure-functions-core-tools@4
54+
55+
- name: 🗄️ Start Azurite
56+
run: |
57+
mkdir -p /tmp/azurite
58+
azurite --silent --location /tmp/azurite \
59+
--blobPort 10000 --queuePort 10001 --tablePort 10002 &
60+
echo "Waiting for Azurite blob endpoint..."
61+
for i in $(seq 1 30); do
62+
if (echo > /dev/tcp/127.0.0.1/10000) >/dev/null 2>&1; then
63+
echo "Azurite is up."
64+
break
65+
fi
66+
sleep 1
67+
done
68+
69+
# Installs the PUBLISHED durable-functions / @azure/functions packages and
70+
# compiles the ported BasicNode app to dist/ (consumed by `func start`).
71+
- name: 📦 Install + build test-app
72+
working-directory: test/e2e-functions/test-app
73+
run: |
74+
npm install
75+
npm run build
76+
77+
- name: ✅ Run Functions host E2E tests
78+
run: npm run test:e2e:functions:internal
79+
timeout-minutes: 20

.prettierignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ dist
77
coverage
88
# don't lint proto files and output
99
proto
10+
11+
# ported Azure Functions sample app — kept close to
12+
# azure-functions-durable-js's BasicNode (its own 4-space toolchain)
13+
test/e2e-functions/test-app

eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ export default tseslint.config(
3939
"**/node_modules/**",
4040
"**/src/version.ts",
4141
"**/jest.config.js",
42+
"jest.functions-e2e.config.js",
4243
"**/src/proto/**",
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.
46+
"test/e2e-functions/test-app/**",
4347
],
4448
}
4549
);

jest.functions-e2e.config.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
// Copyright (c) Microsoft Corporation. All rights reserved.
3+
// Licensed under the MIT License.
4+
//
5+
// Dedicated jest config for the gated Azure Functions host e2e suite. It is
6+
// intentionally NOT part of the default `npm test` / workspaces test run and is
7+
// only executed via `npm run test:e2e:functions:internal`. These specs drive a
8+
// real `func start` host over HTTP and skip cleanly without the local toolchain.
9+
module.exports = {
10+
preset: "ts-jest",
11+
testEnvironment: "node",
12+
roots: ["<rootDir>/test/e2e-functions"],
13+
testMatch: ["**/test/e2e-functions/**/*.spec.ts"],
14+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
15+
transform: {
16+
"^.+\\.tsx?$": [
17+
"ts-jest",
18+
{
19+
tsconfig: "tsconfig.base.json",
20+
},
21+
],
22+
},
23+
globalSetup: "<rootDir>/test/e2e-functions/global-setup.ts",
24+
globalTeardown: "<rootDir>/test/e2e-functions/global-teardown.ts",
25+
// Host cold-start (extension-bundle download + worker spin-up) dominates.
26+
testTimeout: 300_000,
27+
};

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"test:e2e:one": "jest tests/e2e --runInBand --detectOpenHandles --testNamePattern",
1919
"test:e2e:azuremanaged:internal": "jest test/e2e-azuremanaged --detectOpenHandles",
2020
"test:e2e:azuremanaged": "./scripts/test-e2e-azuremanaged.sh",
21+
"test:e2e:functions:internal": "jest -c jest.functions-e2e.config.js --runInBand --detectOpenHandles",
22+
"test:e2e:functions": "./scripts/test-e2e-functions.sh",
2123
"lint": "eslint .",
2224
"pretty": "prettier --list-different \"**/*.{ts,tsx,js,jsx,json,md}\"",
2325
"pretty-fix": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",

packages/durabletask-js/src/tracing/trace-helper.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,9 +690,17 @@ export function setOrchestrationStatusFromActions(
690690
span.setAttribute(DurableTaskAttributes.TASK_STATUS, statusName);
691691
}
692692

693-
// Match .NET: set span error status when orchestration completes with Failed
693+
// Match .NET: set span error status when orchestration completes with Failed.
694+
// Read error message from failureDetails (where setFailed() stores it),
695+
// falling back to result for backwards compatibility. Use nullish coalescing
696+
// (like the task/sub-orchestration failure paths above and .NET's TraceHelper)
697+
// so an explicitly empty failureDetails message is preserved rather than
698+
// masked by the generic fallback.
694699
if (status === pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED) {
695-
const errorMessage = completeAction.getResult()?.getValue() ?? "Orchestration failed";
700+
const errorMessage =
701+
completeAction.getFailuredetails()?.getErrormessage() ??
702+
completeAction.getResult()?.getValue() ??
703+
"Orchestration failed";
696704
span.setStatus({ code: otel.SpanStatusCode.ERROR, message: errorMessage });
697705
} else {
698706
span.setStatus({ code: otel.SpanStatusCode.OK });

packages/durabletask-js/test/tracing.spec.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,15 +1124,17 @@ describe("Trace Helper - setOrchestrationStatusFromActions", () => {
11241124
expect(spans[0].status.code).toBe(otel.SpanStatusCode.OK);
11251125
});
11261126

1127-
it("should set status attribute for failed orchestration and ERROR span status", () => {
1127+
it("should set status attribute for failed orchestration and ERROR span status with failureDetails", () => {
11281128
const tracer = otel.trace.getTracer(TRACER_NAME);
11291129
const span = tracer.startSpan("orch-status-failed");
11301130

1131+
// Match the real runtime path: setFailed() sets failureDetails but NOT result
11311132
const completeAction = new pb.CompleteOrchestrationAction();
11321133
completeAction.setOrchestrationstatus(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1133-
const resultValue = new StringValue();
1134-
resultValue.setValue("User code threw an error");
1135-
completeAction.setResult(resultValue);
1134+
const failureDetails = new pb.TaskFailureDetails();
1135+
failureDetails.setErrormessage("User code threw an error");
1136+
failureDetails.setErrortype("Error");
1137+
completeAction.setFailuredetails(failureDetails);
11361138

11371139
const action = new pb.OrchestratorAction();
11381140
action.setCompleteorchestration(completeAction);
@@ -1146,6 +1148,74 @@ describe("Trace Helper - setOrchestrationStatusFromActions", () => {
11461148
expect(spans[0].status.message).toBe("User code threw an error");
11471149
});
11481150

1151+
it("should fall back to result field for failed orchestration error message", () => {
1152+
const tracer = otel.trace.getTracer(TRACER_NAME);
1153+
const span = tracer.startSpan("orch-status-failed-result-fallback");
1154+
1155+
// Edge case: no failureDetails, but result is set (backwards compatibility)
1156+
const completeAction = new pb.CompleteOrchestrationAction();
1157+
completeAction.setOrchestrationstatus(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1158+
const resultValue = new StringValue();
1159+
resultValue.setValue("Legacy error message");
1160+
completeAction.setResult(resultValue);
1161+
1162+
const action = new pb.OrchestratorAction();
1163+
action.setCompleteorchestration(completeAction);
1164+
1165+
setOrchestrationStatusFromActions(span, [action]);
1166+
span.end();
1167+
1168+
const spans = exporter.getFinishedSpans();
1169+
expect(spans[0].status.code).toBe(otel.SpanStatusCode.ERROR);
1170+
expect(spans[0].status.message).toBe("Legacy error message");
1171+
});
1172+
1173+
it("should use default error message when neither failureDetails nor result is set", () => {
1174+
const tracer = otel.trace.getTracer(TRACER_NAME);
1175+
const span = tracer.startSpan("orch-status-failed-no-details");
1176+
1177+
const completeAction = new pb.CompleteOrchestrationAction();
1178+
completeAction.setOrchestrationstatus(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1179+
1180+
const action = new pb.OrchestratorAction();
1181+
action.setCompleteorchestration(completeAction);
1182+
1183+
setOrchestrationStatusFromActions(span, [action]);
1184+
span.end();
1185+
1186+
const spans = exporter.getFinishedSpans();
1187+
expect(spans[0].status.code).toBe(otel.SpanStatusCode.ERROR);
1188+
expect(spans[0].status.message).toBe("Orchestration failed");
1189+
});
1190+
1191+
it("should preserve an explicitly empty failureDetails message over the result/default fallback", () => {
1192+
const tracer = otel.trace.getTracer(TRACER_NAME);
1193+
const span = tracer.startSpan("orch-status-failed-empty-message");
1194+
1195+
// failureDetails is present (orchestration failed) but the error message is
1196+
// empty. failureDetails is authoritative, so nullish coalescing must keep the
1197+
// empty string rather than falling through to result/"Orchestration failed".
1198+
const completeAction = new pb.CompleteOrchestrationAction();
1199+
completeAction.setOrchestrationstatus(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1200+
const failureDetails = new pb.TaskFailureDetails();
1201+
failureDetails.setErrormessage("");
1202+
failureDetails.setErrortype("Error");
1203+
completeAction.setFailuredetails(failureDetails);
1204+
const resultValue = new StringValue();
1205+
resultValue.setValue("Should not be used");
1206+
completeAction.setResult(resultValue);
1207+
1208+
const action = new pb.OrchestratorAction();
1209+
action.setCompleteorchestration(completeAction);
1210+
1211+
setOrchestrationStatusFromActions(span, [action]);
1212+
span.end();
1213+
1214+
const spans = exporter.getFinishedSpans();
1215+
expect(spans[0].status.code).toBe(otel.SpanStatusCode.ERROR);
1216+
expect(spans[0].status.message).toBe("");
1217+
});
1218+
11491219
it("should not set status when no completion action present", () => {
11501220
const tracer = otel.trace.getTracer(TRACER_NAME);
11511221
const span = tracer.startSpan("orch-status-none");

scripts/test-e2e-functions.sh

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/bin/bash
2+
# Script to run the gated Azure Functions host E2E tests.
3+
#
4+
# This mirrors scripts/test-e2e-azuremanaged.sh. The suite launches a real
5+
# Azure Functions host ("func start") for test/e2e-functions/test-app, backed by
6+
# Azurite (the local Azure Storage emulator), and drives it over HTTP.
7+
#
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+
#
11+
# Prerequisites (the suite SKIPS cleanly if any are missing):
12+
# - Azure Functions Core Tools ('func') v4 on PATH
13+
# - Azurite reachable on 127.0.0.1:10000 (started here if the 'azurite' CLI is
14+
# installed and the port is free)
15+
16+
set -uo pipefail
17+
18+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
19+
TEST_APP_DIR="$ROOT_DIR/test/e2e-functions/test-app"
20+
21+
AZURITE_HOST="127.0.0.1"
22+
AZURITE_BLOB_PORT="10000"
23+
24+
started_azurite=""
25+
26+
azurite_up() {
27+
(exec 3<>"/dev/tcp/$AZURITE_HOST/$AZURITE_BLOB_PORT") 2>/dev/null && exec 3>&- 3<&-
28+
}
29+
30+
if ! azurite_up; then
31+
if command -v azurite >/dev/null 2>&1; then
32+
echo "Starting Azurite..."
33+
AZURITE_DATA="$(mktemp -d)"
34+
azurite --silent --location "$AZURITE_DATA" \
35+
--blobPort 10000 --queuePort 10001 --tablePort 10002 &
36+
started_azurite="$!"
37+
for _ in $(seq 1 30); do
38+
azurite_up && break
39+
sleep 1
40+
done
41+
else
42+
echo "Azurite is not running and 'azurite' is not installed; the suite will skip."
43+
fi
44+
fi
45+
46+
# Install + build the ported BasicNode app against the published durable-functions
47+
# package so 'func start' has a dist/ to serve. If this fails there is no app to
48+
# run, so fail fast instead of letting the suite skip and masking the error.
49+
echo "Installing + building test-app..."
50+
if ! ( cd "$TEST_APP_DIR" && npm install && npm run build ); then
51+
echo "test-app install/build failed." >&2
52+
if [ -n "$started_azurite" ]; then
53+
echo "Stopping Azurite..."
54+
kill "$started_azurite" 2>/dev/null || true
55+
fi
56+
exit 1
57+
fi
58+
59+
echo "Running Functions host E2E tests..."
60+
( cd "$ROOT_DIR" && npm run test:e2e:functions:internal )
61+
status=$?
62+
63+
if [ -n "$started_azurite" ]; then
64+
echo "Stopping Azurite..."
65+
kill "$started_azurite" 2>/dev/null || true
66+
fi
67+
68+
exit $status

0 commit comments

Comments
 (0)