diff --git a/.github/workflows/smoketest-dotnet-v2.yml b/.github/workflows/smoketest-dotnet-v2.yml index 2dc5c5417..3582f19f1 100644 --- a/.github/workflows/smoketest-dotnet-v2.yml +++ b/.github/workflows/smoketest-dotnet-v2.yml @@ -1,17 +1,7 @@ name: Smoke Test - .NET on Functions V2 on: - workflow_dispatch: - push: - branches: [ main, dev ] - paths: - - 'src/**' - - 'test/SmokeTests/SmokeTestsV2/**' - pull_request: - branches: [ main, dev ] - paths: - - 'src/**' - - 'test/SmokeTests/SmokeTestsV2/**' + {} jobs: build: diff --git a/.github/workflows/smoketest-dotnet-v3.yml b/.github/workflows/smoketest-dotnet-v3.yml index 4644c5df9..a02a5a409 100644 --- a/.github/workflows/smoketest-dotnet-v3.yml +++ b/.github/workflows/smoketest-dotnet-v3.yml @@ -1,17 +1,7 @@ name: Smoke Test - .NET on Functions V3 on: - workflow_dispatch: - push: - branches: [ main, dev ] - paths: - - 'src/**' - - 'test/SmokeTests/SmokeTestsV3/**' - pull_request: - branches: [ main, dev ] - paths: - - 'src/**' - - 'test/SmokeTests/SmokeTestsV3/**' + {} jobs: build: diff --git a/.gitignore b/.gitignore index 82ada0f8a..ba53f58b3 100644 --- a/.gitignore +++ b/.gitignore @@ -297,3 +297,6 @@ functions-extensions/ .vscode/ .ionide/ /src/WebJobs.Extensions.DurableTask/Microsoft.Azure.WebJobs.Extensions.DurableTask.xml + +# E2E Tests build output +/src/WebJobs.Extensions.DurableTask/out/* diff --git a/WebJobs.Extensions.DurableTask.sln b/WebJobs.Extensions.DurableTask.sln index edda04460..693524396 100644 --- a/WebJobs.Extensions.DurableTask.sln +++ b/WebJobs.Extensions.DurableTask.sln @@ -87,6 +87,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DFPerfScenariosV4", "test\D EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.DurableTask.Tests", "test\Worker.Extensions.DurableTask.Tests\Worker.Extensions.DurableTask.Tests.csproj", "{76DEC17C-BF6A-498A-8E8A-7D6CB2E03284}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "E2ETests", "test\e2e\Tests\E2ETests.csproj", "{63628712-4196-4865-B268-5BA3D8F08DE1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -155,6 +157,10 @@ Global {76DEC17C-BF6A-498A-8E8A-7D6CB2E03284}.Debug|Any CPU.Build.0 = Debug|Any CPU {76DEC17C-BF6A-498A-8E8A-7D6CB2E03284}.Release|Any CPU.ActiveCfg = Release|Any CPU {76DEC17C-BF6A-498A-8E8A-7D6CB2E03284}.Release|Any CPU.Build.0 = Release|Any CPU + {63628712-4196-4865-B268-5BA3D8F08DE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63628712-4196-4865-B268-5BA3D8F08DE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63628712-4196-4865-B268-5BA3D8F08DE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63628712-4196-4865-B268-5BA3D8F08DE1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -184,6 +190,7 @@ Global {7387E723-E153-4B7A-B105-8C67BFBD48CF} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} {FC8AD123-F949-4D21-B817-E5A4BBF7F69B} = {7387E723-E153-4B7A-B105-8C67BFBD48CF} {76DEC17C-BF6A-498A-8E8A-7D6CB2E03284} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} + {63628712-4196-4865-B268-5BA3D8F08DE1} = {78BCF152-C22C-408F-9FB1-0F8C99B154B5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5E9AC327-DE18-41A5-A55D-E44CB4281943} diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index fea9a0ffd..605acc041 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -154,11 +154,26 @@ public override Task Hello(Empty request, ServerCallContext context) { try { - string instanceId = await this.GetClient(context).StartNewAsync( - request.Name, request.InstanceId, Raw(request.Input)); + string instanceId = request.InstanceId ?? Guid.NewGuid().ToString("N"); + TaskHubClient taskhubClient = new TaskHubClient(this.GetDurabilityProvider(context)); + OrchestrationInstance instance; + + // TODO: Ideally, we'd have a single method in the taskhubClient that can handle both scheduled and non-scheduled starts. + // TODO: the type of `ScheduledStartTimestamp` is not nullable. Can we change it to `DateTime?` in the proto file? + if (request.ScheduledStartTimestamp != null) + { + instance = await taskhubClient.CreateScheduledOrchestrationInstanceAsync( + name: request.Name, version: request.Version, instanceId: instanceId, input: Raw(request.Input), startAt: request.ScheduledStartTimestamp.ToDateTime()); + } + else + { + instance = await taskhubClient.CreateOrchestrationInstanceAsync(request.Name, request.Version, instanceId, Raw(request.Input)); + } + + // TODO: should this not include the ExecutionId and other elements of the taskhubClient response? return new P.CreateInstanceResponse { - InstanceId = instanceId, + InstanceId = instance.InstanceId, }; } catch (OrchestrationAlreadyExistsException) @@ -231,13 +246,13 @@ public override Task Hello(Empty request, ServerCallContext context) EntityBackendQueries.EntityQueryResult result = await entityOrchestrationService.EntityBackendQueries!.QueryEntitiesAsync( new EntityBackendQueries.EntityQuery() { - InstanceIdStartsWith = query.InstanceIdStartsWith, - LastModifiedFrom = query.LastModifiedFrom?.ToDateTime(), - LastModifiedTo = query.LastModifiedTo?.ToDateTime(), - IncludeTransient = query.IncludeTransient, - IncludeState = query.IncludeState, - ContinuationToken = query.ContinuationToken, - PageSize = query.PageSize, + InstanceIdStartsWith = query.InstanceIdStartsWith, + LastModifiedFrom = query.LastModifiedFrom?.ToDateTime(), + LastModifiedTo = query.LastModifiedTo?.ToDateTime(), + IncludeTransient = query.IncludeTransient, + IncludeState = query.IncludeState, + ContinuationToken = query.ContinuationToken, + PageSize = query.PageSize, }, context.CancellationToken); diff --git a/test/e2e/Apps/BasicDotNetIsolated/.gitignore b/test/e2e/Apps/BasicDotNetIsolated/.gitignore new file mode 100644 index 000000000..ff5b00c50 --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/test/e2e/Apps/BasicDotNetIsolated/BasicDotNetIsolated.sln b/test/e2e/Apps/BasicDotNetIsolated/BasicDotNetIsolated.sln new file mode 100644 index 000000000..805d53ab7 --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/BasicDotNetIsolated.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "app", "app.csproj", "{A3D6D881-0115-409E-92F0-3D50FB152524}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "obj", "obj", "{44B4C292-28D2-4963-89FE-41DDC7C13E97}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Debug", "Debug", "{CF64E527-9C6F-447E-AD05-0EE52407CCF1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "net8.0", "net8.0", "{9ABF536C-4F1B-42F5-AAFE-7AD88832DBDB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkerExtensions", "obj\Debug\net8.0\WorkerExtensions\WorkerExtensions.csproj", "{65AF45AC-6495-4740-BB7C-67BD125DF158}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A3D6D881-0115-409E-92F0-3D50FB152524}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3D6D881-0115-409E-92F0-3D50FB152524}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3D6D881-0115-409E-92F0-3D50FB152524}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3D6D881-0115-409E-92F0-3D50FB152524}.Release|Any CPU.Build.0 = Release|Any CPU + {65AF45AC-6495-4740-BB7C-67BD125DF158}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65AF45AC-6495-4740-BB7C-67BD125DF158}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65AF45AC-6495-4740-BB7C-67BD125DF158}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65AF45AC-6495-4740-BB7C-67BD125DF158}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CF64E527-9C6F-447E-AD05-0EE52407CCF1} = {44B4C292-28D2-4963-89FE-41DDC7C13E97} + {9ABF536C-4F1B-42F5-AAFE-7AD88832DBDB} = {CF64E527-9C6F-447E-AD05-0EE52407CCF1} + {65AF45AC-6495-4740-BB7C-67BD125DF158} = {9ABF536C-4F1B-42F5-AAFE-7AD88832DBDB} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D4D85C8B-ADB6-47BE-A565-B37D31B6FCD5} + EndGlobalSection +EndGlobal diff --git a/test/e2e/Apps/BasicDotNetIsolated/HelloCities.cs b/test/e2e/Apps/BasicDotNetIsolated/HelloCities.cs new file mode 100644 index 000000000..1db525394 --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/HelloCities.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.Durable.Tests.E2E +{ + public static class HelloCities + { + [Function(nameof(HelloCities))] + public static async Task> RunOrchestrator( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + ILogger logger = context.CreateReplaySafeLogger(nameof(HelloCities)); + logger.LogInformation("Saying hello."); + var outputs = new List(); + + // Replace name and input with values relevant for your Durable Functions Activity + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Tokyo")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Seattle")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "London")); + + // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + return outputs; + } + + [Function(nameof(SayHello))] + public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger("SayHello"); + logger.LogInformation("Saying hello to {name}.", name); + return $"Hello {name}!"; + } + + [Function("HelloCities_HttpStart")] + public static async Task HttpStart( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger("HelloCities_HttpStart"); + + // Function input comes from the request content. + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + nameof(HelloCities)); + + logger.LogInformation("Started orchestration with ID = '{instanceId}'.", instanceId); + + // Returns an HTTP 202 response with an instance management payload. + // See https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-http-api#start-orchestration + return await client.CreateCheckStatusResponseAsync(req, instanceId); + } + + [Function("HelloCities_HttpStart_Scheduled")] + public static async Task HttpStartScheduled( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext, + DateTime scheduledStartTime) + { + ILogger logger = executionContext.GetLogger("HelloCities_HttpStart"); + + var startOptions = new StartOrchestrationOptions(StartAt: scheduledStartTime); + + // Function input comes from the request content. + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + nameof(HelloCities), startOptions); + + logger.LogInformation("Started orchestration with ID = '{instanceId}'.", instanceId); + + // Returns an HTTP 202 response with an instance management payload. + // See https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-http-api#start-orchestration + return await client.CreateCheckStatusResponseAsync(req, instanceId); + } + } +} diff --git a/test/e2e/Apps/BasicDotNetIsolated/Program.cs b/test/e2e/Apps/BasicDotNetIsolated/Program.cs new file mode 100644 index 000000000..98d1feb10 --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/Program.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics; + +var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .ConfigureServices(services => { + services.AddApplicationInsightsTelemetryWorkerService(); + services.ConfigureFunctionsApplicationInsights(); + }) + .Build(); + +// Bool.parse +if (Environment.GetEnvironmentVariable("DURABLE_ATTACH_DEBUGGER") == "True") { + Debugger.Launch(); +} + +host.Run(); diff --git a/test/e2e/Apps/BasicDotNetIsolated/app.csproj b/test/e2e/Apps/BasicDotNetIsolated/app.csproj new file mode 100644 index 000000000..3a3692b7c --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/app.csproj @@ -0,0 +1,35 @@ + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + \ No newline at end of file diff --git a/test/e2e/Apps/BasicDotNetIsolated/host.json b/test/e2e/Apps/BasicDotNetIsolated/host.json new file mode 100644 index 000000000..5df170b64 --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file diff --git a/test/e2e/Apps/BasicDotNetIsolated/nuget.config b/test/e2e/Apps/BasicDotNetIsolated/nuget.config new file mode 100644 index 000000000..ae24a00e0 --- /dev/null +++ b/test/e2e/Apps/BasicDotNetIsolated/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/e2e/Tests/Constants.cs b/test/e2e/Tests/Constants.cs new file mode 100644 index 000000000..e99093935 --- /dev/null +++ b/test/e2e/Tests/Constants.cs @@ -0,0 +1,15 @@ +// 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"; +} diff --git a/test/e2e/Tests/E2ETests.csproj b/test/e2e/Tests/E2ETests.csproj new file mode 100644 index 000000000..e9587197a --- /dev/null +++ b/test/e2e/Tests/E2ETests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/test/e2e/Tests/Fixtures/FixtureHelpers.cs b/test/e2e/Tests/Fixtures/FixtureHelpers.cs new file mode 100644 index 000000000..3a13b0344 --- /dev/null +++ b/test/e2e/Tests/Fixtures/FixtureHelpers.cs @@ -0,0 +1,77 @@ +// 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("--csharp"); + 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) => logger.LogError(e?.Data); + funcProcess.OutputDataReceived += (sender, e) => logger.LogInformation(e?.Data); + + 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 + } + } + } +} \ No newline at end of file diff --git a/test/e2e/Tests/Fixtures/FunctionAppFixture.cs b/test/e2e/Tests/Fixtures/FunctionAppFixture.cs new file mode 100644 index 000000000..6f4e6b4ba --- /dev/null +++ b/test/e2e/Tests/Fixtures/FunctionAppFixture.cs @@ -0,0 +1,164 @@ +// 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/BasicDotNetIsolated/bin"); + 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.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. +} diff --git a/test/e2e/Tests/Helpers/DurableHelpers.cs b/test/e2e/Tests/Helpers/DurableHelpers.cs new file mode 100644 index 000000000..dd8ef2fe2 --- /dev/null +++ b/test/e2e/Tests/Helpers/DurableHelpers.cs @@ -0,0 +1,59 @@ +// 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(); + + 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 string ParseStatusQueryGetUri(HttpResponseMessage invocationStartResponse) + { + string? responseString = invocationStartResponse.Content?.ReadAsStringAsync().Result; + + if (string.IsNullOrEmpty(responseString)) + { + return string.Empty; + } + JsonNode? responseJsonNode = JsonNode.Parse(responseString); + if (responseJsonNode == null) + { + return string.Empty; + } + + string? statusQueryGetUri = responseJsonNode["StatusQueryGetUri"]?.GetValue(); + return statusQueryGetUri ?? string.Empty; + } + internal static OrchestrationStatusDetails GetRunningOrchestrationDetails(string statusQueryGetUri) + { + var statusQueryResponse = _httpClient.GetAsync(statusQueryGetUri); + + string? statusQueryResponseString = statusQueryResponse.Result.Content.ReadAsStringAsync().Result; + + return new OrchestrationStatusDetails(statusQueryResponseString); + } +} diff --git a/test/e2e/Tests/Helpers/HttpHelpers.cs b/test/e2e/Tests/Helpers/HttpHelpers.cs new file mode 100644 index 000000000..9de59f6ca --- /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 000000000..6e8f77f36 --- /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); + _currentTestOutput?.WriteLine(formattedString); + } + + 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 000000000..04f767a03 --- /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 000000000..3d2a53a29 --- /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/HelloCitiesTest.cs b/test/e2e/Tests/Tests/HelloCitiesTest.cs new file mode 100644 index 000000000..f618ac9e2 --- /dev/null +++ b/test/e2e/Tests/Tests/HelloCitiesTest.cs @@ -0,0 +1,72 @@ +// 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) + { + _fixture = fixture; + _fixture.TestLogs.UseTestLogger(testOutputHelper); + _output = testOutputHelper; + } + + [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, ""); + string actualMessage = await response.Content.ReadAsStringAsync(); + + Assert.Equal(expectedStatusCode, response.StatusCode); + string statusQueryGetUri = DurableHelpers.ParseStatusQueryGetUri(response); + Thread.Sleep(1000); + var orchestrationDetails = DurableHelpers.GetRunningOrchestrationDetails(statusQueryGetUri); + Assert.Equal("Completed", orchestrationDetails.RuntimeStatus); + 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 actualMessage = await response.Content.ReadAsStringAsync(); + + string statusQueryGetUri = DurableHelpers.ParseStatusQueryGetUri(response); + + Assert.Equal(expectedStatusCode, response.StatusCode); + + var orchestrationDetails = DurableHelpers.GetRunningOrchestrationDetails(statusQueryGetUri); + while (DateTime.UtcNow < scheduledStartTime) + { + _output.WriteLine($"Test scheduled for {scheduledStartTime}, current time {DateTime.Now}"); + orchestrationDetails = DurableHelpers.GetRunningOrchestrationDetails(statusQueryGetUri); + Assert.Equal("Pending", orchestrationDetails.RuntimeStatus); + Thread.Sleep(3000); + } + + // Give a small amount of time for the orchestration to complete, even if scheduled to run immediately + Thread.Sleep(1000); + _output.WriteLine($"Test scheduled for {scheduledStartTime}, current time {DateTime.Now}, looking for completed"); + + var finalOrchestrationDetails = DurableHelpers.GetRunningOrchestrationDetails(statusQueryGetUri); + Assert.Equal("Completed", finalOrchestrationDetails.RuntimeStatus); + + Assert.True(finalOrchestrationDetails.LastUpdatedTime > scheduledStartTime); + } +} diff --git a/test/e2e/Tests/build-e2e-test.ps1 b/test/e2e/Tests/build-e2e-test.ps1 new file mode 100644 index 000000000..53f00b0ca --- /dev/null +++ b/test/e2e/Tests/build-e2e-test.ps1 @@ -0,0 +1,156 @@ +#!/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] + $Clean, + + [Switch] + $SkipStorageEmulator, + + [Switch] + $SkipCosmosDBEmulator, + + [Switch] + $SkipCoreTools, + + [Switch] + $SkipBuildOnPack +) + +$ProjectBaseDirectory = "$PSScriptRoot\..\..\..\" +$ProjectTemporaryPath = Join-Path ([System.IO.Path]::GetTempPath()) "DurableTaskExtensionE2ETests" +mkdir $ProjectTemporaryPath -ErrorAction SilentlyContinue > $Null +$WebJobsExtensionProjectDirectory = Join-Path $ProjectBaseDirectory "src\WebJobs.Extensions.DurableTask" +$WorkerExtensionProjectDirectory = Join-Path $ProjectBaseDirectory "src\Worker.Extensions.DurableTask" +$E2EAppProjectDirectory = Join-Path $ProjectBaseDirectory "test\e2e\Apps\BasicDotNetIsolated" + +$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 + } +} + +$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" + } + + 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 "------" +} + +Write-Host "Removing old packages from test app" +Set-Location $E2EAppProjectDirectory +Get-ChildItem -Path ./packages -Include * -File -Recurse | ForEach-Object { $_.Delete()} + +Write-Host "Building WebJobs extension project" + +Set-Location $WebJobsExtensionProjectDirectory +if (!(Test-Path "./out")) { + mkdir ./out -ErrorAction SilentlyContinue > $Null +} +Get-ChildItem -Path ./out -Include * -File -Recurse | ForEach-Object { $_.Delete()} +dotnet build -c Debug "$WebJobsExtensionProjectDirectory\WebJobs.Extensions.DurableTask.csproj" --output ./out + +Write-Host "Moving nupkg from WebJobs extension to $E2EAppProjectDirectory/packages" +Set-Location ./out +dotnet nuget push *.nupkg --source "$E2EAppProjectDirectory/packages" + +Write-Host "Updating app .csproj to reference built package versions" +Set-Location $E2EAppProjectDirectory +$files = Get-ChildItem -Path ./packages -Include * -File -Recurse +$files | ForEach-Object { + if ($_.Name -match 'Microsoft.Azure.WebJobs.Extensions.DurableTask') + { + $webJobsExtensionVersion = $_.Name -replace 'Microsoft.Azure.WebJobs.Extensions.DurableTask\.|\.nupkg' + + Write-Host "Removing cached version $webJobsExtensionVersion of WebJobs extension from nuget cache, if exists" + $cachedVersionFolders = Get-ChildItem -Path (Join-Path $LocalNugetCacheDirectory "microsoft.azure.webjobs.extensions.durabletask") -Directory + $cachedVersionFolders | ForEach-Object { + if ($_.Name -eq $webJobsExtensionVersion) + { + Write-Host "Removing cached version $webJobsExtensionVersion from nuget cache" + Remove-Item -Recurse -Force $_.FullName -ErrorAction Stop + } + } + } +} + +Write-Host "Building app project" +dotnet clean app.csproj +dotnet build app.csproj + +Set-Location $PSScriptRoot + +if ($SkipStorageEmulator -And $SkipCosmosDBEmulator) +{ + Write-Host + Write-Host "---Skipping emulator startup---" + Write-Host +} +else +{ + .\start-emulators.ps1 -SkipStorageEmulator:$SkipStorageEmulator -StartCosmosDBEmulator:$false -EmulatorStartDir $ProjectTemporaryPath +} + +StopOnFailedExecution diff --git a/test/e2e/Tests/e2e-tests-readme.md b/test/e2e/Tests/e2e-tests-readme.md new file mode 100644 index 000000000..d048318a0 --- /dev/null +++ b/test/e2e/Tests/e2e-tests-readme.md @@ -0,0 +1,59 @@ +# End-to-End Test Project + +This document provides instructions on how to use the end-to-end (E2E) test project for the Azure Functions Durable Extension. + +## Prerequisites + +- PowerShell +- npm/Node +- .NET SDK + +## Running the E2E Tests + +### Step 1: Increment the host and worker package versions (optional) + +Note: This step is optional. However, if you do not perform this step, the versions of these two packages in your local NuGet cache will be replaced with the build output from the test run, which may lead to unexpected behavior debugging live versions in other apps. Be warned. + +Modify the following files: +``` +\src\WebJobs.Extensions.DurableTask\WebJobs.Extensions.DurableTask.csproj +\src\Worker.Extensions.DurableTask\AssemblyInfo.cs +\src\Worker.Extensions.DurableTask\Worker.Extensions.DurableTask.csproj +``` + +### Step 2: Build the E2E Test Project + +To build the E2E test project, 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 extension code by: + * Building the host and worker extensions from their projects within this repo + * Packing the extensions into local NuGet packages + * Copying the built packages into the test app's local nuget source folder as configured in nuget.config + * Updating the test app's .csproj files to reference the local package version + * Building the test app projects +3. Install and start azurite emulator using Node + +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 000000000..0fc490eff --- /dev/null +++ b/test/e2e/Tests/start-emulators.ps1 @@ -0,0 +1,198 @@ +# 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)] + [Switch] + $StartCosmosDBEmulator, + [Parameter(Mandatory=$false)] + $EmulatorStartDir, + [Parameter(Mandatory=$false)] + [Switch] + $NoWait +) + +if (Test-Path($EmulatorStartDir)) { + Set-Location $EmulatorStartDir +} + +$DebugPreference = 'Continue' + +Write-Host "Start CosmosDB Emulator: $StartCosmosDBEmulator" +Write-Host "Skip Storage Emulator: $SkipStorageEmulator" + +$startedCosmos = $false +$startedStorage = $false + +# Reassigning $IsWindows no longer allowed - might need to refactor this logic +# if (!$IsWindows -and !$IsLinux -and !$IsMacOs) +# { +# # For pre-PS6 +# Write-Host "Could not resolve OS. Assuming Windows." +# $IsWindows = $true +# } + +if (!$IsWindows -and $StartCosmosDBEmulator) +{ + Write-Host "Skipping CosmosDB emulator because it is not supported on non-Windows OS." + $StartCosmosDBEmulator = $false +} + +if ($StartCosmosDBEmulator) +{ + # Locally, you may need to run PowerShell with administrative privileges + Add-MpPreference -ExclusionPath "$env:ProgramFiles\Azure Cosmos DB Emulator" + Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator" +} + +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 ($StartCosmosDBEmulator) +{ + Write-Host "" + Write-Host "---Starting CosmosDB emulator---" + $cosmosStatus = Get-CosmosDbEmulatorStatus + Write-Host "CosmosDB emulator status: $cosmosStatus" + + if ($cosmosStatus -eq "StartPending") + { + $startedCosmos = $true + } + elseif ($cosmosStatus -ne "Running") + { + Write-Host "CosmosDB emulator is not running. Starting emulator." + Start-Process "$env:ProgramFiles\Azure Cosmos DB Emulator\CosmosDB.Emulator.exe" "/NoExplorer /NoUI /DisableRateLimiting /PartitionCount=50 /Consistency=Strong /EnablePreview /EnableSqlComputeEndpoint" -Verb RunAs + $startedCosmos = $true + } + else + { + Write-Host "CosmosDB emulator is already running." + } +} + +if (!$SkipStorageEmulator) +{ + Write-Host "------" + Write-Host "" + Write-Host "---Starting Storage emulator---" + $storageEmulatorRunning = IsStorageEmulatorRunning + + if ($storageEmulatorRunning -eq $false) + { + if ($IsWindows) + { + npm install -g azurite + mkdir "./azurite" + Start-Process azurite.cmd -WorkingDirectory "./azurite" -ArgumentList "--silent" + } + else + { + sudo npm install -g azurite + sudo mkdir azurite + 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 ($StartCosmosDBEmulator -and $startedCosmos -eq $true) +{ + Write-Host "---Waiting for CosmosDB emulator to be running---" + $cosmosStatus = Get-CosmosDbEmulatorStatus + Write-Host "CosmosDB emulator status: $cosmosStatus" + + $waitSuccess = Wait-CosmosDbEmulator -Status Running -Timeout 60 -ErrorAction Continue + + if ($waitSuccess -ne $true) + { + Write-Host "CosmosDB emulator not yet running after waiting 60 seconds. Restarting." + Write-Host "Shutting down and restarting" + Start-Process "$env:ProgramFiles\Azure Cosmos DB Emulator\CosmosDB.Emulator.exe" "/Shutdown" -Verb RunAs + sleep 30; + + for ($j=0; $j -lt 3; $j++) { + Write-Host "Attempt $j" + Start-Process "$env:ProgramFiles\Azure Cosmos DB Emulator\CosmosDB.Emulator.exe" "/NoExplorer /NoUI /DisableRateLimiting /PartitionCount=50 /Consistency=Strong /EnablePreview /EnableSqlComputeEndpoint" -Verb RunAs + + for ($i=0; $i -lt 5; $i++) { + $status = Get-CosmosDbEmulatorStatus + Write-Host "Cosmos DB Emulator Status: $status" + + if ($status -ne "Running") { + sleep 30; + } + else { + break; + } + } + + if ($status -ne "Running") { + Write-Host "Shutting down and restarting" + Start-Process "$env:ProgramFiles\Azure Cosmos DB Emulator\CosmosDB.Emulator.exe" "/Shutdown" -Verb RunAs + sleep 30; + } + else { + break; + } + } + + if ($status -ne "Running") { + Write-Error "Emulator failed to start" + } + } + + Write-Host "------" + Write-Host +} + +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