Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
: TimeSpan.FromSeconds(60);

private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ protected async Task RunSampleTestAsync(string samplePath, Func<Process, Blockin
{
string uniqueTaskHubName = $"{this.TaskHubPrefix}-{Guid.NewGuid():N}"[..^26];

// Build the sample project first so that build failures are caught immediately
// instead of silently failing inside 'dotnet run' and causing a timeout.
await this.BuildSampleAsync(samplePath);

using BlockingCollection<OutputLog> logsContainer = [];
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);

Expand All @@ -154,7 +158,11 @@ protected async Task RunSampleTestAsync(string samplePath, Func<Process, Blockin
}
finally
{
logsContainer.CompleteAdding();
if (!logsContainer.IsAddingCompleted)
{
logsContainer.CompleteAdding();
}

await this.StopProcessAsync(appProcess);
}
}
Expand Down Expand Up @@ -329,12 +337,56 @@ private async Task<bool> IsRedisRunningAsync()
}
}

private async Task BuildSampleAsync(string samplePath)
{
this.OutputHelper.WriteLine($"Building sample at {samplePath}...");

ProcessStartInfo buildInfo = new()
{
FileName = "dotnet",
Arguments = $"build --framework {DotnetTargetFramework}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};

using Process buildProcess = new() { StartInfo = buildInfo };
buildProcess.Start();

// Read both streams asynchronously to avoid deadlocks from filled pipe buffers
Task<string> stdoutTask = buildProcess.StandardOutput.ReadToEndAsync();
Task<string> stderrTask = buildProcess.StandardError.ReadToEndAsync();

using CancellationTokenSource buildCts = new(TimeSpan.FromMinutes(5));
try
{
await buildProcess.WaitForExitAsync(buildCts.Token);
}
catch (OperationCanceledException)
{
buildProcess.Kill(entireProcessTree: true);
throw new TimeoutException($"Build timed out after 5 minutes for sample at {samplePath}.");
}

await Task.WhenAll(stdoutTask, stderrTask);

string stdout = stdoutTask.Result;
string stderr = stderrTask.Result;
if (buildProcess.ExitCode != 0)
{
throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}");
}

this.OutputHelper.WriteLine($"Build completed for {samplePath}.");
Comment thread
westey-m marked this conversation as resolved.
}

private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
{
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run --framework {DotnetTargetFramework}",
Arguments = $"run --no-build --framework {DotnetTargetFramework}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
Expand All @@ -360,11 +412,21 @@ void SetAndLogEnvironmentVariable(string key, string value)

this.ConfigureAdditionalEnvironmentVariables(startInfo, SetAndLogEnvironmentVariable);

Process process = new() { StartInfo = startInfo };
Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true };

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);

// When the process exits unexpectedly (e.g. build failure), complete the log collection
// so that ReadLogLine returns null immediately instead of blocking until the test timeout.
process.Exited += (sender, e) =>
{
if (!logs.IsAddingCompleted)
{
logs.CompleteAdding();
}
};

if (!process.Start())
{
throw new InvalidOperationException("Failed to start the console app");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics;

namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;

/// <summary>
/// Shared test helpers for Azure Functions integration tests.
/// </summary>
internal static class AzureFunctionsTestHelper
{
private static readonly TimeSpan s_buildTimeout = TimeSpan.FromMinutes(5);

/// <summary>
/// Builds the sample project, failing fast if the build fails or times out.
/// </summary>
internal static async Task BuildSampleAsync(
string samplePath,
string buildArgs,
ITestOutputHelper outputHelper)
{
outputHelper.WriteLine($"Building sample at {samplePath}...");

ProcessStartInfo buildInfo = new()
{
FileName = "dotnet",
Arguments = $"build {buildArgs}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};

using Process buildProcess = new() { StartInfo = buildInfo };
buildProcess.Start();

// Read both streams asynchronously to avoid deadlocks from filled pipe buffers
Task<string> stdoutTask = buildProcess.StandardOutput.ReadToEndAsync();
Task<string> stderrTask = buildProcess.StandardError.ReadToEndAsync();

using CancellationTokenSource buildCts = new(s_buildTimeout);
try
{
await buildProcess.WaitForExitAsync(buildCts.Token);
}
catch (OperationCanceledException)
{
buildProcess.Kill(entireProcessTree: true);
throw new TimeoutException($"Build timed out after {s_buildTimeout.TotalMinutes} minutes for sample at {samplePath}.");
}

await Task.WhenAll(stdoutTask, stderrTask);

string stdout = stdoutTask.Result;
string stderr = stderrTask.Result;
if (buildProcess.ExitCode != 0)
{
throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}");
}

outputHelper.WriteLine($"Build completed for {samplePath}.");
}

/// <summary>
/// Polls the Azure Functions host until it responds to an HTTP HEAD request,
/// failing fast if the host process exits unexpectedly.
/// </summary>
internal static async Task WaitForFunctionsReadyAsync(
Process funcProcess,
string port,
HttpClient httpClient,
ITestOutputHelper outputHelper,
TimeSpan timeout,
string? samplePath = null)
{
outputHelper.WriteLine(
$"Waiting for Azure Functions Core Tools to be ready at http://localhost:{port}/...");

using CancellationTokenSource cts = new(timeout);
while (true)
{
// Fail fast if the host process has exited (e.g. build or startup failure)
if (funcProcess.HasExited)
{
string context = samplePath != null ? $" for sample '{samplePath}'" : string.Empty;
throw new InvalidOperationException(
$"The Azure Functions host process exited unexpectedly with code {funcProcess.ExitCode}{context}.");
}

try
{
using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{port}/");
using HttpResponseMessage response = await httpClient.SendAsync(request);
outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}");
if (response.IsSuccessStatusCode)
{
return;
}
}
catch (HttpRequestException)
{
// Expected when the app isn't yet ready
}

try
{
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
string context = samplePath != null ? $" for sample '{samplePath}'" : string.Empty;
throw new TimeoutException(
$"Timeout waiting for 'Azure Functions Core Tools is ready'{context}");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -803,15 +803,17 @@ private async Task WaitForConditionAsync(Func<Task<bool>> condition, string mess
private async Task RunSampleTestAsync(string samplePath, Func<IReadOnlyList<OutputLog>, Task> testAction)
{
// Build the sample project first (it may not have been built as part of the solution)
await this.BuildSampleAsync(samplePath);
await AzureFunctionsTestHelper.BuildSampleAsync(
samplePath, $"-f {s_dotnetTargetFramework} -c {BuildConfiguration}", this._outputHelper);

// Start the Azure Functions app
List<OutputLog> logsContainer = [];
using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer);
try
{
// Wait for the app to be ready
await this.WaitForAzureFunctionsAsync();
await AzureFunctionsTestHelper.WaitForFunctionsReadyAsync(
funcProcess, AzureFunctionsPort, s_sharedHttpClient, this._outputHelper, s_functionsReadyTimeout, samplePath);

// Run the test
await testAction(logsContainer);
Expand All @@ -824,38 +826,6 @@ private async Task RunSampleTestAsync(string samplePath, Func<IReadOnlyList<Outp

private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);

private async Task BuildSampleAsync(string samplePath)
{
this._outputHelper.WriteLine($"Building sample at {samplePath}...");

ProcessStartInfo buildInfo = new()
{
FileName = "dotnet",
Arguments = $"build -f {s_dotnetTargetFramework} -c {BuildConfiguration}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};

using Process buildProcess = new() { StartInfo = buildInfo };
buildProcess.Start();

// Read both streams asynchronously to avoid deadlocks from filled pipe buffers
Task<string> stdoutTask = buildProcess.StandardOutput.ReadToEndAsync();
Task<string> stderrTask = buildProcess.StandardError.ReadToEndAsync();
await buildProcess.WaitForExitAsync();

string stderr = await stderrTask;
if (buildProcess.ExitCode != 0)
{
string stdout = await stdoutTask;
throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}");
}

this._outputHelper.WriteLine($"Build completed for {samplePath}.");
}

private Process StartFunctionApp(string samplePath, List<OutputLog> logs)
{
ProcessStartInfo startInfo = new()
Expand Down Expand Up @@ -919,30 +889,6 @@ private Process StartFunctionApp(string samplePath, List<OutputLog> logs)
return process;
}

private async Task WaitForAzureFunctionsAsync()
{
this._outputHelper.WriteLine(
$"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/...");
await this.WaitForConditionAsync(
condition: async () =>
{
try
{
using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/");
using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request);
this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}");
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
// Expected when the app isn't yet ready
return false;
}
},
message: "Azure Functions Core Tools is ready",
timeout: s_functionsReadyTimeout);
}

private async Task WaitForOrchestrationCompletionAsync(Uri statusUri)
{
using CancellationTokenSource timeoutCts = new(s_orchestrationTimeout);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
private static bool s_infrastructureStarted;
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1);

// In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough.
// Timeout for the Azure Functions host to become ready after building.
private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180);

private static readonly string s_samplesPath = Path.GetFullPath(
Expand Down Expand Up @@ -425,11 +425,17 @@ private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Messa

private async Task RunSampleTestAsync(string samplePath, bool requiresOpenAI, Func<IReadOnlyList<OutputLog>, Task> testAction)
{
// Build the sample project first (it may not have been built as part of the solution)
await AzureFunctionsTestHelper.BuildSampleAsync(
samplePath, $"-f {s_dotnetTargetFramework} -c {BuildConfiguration}", this._outputHelper);

// Start the Azure Functions app
List<OutputLog> logsContainer = [];
using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer, requiresOpenAI);
try
{
await this.WaitForAzureFunctionsAsync();
await AzureFunctionsTestHelper.WaitForFunctionsReadyAsync(
funcProcess, AzureFunctionsPort, s_sharedHttpClient, this._outputHelper, s_functionsReadyTimeout, samplePath);
await testAction(logsContainer);
}
finally
Expand All @@ -443,7 +449,7 @@ private Process StartFunctionApp(string samplePath, List<OutputLog> logs, bool r
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}",
Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
Expand Down Expand Up @@ -504,29 +510,6 @@ private Process StartFunctionApp(string samplePath, List<OutputLog> logs, bool r
return process;
}

private async Task WaitForAzureFunctionsAsync()
{
this._outputHelper.WriteLine(
$"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/...");
await this.WaitForConditionAsync(
condition: async () =>
{
try
{
using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/");
using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request);
this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}");
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
},
message: "Azure Functions Core Tools is ready",
timeout: s_functionsReadyTimeout);
}

private async Task RunCommandAsync(string command, string[] args)
{
ProcessStartInfo startInfo = new()
Expand Down
Loading