diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c1e81e77..8f282301 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -49,6 +49,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: 3.9 + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -58,3 +59,137 @@ jobs: - name: Run tests run: | python -m pytest + + e2e-azurestorage-linux: + runs-on: ubuntu-latest + env: + E2E_TEST_DURABLE_BACKEND: 'AzureStorage' + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' # Azurite requires at least Node 18 + + - name: Setup E2E tests + shell: pwsh + run: | + .\test\e2e\Tests\build-e2e-test.ps1 + + - name: Build + working-directory: test/e2e/Tests + run: dotnet build + + - name: Run E2E tests + working-directory: test/e2e/Tests + run: dotnet test --filter AzureStorage!=Skip + + e2e-azurestorage-windows: + runs-on: windows-latest + env: + E2E_TEST_DURABLE_BACKEND: 'AzureStorage' + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js (needed for Azurite) + uses: actions/setup-node@v3 + with: + node-version: '18.x' # Azurite requires at least Node 18 + + - name: Setup E2E tests + shell: pwsh + run: | + .\test\e2e\Tests\build-e2e-test.ps1 + + - name: Build + working-directory: test/e2e/Tests + run: dotnet build + + - name: Run E2E tests + working-directory: test/e2e/Tests + run: dotnet test --filter AzureStorage!=Skip + + e2e-mssql: + runs-on: ubuntu-latest + env: + E2E_TEST_DURABLE_BACKEND: "MSSQL" + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Initialize Environment Variables + run: | + echo "MSSQL_SA_PASSWORD=TEST12_$(echo $RANDOM)!" >> $GITHUB_ENV + + - name: Setup E2E tests + shell: pwsh + run: | + .\test\e2e\Tests\build-e2e-test.ps1 -StartMSSqlContainer + + - name: Build + working-directory: test/e2e/Tests + run: dotnet build + + - name: Run E2E tests + working-directory: test/e2e/Tests + run: dotnet test --filter MSSQL!=Skip + + e2e-dts: + runs-on: ubuntu-latest + env: + E2E_TEST_DURABLE_BACKEND: "azureManaged" + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + + - name: Setup E2E tests + shell: pwsh + run: | + .\test\e2e\Tests\build-e2e-test.ps1 -StartDTSContainer + + - name: Build + working-directory: test/e2e/Tests + run: dotnet build + + - name: Run E2E tests + working-directory: test/e2e/Tests + run: dotnet test --logger "console;verbosity=detailed" --filter DTS!=Skip diff --git a/.gitignore b/.gitignore index 3a61d052..4175f355 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ appsettings.*.json # azurite emulator __azurite_db_*.json + +# E2E test folders +/test/e2e/tests/node_modules/* \ No newline at end of file diff --git a/samples-v2/function_chaining/function_app.py b/samples-v2/function_chaining/function_app.py index d3a0cc0b..0ceb1910 100644 --- a/samples-v2/function_chaining/function_app.py +++ b/samples-v2/function_chaining/function_app.py @@ -22,4 +22,4 @@ def my_orchestrator(context: df.DurableOrchestrationContext): @myApp.activity_trigger(input_name="city") def say_hello(city: str) -> str: - return f"Hello {city}!" \ No newline at end of file + return f"Hello {city}!" diff --git a/test/e2e/Apps/BasicPython/activity_error_handling.py b/test/e2e/Apps/BasicPython/activity_error_handling.py new file mode 100644 index 00000000..4c97715b --- /dev/null +++ b/test/e2e/Apps/BasicPython/activity_error_handling.py @@ -0,0 +1,103 @@ +from datetime import datetime +import logging +import azure.functions as func +import azure.durable_functions as df + +bp = df.Blueprint() + +attempt_count = {} + +class CustomException(Exception): + pass + +@bp.route(route="RethrowActivityException_HttpStart") +@bp.durable_client_input(client_name="client") +async def rethrow_activity_exception_http(req: func.HttpRequest, client): + instance_id = await client.start_new('rethrow_activity_exception') + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.route(route="CatchActivityException_HttpStart") +@bp.durable_client_input(client_name="client") +async def catch_activity_exception_http(req: func.HttpRequest, client): + instance_id = await client.start_new('catch_activity_exception') + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.route(route="CatchActivityExceptionFailureDetails_HttpStart") +@bp.durable_client_input(client_name="client") +async def catch_activity_exception_fd_http(req: func.HttpRequest, client): + instance_id = await client.start_new('catch_activity_exception_failure_details') + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.route(route="RetryActivityException_HttpStart") +@bp.durable_client_input(client_name="client") +async def retry_activity_exception_http(req: func.HttpRequest, client): + instance_id = await client.start_new('retry_activity_function') + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.route(route="CustomRetryActivityException_HttpStart") +@bp.durable_client_input(client_name="client") +async def custom_retry_activity_exception_http(req: func.HttpRequest, client): + instance_id = await client.start_new('custom_retry_activity_function') + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.orchestration_trigger(context_name="context") +def rethrow_activity_exception(context: df.DurableOrchestrationContext): + yield context.call_activity('raise_exception', context.instance_id) + +@bp.orchestration_trigger(context_name="context") +def catch_activity_exception(context: df.DurableOrchestrationContext): + try: + yield context.call_activity('raise_exception', context.instance_id) + except Exception as e: + logging.error(f"Caught exception: {e}") + return f"Caught exception: {e}" + +@bp.orchestration_trigger(context_name="context") +def catch_activity_exception_failure_details(context: df.DurableOrchestrationContext): + try: + yield context.call_activity('raise_exception', context.instance_id) + except Exception as e: + logging.error(f"Caught exception: {e}") + return f"Caught exception: {e}" + +@bp.orchestration_trigger(context_name="context") +def retry_activity_function(context: df.DurableOrchestrationContext): + yield context.call_activity_with_retry('raise_exception', retry_options=df.RetryOptions( + first_retry_interval_in_milliseconds=5000, + max_number_of_attempts=3 + ), input_=context.instance_id) + return "Success" + +@bp.orchestration_trigger(context_name="context") +def custom_retry_activity_function(context: df.DurableOrchestrationContext): + yield context.call_activity_with_retry('raise_complex_exception', retry_options=df.RetryOptions( + first_retry_interval_in_milliseconds=5000, + max_number_of_attempts=3 + ), input_=context.instance_id) + return "Success" + +@bp.activity_trigger(input_name="instance") +def raise_exception(instance: str) -> str: + global attempt_count + if instance not in attempt_count: + attempt_count[instance] = 1 + raise CustomException(f"This activity failed") + return "This activity succeeded" + +@bp.activity_trigger(input_name="instance2") +def raise_complex_exception(instance2: str) -> str: + global attempt_count + if instance2 not in attempt_count: + attempt_count[instance2] = 1 + raise CustomException(f"This activity failed") from Exception("More information about the failure") + return "This activity succeeded" diff --git a/test/e2e/Apps/BasicPython/function_app.py b/test/e2e/Apps/BasicPython/function_app.py new file mode 100644 index 00000000..7e380f81 --- /dev/null +++ b/test/e2e/Apps/BasicPython/function_app.py @@ -0,0 +1,31 @@ +import azure.functions as func +import logging + +from hello_cities import bp +from activity_error_handling import bp as error_handling_bp + +app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + +@app.route(route="http_trigger") +def http_trigger(req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + + name = req.params.get('name') + if not name: + try: + req_body = req.get_json() + except ValueError: + pass + else: + name = req_body.get('name') + + if name: + return func.HttpResponse(f"Hello, {name}. This HTTP triggered function executed successfully.") + else: + return func.HttpResponse( + "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.", + status_code=200 + ) + +app.register_blueprint(bp) +app.register_blueprint(error_handling_bp) \ No newline at end of file diff --git a/test/e2e/Apps/BasicPython/hello_cities.py b/test/e2e/Apps/BasicPython/hello_cities.py new file mode 100644 index 00000000..7c5ca39d --- /dev/null +++ b/test/e2e/Apps/BasicPython/hello_cities.py @@ -0,0 +1,41 @@ +from datetime import datetime +import logging +import azure.functions as func +import azure.durable_functions as df + +bp = df.Blueprint() + +@bp.route(route="HelloCities_HttpStart") +@bp.durable_client_input(client_name="client") +async def http_start(req: func.HttpRequest, client): + instance_id = await client.start_new('hello_cities') + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.route(route="HelloCities_HttpStart_Scheduled") +@bp.durable_client_input(client_name="client") +async def http_start_scheduled(req: func.HttpRequest, client): + instance_id = await client.start_new('hello_cities', None, req.params.get('ScheduledStartTime')) + + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req, instance_id) + +@bp.orchestration_trigger(context_name="context") +def hello_cities(context: df.DurableOrchestrationContext): + scheduled_start_time = context.get_input() or context.current_utc_datetime + if isinstance(scheduled_start_time, str): + scheduled_start_time = datetime.fromisoformat(scheduled_start_time) + + if scheduled_start_time > context.current_utc_datetime: + yield context.create_timer(scheduled_start_time) + + result1 = yield context.call_activity('say_hello', "Tokyo") + result2 = yield context.call_activity('say_hello', "Seattle") + result3 = yield context.call_activity('say_hello', "London") + return [result1, result2, result3] + +@bp.activity_trigger(input_name="city") +def say_hello(city: str) -> str: + logging.info(f"Saying hello to {city}.") + return f"Hello {city}!" diff --git a/test/e2e/Apps/BasicPython/host.json b/test/e2e/Apps/BasicPython/host.json new file mode 100644 index 00000000..09338ffe --- /dev/null +++ b/test/e2e/Apps/BasicPython/host.json @@ -0,0 +1,24 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensions": { + "durableTask": { + "tracing": { + "DistributedTracingEnabled": true, + "Version": "V2" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.29.0, 5.0.0)" + } +} \ No newline at end of file diff --git a/test/e2e/Apps/BasicPython/local.settings.json b/test/e2e/Apps/BasicPython/local.settings.json new file mode 100644 index 00000000..015fb759 --- /dev/null +++ b/test/e2e/Apps/BasicPython/local.settings.json @@ -0,0 +1,8 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=xxxx;IngestionEndpoint =https://xxxx.applicationinsights.azure.com/;LiveEndpoint=https://xxxx.livediagnostics.monitor.azure.com/" + } +} \ No newline at end of file diff --git a/test/e2e/Apps/BasicPython/requirements.txt b/test/e2e/Apps/BasicPython/requirements.txt new file mode 100644 index 00000000..7d68809f --- /dev/null +++ b/test/e2e/Apps/BasicPython/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azure-functions-durable \ No newline at end of file diff --git a/test/e2e/Tests/Constants.cs b/test/e2e/Tests/Constants.cs new file mode 100644 index 00000000..8393d550 --- /dev/null +++ b/test/e2e/Tests/Constants.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +internal class Constants +{ + public static readonly IConfiguration Configuration = TestUtility.GetTestConfiguration(); + + internal static readonly string FunctionsHostUrl = Configuration["FunctionAppUrl"] ?? "http://localhost:7071"; + + internal const string FunctionAppCollectionName = "DurableTestsCollection"; + internal const string FunctionAppCollectionSequentialName = "DurableTestsCollectionSequential"; +} diff --git a/test/e2e/Tests/E2ETests.csproj b/test/e2e/Tests/E2ETests.csproj new file mode 100644 index 00000000..fd9f1c87 --- /dev/null +++ b/test/e2e/Tests/E2ETests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + \ No newline at end of file diff --git a/test/e2e/Tests/Fixtures/FixtureHelpers.cs b/test/e2e/Tests/Fixtures/FixtureHelpers.cs new file mode 100644 index 00000000..83038e91 --- /dev/null +++ b/test/e2e/Tests/Fixtures/FixtureHelpers.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +public static class FixtureHelpers +{ + public static Process GetFuncHostProcess(string appPath, bool enableAuth = false) + { + var cliPath = Path.Combine(Path.GetTempPath(), @"DurableTaskExtensionE2ETests/Azure.Functions.Cli/func"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + cliPath += ".exe"; + } + + if (!File.Exists(cliPath)) + { + throw new InvalidOperationException($"Could not find '{cliPath}'. Try running '{Path.Combine("build-e2e-test.ps1")}' to install it."); + } + + var funcProcess = new Process(); + + funcProcess.StartInfo.UseShellExecute = false; + funcProcess.StartInfo.RedirectStandardError = true; + funcProcess.StartInfo.RedirectStandardOutput = true; + funcProcess.StartInfo.CreateNoWindow = true; + funcProcess.StartInfo.WorkingDirectory = appPath; + funcProcess.StartInfo.FileName = cliPath; + funcProcess.StartInfo.ArgumentList.Add("host"); + funcProcess.StartInfo.ArgumentList.Add("start"); + funcProcess.StartInfo.ArgumentList.Add("--verbose"); + + if (enableAuth) + { + funcProcess.StartInfo.ArgumentList.Add("--enableAuth"); + } + + return funcProcess; + } + + public static void StartProcessWithLogging(Process funcProcess, ILogger logger) + { + funcProcess.ErrorDataReceived += (sender, e) => { + try { logger.LogError(e?.Data); } + catch (InvalidOperationException) { } + }; + funcProcess.OutputDataReceived += (sender, e) => { + try { logger.LogInformation(e?.Data); } + catch (InvalidOperationException) { } + }; + + funcProcess.Start(); + + logger.LogInformation($"Started '{funcProcess.StartInfo.FileName}'"); + + funcProcess.BeginErrorReadLine(); + funcProcess.BeginOutputReadLine(); + } + + public static void KillExistingProcessesMatchingName(string processName) + { + foreach (var process in Process.GetProcessesByName(processName)) + { + try + { + process.Kill(); + } + catch + { + // Best effort + } + } + } + + internal static void AddDurableBackendEnvironmentVariables(Process funcProcess, ILogger testLogger) + { + string? durableBackendEnvVarValue = Environment.GetEnvironmentVariable("E2E_TEST_DURABLE_BACKEND"); + switch ((durableBackendEnvVarValue ?? "").ToLowerInvariant()) + { + case "azurestorage": + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__MaxGrpcMessageSizeInBytes"] = "6291456"; + return; + case "mssql": + string? sqlPassword = Environment.GetEnvironmentVariable("MSSQL_SA_PASSWORD"); + if (string.IsNullOrEmpty(sqlPassword)) + { + testLogger.LogWarning("Environment variable MSSQL_SA_PASSWORD not set, connection string to SQL emulator may fail"); + } + funcProcess.StartInfo.EnvironmentVariables["SQLDB_Connection"] = $"Server=localhost,1433;Database=DurableDB;User Id=sa;Password={sqlPassword};TrustServerCertificate=True;"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__storageProvider__type"] = "mssql"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__storageProvider__connectionStringName"] = "SQLDB_Connection"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__storageProvider__createDatabaseIfNotExists"] = "true"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__MaxGrpcMessageSizeInBytes"] = "6291456"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__ThrowStatusExceptionsOnRaiseEvent"] = "true"; + return; + case "azuremanaged": + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__hubName"] = "default"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__storageProvider__type"] = "azureManaged"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__storageProvider__connectionStringName"] = "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"; + funcProcess.StartInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] = $"Endpoint=http://localhost:8080;Authentication=None"; + funcProcess.StartInfo.EnvironmentVariables["AzureFunctionsJobHost__extensions__durableTask__ThrowStatusExceptionsOnRaiseEvent"] = "true"; + return; + default: + testLogger.LogWarning("Environment variable E2E_TEST_DURABLE_BACKEND not set, tests configured for Azure Storage"); + return; + } + } +} diff --git a/test/e2e/Tests/Fixtures/FunctionAppFixture.cs b/test/e2e/Tests/Fixtures/FunctionAppFixture.cs new file mode 100644 index 00000000..20beba13 --- /dev/null +++ b/test/e2e/Tests/Fixtures/FunctionAppFixture.cs @@ -0,0 +1,175 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +public class FunctionAppFixture : IAsyncLifetime +{ + private readonly ILogger _logger; + private bool _disposed; + private Process? _funcProcess; + + private JobObjectRegistry? _jobObjectRegistry; + + public FunctionAppFixture(IMessageSink messageSink) + { + // initialize logging + ILoggerFactory loggerFactory = new LoggerFactory(); + this.TestLogs = new TestLoggerProvider(messageSink); + loggerFactory.AddProvider(this.TestLogs); + this._logger = loggerFactory.CreateLogger(); + } + + public async Task InitializeAsync() + { + // start host via CLI if testing locally + if (Constants.FunctionsHostUrl.Contains("localhost")) + { + // kill existing func processes + this._logger.LogInformation("Shutting down any running functions hosts.."); + FixtureHelpers.KillExistingProcessesMatchingName("func"); + + // start functions process + this._logger.LogInformation($"Starting functions host for {Constants.FunctionAppCollectionName}..."); + + string rootDir = Path.GetFullPath(@"../../../../../../"); + string e2eAppBinPath = Path.Combine(rootDir, @"test/e2e/Apps/BasicPython"); + string? e2eHostJson = Directory.GetFiles(e2eAppBinPath, "host.json", SearchOption.AllDirectories).FirstOrDefault(); + + if (e2eHostJson == null) + { + throw new InvalidOperationException($"Could not find a built worker app under '{e2eAppBinPath}'"); + } + + string? e2eAppPath = Path.GetDirectoryName(e2eHostJson); + + if (e2eAppPath == null) + { + throw new InvalidOperationException($"Located host.json for app at {e2eHostJson} but could not resolve the app base directory"); + } + + this._funcProcess = FixtureHelpers.GetFuncHostProcess(e2eAppPath); + string workingDir = this._funcProcess.StartInfo.WorkingDirectory; + this._logger.LogInformation($" Working dir: '${workingDir}' Exists: '{Directory.Exists(workingDir)}'"); + string fileName = this._funcProcess.StartInfo.FileName; + this._logger.LogInformation($" File name: '${fileName}' Exists: '{File.Exists(fileName)}'"); + + //TODO: This may be added back if we want cosmos tests + //await CosmosDBHelpers.TryCreateDocumentCollectionsAsync(_logger); + + //TODO: WORKER ATTACH ISSUES + // Abandoning this attach method for now - It seems like Debugger.Launch() from the app can't detect the running VS instance. + // Not sure if this is because VS is the parent process, or because it is already attached to testhost.exe, but for now we + // will rely on manual attach. Some possible solution with DTE might exist but for now, it relies on a specific VS version + //if (Debugger.IsAttached) + //{ + // _funcProcess.StartInfo.EnvironmentVariables["DURABLE_ATTACH_DEBUGGER"] = "True"; + //} + + FixtureHelpers.AddDurableBackendEnvironmentVariables(this._funcProcess, this._logger); + + FixtureHelpers.StartProcessWithLogging(this._funcProcess, this._logger); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // ensure child processes are cleaned up + _jobObjectRegistry = new JobObjectRegistry(); + _jobObjectRegistry.Register(this._funcProcess); + } + + using var httpClient = new HttpClient(); + this._logger.LogInformation("Waiting for host to be running..."); + await TestUtility.RetryAsync(async () => + { + try + { + var response = await httpClient.GetAsync($"{Constants.FunctionsHostUrl}/admin/host/status"); + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("state", out JsonElement value) && + value.GetString() == "Running") + { + this._logger.LogInformation($" Current state: Running"); + return true; + } + + this._logger.LogInformation($" Current state: {value}"); + return false; + } + catch + { + if (_funcProcess.HasExited) + { + // Something went wrong starting the host - check the logs + this._logger.LogInformation($" Current state: process exited - something may have gone wrong."); + return false; + } + + // Can get exceptions before host is running. + this._logger.LogInformation($" Current state: process starting"); + return false; + } + }, userMessageCallback: () => string.Join(System.Environment.NewLine, TestLogs.CoreToolsLogs)); + } + + //TODO: This line would launch the jit debugger for func - still some issues here, however. + // ISSUE 1: Windows only implementation + // ISSUE 2: For some reason, the loaded symbols for the WebJobs extension + // a) don't load automatically + // b) don't match the version from the local repo + // ISSUE 3: See the worker attach comments above + //Process.Start("cmd.exe", "/C vsjitdebugger.exe -p " + _funcProcess.Id.ToString()); + } + + internal TestLoggerProvider TestLogs { get; private set; } + + + public Task DisposeAsync() + { + if (!this._disposed) + { + if (this._funcProcess != null) + { + try + { + this._funcProcess.Kill(); + this._funcProcess.Dispose(); + } + catch + { + // process may not have started + } + } + + this._jobObjectRegistry?.Dispose(); + } + + this._disposed = true; + + return Task.CompletedTask; + } +} + +[CollectionDefinition(Constants.FunctionAppCollectionName)] +public class FunctionAppCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} + + +[CollectionDefinition(Constants.FunctionAppCollectionSequentialName, DisableParallelization = true)] +public class FunctionAppCollectionSequential : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/test/e2e/Tests/Helpers/DurableHelpers.cs b/test/e2e/Tests/Helpers/DurableHelpers.cs new file mode 100644 index 00000000..c4a1b1ac --- /dev/null +++ b/test/e2e/Tests/Helpers/DurableHelpers.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Text.Json.Nodes; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +internal class DurableHelpers +{ + static readonly HttpClient _httpClient = new HttpClient(); + + static readonly List finalStates = new List() + { + "Completed", + "Terminated", + "Failed" + }; + + internal class OrchestrationStatusDetails + { + public string RuntimeStatus { get; set; } = string.Empty; + public string Input { get; set; } = string.Empty; + public string Output { get; set; } = string.Empty; + public DateTime CreatedTime { get; set; } + public DateTime LastUpdatedTime { get; set; } + public OrchestrationStatusDetails(string statusQueryResponse) + { + JsonNode? statusQueryJsonNode = JsonNode.Parse(statusQueryResponse); + if (statusQueryJsonNode == null) + { + return; + } + this.RuntimeStatus = statusQueryJsonNode["runtimeStatus"]?.GetValue() ?? string.Empty; + this.Input = statusQueryJsonNode["input"]?.ToString() ?? string.Empty; + this.Output = statusQueryJsonNode["output"]?.ToString() ?? string.Empty; + this.CreatedTime = DateTime.Parse(statusQueryJsonNode["createdTime"]?.GetValue() ?? string.Empty).ToUniversalTime(); + this.LastUpdatedTime = DateTime.Parse(statusQueryJsonNode["lastUpdatedTime"]?.GetValue() ?? string.Empty).ToUniversalTime(); + } + } + + internal static async Task ParseStatusQueryGetUriAsync(HttpResponseMessage invocationStartResponse) + { + string? responseString = await invocationStartResponse.Content.ReadAsStringAsync(); + // NOTE: Differs from .NET: statusQueryGetUri vs StatusQueryGetUri + return TokenizeAndGetValueFromKeyAsString(responseString, "statusQueryGetUri"); + } + + internal static async Task ParseInstanceIdAsync(HttpResponseMessage invocationStartResponse) + { + string? responseString = await invocationStartResponse.Content.ReadAsStringAsync(); + return TokenizeAndGetValueFromKeyAsString(responseString, "Id"); + } + + internal static async Task GetRunningOrchestrationDetailsAsync(string statusQueryGetUri) + { + var statusQueryResponse = await _httpClient.GetAsync(statusQueryGetUri); + + string? statusQueryResponseString = await statusQueryResponse.Content.ReadAsStringAsync(); + + return new OrchestrationStatusDetails(statusQueryResponseString); + } + + internal static async Task WaitForOrchestrationStateAsync(string statusQueryGetUri, string desiredState, int maxTimeoutSeconds) + { + DateTime timeoutTime = DateTime.Now + TimeSpan.FromSeconds(maxTimeoutSeconds); + while (DateTime.Now < timeoutTime) + { + var currentStatus = await GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + if (currentStatus.RuntimeStatus == desiredState) + { + return; + } + if (finalStates.Contains(currentStatus.RuntimeStatus)) + { + throw new TaskCanceledException($"Orchestration reached {currentStatus.RuntimeStatus} state when test was expecting {desiredState}"); + } + await Task.Delay(100); + } + throw new TimeoutException($"Orchestration did not reach {desiredState} status within {maxTimeoutSeconds} seconds."); + } + + private static string TokenizeAndGetValueFromKeyAsString(string? json, string key) + { + if (string.IsNullOrEmpty(json)) + { + return string.Empty; + } + JsonNode? responseJsonNode = JsonNode.Parse(json); + if (responseJsonNode == null) + { + return string.Empty; + } + + string? statusQueryGetUri = responseJsonNode[key]?.GetValue(); + return statusQueryGetUri ?? string.Empty; + } +} diff --git a/test/e2e/Tests/Helpers/HttpHelpers.cs b/test/e2e/Tests/Helpers/HttpHelpers.cs new file mode 100644 index 00000000..9de59f6c --- /dev/null +++ b/test/e2e/Tests/Helpers/HttpHelpers.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +class HttpHelpers +{ + public static async Task InvokeHttpTrigger(string functionName, string queryString = "") + { + // Basic http request + using HttpRequestMessage request = GetTestRequest(functionName, queryString); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); + return await GetResponseMessage(request); + } + + public static async Task InvokeHttpTriggerWithBody(string functionName, string body, string mediaType) + { + HttpRequestMessage request = GetTestRequest(functionName); + request.Content = new StringContent(body); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(mediaType)); + return await GetResponseMessage(request); + } + + private static HttpRequestMessage GetTestRequest(string functionName, string queryString = "") + { + return new HttpRequestMessage + { + RequestUri = new Uri($"{Constants.FunctionsHostUrl}/api/{functionName}{queryString}"), + Method = HttpMethod.Post + }; + } + + private static async Task GetResponseMessage(HttpRequestMessage request) + { + HttpResponseMessage? response = null; + using var httpClient = new HttpClient(); + response = await httpClient.SendAsync(request); + + return response; + } +} diff --git a/test/e2e/Tests/Helpers/TestLoggerProvider.cs b/test/e2e/Tests/Helpers/TestLoggerProvider.cs new file mode 100644 index 00000000..e60d3fcc --- /dev/null +++ b/test/e2e/Tests/Helpers/TestLoggerProvider.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +internal class TestLoggerProvider : ILoggerProvider, ILogger +{ + private readonly IMessageSink _messageSink; + private ITestOutputHelper? _currentTestOutput; + IList _logs = new List(); + + public TestLoggerProvider(IMessageSink messageSink) + { + _messageSink = messageSink; + } + + public IEnumerable CoreToolsLogs => _logs.ToArray(); + + // This needs to be created/disposed per-test so we can associate logs + // with the specific running test. + public IDisposable UseTestLogger(ITestOutputHelper testOutput) + { + // reset these every test + _currentTestOutput = testOutput; + return new DisposableOutput(this); + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public ILogger CreateLogger(string categoryName) + { + return this; + } + + public void Dispose() + { + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + string formattedString = formatter(state, exception); + _messageSink.OnMessage(new DiagnosticMessage(formattedString)); + _logs.Add(formattedString); + try { _currentTestOutput?.WriteLine(formattedString); } catch { } + } + + private class DisposableOutput : IDisposable + { + private readonly TestLoggerProvider _xunitLogger; + + public DisposableOutput(TestLoggerProvider xunitLogger) + { + _xunitLogger = xunitLogger; + } + + public void Dispose() + { + _xunitLogger._currentTestOutput = null; + } + } +} diff --git a/test/e2e/Tests/Helpers/TestUtility.cs b/test/e2e/Tests/Helpers/TestUtility.cs new file mode 100644 index 00000000..04f767a0 --- /dev/null +++ b/test/e2e/Tests/Helpers/TestUtility.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +public static class TestUtility +{ + public static IConfiguration GetTestConfiguration() + { + return new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddTestSettings() + .Build(); + } + + public static IConfigurationBuilder AddTestSettings(this IConfigurationBuilder builder) + { + string configPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azurefunctions", "appsettings.tests.json"); + return builder.AddJsonFile(configPath, true); + } + + public static async Task RetryAsync(Func> condition, int timeout = 60 * 1000, int pollingInterval = 2 * 1000, bool throwWhenDebugging = false, Func? userMessageCallback = null) + { + DateTime start = DateTime.Now; + while (!await condition()) + { + await Task.Delay(pollingInterval); + + bool shouldThrow = !Debugger.IsAttached || (Debugger.IsAttached && throwWhenDebugging); + if (shouldThrow && (DateTime.Now - start).TotalMilliseconds > timeout) + { + string error = "Condition not reached within timeout."; + if (userMessageCallback != null) + { + error += " " + userMessageCallback(); + } + throw new ApplicationException(error); + } + } + } +} diff --git a/test/e2e/Tests/JobObjectRegistry.cs b/test/e2e/Tests/JobObjectRegistry.cs new file mode 100644 index 00000000..3d2a53a2 --- /dev/null +++ b/test/e2e/Tests/JobObjectRegistry.cs @@ -0,0 +1,145 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +// Taken from: https://github.com/Azure/azure-functions-host/blob/69111926ee920d4ba10829c8fa34303bb8165a42/src/WebJobs.Script/Workers/ProcessManagement/JobObjectRegistry.cs +// This kills child func.exe even if tests are killed from VS mid-run. + +// Registers processes on windows with a job object to ensure disposal after parent exit. +internal class JobObjectRegistry : IDisposable +{ + private IntPtr _handle; + private bool _disposed = false; + + public JobObjectRegistry() + { + _handle = CreateJobObject(null, null); + + var info = new JOBOBJECT_BASIC_LIMIT_INFORMATION + { + LimitFlags = 0x2000 + }; + + var extendedInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + BasicLimitInformation = info + }; + + int length = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr extendedInfoPtr = Marshal.AllocHGlobal(length); + Marshal.StructureToPtr(extendedInfo, extendedInfoPtr, false); + + if (!SetInformationJobObject(_handle, JobObjectInfoType.ExtendedLimitInformation, extendedInfoPtr, (uint)length)) + { + throw new Exception(string.Format("Unable to set information. Error: {0}", Marshal.GetLastWin32Error())); + } + } + + public bool Register(Process proc) + { + return AssignProcessToJobObject(_handle, proc.Handle); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(object? a, string? lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoType infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr job); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + // Dispose of managed resources. + } + + Close(); + _disposed = true; + } + + public void Close() + { + if (_handle != IntPtr.Zero) + { + CloseHandle(_handle); + } + _handle = IntPtr.Zero; + } +} + +public enum JobObjectInfoType +{ + AssociateCompletionPortInformation = 7, + BasicLimitInformation = 2, + BasicUIRestrictions = 4, + EndOfJobTimeInformation = 6, + ExtendedLimitInformation = 9, + SecurityLimitInformation = 5, + GroupInformation = 11 +} + +[StructLayout(LayoutKind.Sequential)] +internal struct IO_COUNTERS +{ + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct JOBOBJECT_BASIC_LIMIT_INFORMATION +{ + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; +} + +[StructLayout(LayoutKind.Sequential)] +public struct SECURITY_ATTRIBUTES +{ + public uint nLength; + public IntPtr lpSecurityDescriptor; + public int bInheritHandle; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION +{ + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; +} diff --git a/test/e2e/Tests/Tests/ActivityInputTypeTests.cs b/test/e2e/Tests/Tests/ActivityInputTypeTests.cs new file mode 100644 index 00000000..09a6ae21 --- /dev/null +++ b/test/e2e/Tests/Tests/ActivityInputTypeTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using System.Text.Json; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class ActivityInputTypeTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public ActivityInputTypeTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + // This test verifies that different types of inputs can be properly serialized and passed to activity functions. + [Fact(Skip="Not yet implemented in Python")] + public async Task DifferentActivityInputTypeTests() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("ActivityInputType_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + + // Verify that all activity functions can successfully receive their corresponding input types: + // - byte[] + // - empty byte[] + // - single byte + // - custom class (with byte[] property) + // - int[] + // - string + // - custom class array + // This especially verifies that byte[] serialization works correctly without any errors + Assert.Contains("Received byte[]: [1, 2, 3, 4, 5]", orchestrationDetails.Output); + Assert.Contains("Received byte[]: []", orchestrationDetails.Output); + Assert.Contains("Received byte: 42", orchestrationDetails.Output); + Assert.Contains("Received CustomClass: {Name: Test, Age: 25, Duration: 01:00:00, Data: [1, 2, 3]}", orchestrationDetails.Output); + Assert.Contains("Received int[]: [1, 2, 3, 4, 5]", orchestrationDetails.Output); + Assert.Contains("Received string: Test string input", orchestrationDetails.Output); + Assert.Contains("Received CustomClass[]: [{Name: Test1, Age: 25, Duration: 00:30:00, Data: [1, 2, 3]}, {Name: Test2, Age: 30, Duration: 00:45:00, Data: []}]", orchestrationDetails.Output); + + // Verify there were no serialization errors, especially for byte[] types + Assert.DoesNotContain("Error:", orchestrationDetails.Output); + } +} diff --git a/test/e2e/Tests/Tests/DistributedTracingTests.cs b/test/e2e/Tests/Tests/DistributedTracingTests.cs new file mode 100644 index 00000000..75b31b42 --- /dev/null +++ b/test/e2e/Tests/Tests/DistributedTracingTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Xunit.Abstractions; +using Xunit; +using System.Diagnostics; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class DistributedTracingTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + private readonly ActivityListener activityListener; + + public DistributedTracingTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + + // Initialize the ActivityListener here + this.activityListener = new ActivityListener + { + ShouldListenTo = source => source.Name == "DistributedTracingTests", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => { /* Handle activity started */ }, + ActivityStopped = activity => { /* Handle activity stopped */ } + }; + ActivitySource.AddActivityListener(this.activityListener); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("DTS", "Skip")] // Distributed tracing is currently not working in DTS + public async Task DistributedTracingTest() + { + // Start Activity + ActivitySource activitySource = new ActivitySource("DistributedTracingTests"); + using Activity? activity = activitySource.StartActivity("HttpTriggerTests"); + + Assert.NotNull(activity); + + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("DistributedTracing_HttpStart", ""); + + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + string output = orchestrationDetails.Output; + ActivityContext.TryParse(output, null, out ActivityContext activityContext); + + Assert.Equal(activity?.TraceId.ToString(), activityContext.TraceId.ToString()); + } +} \ No newline at end of file diff --git a/test/e2e/Tests/Tests/ErrorHandlingTests.cs b/test/e2e/Tests/Tests/ErrorHandlingTests.cs new file mode 100644 index 00000000..0df1eb0c --- /dev/null +++ b/test/e2e/Tests/Tests/ErrorHandlingTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Microsoft.DurableTask; +using Newtonsoft.Json; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class ErrorHandlingTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public ErrorHandlingTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + // NOTE: All of these tests differ from .NET as the error messages are different. Need to figure out a way for the tests to localize the + // expected string outputs to the function language if this code is going to be globalized + + [Fact] + [Trait("MSSQL", "Skip")] // This test fails for MSSQL unless this bug is fixed: https://github.com/microsoft/durabletask-mssql/issues/287 + [Trait("DTS", "Skip")] // DTS will fail this test unless this bug is fixed: https://msazure.visualstudio.com/Antares/_workitems/edit/31779638 + public async Task OrchestratorWithUncaughtActivityException_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("RethrowActivityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Failed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + + Assert.StartsWith("Orchestrator function 'rethrow_activity_exception' failed: Activity function 'raise_exception' failed: ", orchestrationDetails.Output); + Assert.Contains("CustomException: This activity failed", orchestrationDetails.Output); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("MSSQL", "Skip")] // Durable Entities are not supported in MSSQL/Dotnet Isolated, see https://github.com/microsoft/durabletask-mssql/issues/205 + [Trait("DTS", "Skip")] // DTS will fail this test unless this bug is fixed: https://msazure.visualstudio.com/Antares/_workitems/edit/31779638 + public async Task OrchestratorWithUncaughtEntityException_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("RethrowEntityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Failed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + + Assert.StartsWith("Microsoft.DurableTask.Entities.EntityOperationFailedException", orchestrationDetails.Output); + Assert.Contains("This entity failed", orchestrationDetails.Output); + } + + [Fact] + public async Task OrchestratorWithCaughtActivityException_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("CatchActivityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.StartsWith("Caught exception: Activity function 'raise_exception' failed: ", orchestrationDetails.Output); + Assert.Contains("CustomException: This activity failed", orchestrationDetails.Output); + } + + [Fact(Skip = "Skipped for Python as failure_details is a dotnet implementation detail")] + public async Task OrchestratorWithCaughtActivityExceptionFailuredetails_ContainRightErrorType() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("CatchActivityExceptionFailureDetails_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + // Deserialize the output to FailureDetails + var failureDetails = JsonConvert.DeserializeObject(orchestrationDetails.Output); + + // Check FailureDetails contains the right error type and error message, + // Here it should be the same one as the activity function Raise Exception throws. + Assert.NotNull(failureDetails); + Assert.Equal("System.InvalidOperationException", failureDetails.ErrorType); + Assert.Equal("This activity failed", failureDetails.ErrorMessage); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("MSSQL", "Skip")] // Durable Entities are not supported in MSSQL/Dotnet Isolated, see https://github.com/microsoft/durabletask-mssql/issues/205 + public async Task OrchestratorWithCaughtEntityException_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("CatchEntityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.StartsWith("Operation 'ThrowFirstTimeOnly' of entity '@counter@MyExceptionEntity' failed:", orchestrationDetails.Output); + Assert.Contains("This entity failed", orchestrationDetails.Output); + Assert.Contains("More information about the failure", orchestrationDetails.Output); + + // For now, we deliberately do not return inner exception details on entity failure. + // If this changes in the future, update this test. + Assert.DoesNotContain("Inner exception message", orchestrationDetails.Output); + } + + [Fact] + public async Task OrchestratorWithRetriedActivityException_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("RetryActivityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.Equal("Success", orchestrationDetails.Output); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("CustomException") && + x.Contains("This activity failed")); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("MSSQL", "Skip")] // Durable Entities are not supported in MSSQL/Dotnet Isolated, see https://github.com/microsoft/durabletask-mssql/issues/205 + [Trait("DTS", "Skip")] // DTS will fail this test unless this issue is fixed, see https://msazure.visualstudio.com/Antares/_workitems/edit/31778744 + public async Task OrchestratorWithRetriedEntityException_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("RetryEntityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.Equal("Success", orchestrationDetails.Output); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + // For entities, these logs are not emitted as one continuous log, but each line of the exception .ToString() is + // logged individually. + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains(nameof(InvalidOperationException)) && + x.Contains("This entity failed")); + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("More information about the failure")); + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains(nameof(OverflowException)) && + x.Contains("Inner exception message")); + } + + [Fact] + public async Task OrchestratorWithCustomRetriedActivityException_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("CustomRetryActivityException_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.Equal("Success", orchestrationDetails.Output); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + // We want to ensure that multiline exception messages and inner exceptions are preserved + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("CustomException") && + x.Contains("Exception") && + x.Contains("This activity failed") && + x.Contains("More information about the failure")); + } +} diff --git a/test/e2e/Tests/Tests/ExternalEventTests.cs b/test/e2e/Tests/Tests/ExternalEventTests.cs new file mode 100644 index 00000000..a44ff7ff --- /dev/null +++ b/test/e2e/Tests/Tests/ExternalEventTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using System.Text.Json; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class ExternalEventTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public ExternalEventTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + // Test that sending an event to a running orchestrator waiting for an external event will complete successfully, + // and sending an event to a completed instance will throw a FailedPrecondition RpcException with details error message. + [Fact(Skip = "Not yet implemented in Python")] + public async Task RaiseExternalEventTests() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("ExternalEventOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string jsonContent = JsonSerializer.Serialize(instanceId); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + // Send Event to the above Orchestrator which is waiting for external event. + await HttpHelpers.InvokeHttpTriggerWithBody("SendExternalEvent_HttpStart", jsonContent, "application/json"); + + // Make sure orchestration instance completes successfully. + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + // Send external event again to the completed orchestrator, which we will get a exception back. + HttpResponseMessage resendEventResponse = await HttpHelpers.InvokeHttpTriggerWithBody("SendExternalEvent_HttpStart", jsonContent, "application/json"); + string responseContent = await resendEventResponse.Content.ReadAsStringAsync(); + + // Verify the returned exception contains the correct information. + Assert.Contains("FailedPrecondition", responseContent); + Assert.Contains("The orchestration instance with the provided instance id is not running.", responseContent); + } + + // Test that sending an event to a not-exist InstanceId will throw an NotFoundRpc Exception. + [Fact(Skip = "Not yet implemented in Python")] + public async Task NotFoundInstanceTest() + { + string jsonContent = JsonSerializer.Serialize("instance-does-not-exist-test"); + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("SendExternalEvent_HttpStart", jsonContent, "application/json"); + string responseContent = await response.Content.ReadAsStringAsync(); + + // Verify the returned exception contains the correct information. + Assert.Contains("NotFound", responseContent); + Assert.Contains("No instance with ID 'instance-does-not-exist-test' was found", responseContent); + } +} diff --git a/test/e2e/Tests/Tests/HelloCitiesTest.cs b/test/e2e/Tests/Tests/HelloCitiesTest.cs new file mode 100644 index 00000000..d1767f46 --- /dev/null +++ b/test/e2e/Tests/Tests/HelloCitiesTest.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class HttpEndToEndTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public HttpEndToEndTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + // Due to some kind of asynchronous race condition in XUnit, when running these tests in pipelines, + // the output may be disposed before the message is written. Just ignore these types of errors for now. + private void WriteOutput(string message) + { + try + { + this.output.WriteLine(message); + } + catch + { + // Ignore + } + } + + [Theory] + [InlineData("HelloCities_HttpStart", HttpStatusCode.Accepted, "Hello Tokyo!")] + public async Task HttpTriggerTests(string functionName, HttpStatusCode expectedStatusCode, string partialExpectedOutput) + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionName, ""); + + Assert.Equal(expectedStatusCode, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.Contains(partialExpectedOutput, orchestrationDetails.Output); + } + + [Theory] + [InlineData("HelloCities_HttpStart_Scheduled", 5, HttpStatusCode.Accepted)] + [InlineData("HelloCities_HttpStart_Scheduled", -5, HttpStatusCode.Accepted)] + public async Task ScheduledStartTests(string functionName, int startDelaySeconds, HttpStatusCode expectedStatusCode) + { + var testStartTime = DateTime.UtcNow; + var scheduledStartTime = testStartTime + TimeSpan.FromSeconds(startDelaySeconds); + string urlQueryString = $"?ScheduledStartTime={scheduledStartTime.ToString("o")}"; + + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger(functionName, urlQueryString); + + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + Assert.Equal(expectedStatusCode, response.StatusCode); + + if (scheduledStartTime > DateTime.UtcNow + TimeSpan.FromSeconds(1)) + { + // NOTE: Differs from .NET - We don't support ScheduledStartTime in Python, so this test is implemented in the app with timers. + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 30); + } + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", Math.Max(startDelaySeconds, 0) + 30); + + // This +2s should not be necessary - however, experimentally the orchestration may run up to ~1 second before the scheduled time. + // It is unclear currently whether this is a bug where orchestrations run early, or a clock difference/error, + // but leaving this logic in for now until further investigation. + Assert.True(DateTime.UtcNow + TimeSpan.FromSeconds(2) >= scheduledStartTime); + + var finalOrchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + WriteOutput($"Last updated at {finalOrchestrationDetails.LastUpdatedTime}, scheduled to complete at {scheduledStartTime}"); + Assert.True(finalOrchestrationDetails.LastUpdatedTime + TimeSpan.FromSeconds(2) >= scheduledStartTime); + } +} diff --git a/test/e2e/Tests/Tests/LargeOutputOrchestratorTests.cs b/test/e2e/Tests/Tests/LargeOutputOrchestratorTests.cs new file mode 100644 index 00000000..77b0db75 --- /dev/null +++ b/test/e2e/Tests/Tests/LargeOutputOrchestratorTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class LargeOutputOrchestratorTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public LargeOutputOrchestratorTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + [Theory(Skip = "Not yet implemented in Python")] + [InlineData(65)] // Provide a value slightly exceeding the 64 KB Azure Queue Storage limit to trigger use of blob storage instead at Azure Storage backend. + public async Task LargeOutputStatusQueryTests(int sizeInKB) + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("LargeOutputOrchestrator_HttpStart", sizeInKB.ToString(), "application/json"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + string largeOutput = GenerateLargeString(sizeInKB); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + + // Verify that large orchestrator outputs stored in blob storage are correctly returned via statusQueryGetUri + Assert.Contains(largeOutput, orchestrationDetails.Output); + } + + [Theory(Skip = "Not yet implemented in Python")] + [InlineData(4608)]// This value exceeds the default 4 MB, as the test sets the threshold to 6 MB. + [Trait("DTS", "Skip")] + public async Task DurableTaskClientWriteOutputTests(int sizeInKB) + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTriggerWithBody("LargeOutputOrchestrator_HttpStart", sizeInKB.ToString(), "application/json"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + HttpResponseMessage result = await HttpHelpers.InvokeHttpTrigger("LargeOutputOrchestrator_Query_Output", $"?id={instanceId}"); + var expectedOutput = GenerateLargeString(sizeInKB); + + // Verify that large orchestrator outputs stored in blob storage are correctly returned when using OrchestrationMetada.ReadOutputAs() + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + var content = await result.Content.ReadAsStringAsync(); + Assert.Contains(expectedOutput, content); + } + + static string GenerateLargeString(int sizeInKB) + { + return new string('A', sizeInKB * 1024); + } +} \ No newline at end of file diff --git a/test/e2e/Tests/Tests/OrchestrationQueryTests.cs b/test/e2e/Tests/Tests/OrchestrationQueryTests.cs new file mode 100644 index 00000000..dcfb9ed9 --- /dev/null +++ b/test/e2e/Tests/Tests/OrchestrationQueryTests.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using System.Text.Json.Nodes; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionSequentialName)] +public class OrchestrationQueryTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public OrchestrationQueryTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task ListAllOrchestrations_ShouldSucceed() + { + using HttpResponseMessage statusResponse = await HttpHelpers.InvokeHttpTrigger("GetAllInstances", ""); + + Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); + + string? statusResponseMessage = await statusResponse.Content.ReadAsStringAsync(); + Assert.NotNull(statusResponseMessage); + + JsonNode? statusResponseJsonNode = JsonNode.Parse(statusResponseMessage); + Assert.NotNull(statusResponseJsonNode); + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task ListRunningOrchestrations_ShouldContainRunningOrchestration() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("LongOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 30); + try + { + using HttpResponseMessage statusResponse = await HttpHelpers.InvokeHttpTrigger("GetRunningInstances", ""); + + Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); + string? statusResponseMessage = await statusResponse.Content.ReadAsStringAsync(); + Assert.NotNull(statusResponseMessage); + + JsonNode? statusResponseJsonNode = JsonNode.Parse(statusResponseMessage); + Assert.NotNull(statusResponseJsonNode); + + Assert.Contains(statusResponseJsonNode.AsArray(), x => x?["InstanceId"]?.ToString() == instanceId); + } + finally + { + await TryTerminateInstanceAsync(instanceId); + } + } + private static async Task TryTerminateInstanceAsync(string instanceId) + { + try + { + // Clean up the instance by terminating it - no-op if this fails + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + return true; + } + catch (Exception) { } + return false; + } +} diff --git a/test/e2e/Tests/Tests/PurgeInstancesTests.cs b/test/e2e/Tests/Tests/PurgeInstancesTests.cs new file mode 100644 index 00000000..13945ea2 --- /dev/null +++ b/test/e2e/Tests/Tests/PurgeInstancesTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionSequentialName)] +public class PurgeInstancesTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public PurgeInstancesTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + [Fact(Skip = "Not yet implemented in Python")] + public async Task PurgeOrchestrationHistory_StartAndEnd_Succeeds() + { + DateTime purgeStartTime = DateTime.MinValue; + DateTime purgeEndTime = DateTime.UtcNow; + string queryParams = $"?purgeStartTime={purgeStartTime:o}&purgeEndTime={purgeEndTime:o}"; + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", queryParams); + string actualMessage = await response.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged [0-9]* records$", actualMessage); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Skip = "Not yet implemented in Python")] + public async Task PurgeOrchestrationHistory_Start_Succeeds() + { + DateTime purgeStartTime = DateTime.MinValue; + DateTime purgeEndTime = DateTime.UtcNow; + string queryParams = $"?purgeStartTime={purgeStartTime:o}"; + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", queryParams); + string actualMessage = await response.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged [0-9]* records$", actualMessage); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("DTS", "Skip")] // Skip this test as there is a bug with current DTS backend, the createdTimeTo couldn't be null. + public async Task PurgeOrchestrationHistory_End_Succeeds() + { + DateTime purgeStartTime = DateTime.MinValue; + DateTime purgeEndTime = DateTime.UtcNow; + string queryParams = $"?purgeEndTime={purgeEndTime:o}"; + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", queryParams); + string actualMessage = await response.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged [0-9]* records$", actualMessage); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("DTS", "Skip")] // Skip this test as there is a bug with current DTS backend, the createdTimeTo couldn't be null. + public async Task PurgeOrchestrationHistory_NoBoundaries_Succeeds() + { + DateTime purgeStartTime = DateTime.MinValue; + DateTime purgeEndTime = DateTime.UtcNow; + string queryParams = $""; + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", queryParams); + string actualMessage = await response.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged [0-9]* records$", actualMessage); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("DTS", "Skip")] // Skip this test as there is a bug with current DTS backend, the createdTimeTo couldn't be null. + public async Task PurgeOrchestrationHistoryAfterInvocation_Succeeds() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("HelloCities_HttpStart", ""); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + DateTime purgeEndTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); + using HttpResponseMessage purgeResponse = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?purgeEndTime={purgeEndTime:o}"); + string purgeMessage = await purgeResponse.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged [0-9]* records$", purgeMessage); + Assert.DoesNotMatch(@"^Purged 0 records$", purgeMessage); + Assert.Equal(HttpStatusCode.OK, purgeResponse.StatusCode); + } + + [Fact(Skip = "Not yet implemented in Python")] + [Trait("DTS", "Skip")] // Skip this test as there is a bug with current DTS backend, the createdTimeTo couldn't be null. + public async Task PurgeAfterPurge_ZeroRows() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("HelloCities_HttpStart", ""); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + DateTime purgeEndTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); + using HttpResponseMessage purgeResponse = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?purgeEndTime={purgeEndTime:o}"); + string purgeMessage = await purgeResponse.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged [0-9]* records$", purgeMessage); + using HttpResponseMessage purgeAgainResponse = await HttpHelpers.InvokeHttpTrigger("PurgeOrchestrationHistory", $"?purgeEndTime={purgeEndTime:o}"); + string purgeAgainMessage = await purgeAgainResponse.Content.ReadAsStringAsync(); + Assert.Matches(@"^Purged 0 records$", purgeAgainMessage); + Assert.Equal(HttpStatusCode.OK, purgeAgainResponse.StatusCode); + } +} diff --git a/test/e2e/Tests/Tests/SuspendResumeTests.cs b/test/e2e/Tests/Tests/SuspendResumeTests.cs new file mode 100644 index 00000000..d5637d8f --- /dev/null +++ b/test/e2e/Tests/Tests/SuspendResumeTests.cs @@ -0,0 +1,176 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class SuspendResumeTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public SuspendResumeTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task SuspendAndResumeRunningOrchestration_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("LongOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 5); + try + { + using HttpResponseMessage suspendResponse = await HttpHelpers.InvokeHttpTrigger("SuspendInstance", $"?instanceId={instanceId}"); + await AssertRequestSucceedsAsync(suspendResponse); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Suspended", 5); + + using HttpResponseMessage resumeResponse = await HttpHelpers.InvokeHttpTrigger("ResumeInstance", $"?instanceId={instanceId}"); + await AssertRequestSucceedsAsync(resumeResponse); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 5); + } + finally + { + await TryTerminateInstanceAsync(instanceId); + } + } + + [Fact(Skip = "Not yet implemented in Python")] + public async Task SuspendSuspendedOrchestration_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("LongOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 5); + try + { + using HttpResponseMessage suspendResponse = await HttpHelpers.InvokeHttpTrigger("SuspendInstance", $"?instanceId={instanceId}"); + await AssertRequestSucceedsAsync(suspendResponse); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Suspended", 5); + + using HttpResponseMessage resumeResponse = await HttpHelpers.InvokeHttpTrigger("SuspendInstance", $"?instanceId={instanceId}"); + await AssertRequestFailsAsync(resumeResponse); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("Cannot suspend orchestration instance in the Suspended state.") && + x.Contains(instanceId)); + } + finally + { + await TryTerminateInstanceAsync(instanceId); + } + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task ResumeRunningOrchestration_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("LongOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 5); + try + { + using HttpResponseMessage resumeResponse = await HttpHelpers.InvokeHttpTrigger("ResumeInstance", $"?instanceId={instanceId}"); + await AssertRequestFailsAsync(resumeResponse); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("Cannot resume orchestration instance in the Running state.") && + x.Contains(instanceId)); + } + finally + { + await TryTerminateInstanceAsync(instanceId); + } + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task SuspendResumeCompletedOrchestration_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("HelloCities_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 5); + try + { + using HttpResponseMessage suspendResponse = await HttpHelpers.InvokeHttpTrigger("SuspendInstance", $"?instanceId={instanceId}"); + await AssertRequestFailsAsync(suspendResponse); + + using HttpResponseMessage resumeResponse = await HttpHelpers.InvokeHttpTrigger("ResumeInstance", $"?instanceId={instanceId}"); + await AssertRequestFailsAsync(resumeResponse); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("Cannot suspend orchestration instance in the Completed state.") && + x.Contains(instanceId)); + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("Cannot resume orchestration instance in the Completed state.") && + x.Contains(instanceId)); + } + finally + { + await TryTerminateInstanceAsync(instanceId); + } + } + + private static async Task AssertRequestSucceedsAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + string? responseMessage = await response.Content.ReadAsStringAsync(); + Assert.NotNull(responseMessage); + Assert.Empty(responseMessage); + } + + private static async Task AssertRequestFailsAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + string? responseMessage = await response.Content.ReadAsStringAsync(); + Assert.NotNull(responseMessage); + // Unclear error message - see https://github.com/Azure/azure-functions-durable-extension/issues/3027, will update this code when that bug is fixed + Assert.Equal("Status(StatusCode=\"Unknown\", Detail=\"Exception was thrown by handler.\")", responseMessage); + } + + private static async Task TryTerminateInstanceAsync(string instanceId) + { + try + { + // Clean up the instance by terminating it - no-op if this fails + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + return true; + } + catch (Exception) { } + return false; + } +} diff --git a/test/e2e/Tests/Tests/TerminateOrchestratorTests.cs b/test/e2e/Tests/Tests/TerminateOrchestratorTests.cs new file mode 100644 index 00000000..9279855e --- /dev/null +++ b/test/e2e/Tests/Tests/TerminateOrchestratorTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class TerminateOrchestratorTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public TerminateOrchestratorTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task TerminateRunningOrchestration_ShouldSucceed() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("LongOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 30); + + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + await AssertTerminateRequestSucceedsAsync(terminateResponse); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Terminated", 30); + } + + + [Fact(Skip = "Will enable when https://github.com/Azure/azure-functions-durable-extension/issues/3025 is fixed")] + public async Task TerminateScheduledOrchestration_ShouldSucceed() + { + DateTime scheduledStartTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("HelloCities_HttpStart_Scheduled", $"?scheduledStartTime={scheduledStartTime.ToString("o")}"); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Pending", 30); + + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + await AssertTerminateRequestSucceedsAsync(terminateResponse); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Terminated", 30); + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task TerminateTerminatedOrchestration_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("LongOrchestrator_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Running", 30); + + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + await AssertTerminateRequestSucceedsAsync(terminateResponse); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Terminated", 30); + + using HttpResponseMessage terminateAgainResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + await AssertTerminateRequestFailsAsync(terminateAgainResponse); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("Cannot terminate orchestration instance in the Terminated state.") && + x.Contains(instanceId)); + } + + + [Fact(Skip = "Not yet implemented in Python")] + public async Task TerminateCompletedOrchestration_ShouldFail() + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("HelloCities_HttpStart", ""); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string instanceId = await DurableHelpers.ParseInstanceIdAsync(response); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={instanceId}"); + await AssertTerminateRequestFailsAsync(terminateResponse); + + // Give some time for Core Tools to write logs out + Thread.Sleep(500); + + Assert.Contains(this.fixture.TestLogs.CoreToolsLogs, x => x.Contains("Cannot terminate orchestration instance in the Completed state.") && + x.Contains(instanceId)); + } + + [Fact(Skip = "Not yet implemented in Python")] + public async Task TerminateNonExistantOrchestration_ShouldFail() + { + using HttpResponseMessage terminateResponse = await HttpHelpers.InvokeHttpTrigger("TerminateInstance", $"?instanceId={Guid.NewGuid().ToString()}"); + await AssertTerminateRequestFailsAsync(terminateResponse); + } + + private static async Task AssertTerminateRequestFailsAsync(HttpResponseMessage terminateResponse) + { + Assert.Equal(HttpStatusCode.BadRequest, terminateResponse.StatusCode); + + string? terminateResponseMessage = await terminateResponse.Content.ReadAsStringAsync(); + Assert.NotNull(terminateResponseMessage); + // Unclear error message - see https://github.com/Azure/azure-functions-durable-extension/issues/3027, will update this code when that bug is fixed + Assert.Equal("Status(StatusCode=\"Unknown\", Detail=\"Exception was thrown by handler.\")", terminateResponseMessage); + } + + private static async Task AssertTerminateRequestSucceedsAsync(HttpResponseMessage terminateResponse) + { + Assert.Equal(HttpStatusCode.OK, terminateResponse.StatusCode); + + string? terminateResponseMessage = await terminateResponse.Content.ReadAsStringAsync(); + Assert.NotNull(terminateResponseMessage); + Assert.Empty(terminateResponseMessage); + } +} diff --git a/test/e2e/Tests/Tests/TimeoutTests.cs b/test/e2e/Tests/Tests/TimeoutTests.cs new file mode 100644 index 00000000..e23bf6be --- /dev/null +++ b/test/e2e/Tests/Tests/TimeoutTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Net; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Azure.Durable.Tests.DotnetIsolatedE2E; + +[Collection(Constants.FunctionAppCollectionName)] +public class TimeoutTests +{ + private readonly FunctionAppFixture fixture; + private readonly ITestOutputHelper output; + + public TimeoutTests(FunctionAppFixture fixture, ITestOutputHelper testOutputHelper) + { + this.fixture = fixture; + this.fixture.TestLogs.UseTestLogger(testOutputHelper); + this.output = testOutputHelper; + } + + [Theory(Skip = "Not yet implemented in Python")] + [InlineData(2, "The activity function timed out")] + [InlineData(10, "The activity function completed successfully")] + public async Task TimeoutFunction_ShouldTimeoutWhenAppropriate(int timeoutSeconds, string expectedOutput) + { + using HttpResponseMessage response = await HttpHelpers.InvokeHttpTrigger("TimeoutOrchestrator_HttpStart", $"?timeoutSeconds={timeoutSeconds}"); + string actualMessage = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + string statusQueryGetUri = await DurableHelpers.ParseStatusQueryGetUriAsync(response); + + await DurableHelpers.WaitForOrchestrationStateAsync(statusQueryGetUri, "Completed", 30); + + var orchestrationDetails = await DurableHelpers.GetRunningOrchestrationDetailsAsync(statusQueryGetUri); + Assert.Equal(expectedOutput, orchestrationDetails.Output); + } +} diff --git a/test/e2e/Tests/build-e2e-test.ps1 b/test/e2e/Tests/build-e2e-test.ps1 new file mode 100644 index 00000000..855db1a9 --- /dev/null +++ b/test/e2e/Tests/build-e2e-test.ps1 @@ -0,0 +1,222 @@ +#!/usr/bin/env pwsh +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +[CmdletBinding()] +param( + [Switch] + $SkipStorageEmulator, + + [Switch] + $StartMSSqlContainer, + + [Switch] + $StartDTSContainer, + + [Switch] + $SkipCoreTools, + + # This param can be used during local runs of the build script to deliberately skip the build and run only the azurite/mssql logic + # For instance, the command ./build-e2e-test.ps1 -SkipBuild -StartMSSqlContainer will start azurite and the MSSQL docker container only. + [Switch] + $SkipBuild +) + +if ($PSVersionTable.PSEdition -ne 'Core') { + Write-Warning "You are not running PowerShell Core. Please switch to PowerShell Core (>= PS 6) for better compatibility and performance." + Write-Warning "See https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.5" + exit 1 +} + +$ErrorActionPreference = "Stop" + +$CORE_TOOLS_VERSION = '4.0.7317' + +$ProjectBaseDirectory = Join-Path $PSScriptRoot "..\..\.." +$ProjectTemporaryPath = Join-Path ([System.IO.Path]::GetTempPath()) "DurableTaskExtensionE2ETests" +New-Item -Path $ProjectTemporaryPath -ItemType Directory -ErrorAction SilentlyContinue +$E2EAppProjectDirectory = Join-Path $ProjectBaseDirectory "test\e2e\Apps\BasicPython" +$E2EAppVenvDirectory = Join-Path $E2EAppProjectDirectory ".venv" + +$LocalNugetCacheDirectory = $env:NUGET_PACKAGES +if (!$LocalNugetCacheDirectory) { + $LocalNugetCacheDirectory = "$env:USERPROFILE\.nuget\packages" +} + +$FunctionsRuntimeVersion = 4 + +# A function that checks exit codes and fails script if an error is found +function StopOnFailedExecution { + if ($LastExitCode) + { + exit $LastExitCode + } +} + +function EnsureValidPythonVersion { + # Check if Python is installed and available in PATH + try { + $pythonVersionOutput = python --version 2>&1 + } catch { + Write-Warning "Python is not installed or not found in PATH." + exit 1 + } + + # Extract version number using regex + if ($pythonVersionOutput -match 'Python (\d+)\.(\d+)\.(\d+)') { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + $patch = [int]$Matches[3] + + if ($major -ne 3 -or $minor -ne 11) { + Write-Error "Python version is $major.$minor.$patch. Version 3.11 is required." + } else { + Write-Host "Python version is $major.$minor.$patch. All good!" -ForegroundColor Green + } + } else { + Write-Error "Unable to parse Python version from output: $pythonVersionOutput" + } +} + +$FUNC_CLI_DIRECTORY = Join-Path $ProjectTemporaryPath 'Azure.Functions.Cli' +if($SkipCoreTool -or (Test-Path $FUNC_CLI_DIRECTORY)) +{ + Write-Host "---Skipping Core Tools download---" +} +else +{ + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + if ($IsWindows) { + $os = "win" + $coreToolsURL = $env:CORE_TOOLS_URL + } + else { + if ($IsMacOS) { + $os = "osx" + } else { + $os = "linux" + $coreToolsURL = $env:CORE_TOOLS_URL_LINUX + } + } + + if ([string]::IsNullOrWhiteSpace($coreToolsURL)) + { + # $coreToolsURL = "https://functionsclibuilds.blob.core.windows.net/builds/$FunctionsRuntimeVersion/latest/Azure.Functions.Cli.$os-$arch.zip" + # $versionUrl = "https://functionsclibuilds.blob.core.windows.net/builds/$FunctionsRuntimeVersion/latest/version.txt" + + $coreToolsURL = "https://github.com/Azure/azure-functions-core-tools/releases/download/$CORE_TOOLS_VERSION/Azure.Functions.Cli.$os-$arch.$CORE_TOOLS_VERSION.zip" + + } + + Write-Host "" + Write-Host "---Downloading the Core Tools for Functions V$FunctionsRuntimeVersion---" + Write-Host "Core Tools download url: $coreToolsURL" + + Write-Host 'Deleting Functions Core Tools if exists...' + Remove-Item -Force "$FUNC_CLI_DIRECTORY.zip" -ErrorAction Ignore + Remove-Item -Recurse -Force $FUNC_CLI_DIRECTORY -ErrorAction Ignore + + if ($versionUrl) + { + $version = Invoke-RestMethod -Uri $versionUrl + Write-Host "Downloading Functions Core Tools (Version: $version)..." + } + + $output = "$FUNC_CLI_DIRECTORY.zip" + Invoke-RestMethod -Uri $coreToolsURL -OutFile $output + + Write-Host 'Extracting Functions Core Tools...' + Expand-Archive $output -DestinationPath $FUNC_CLI_DIRECTORY + + if ($IsMacOS -or $IsLinux) + { + & "chmod" "a+x" "$FUNC_CLI_DIRECTORY/func" + } + + Write-Host "------" +} + +if (!$SkipBuild) +{ + EnsureValidPythonVersion + + python3 -m pip install --upgrade pip + python3 -m pip install -r (Join-Path $E2EAppProjectDirectory "requirements.txt") --upgrade + python3 -m pip install -r (Join-Path $ProjectBaseDirectory "requirements.txt") --upgrade + python3 -m pip install $ProjectBaseDirectory --force-reinstall --upgrade --no-deps --no-cache-dir +} + +Set-Location $PSScriptRoot + +if ($SkipStorageEmulator) +{ + Write-Host + Write-Host "---Skipping emulator startup---" + Write-Host +} +else +{ + .\start-emulators.ps1 -SkipStorageEmulator:$SkipStorageEmulator -EmulatorStartDir $ProjectTemporaryPath +} + +function StartMSSQLContainer($mssqlPwd) { + Write-Host "Pulling down the mcr.microsoft.com/mssql/server:2022-latest image..." + docker pull mcr.microsoft.com/mssql/server:2022-latest + + # Start the SQL Server docker container with the specified edition + Write-Host "Starting SQL Server 2022-latest Express docker container on port 1433" -ForegroundColor DarkYellow + docker run --name mssql-server -e ACCEPT_EULA=Y -e "MSSQL_SA_PASSWORD=$mssqlPwd" -e "MSSQL_PID=Express" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + # The container needs a bit more time before it can start accepting commands + Write-Host "Sleeping for 30 seconds to let the container finish initializing..." -ForegroundColor Yellow + Start-Sleep -Seconds 30 + + # Check to see what containers are running + docker ps +} + +function StartDTSContainer() { + Write-Host "Pulling down the mcr.microsoft.com/dts/dts-emulator:v0.0.4 image..." + docker pull mcr.microsoft.com/dts/dts-emulator:v0.0.4 + + # Start the DTS Server docker container with the specified edition + Write-Host "Starting DTS docker container on port 8080" -ForegroundColor DarkYellow + docker run -i -p 8080:8080 -p 8082:8082 -d mcr.microsoft.com/dts/dts-emulator:v0.0.4 + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + # The container needs a bit more time before it can start accepting commands + Write-Host "Sleeping for 30 seconds to let the container finish initializing..." -ForegroundColor Yellow + Start-Sleep -Seconds 30 + + # Check to see what containers are running + docker ps +} + +Set-Location $PSScriptRoot + +if ($StartMSSqlContainer) +{ + $mssqlPwd = $env:MSSQL_SA_PASSWORD + if (!$mssqlPwd) { + Write-Warning "No MSSQL_SA_PASSWORD environment variable found! Skipping SQL Server container startup." + } + else { + StartMSSQLContainer $mssqlPwd + } +} + +if ($StartDTSContainer) +{ + StartDTSContainer +} + +StopOnFailedExecution diff --git a/test/e2e/Tests/e2e-tests-readme.md b/test/e2e/Tests/e2e-tests-readme.md new file mode 100644 index 00000000..bc4b0ef6 --- /dev/null +++ b/test/e2e/Tests/e2e-tests-readme.md @@ -0,0 +1,47 @@ +# End-to-End Test Project + +This document provides instructions on how to use the end-to-end (E2E) test project for the Azure Functions Python SDK. + +## Prerequisites + +- PowerShell +- npm/Node +- .NET SDK + +## Running the E2E Tests + +### Step 1: Setup the E2E functionapp and tests + +To perform initial setup for these tests, run the following PowerShell script: + +```powershell +./build-e2e-test.ps1 +``` + +This script prepares your system for running the E2E tests by performing the following steps: + +1. Installing a copy of Core Tools into your system's temp directory to ensure an unmodified Core Tools. This is necessary, as the tests will not attempt to use the "func" referenced in PATH +2. Ensure the test app(s) are running the correct Python SDK code by: + * Creating a Python venv in the functionapp folder if one does not already exist + * Installing Python dependencies into the venv + * Building the azure-functions-durable module from the repo into the e2e app +3. Install and start azurite emulator using Node (and other Docker containers for MSSQL/DTS as needed) + +NOTE: It should not be necessary to run start-emulators.ps1 manually, as it should be called by the build script. If you have a instance of Azurite already running, it will recognize and skip this step. + +### Step 3: Build the test project + +At this point, you are ready to run the tests. You may start them using the Visual Studio test explorer as normal, the tests will take care of instancing Core Tools and starting the apps. +NOTE: ENSURE AZURITE IS RUNNING. If Azure is not available, the function app loaded by the test framework will 502 and the test suite will loop indefinitely waiting for it to come up. This will be addressed in future versions. + +### Step 4: Attach a Debugger + +To debug the extension code while running test functions, you need to attach a debugger to the `func` process before the test code runs. Follow these steps: + +1. Open your preferred IDE (e.g., Visual Studio or Visual Studio Code). +2. Set a breakpoint in the test. +3. Manually search for and attach the test process. For Out-Of-Process workers, attach func.exe to debug the host extension, and attach the child process representing the worker (dotnet.exe for dotnet OOProc) to debug the worker extension. + +## Conclusion + +Following these steps will help you set up and run the E2E tests for the Azure Functions Durable Extension project. If you encounter any issues, refer to the project documentation or seek help from the community. diff --git a/test/e2e/Tests/start-emulators.ps1 b/test/e2e/Tests/start-emulators.ps1 new file mode 100644 index 00000000..ae9f8614 --- /dev/null +++ b/test/e2e/Tests/start-emulators.ps1 @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +param( + [Parameter(Mandatory=$false)] + [Switch] + $SkipStorageEmulator, + [Parameter(Mandatory=$false)] + $EmulatorStartDir, + [Parameter(Mandatory=$false)] + [Switch] + $NoWait +) + +if ($PSVersionTable.PSEdition -ne 'Core') { + Write-Warning "You are not running PowerShell Core. Please switch to PowerShell Core (>= PS 6) for better compatibility and performance." + Write-Warning "See https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.5" + exit 1 +} + +if (Test-Path($EmulatorStartDir)) { + Set-Location $EmulatorStartDir +} + +$DebugPreference = 'Continue' + +Write-Host "Skip Storage Emulator: $SkipStorageEmulator" + +$startedStorage = $false + +function IsStorageEmulatorRunning() +{ + try + { + $response = Invoke-WebRequest -Uri "http://127.0.0.1:10000/" + $StatusCode = $Response.StatusCode + } + catch + { + $StatusCode = $_.Exception.Response.StatusCode.value__ + } + + if ($StatusCode -eq 400) + { + return $true + } + + return $false +} + +if (!$SkipStorageEmulator) +{ + Write-Host "------" + Write-Host "" + Write-Host "---Starting Storage emulator---" + $storageEmulatorRunning = IsStorageEmulatorRunning + + if ($storageEmulatorRunning -eq $false) + { + if ($IsWindows) + { + npm install -g azurite + New-Item -Path "./azurite" -ItemType Directory -ErrorAction SilentlyContinue + Start-Process azurite.cmd -WorkingDirectory "./azurite" -ArgumentList "--silent" + } + else + { + sudo npm install -g azurite + New-Item -Path "./azurite" -ItemType Directory -ErrorAction SilentlyContinue + sudo azurite --silent --location azurite --debug azurite\debug.log & + } + + $startedStorage = $true + } + else + { + Write-Host "Storage emulator is already running." + } + + Write-Host "------" + Write-Host +} + +if ($NoWait -eq $true) +{ + Write-Host "'NoWait' specified. Exiting." + Write-Host + exit 0 +} + +if (!$SkipStorageEmulator -and $startedStorage -eq $true) +{ + Write-Host "---Waiting for Storage emulator to be running---" + $storageEmulatorRunning = IsStorageEmulatorRunning + while ($storageEmulatorRunning -eq $false) + { + Write-Host "Storage emulator not ready." + Start-Sleep -Seconds 5 + $storageEmulatorRunning = IsStorageEmulatorRunning + } + Write-Host "Storage emulator ready." + Write-Host "------" + Write-Host +} \ No newline at end of file diff --git a/test/e2e/Tests/test.runsettings b/test/e2e/Tests/test.runsettings new file mode 100644 index 00000000..cccdd712 --- /dev/null +++ b/test/e2e/Tests/test.runsettings @@ -0,0 +1,17 @@ + + + + + + + + + + AzureStorage + + + \ No newline at end of file