diff --git a/Azure.Functions.Cli.sln b/Azure.Functions.Cli.sln index ab2673cd5..7c7f354a4 100644 --- a/Azure.Functions.Cli.sln +++ b/Azure.Functions.Cli.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Functions.Cli.Abstrac EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreToolsHost", "src\CoreToolsHost\CoreToolsHost.csproj", "{0333D5B6-B628-4605-A51E-D0AEE4C3F1FC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Functions.Cli.TestFramework", "test\Cli\TestFramework\Azure.Functions.Cli.TestFramework.csproj", "{3A8E1907-E3A2-1CE0-BA8B-805B655FAF09}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +53,10 @@ Global {0333D5B6-B628-4605-A51E-D0AEE4C3F1FC}.Debug|Any CPU.Build.0 = Debug|Any CPU {0333D5B6-B628-4605-A51E-D0AEE4C3F1FC}.Release|Any CPU.ActiveCfg = Release|Any CPU {0333D5B6-B628-4605-A51E-D0AEE4C3F1FC}.Release|Any CPU.Build.0 = Release|Any CPU + {3A8E1907-E3A2-1CE0-BA8B-805B655FAF09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A8E1907-E3A2-1CE0-BA8B-805B655FAF09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A8E1907-E3A2-1CE0-BA8B-805B655FAF09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A8E1907-E3A2-1CE0-BA8B-805B655FAF09}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -63,6 +69,7 @@ Global {78231B55-D243-46F1-9C7F-7831B40ED2D8} = {154FDAF2-0E86-450E-BE57-4E3D410B0FAC} {BC78165E-CE5B-4303-BB8E-BC172E5B86E0} = {154FDAF2-0E86-450E-BE57-4E3D410B0FAC} {0333D5B6-B628-4605-A51E-D0AEE4C3F1FC} = {5F51C958-39C0-4E0C-9165-71D0BCE647BC} + {3A8E1907-E3A2-1CE0-BA8B-805B655FAF09} = {6EE1D011-2334-44F2-9D41-608B969DAE6D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FA1E01D6-A57B-4061-A333-EDC511D283C0} diff --git a/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs b/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs new file mode 100644 index 000000000..280bb3cb6 --- /dev/null +++ b/test/Cli/TestFramework/Assertions/CommandResultAssertions.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +// Based off of: https://github.com/dotnet/sdk/blob/e793aa4709d28cd783712df40413448250e26fea/test/Microsoft.NET.TestFramework/Assertions/CommandResultAssertions.cs +using Azure.Functions.Cli.Abstractions.Command; +using FluentAssertions; +using FluentAssertions.Execution; + +namespace Azure.Functions.Cli.TestFramework.Assertions +{ + public class CommandResultAssertions(CommandResult commandResult) + { + private readonly CommandResult _commandResult = commandResult; + + public CommandResultAssertions ExitWith(int expectedExitCode) + { + Execute.Assertion.ForCondition(_commandResult.ExitCode == expectedExitCode) + .FailWith($"Expected command to exit with {expectedExitCode} but it did not. Error message: {_commandResult.StdErr}"); + return this; + } + + public AndConstraint HaveStdOutContaining(string pattern) + { + Execute.Assertion.ForCondition(_commandResult.StdOut is not null && _commandResult.StdOut.Contains(pattern)) + .FailWith($"The command output did not contain expected result: {pattern}{Environment.NewLine}"); + return new AndConstraint(this); + } + + public AndConstraint HaveStdErrContaining(string pattern) + { + Execute.Assertion.ForCondition(_commandResult.StdErr is not null && _commandResult.StdErr.Contains(pattern)) + .FailWith($"The command output did not contain expected result: {pattern}{Environment.NewLine}"); + return new AndConstraint(this); + } + + public AndConstraint NotHaveStdOutContaining(string pattern) + { + Execute.Assertion.ForCondition(_commandResult.StdOut is not null && !_commandResult.StdOut.Contains(pattern)) + .FailWith($"The command output did contain expected result: {pattern}{Environment.NewLine}"); + return new AndConstraint(this); + } + } +} diff --git a/test/Cli/TestFramework/Assertions/CommandResultExtensions.cs b/test/Cli/TestFramework/Assertions/CommandResultExtensions.cs new file mode 100644 index 000000000..c4a62a04e --- /dev/null +++ b/test/Cli/TestFramework/Assertions/CommandResultExtensions.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +// Copied from: https://github.com/dotnet/sdk/blob/e793aa4709d28cd783712df40413448250e26fea/test/Microsoft.NET.TestFramework/Assertions/CommandResultExtensions.cs +using Azure.Functions.Cli.Abstractions.Command; + +namespace Azure.Functions.Cli.TestFramework.Assertions +{ + public static class CommandResultExtensions + { + public static CommandResultAssertions Should(this CommandResult commandResult) => new(commandResult); + } +} diff --git a/test/Cli/TestFramework/Azure.Functions.Cli.TestFramework.csproj b/test/Cli/TestFramework/Azure.Functions.Cli.TestFramework.csproj new file mode 100644 index 000000000..84ea0cdac --- /dev/null +++ b/test/Cli/TestFramework/Azure.Functions.Cli.TestFramework.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + true + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Cli/TestFramework/CommandInfo.cs b/test/Cli/TestFramework/CommandInfo.cs new file mode 100644 index 000000000..19d10a809 --- /dev/null +++ b/test/Cli/TestFramework/CommandInfo.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +// Based off of: https://github.com/dotnet/sdk/blob/e793aa4709d28cd783712df40413448250e26fea/test/Microsoft.NET.TestFramework/Commands/SdkCommandSpec.cs +using System.Diagnostics; +using Azure.Functions.Cli.Abstractions.Command; + +namespace Azure.Functions.Cli.TestFramework +{ + public class CommandInfo + { + public required string FileName { get; set; } + + public List Arguments { get; set; } = []; + + public Dictionary Environment { get; set; } = []; + + public List EnvironmentToRemove { get; } = []; + + public required string WorkingDirectory { get; set; } + + public string? TestName { get; set; } + + public Command ToCommand() + { + var process = new Process() + { + StartInfo = ToProcessStartInfo() + }; + + return new Command(process, trimTrailingNewlines: true); + } + + public ProcessStartInfo ToProcessStartInfo() + { + var psi = new ProcessStartInfo + { + FileName = FileName, + Arguments = string.Join(" ", Arguments), + UseShellExecute = false + }; + + foreach (KeyValuePair kvp in Environment) + { + psi.Environment[kvp.Key] = kvp.Value; + } + + foreach (string envToRemove in EnvironmentToRemove) + { + psi.Environment.Remove(envToRemove); + } + + if (WorkingDirectory is not null) + { + psi.WorkingDirectory = WorkingDirectory; + } + + return psi; + } + } +} diff --git a/test/Cli/TestFramework/Commands/FuncCommand.cs b/test/Cli/TestFramework/Commands/FuncCommand.cs new file mode 100644 index 000000000..1509214ee --- /dev/null +++ b/test/Cli/TestFramework/Commands/FuncCommand.cs @@ -0,0 +1,217 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +// Based off of: https://github.com/dotnet/sdk/blob/e793aa4709d28cd783712df40413448250e26fea/test/Microsoft.NET.TestFramework/Commands/TestCommand.cs +using System.Diagnostics; +using Azure.Functions.Cli.Abstractions.Command; +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.TestFramework.Commands +{ + public abstract class FuncCommand(ITestOutputHelper log) + { + private readonly Dictionary _environment = []; + + public ITestOutputHelper Log { get; } = log; + + public string? WorkingDirectory { get; set; } + + public List Arguments { get; set; } = []; + + public List EnvironmentToRemove { get; } = []; + + // These only work via Execute(), not when using GetProcessStartInfo() + public Action? CommandOutputHandler { get; set; } + + public Func? ProcessStartedHandler { get; set; } + + public StreamWriter? FileWriter { get; private set; } = null; + + public string? LogFilePath { get; private set; } + + protected abstract CommandInfo CreateCommand(IEnumerable args); + + public FuncCommand WithEnvironmentVariable(string name, string value) + { + _environment[name] = value; + return this; + } + + public FuncCommand WithWorkingDirectory(string workingDirectory) + { + WorkingDirectory = workingDirectory; + return this; + } + + private CommandInfo CreateCommandInfo(IEnumerable args) + { + CommandInfo commandInfo = CreateCommand(args); + foreach (KeyValuePair kvp in _environment) + { + commandInfo.Environment[kvp.Key] = kvp.Value; + } + + foreach (string envToRemove in EnvironmentToRemove) + { + commandInfo.EnvironmentToRemove.Add(envToRemove); + } + + if (WorkingDirectory is not null) + { + commandInfo.WorkingDirectory = WorkingDirectory; + } + + if (Arguments.Count != 0) + { + commandInfo.Arguments = [.. Arguments, .. commandInfo.Arguments]; + } + + return commandInfo; + } + + public ProcessStartInfo GetProcessStartInfo(params string[] args) + { + CommandInfo commandSpec = CreateCommandInfo(args); + return commandSpec.ToProcessStartInfo(); + } + + public virtual CommandResult Execute(IEnumerable args) + { + CommandInfo spec = CreateCommandInfo(args); + ICommand command = spec + .ToCommand() + .CaptureStdOut() + .CaptureStdErr(); + + string? funcExeDirectory = Path.GetDirectoryName(spec.FileName); + + if (!string.IsNullOrEmpty(funcExeDirectory)) + { + Directory.SetCurrentDirectory(funcExeDirectory); + } + + string? directoryToLogTo = Environment.GetEnvironmentVariable("DirectoryToLogTo"); + if (string.IsNullOrEmpty(directoryToLogTo)) + { + directoryToLogTo = Directory.GetCurrentDirectory(); + } + + // Ensure directory exists + Directory.CreateDirectory(directoryToLogTo); + + // Create a more unique filename to avoid conflicts + string uniqueId = Guid.NewGuid().ToString("N")[..8]; + LogFilePath = Path.Combine( + directoryToLogTo, + $"func_{spec.Arguments.First()}_{spec.TestName}_{DateTime.Now:yyyyMMdd_HHmmss}_{uniqueId}.log"); + + // Make sure we're only opening the file once + try + { + // Open with FileShare.Read to allow others to read but not write + var fileStream = new FileStream(LogFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); + FileWriter = new StreamWriter(fileStream) + { + AutoFlush = true + }; + + // Write initial information + FileWriter.WriteLine($"=== Test started at {DateTime.Now} ==="); + FileWriter.WriteLine($"Test Name: {spec.TestName}"); + string? display = $"func {string.Join(" ", spec.Arguments)}"; + FileWriter.WriteLine($"Command: {display}"); + FileWriter.WriteLine($"Working Directory: {spec.WorkingDirectory ?? "not specified"}"); + FileWriter.WriteLine("===================================="); + + command.OnOutputLine(line => + { + try + { + // Write to the file if it's still open + if (FileWriter is not null && FileWriter.BaseStream is not null) + { + FileWriter.WriteLine($"[STDOUT] {line}"); + FileWriter.Flush(); + } + + Log.WriteLine($"》 {line}"); + CommandOutputHandler?.Invoke(line); + } + catch (Exception ex) + { + Log.WriteLine($"Error writing to log file: {ex.Message}"); + } + }); + + command.OnErrorLine(line => + { + try + { + // Write to the file if it's still open + if (FileWriter is not null && FileWriter.BaseStream is not null) + { + FileWriter.WriteLine($"[STDERR] {line}"); + FileWriter.Flush(); + } + + if (!string.IsNullOrEmpty(line)) + { + Log.WriteLine($"❌ {line}"); + } + } + catch (Exception ex) + { + Log.WriteLine($"Error writing to log file: {ex.Message}"); + } + }); + + Log.WriteLine($"Executing '{display}':"); + Log.WriteLine($"Output being captured to: {LogFilePath}"); + + CommandResult result = ((Command)command).Execute(ProcessStartedHandler, FileWriter); + + FileWriter.WriteLine("===================================="); + FileWriter.WriteLine($"Command exited with code: {result.ExitCode}"); + FileWriter.WriteLine($"=== Test ended at {DateTime.Now} ==="); + + Log.WriteLine($"Command '{display}' exited with exit code {result.ExitCode}."); + + return result; + } + finally + { + // Make sure to close and dispose the writer + if (FileWriter is not null) + { + try + { + FileWriter.Close(); + FileWriter.Dispose(); + } + catch (Exception ex) + { + Log.WriteLine($"Error closing log file: {ex.Message}"); + } + } + } + } + + public static void LogCommandResult(ITestOutputHelper log, CommandResult result) + { + log.WriteLine($"> {result.StartInfo.FileName} {result.StartInfo.Arguments}"); + log.WriteLine(result.StdOut); + + if (!string.IsNullOrEmpty(result.StdErr)) + { + log.WriteLine(string.Empty); + log.WriteLine("StdErr:"); + log.WriteLine(result.StdErr); + } + + if (result.ExitCode != 0) + { + log.WriteLine($"Exit Code: {result.ExitCode}"); + } + } + } +} diff --git a/test/Cli/TestFramework/Commands/FuncInitCommand.cs b/test/Cli/TestFramework/Commands/FuncInitCommand.cs new file mode 100644 index 000000000..317d40fd9 --- /dev/null +++ b/test/Cli/TestFramework/Commands/FuncInitCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.TestFramework.Commands +{ + public class FuncInitCommand(string funcPath, string testName, ITestOutputHelper log) : FuncCommand(log) + { + private readonly string _commandName = "init"; + private readonly string _funcPath = funcPath; + private readonly string _testName = testName; + + protected override CommandInfo CreateCommand(IEnumerable args) + { + var arguments = new List { _commandName }.Concat(args).ToList(); + + if (WorkingDirectory is null) + { + throw new InvalidOperationException("Working Directory must be set"); + } + + var commandInfo = new CommandInfo() + { + FileName = _funcPath, + Arguments = arguments, + WorkingDirectory = WorkingDirectory, + TestName = _testName, + }; + + return commandInfo; + } + } +} diff --git a/test/Cli/TestFramework/Commands/FuncNewCommand.cs b/test/Cli/TestFramework/Commands/FuncNewCommand.cs new file mode 100644 index 000000000..13972e03b --- /dev/null +++ b/test/Cli/TestFramework/Commands/FuncNewCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.TestFramework.Commands +{ + public class FuncNewCommand(string funcPath, string testName, ITestOutputHelper log) : FuncCommand(log) + { + private readonly string _commandName = "new"; + private readonly string _funcPath = funcPath; + private readonly string _testName = testName; + + protected override CommandInfo CreateCommand(IEnumerable args) + { + var arguments = new List { _commandName }.Concat(args).ToList(); + + if (WorkingDirectory is null) + { + throw new InvalidOperationException("Working Directory must be set"); + } + + var commandInfo = new CommandInfo() + { + FileName = _funcPath, + Arguments = arguments, + WorkingDirectory = WorkingDirectory, + TestName = _testName, + }; + + return commandInfo; + } + } +} diff --git a/test/Cli/TestFramework/Commands/FuncSettingsCommand.cs b/test/Cli/TestFramework/Commands/FuncSettingsCommand.cs new file mode 100644 index 000000000..90f90c4ac --- /dev/null +++ b/test/Cli/TestFramework/Commands/FuncSettingsCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.TestFramework.Commands +{ + public class FuncSettingsCommand(string funcPath, string testName, ITestOutputHelper log) : FuncCommand(log) + { + private readonly string _commandName = "settings"; + private readonly string _funcPath = funcPath; + private readonly string _testName = testName; + + protected override CommandInfo CreateCommand(IEnumerable args) + { + var arguments = new List { _commandName }.Concat(args).ToList(); + + if (WorkingDirectory is null) + { + throw new InvalidOperationException("Working Directory must be set"); + } + + var commandInfo = new CommandInfo() + { + FileName = _funcPath, + Arguments = arguments, + WorkingDirectory = WorkingDirectory, + TestName = _testName, + }; + + return commandInfo; + } + } +} diff --git a/test/Cli/TestFramework/Commands/FuncStartCommand.cs b/test/Cli/TestFramework/Commands/FuncStartCommand.cs new file mode 100644 index 000000000..b0bdfc59e --- /dev/null +++ b/test/Cli/TestFramework/Commands/FuncStartCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.TestFramework.Commands +{ + public class FuncStartCommand(string funcPath, string testName, ITestOutputHelper log) : FuncCommand(log) + { + private readonly string _commandName = "start"; + private readonly string _funcPath = funcPath; + private readonly string _testName = testName; + + protected override CommandInfo CreateCommand(IEnumerable args) + { + var arguments = new List { _commandName }.Concat(args).ToList(); + + if (WorkingDirectory is null) + { + throw new InvalidOperationException("Working Directory must be set"); + } + + var commandInfo = new CommandInfo() + { + FileName = _funcPath, + Arguments = arguments, + WorkingDirectory = WorkingDirectory, + TestName = _testName, + }; + + return commandInfo; + } + } +} diff --git a/test/Cli/TestFramework/Helpers/CopyDirectoryHelpers.cs b/test/Cli/TestFramework/Helpers/CopyDirectoryHelpers.cs new file mode 100644 index 000000000..1fd72fe7b --- /dev/null +++ b/test/Cli/TestFramework/Helpers/CopyDirectoryHelpers.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Azure.Functions.Cli.TestFramework.Helpers +{ + public static class CopyDirectoryHelpers + { + public static void CopyDirectory(string sourceDir, string destinationDir) + { + // Create all subdirectories + foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dirPath.Replace(sourceDir, destinationDir)); + } + + // Copy all files + foreach (string filePath in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + string destFile = filePath.Replace(sourceDir, destinationDir); + File.Copy(filePath, destFile, true); + } + } + + public static void CopyDirectoryWithout(string sourceDir, string destinationDir, string excludeFile) + { + // Create all subdirectories + foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dirPath.Replace(sourceDir, destinationDir)); + } + + // Copy all files except the excluded one + foreach (string filePath in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + if (!Path.GetFileName(filePath).Equals(excludeFile, StringComparison.OrdinalIgnoreCase)) + { + string destFile = filePath.Replace(sourceDir, destinationDir); + File.Copy(filePath, destFile, true); + } + } + } + } +} diff --git a/test/Cli/TestFramework/Helpers/FunctionAppSetupHelper.cs b/test/Cli/TestFramework/Helpers/FunctionAppSetupHelper.cs new file mode 100644 index 000000000..1cbe9387e --- /dev/null +++ b/test/Cli/TestFramework/Helpers/FunctionAppSetupHelper.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Functions.Cli.TestFramework.Commands; +using Xunit.Abstractions; + +namespace Azure.Functions.Cli.TestFramework.Helpers +{ + public static class FunctionAppSetupHelper + { + public static async Task ExecuteCommandWithRetryAsync( + string funcPath, + string testName, + string workingDirectory, + ITestOutputHelper log, + IEnumerable args, + Func commandFactory, + Action? configureCommand = null) + { + int retryNumber = 1; + await RetryHelper.RetryAsync( + () => + { + try + { + log.WriteLine($"Retry number: {retryNumber}"); + retryNumber += 1; + + FuncCommand command = commandFactory(funcPath, testName, log); + + // Apply any additional configuration + configureCommand?.Invoke(command); + + Abstractions.Command.CommandResult result = command + .WithWorkingDirectory(workingDirectory) + .Execute(args); + + log.WriteLine($"Done executing. Value of result.exitcode: {result.ExitCode}"); + return Task.FromResult(result.ExitCode == 0); + } + catch (Exception ex) + { + log.WriteLine(ex.Message); + return Task.FromResult(false); + } + }, + timeout: 300 * 10000); + } + + public static async Task FuncInitWithRetryAsync( + string funcPath, + string testName, + string workingDirectory, + ITestOutputHelper log, + IEnumerable args) + { + await ExecuteCommandWithRetryAsync( + funcPath, + testName, + workingDirectory, + log, + args, + (path, name, logger) => new FuncInitCommand(path, name, logger)); + } + + public static async Task FuncNewWithRetryAsync( + string funcPath, + string testName, + string workingDirectory, + ITestOutputHelper log, + IEnumerable args, + string? workerRuntime = null) + { + await ExecuteCommandWithRetryAsync( + funcPath, + testName, + workingDirectory, + log, + args, + (path, name, logger) => new FuncNewCommand(path, name, logger), + command => + { + if (!string.IsNullOrEmpty(workerRuntime)) + { + ((FuncNewCommand)command).WithEnvironmentVariable("FUNCTIONS_WORKER_RUNTIME", workerRuntime); + } + }); + } + } +} diff --git a/test/Cli/TestFramework/Helpers/LogWatcher.cs b/test/Cli/TestFramework/Helpers/LogWatcher.cs new file mode 100644 index 000000000..8f64a5dd2 --- /dev/null +++ b/test/Cli/TestFramework/Helpers/LogWatcher.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Azure.Functions.Cli.TestFramework.Helpers +{ + public static class LogWatcher + { + public static async Task WaitForLogOutput(StreamReader stdout, string expectedOutput, TimeSpan timeout) + { + var tcs = new TaskCompletionSource(); + using var cancellationTokenSource = new CancellationTokenSource(timeout); + + // First check current content + string currentContent = stdout.ReadToEnd(); + stdout.BaseStream.Position = 0; // Reset position to start + + if (currentContent.Contains(expectedOutput)) + { + tcs.SetResult(true); + } + else + { + var timer = new Timer( + state => + { + try + { + // Save the current position + long currentPosition = stdout.BaseStream.Position; + + // Check if there's new content + if (stdout.Peek() > -1) + { + string newContent = stdout.ReadToEnd(); + if (newContent.Contains(expectedOutput)) + { + tcs.TrySetResult(true); + return; + } + + // Reset position back to the end + stdout.BaseStream.Position = stdout.BaseStream.Length; + } + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }, + null, + TimeSpan.Zero, + TimeSpan.FromSeconds(1)); + + // Cancel the waiting task if the timeout is reached + cancellationTokenSource.Token.Register(() => + { + tcs.TrySetCanceled(); + timer.Dispose(); + }); + } + + await tcs.Task; + } + } +} diff --git a/test/Cli/TestFramework/Helpers/ProcessHelper.cs b/test/Cli/TestFramework/Helpers/ProcessHelper.cs new file mode 100644 index 000000000..f96d34bb4 --- /dev/null +++ b/test/Cli/TestFramework/Helpers/ProcessHelper.cs @@ -0,0 +1,137 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Azure.Functions.Cli.TestFramework.Helpers +{ + public class ProcessHelper + { + private static readonly string _functionsHostUrl = "http://localhost"; + + public static async Task WaitForFunctionHostToStart( + Process funcProcess, + int port, + StreamWriter fileWriter) + { + string url = $"{_functionsHostUrl}:{port}"; + using var httpClient = new HttpClient(); + + void LogMessage(string message) + { + Console.WriteLine(message); + fileWriter?.WriteLine($"[HOST STATUS] {message}"); + fileWriter?.Flush(); + } + + LogMessage($"Starting to wait for function host on {url} at {DateTime.Now}"); + LogMessage($"PID of process: {funcProcess.Id}"); + int retry = 1; + + await RetryHelper.RetryAsync(async () => + { + try + { + LogMessage($"Retry number: {retry}"); + fileWriter?.Flush(); + retry += 1; + + if (funcProcess.HasExited) + { + LogMessage($"Function host process exited with code {funcProcess.ExitCode} - cannot continue waiting"); + return true; + } + + LogMessage($"Trying to get ping response"); + + // Try ping endpoint + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + HttpResponseMessage pingResponse = await httpClient.GetAsync($"{url}/admin/host/ping", cts.Token); + + LogMessage($"Got ping response"); + + if (pingResponse.IsSuccessStatusCode) + { + LogMessage("Host responded to ping - assuming it's running"); + return true; + } + + LogMessage($"Returning false"); + return false; + } + catch (Exception ex) + { + LogMessage($"Error checking host status: {ex.Message}"); + return false; + } + }); + } + + public static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + try + { + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + return port; + } + finally + { + listener.Stop(); + listener.Server.Dispose(); + } + } + + public static async Task ProcessStartedHandlerHelper(int port, Process process, StreamWriter fileWriter, string functionCall = "", bool shouldDelayForLogs = false) + { + string capturedContent = string.Empty; + try + { + fileWriter.WriteLine("[HANDLER] Starting process started handler helper"); + fileWriter.Flush(); + + fileWriter.WriteLine($"[HANDLER] Process working directory: {process.StartInfo.WorkingDirectory}"); + fileWriter.Flush(); + + await WaitForFunctionHostToStart(process, port, fileWriter); + + fileWriter.WriteLine("[HANDLER] Host has started"); + fileWriter.Flush(); + + if (!string.IsNullOrEmpty(functionCall)) + { + using var client = new HttpClient(); + HttpResponseMessage response = await client.GetAsync($"http://localhost:{port}/api/{functionCall}"); + capturedContent = await response.Content.ReadAsStringAsync(); + fileWriter.WriteLine($"[HANDLER] Captured content: {capturedContent}"); + fileWriter.Flush(); + } + } + catch (Exception ex) + { + fileWriter.WriteLine($"[HANDLER] Caught the following exception: {ex.Message}"); + fileWriter.Flush(); + } + finally + { + fileWriter.WriteLine($"[HANDLER] Going to kill process"); + fileWriter.Flush(); + + // Wait 5 seconds for all the logs to show up first if we need them + if (shouldDelayForLogs) + { + await Task.Delay(5000); + } + + process.Kill(true); + } + + fileWriter.WriteLine($"[HANDLER] Returning captured content"); + fileWriter.Flush(); + return capturedContent; + } + } +} diff --git a/test/Cli/TestFramework/Helpers/QueueStorageHelper.cs b/test/Cli/TestFramework/Helpers/QueueStorageHelper.cs new file mode 100644 index 000000000..82d2ed9f4 --- /dev/null +++ b/test/Cli/TestFramework/Helpers/QueueStorageHelper.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; + +namespace Azure.Functions.Cli.TestFramework.Helpers +{ + public static class QueueStorageHelper + { + public const string StorageEmulatorConnectionString = "UseDevelopmentStorage=true"; + + private static QueueClient CreateQueueClient(string queueName) + { + var options = new QueueClientOptions + { + MessageEncoding = QueueMessageEncoding.Base64 + }; + + return new QueueClient(StorageEmulatorConnectionString, queueName, options); + } + + public static async Task DeleteQueue(string queueName) + { + QueueClient queueClient = CreateQueueClient(queueName); + await queueClient.DeleteIfExistsAsync(); + } + + public static async Task ClearQueue(string queueName) + { + QueueClient queueClient = CreateQueueClient(queueName); + if (await queueClient.ExistsAsync()) + { + await queueClient.ClearMessagesAsync(); + } + } + + public static async Task CreateQueue(string queueName) + { + QueueClient queueClient = CreateQueueClient(queueName); + await queueClient.CreateIfNotExistsAsync(); + } + + public static async Task InsertIntoQueue(string queueName, string queueMessage) + { + QueueClient queueClient = CreateQueueClient(queueName); + await queueClient.CreateIfNotExistsAsync(); + Response response = await queueClient.SendMessageAsync(queueMessage); + return response.Value.MessageId; + } + + public static async Task ReadFromQueue(string queueName) + { + QueueClient queueClient = CreateQueueClient(queueName); + QueueMessage? retrievedMessage = null; + + await RetryHelper.RetryAsync(async () => + { + Response response = await queueClient.ReceiveMessageAsync(); + retrievedMessage = response.Value; + return retrievedMessage is not null; + }); + + await queueClient.DeleteMessageAsync(retrievedMessage!.MessageId, retrievedMessage.PopReceipt); + return retrievedMessage.Body.ToString(); + } + + public static async Task> ReadMessagesFromQueue(string queueName) + { + QueueClient queueClient = CreateQueueClient(queueName); + QueueMessage[]? retrievedMessages = null; + var messages = new List(); + await RetryHelper.RetryAsync(async () => + { + retrievedMessages = await queueClient.ReceiveMessagesAsync(maxMessages: 3); + return retrievedMessages is not null; + }); + + foreach (QueueMessage msg in retrievedMessages!) + { + messages.Add(msg.Body.ToString()); + await queueClient.DeleteMessageAsync(msg.MessageId, msg.PopReceipt); + } + + return messages; + } + } +} diff --git a/test/Cli/TestFramework/Helpers/RetryHelper.cs b/test/Cli/TestFramework/Helpers/RetryHelper.cs new file mode 100644 index 000000000..221b20d20 --- /dev/null +++ b/test/Cli/TestFramework/Helpers/RetryHelper.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Diagnostics; + +namespace Azure.Functions.Cli.TestFramework.Helpers +{ + public static class RetryHelper + { + public static async Task RetryAsync(Func> condition, int timeout = 120 * 1000, int pollingInterval = 2 * 1000, bool throwWhenDebugging = false, Func? userMessageCallback = null) + { + DateTime start = DateTime.Now; + int attempt = 1; + while (!await condition()) + { + await Task.Delay(pollingInterval); + attempt += 1; + + bool shouldThrow = !Debugger.IsAttached || (Debugger.IsAttached && throwWhenDebugging); + + if (shouldThrow && (DateTime.Now - start).TotalMilliseconds > timeout) + { + string error = "Condition not reached within timeout."; + if (userMessageCallback is not null) + { + error += " " + userMessageCallback(); + } + + throw new ApplicationException(error); + } + } + } + } +} diff --git a/test/Cli/TestFramework/README.md b/test/Cli/TestFramework/README.md new file mode 100644 index 000000000..b719fd3a0 --- /dev/null +++ b/test/Cli/TestFramework/README.md @@ -0,0 +1,4 @@ +This project contains the test framework that can be used for tests. `FuncCommand` is the base class that implements Command defined in the abstractions project. +Each specific type of command, such as `FuncStartCommand`, can inherit from `FuncCommand` and be used in the tests directly. + +Note that some of the classes are based off the [.NET SDK repo](https://github.com/dotnet/sdk). \ No newline at end of file