.NET: [Feature Branch] Add basic durable workflow support (#3648)

* Add basic durable workflow support.

* PR feedback fixes

* Add conditional edge sample.

* PR feedback fixes.

* Minor cleanup.

* Minor cleanup

* Minor formatting improvements.

* Improve comments/documentation on the execution flow.
This commit is contained in:
Shyju Krishnankutty
2026-02-06 16:02:42 -08:00
committed by GitHub
Unverified
parent 98cd72839e
commit e8d0bd9051
54 changed files with 4863 additions and 505 deletions
@@ -2,47 +2,32 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Integration tests for validating the durable agent console app samples
/// located in samples/Durable/Agents/ConsoleApps.
/// </summary>
[Collection("Samples")]
[Trait("Category", "SampleValidation")]
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper)
{
private const string DtsPort = "8080";
private const string RedisPort = "6379";
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private static bool s_infrastructureStarted;
private static readonly string s_samplesPath = Path.GetFullPath(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "Durable", "Agents", "ConsoleApps"));
private readonly ITestOutputHelper _outputHelper = outputHelper;
/// <inheritdoc />
protected override string SamplesPath => s_samplesPath;
async Task IAsyncLifetime.InitializeAsync()
{
if (!s_infrastructureStarted)
{
await this.StartSharedInfrastructureAsync();
s_infrastructureStarted = true;
}
}
/// <inheritdoc />
protected override bool RequiresRedis => true;
async Task IAsyncLifetime.DisposeAsync()
/// <inheritdoc />
protected override void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action<string, string> setEnvVar)
{
// Nothing to clean up
await Task.CompletedTask;
setEnvVar("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
}
[Fact]
@@ -475,7 +460,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
// (streams can complete very quickly, so we need to interrupt early)
if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2)
{
this._outputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
this.OutputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
interrupted = true;
interruptTime = DateTime.Now;
@@ -493,7 +478,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
foundLastCursor = true;
// Send Enter again to resume
this._outputHelper.WriteLine("Resuming stream from last cursor");
this.OutputHelper.WriteLine("Resuming stream from last cursor");
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
resumed = true;
}
@@ -521,7 +506,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
if (timeSinceInterrupt < TimeSpan.FromSeconds(2))
{
// Continue reading for a bit more to catch the cancellation message
this._outputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
this.OutputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
continue;
}
}
@@ -536,7 +521,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
// Stop once we've verified the interrupt/resume flow works
if (resumed && foundResumeMessage && contentLinesAfterResume >= 5)
{
this._outputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
this.OutputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
break;
}
}
@@ -547,7 +532,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
if (timeSinceInterrupt < TimeSpan.FromSeconds(3))
{
this._outputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
this.OutputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2));
try
{
@@ -558,7 +543,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
foundLastCursor = true;
if (!resumed)
{
this._outputHelper.WriteLine("Resuming stream from last cursor");
this.OutputHelper.WriteLine("Resuming stream from last cursor");
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
resumed = true;
}
@@ -576,7 +561,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
catch (OperationCanceledException)
{
// Timeout - check if we got enough to verify the flow
this._outputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
this.OutputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
}
Assert.True(foundConversationStart, "Conversation start message not found.");
@@ -586,7 +571,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
// but we should still verify we got the conversation started
if (!interrupted)
{
this._outputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
this.OutputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
}
Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly).");
@@ -596,365 +581,4 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart).");
});
}
private static string GetTargetFramework()
{
string filePath = new Uri(typeof(ConsoleAppSamplesValidation).Assembly.Location).LocalPath;
string directory = Path.GetDirectoryName(filePath)!;
string tfm = Path.GetFileName(directory);
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
{
return tfm;
}
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
}
private async Task StartSharedInfrastructureAsync()
{
this._outputHelper.WriteLine("Starting shared infrastructure for console app samples...");
// Start DTS emulator
await this.StartDtsEmulatorAsync();
// Start Redis
await this.StartRedisAsync();
// Wait for infrastructure to be ready
await Task.Delay(TimeSpan.FromSeconds(5));
}
private async Task StartDtsEmulatorAsync()
{
// Start DTS emulator if it's not already running
if (!await this.IsDtsEmulatorRunningAsync())
{
this._outputHelper.WriteLine("Starting DTS emulator...");
await this.RunCommandAsync("docker", [
"run", "-d",
"--name", "dts-emulator",
"-p", $"{DtsPort}:8080",
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
"mcr.microsoft.com/dts/dts-emulator:latest"
]);
}
}
private async Task StartRedisAsync()
{
if (!await this.IsRedisRunningAsync())
{
this._outputHelper.WriteLine("Starting Redis...");
await this.RunCommandAsync("docker", [
"run", "-d",
"--name", "redis",
"-p", $"{RedisPort}:6379",
"redis:latest"
]);
}
}
private async Task<bool> IsDtsEmulatorRunningAsync()
{
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
// DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0
using HttpClient http2Client = new()
{
DefaultRequestVersion = new Version(2, 0),
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
if (response.Content.Headers.ContentLength > 0)
{
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
}
if (response.IsSuccessStatusCode)
{
this._outputHelper.WriteLine("DTS emulator is running");
return true;
}
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
return false;
}
catch (HttpRequestException ex)
{
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
return false;
}
}
private async Task<bool> IsRedisRunningAsync()
{
this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
ProcessStartInfo startInfo = new()
{
FileName = "docker",
Arguments = "exec redis redis-cli ping",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = new() { StartInfo = startInfo };
if (!process.Start())
{
this._outputHelper.WriteLine("Failed to start docker exec command");
return false;
}
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token);
if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase))
{
this._outputHelper.WriteLine("Redis is running");
return true;
}
this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}");
return false;
}
catch (Exception ex)
{
this._outputHelper.WriteLine($"Redis is not running: {ex.Message}");
return false;
}
}
private async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
{
// Generate a unique TaskHub name for this sample test to prevent cross-test interference
// when multiple tests run together and share the same DTS emulator.
string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
// Start the console app
// Use BlockingCollection to safely read logs asynchronously captured from the process
using BlockingCollection<OutputLog> logsContainer = [];
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
try
{
// Run the test
await testAction(appProcess, logsContainer);
}
catch (OperationCanceledException e)
{
throw new TimeoutException("Core test logic timed out!", e);
}
finally
{
logsContainer.CompleteAdding();
await this.StopProcessAsync(appProcess);
}
}
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
/// <summary>
/// Writes a line to the process's stdin and flushes it.
/// Logs the input being sent for debugging purposes.
/// </summary>
private async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
await process.StandardInput.WriteLineAsync(input);
await process.StandardInput.FlushAsync(cancellationToken);
}
/// <summary>
/// Reads a line from the logs queue, filtering for Information level logs (stdout).
/// Returns null if the collection is completed and empty, or if cancellation is requested.
/// </summary>
private string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
// Block until a log entry is available or cancellation is requested
// Take will throw OperationCanceledException if cancelled, or InvalidOperationException if collection is completed
OutputLog log = logs.Take(cancellationToken);
// Check for unhandled exceptions in the logs, which are never expected (but can happen)
if (log.Message.Contains("Unhandled exception"))
{
Assert.Fail("Console app encountered an unhandled exception.");
}
// Only return Information level logs (stdout), skip Error logs (stderr)
if (log.Level == LogLevel.Information)
{
return log.Message;
}
}
}
catch (OperationCanceledException)
{
// Cancellation requested
return null;
}
catch (InvalidOperationException)
{
// Collection is completed and empty
return null;
}
return null;
}
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
{
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run --framework {s_dotnetTargetFramework}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
};
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
void SetAndLogEnvironmentVariable(string key, string value)
{
this._outputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
startInfo.EnvironmentVariables[key] = value;
}
// Set required environment variables for the app
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
SetAndLogEnvironmentVariable("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
Process process = new() { StartInfo = startInfo };
// Capture the output and error streams asynchronously
// These events fire asynchronously, so we add to the blocking collection which is thread-safe
process.ErrorDataReceived += (sender, e) =>
{
if (e.Data != null)
{
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(err)]: {e.Data}";
this._outputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
}
catch (InvalidOperationException)
{
// Collection is completed, ignore
}
}
};
process.OutputDataReceived += (sender, e) =>
{
if (e.Data != null)
{
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(out)]: {e.Data}";
this._outputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
}
catch (InvalidOperationException)
{
// Collection is completed, ignore
}
}
};
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the console app");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
return process;
}
private async Task RunCommandAsync(string command, string[] args)
{
await this.RunCommandAsync(command, workingDirectory: null, args: args);
}
private async Task RunCommandAsync(string command, string? workingDirectory, string[] args)
{
ProcessStartInfo startInfo = new()
{
FileName = command,
Arguments = string.Join(" ", args),
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
using Process process = new() { StartInfo = startInfo };
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the command");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(cancellationTokenSource.Token);
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
}
private async Task StopProcessAsync(Process process)
{
try
{
if (!process.HasExited)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
process.Kill(entireProcessTree: true);
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
await process.WaitForExitAsync(timeoutCts.Token);
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
}
}
catch (Exception ex)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
}
}
private CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
{
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
return new CancellationTokenSource(testTimeout);
}
}
@@ -0,0 +1,449 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Base class for sample validation integration tests providing shared infrastructure
/// setup and utility methods for running console app samples.
/// </summary>
public abstract class SamplesValidationBase : IAsyncLifetime
{
protected const string DtsPort = "8080";
protected const string RedisPort = "6379";
protected static readonly string DotnetTargetFramework = GetTargetFramework();
protected static readonly IConfiguration Configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
// Semaphores for thread-safe initialization of shared infrastructure.
// xUnit may run tests in parallel, so we need to ensure that DTS emulator and Redis
// are started only once across all test instances. Using SemaphoreSlim allows async-safe
// locking, and the double-check pattern (check flag, acquire lock, check flag again)
// minimizes lock contention after initialization is complete.
private static readonly SemaphoreSlim s_dtsInitLock = new(1, 1);
private static readonly SemaphoreSlim s_redisInitLock = new(1, 1);
private static bool s_dtsInfrastructureStarted;
private static bool s_redisInfrastructureStarted;
protected SamplesValidationBase(ITestOutputHelper outputHelper)
{
this.OutputHelper = outputHelper;
}
/// <summary>
/// Gets the test output helper for logging.
/// </summary>
protected ITestOutputHelper OutputHelper { get; }
/// <summary>
/// Gets the base path to the samples directory for this test class.
/// </summary>
protected abstract string SamplesPath { get; }
/// <summary>
/// Gets whether this test class requires Redis infrastructure.
/// </summary>
protected virtual bool RequiresRedis => false;
/// <summary>
/// Gets the task hub name prefix for this test class.
/// </summary>
protected virtual string TaskHubPrefix => "sample";
/// <inheritdoc />
public async Task InitializeAsync()
{
await EnsureDtsInfrastructureStartedAsync(this.OutputHelper, this.StartDtsEmulatorAsync);
if (this.RequiresRedis)
{
await EnsureRedisInfrastructureStartedAsync(this.OutputHelper, this.StartRedisAsync);
}
await Task.Delay(TimeSpan.FromSeconds(5));
}
/// <summary>
/// Ensures DTS infrastructure is started exactly once across all test instances.
/// Static method writes to static field to avoid the code smell of instance methods modifying shared state.
/// </summary>
private static async Task EnsureDtsInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func<Task> startAction)
{
if (s_dtsInfrastructureStarted)
{
return;
}
await s_dtsInitLock.WaitAsync();
try
{
if (!s_dtsInfrastructureStarted)
{
outputHelper.WriteLine("Starting shared DTS infrastructure...");
await startAction();
s_dtsInfrastructureStarted = true;
}
}
finally
{
s_dtsInitLock.Release();
}
}
/// <summary>
/// Ensures Redis infrastructure is started exactly once across all test instances.
/// Static method writes to static field to avoid the code smell of instance methods modifying shared state.
/// </summary>
private static async Task EnsureRedisInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func<Task> startAction)
{
if (s_redisInfrastructureStarted)
{
return;
}
await s_redisInitLock.WaitAsync();
try
{
if (!s_redisInfrastructureStarted)
{
outputHelper.WriteLine("Starting shared Redis infrastructure...");
await startAction();
s_redisInfrastructureStarted = true;
}
}
finally
{
s_redisInitLock.Release();
}
}
/// <inheritdoc />
public Task DisposeAsync() => Task.CompletedTask;
protected sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
/// <summary>
/// Runs a sample test by starting the console app and executing the provided test action.
/// </summary>
protected async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
{
string uniqueTaskHubName = $"{this.TaskHubPrefix}-{Guid.NewGuid():N}"[..^26];
using BlockingCollection<OutputLog> logsContainer = [];
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
try
{
await testAction(appProcess, logsContainer);
}
catch (OperationCanceledException e)
{
throw new TimeoutException("Core test logic timed out!", e);
}
finally
{
logsContainer.CompleteAdding();
await this.StopProcessAsync(appProcess);
}
}
/// <summary>
/// Writes a line to the process's stdin and flushes it.
/// </summary>
protected async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
{
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
await process.StandardInput.WriteLineAsync(input);
await process.StandardInput.FlushAsync(cancellationToken);
}
/// <summary>
/// Reads the next Information-level log line from the queue.
/// Returns null if cancelled or collection is completed.
/// </summary>
protected string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
OutputLog log = logs.Take(cancellationToken);
if (log.Message.Contains("Unhandled exception"))
{
Assert.Fail("Console app encountered an unhandled exception.");
}
if (log.Level == LogLevel.Information)
{
return log.Message;
}
}
}
catch (OperationCanceledException)
{
return null;
}
catch (InvalidOperationException)
{
return null;
}
return null;
}
/// <summary>
/// Creates a cancellation token source with the specified timeout for test operations.
/// </summary>
protected CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
{
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
return new CancellationTokenSource(testTimeout);
}
/// <summary>
/// Allows derived classes to set additional environment variables for the console app process.
/// </summary>
protected virtual void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action<string, string> setEnvVar)
{
}
private static string GetTargetFramework()
{
string filePath = new Uri(typeof(SamplesValidationBase).Assembly.Location).LocalPath;
string directory = Path.GetDirectoryName(filePath)!;
string tfm = Path.GetFileName(directory);
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
{
return tfm;
}
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
}
private async Task StartDtsEmulatorAsync()
{
if (!await this.IsDtsEmulatorRunningAsync())
{
this.OutputHelper.WriteLine("Starting DTS emulator...");
await this.RunCommandAsync("docker", "run", "-d",
"--name", "dts-emulator",
"-p", $"{DtsPort}:8080",
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
"mcr.microsoft.com/dts/dts-emulator:latest");
}
}
private async Task StartRedisAsync()
{
if (!await this.IsRedisRunningAsync())
{
this.OutputHelper.WriteLine("Starting Redis...");
await this.RunCommandAsync("docker", "run", "-d",
"--name", "redis",
"-p", $"{RedisPort}:6379",
"redis:latest");
}
}
private async Task<bool> IsDtsEmulatorRunningAsync()
{
this.OutputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
using HttpClient http2Client = new()
{
DefaultRequestVersion = new Version(2, 0),
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
using HttpResponseMessage response = await http2Client.GetAsync(
new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
if (response.Content.Headers.ContentLength > 0)
{
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
this.OutputHelper.WriteLine($"DTS emulator health check response: {content}");
}
bool isRunning = response.IsSuccessStatusCode;
this.OutputHelper.WriteLine(isRunning ? "DTS emulator is running" : $"DTS emulator not running. Status: {response.StatusCode}");
return isRunning;
}
catch (HttpRequestException ex)
{
this.OutputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
return false;
}
}
private async Task<bool> IsRedisRunningAsync()
{
this.OutputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
ProcessStartInfo startInfo = new()
{
FileName = "docker",
Arguments = "exec redis redis-cli ping",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = new() { StartInfo = startInfo };
if (!process.Start())
{
this.OutputHelper.WriteLine("Failed to start docker exec command");
return false;
}
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token);
bool isRunning = process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase);
this.OutputHelper.WriteLine(isRunning ? "Redis is running" : $"Redis not running. Exit: {process.ExitCode}, Output: {output}");
return isRunning;
}
catch (Exception ex)
{
this.OutputHelper.WriteLine($"Redis is not running: {ex.Message}");
return false;
}
}
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
{
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run --framework {DotnetTargetFramework}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
};
string openAiEndpoint = Configuration["AZURE_OPENAI_ENDPOINT"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
string openAiDeployment = Configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
void SetAndLogEnvironmentVariable(string key, string value)
{
this.OutputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
startInfo.EnvironmentVariables[key] = value;
}
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
this.ConfigureAdditionalEnvironmentVariables(startInfo, SetAndLogEnvironmentVariable);
Process process = new() { StartInfo = startInfo };
process.ErrorDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "err", LogLevel.Error, logs);
process.OutputDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "out", LogLevel.Information, logs);
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the console app");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
return process;
}
private void HandleProcessOutput(string? data, string processName, string stream, LogLevel level, BlockingCollection<OutputLog> logs)
{
if (data is null)
{
return;
}
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{processName}({stream})]: {data}";
this.OutputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, level, data));
}
catch (InvalidOperationException)
{
// Collection completed
}
}
private async Task RunCommandAsync(string command, params string[] args)
{
ProcessStartInfo startInfo = new()
{
FileName = command,
Arguments = string.Join(" ", args),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
this.OutputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
using Process process = new() { StartInfo = startInfo };
process.ErrorDataReceived += (sender, e) => this.OutputHelper.WriteLine($"[{command}(err)]: {e.Data}");
process.OutputDataReceived += (sender, e) => this.OutputHelper.WriteLine($"[{command}(out)]: {e.Data}");
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the command");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(cts.Token);
this.OutputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
}
private async Task StopProcessAsync(Process process)
{
try
{
if (!process.HasExited)
{
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
process.Kill(entireProcessTree: true);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
await process.WaitForExitAsync(cts.Token);
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
}
}
catch (Exception ex)
{
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
}
}
}
@@ -0,0 +1,242 @@
// Copyright (c) Microsoft. All rights reserved.
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Integration tests for validating the durable workflow console app samples
/// located in samples/Durable/Workflow/ConsoleApps.
/// </summary>
[Collection("Samples")]
[Trait("Category", "SampleValidation")]
public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper)
{
private static readonly string s_samplesPath = Path.GetFullPath(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "Durable", "Workflow", "ConsoleApps"));
/// <inheritdoc />
protected override string SamplesPath => s_samplesPath;
/// <inheritdoc />
protected override string TaskHubPrefix => "workflow";
[Fact]
public async Task SequentialWorkflowSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
bool inputSent = false;
bool workflowCompleted = false;
bool foundOrderLookup = false;
bool foundOrderCancel = false;
bool foundSendEmail = false;
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase))
{
await this.WriteInputAsync(process, "12345", testTimeoutCts.Token);
inputSent = true;
}
if (inputSent)
{
foundOrderLookup |= line.Contains("[Activity] OrderLookup:", StringComparison.Ordinal);
foundOrderCancel |= line.Contains("[Activity] OrderCancel:", StringComparison.Ordinal);
foundSendEmail |= line.Contains("[Activity] SendEmail:", StringComparison.Ordinal);
if (line.Contains("Workflow completed. Cancellation email sent for order 12345", StringComparison.OrdinalIgnoreCase))
{
workflowCompleted = true;
break;
}
}
this.AssertNoError(line);
}
Assert.True(inputSent, "Input was not sent to the workflow.");
Assert.True(foundOrderLookup, "OrderLookup executor log entry not found.");
Assert.True(foundOrderCancel, "OrderCancel executor log entry not found.");
Assert.True(foundSendEmail, "SendEmail executor log entry not found.");
Assert.True(workflowCompleted, "Workflow did not complete successfully.");
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
[Fact]
public async Task ConcurrentWorkflowSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
bool inputSent = false;
bool workflowCompleted = false;
bool foundParseQuestion = false;
bool foundAggregator = false;
bool foundAggregatorReceived2Responses = false;
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
if (!inputSent && line.Contains("Enter a science question", StringComparison.OrdinalIgnoreCase))
{
await this.WriteInputAsync(process, "What is gravity?", testTimeoutCts.Token);
inputSent = true;
}
if (inputSent)
{
foundParseQuestion |= line.Contains("[ParseQuestion]", StringComparison.Ordinal);
foundAggregator |= line.Contains("[Aggregator]", StringComparison.Ordinal);
foundAggregatorReceived2Responses |= line.Contains("Received 2 AI agent responses", StringComparison.Ordinal);
if (line.Contains("Aggregation complete", StringComparison.OrdinalIgnoreCase))
{
workflowCompleted = true;
break;
}
}
this.AssertNoError(line);
}
Assert.True(inputSent, "Input was not sent to the workflow.");
Assert.True(foundParseQuestion, "ParseQuestion executor log entry not found.");
Assert.True(foundAggregator, "Aggregator executor log entry not found.");
Assert.True(foundAggregatorReceived2Responses, "Aggregator did not receive 2 AI agent responses.");
Assert.True(workflowCompleted, "Workflow did not complete successfully.");
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
[Fact]
public async Task ConditionalEdgesWorkflowSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "03_ConditionalEdges");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
bool validOrderSent = false;
bool blockedOrderSent = false;
bool validOrderCompleted = false;
bool blockedOrderCompleted = false;
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Send a valid order first (no 'B' in ID)
if (!validOrderSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase))
{
await this.WriteInputAsync(process, "12345", testTimeoutCts.Token);
validOrderSent = true;
}
// Check valid order completed (routed to PaymentProcessor)
if (validOrderSent && !validOrderCompleted &&
line.Contains("PaymentReferenceNumber", StringComparison.OrdinalIgnoreCase))
{
validOrderCompleted = true;
// Send a blocked order (contains 'B')
await this.WriteInputAsync(process, "ORDER-B-999", testTimeoutCts.Token);
blockedOrderSent = true;
}
// Check blocked order completed (routed to NotifyFraud)
if (blockedOrderSent && line.Contains("flagged as fraudulent", StringComparison.OrdinalIgnoreCase))
{
blockedOrderCompleted = true;
break;
}
this.AssertNoError(line);
}
Assert.True(validOrderSent, "Valid order input was not sent.");
Assert.True(validOrderCompleted, "Valid order did not complete (PaymentProcessor path).");
Assert.True(blockedOrderSent, "Blocked order input was not sent.");
Assert.True(blockedOrderCompleted, "Blocked order did not complete (NotifyFraud path).");
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
private void AssertNoError(string line)
{
if (line.Contains("Failed:", StringComparison.OrdinalIgnoreCase) ||
line.Contains("Error:", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail($"Workflow failed: {line}");
}
}
[Fact]
public async Task WorkflowAndAgentsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowAndAgents");
await this.RunSampleTestAsync(samplePath, (process, logs) =>
{
// Arrange
bool foundDemo1 = false;
bool foundBiologistResponse = false;
bool foundChemistResponse = false;
bool foundDemo2 = false;
bool foundPhysicsWorkflow = false;
bool foundDemo3 = false;
bool foundExpertTeamWorkflow = false;
bool foundDemo4 = false;
bool foundChemistryWorkflow = false;
bool allDemosCompleted = false;
// Act
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
foundDemo1 |= line.Contains("DEMO 1:", StringComparison.Ordinal);
foundBiologistResponse |= line.Contains("Biologist:", StringComparison.Ordinal);
foundChemistResponse |= line.Contains("Chemist:", StringComparison.Ordinal);
foundDemo2 |= line.Contains("DEMO 2:", StringComparison.Ordinal);
foundPhysicsWorkflow |= line.Contains("PhysicsExpertReview", StringComparison.Ordinal);
foundDemo3 |= line.Contains("DEMO 3:", StringComparison.Ordinal);
foundExpertTeamWorkflow |= line.Contains("ExpertTeamReview", StringComparison.Ordinal);
foundDemo4 |= line.Contains("DEMO 4:", StringComparison.Ordinal);
foundChemistryWorkflow |= line.Contains("ChemistryExpertReview", StringComparison.Ordinal);
if (line.Contains("All demos completed", StringComparison.OrdinalIgnoreCase))
{
allDemosCompleted = true;
break;
}
this.AssertNoError(line);
}
// Assert
Assert.True(foundDemo1, "DEMO 1 (Direct Agent Conversation) not found.");
Assert.True(foundBiologistResponse, "Biologist agent response not found.");
Assert.True(foundChemistResponse, "Chemist agent response not found.");
Assert.True(foundDemo2, "DEMO 2 (Single-Agent Workflow) not found.");
Assert.True(foundPhysicsWorkflow, "PhysicsExpertReview workflow not found.");
Assert.True(foundDemo3, "DEMO 3 (Multi-Agent Workflow) not found.");
Assert.True(foundExpertTeamWorkflow, "ExpertTeamReview workflow not found.");
Assert.True(foundDemo4, "DEMO 4 (Chemistry Workflow) not found.");
Assert.True(foundChemistryWorkflow, "ChemistryExpertReview workflow not found.");
Assert.True(allDemosCompleted, "Sample did not complete all demos successfully.");
return Task.CompletedTask;
});
}
}
@@ -8,6 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,74 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.Workflows;
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
public sealed class WorkflowNamingHelperTests
{
[Fact]
public void ToOrchestrationFunctionName_ValidWorkflowName_ReturnsPrefixedName()
{
string result = WorkflowNamingHelper.ToOrchestrationFunctionName("MyWorkflow");
Assert.Equal("dafx-MyWorkflow", result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void ToOrchestrationFunctionName_NullOrEmpty_ThrowsArgumentException(string? workflowName)
{
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName!));
}
[Fact]
public void ToWorkflowName_ValidOrchestrationFunctionName_ReturnsWorkflowName()
{
string result = WorkflowNamingHelper.ToWorkflowName("dafx-MyWorkflow");
Assert.Equal("MyWorkflow", result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void ToWorkflowName_NullOrEmpty_ThrowsArgumentException(string? orchestrationFunctionName)
{
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName!));
}
[Theory]
[InlineData("MyWorkflow")]
[InlineData("invalid-prefix-MyWorkflow")]
[InlineData("dafx")]
[InlineData("dafx-")]
public void ToWorkflowName_InvalidOrMissingPrefix_ThrowsArgumentException(string orchestrationFunctionName)
{
Assert.Throws<ArgumentException>(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName));
}
[Fact]
public void GetExecutorName_SimpleExecutorId_ReturnsSameName()
{
string result = WorkflowNamingHelper.GetExecutorName("OrderParser");
Assert.Equal("OrderParser", result);
}
[Fact]
public void GetExecutorName_ExecutorIdWithGuidSuffix_ReturnsNameWithoutSuffix()
{
string result = WorkflowNamingHelper.GetExecutorName("Physicist_8884e71021334ce49517fa2b17b1695b");
Assert.Equal("Physicist", result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void GetExecutorName_NullOrEmpty_ThrowsArgumentException(string? executorId)
{
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.GetExecutorName(executorId!));
}
}