mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
98cd72839e
commit
e8d0bd9051
+20
-396
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+449
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -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>
|
||||
|
||||
+74
@@ -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!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user