diff --git a/.github/csharp.instructions.md b/.github/csharp.instructions.md new file mode 100644 index 00000000..8caf2846 --- /dev/null +++ b/.github/csharp.instructions.md @@ -0,0 +1,118 @@ +--- +description: 'Guidelines for building C# applications' +applyTo: '**/*.cs' +--- + +# C# Development + +## C# Instructions +- Always use the latest version C#, currently C# 14 features. +- Write clear and concise comments for each function. + +## General Instructions +- Make only high confidence suggestions when reviewing code changes. +- Write code with good maintainability practices, including comments on why certain design decisions were made. +- Handle edge cases and write clear exception handling. +- For libraries or external dependencies, mention their usage and purpose in comments. + +## Naming Conventions + +- Follow PascalCase for component names, method names, and public members. +- Use camelCase for private fields and local variables. +- Prefix interface names with "I" (e.g., IUserService). + +## Formatting + +- Apply code-formatting style defined in `.editorconfig`. +- Prefer file-scoped namespace declarations and single-line using directives. +- Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.). +- Ensure that the final return statement of a method is on its own line. +- Use pattern matching and switch expressions wherever possible. +- Use `nameof` instead of string literals when referring to member names. +- Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. + +## Project Setup and Structure + +- Guide users through creating a new .NET project with the appropriate templates. +- Explain the purpose of each generated file and folder to build understanding of the project structure. +- Demonstrate how to organize code using feature folders or domain-driven design principles. +- Show proper separation of concerns with models, services, and data access layers. +- Explain the Program.cs and configuration system in ASP.NET Core 10 including environment-specific settings. + +## Nullable Reference Types + +- Declare variables non-nullable, and check for `null` at entry points. +- Always use `is null` or `is not null` instead of `== null` or `!= null`. +- Trust the C# null annotations and don't add null checks when the type system says a value cannot be null. + +## Data Access Patterns + +- Guide the implementation of a data access layer using Entity Framework Core. +- Explain different options (SQL Server, SQLite, In-Memory) for development and production. +- Demonstrate repository pattern implementation and when it's beneficial. +- Show how to implement database migrations and data seeding. +- Explain efficient query patterns to avoid common performance issues. + +## Authentication and Authorization + +- Guide users through implementing authentication using JWT Bearer tokens. +- Explain OAuth 2.0 and OpenID Connect concepts as they relate to ASP.NET Core. +- Show how to implement role-based and policy-based authorization. +- Demonstrate integration with Microsoft Entra ID (formerly Azure AD). +- Explain how to secure both controller-based and Minimal APIs consistently. + +## Validation and Error Handling + +- Guide the implementation of model validation using data annotations and FluentValidation. +- Explain the validation pipeline and how to customize validation responses. +- Demonstrate a global exception handling strategy using middleware. +- Show how to create consistent error responses across the API. +- Explain problem details (RFC 7807) implementation for standardized error responses. + +## API Versioning and Documentation + +- Guide users through implementing and explaining API versioning strategies. +- Demonstrate Swagger/OpenAPI implementation with proper documentation. +- Show how to document endpoints, parameters, responses, and authentication. +- Explain versioning in both controller-based and Minimal APIs. +- Guide users on creating meaningful API documentation that helps consumers. + +## Logging and Monitoring + +- Guide the implementation of structured logging using Serilog or other providers. +- Explain the logging levels and when to use each. +- Demonstrate integration with Application Insights for telemetry collection. +- Show how to implement custom telemetry and correlation IDs for request tracking. +- Explain how to monitor API performance, errors, and usage patterns. + +## Testing + +- Always include test cases for critical paths of the application. +- Guide users through creating unit tests. +- Do not emit "Act", "Arrange" or "Assert" comments. +- Copy existing style in nearby files for test method names and capitalization. +- Explain integration testing approaches for API endpoints. +- Demonstrate how to mock dependencies for effective testing. +- Show how to test authentication and authorization logic. +- Explain test-driven development principles as applied to API development. +- Use awesomeassertions for asserting expected results. +- Use xUnit for unit testing. +- Use FakeItEasy for mocking dependencies. +- Always separate a test method into the blocks Arrange, Act and Assert. Mark this blocks with comments. + +## Performance Optimization + +- Guide users on implementing caching strategies (in-memory, distributed, response caching). +- Explain asynchronous programming patterns and why they matter for API performance. +- Demonstrate pagination, filtering, and sorting for large data sets. +- Show how to implement compression and other performance optimizations. +- Explain how to measure and benchmark API performance. + +## Deployment and DevOps + +- Guide users through containerizing their API using .NET's built-in container support (`dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer`). +- Explain the differences between manual Dockerfile creation and .NET's container publishing features. +- Explain CI/CD pipelines for NET applications. +- Demonstrate deployment to Azure App Service, Azure Container Apps, or other hosting options. +- Show how to implement health checks and readiness probes. +- Explain environment-specific configurations for different deployment stages. diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 00000000..8caf2846 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,118 @@ +--- +description: 'Guidelines for building C# applications' +applyTo: '**/*.cs' +--- + +# C# Development + +## C# Instructions +- Always use the latest version C#, currently C# 14 features. +- Write clear and concise comments for each function. + +## General Instructions +- Make only high confidence suggestions when reviewing code changes. +- Write code with good maintainability practices, including comments on why certain design decisions were made. +- Handle edge cases and write clear exception handling. +- For libraries or external dependencies, mention their usage and purpose in comments. + +## Naming Conventions + +- Follow PascalCase for component names, method names, and public members. +- Use camelCase for private fields and local variables. +- Prefix interface names with "I" (e.g., IUserService). + +## Formatting + +- Apply code-formatting style defined in `.editorconfig`. +- Prefer file-scoped namespace declarations and single-line using directives. +- Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.). +- Ensure that the final return statement of a method is on its own line. +- Use pattern matching and switch expressions wherever possible. +- Use `nameof` instead of string literals when referring to member names. +- Ensure that XML doc comments are created for any public APIs. When applicable, include `` and `` documentation in the comments. + +## Project Setup and Structure + +- Guide users through creating a new .NET project with the appropriate templates. +- Explain the purpose of each generated file and folder to build understanding of the project structure. +- Demonstrate how to organize code using feature folders or domain-driven design principles. +- Show proper separation of concerns with models, services, and data access layers. +- Explain the Program.cs and configuration system in ASP.NET Core 10 including environment-specific settings. + +## Nullable Reference Types + +- Declare variables non-nullable, and check for `null` at entry points. +- Always use `is null` or `is not null` instead of `== null` or `!= null`. +- Trust the C# null annotations and don't add null checks when the type system says a value cannot be null. + +## Data Access Patterns + +- Guide the implementation of a data access layer using Entity Framework Core. +- Explain different options (SQL Server, SQLite, In-Memory) for development and production. +- Demonstrate repository pattern implementation and when it's beneficial. +- Show how to implement database migrations and data seeding. +- Explain efficient query patterns to avoid common performance issues. + +## Authentication and Authorization + +- Guide users through implementing authentication using JWT Bearer tokens. +- Explain OAuth 2.0 and OpenID Connect concepts as they relate to ASP.NET Core. +- Show how to implement role-based and policy-based authorization. +- Demonstrate integration with Microsoft Entra ID (formerly Azure AD). +- Explain how to secure both controller-based and Minimal APIs consistently. + +## Validation and Error Handling + +- Guide the implementation of model validation using data annotations and FluentValidation. +- Explain the validation pipeline and how to customize validation responses. +- Demonstrate a global exception handling strategy using middleware. +- Show how to create consistent error responses across the API. +- Explain problem details (RFC 7807) implementation for standardized error responses. + +## API Versioning and Documentation + +- Guide users through implementing and explaining API versioning strategies. +- Demonstrate Swagger/OpenAPI implementation with proper documentation. +- Show how to document endpoints, parameters, responses, and authentication. +- Explain versioning in both controller-based and Minimal APIs. +- Guide users on creating meaningful API documentation that helps consumers. + +## Logging and Monitoring + +- Guide the implementation of structured logging using Serilog or other providers. +- Explain the logging levels and when to use each. +- Demonstrate integration with Application Insights for telemetry collection. +- Show how to implement custom telemetry and correlation IDs for request tracking. +- Explain how to monitor API performance, errors, and usage patterns. + +## Testing + +- Always include test cases for critical paths of the application. +- Guide users through creating unit tests. +- Do not emit "Act", "Arrange" or "Assert" comments. +- Copy existing style in nearby files for test method names and capitalization. +- Explain integration testing approaches for API endpoints. +- Demonstrate how to mock dependencies for effective testing. +- Show how to test authentication and authorization logic. +- Explain test-driven development principles as applied to API development. +- Use awesomeassertions for asserting expected results. +- Use xUnit for unit testing. +- Use FakeItEasy for mocking dependencies. +- Always separate a test method into the blocks Arrange, Act and Assert. Mark this blocks with comments. + +## Performance Optimization + +- Guide users on implementing caching strategies (in-memory, distributed, response caching). +- Explain asynchronous programming patterns and why they matter for API performance. +- Demonstrate pagination, filtering, and sorting for large data sets. +- Show how to implement compression and other performance optimizations. +- Explain how to measure and benchmark API performance. + +## Deployment and DevOps + +- Guide users through containerizing their API using .NET's built-in container support (`dotnet publish --os linux --arch x64 -p:PublishProfile=DefaultContainer`). +- Explain the differences between manual Dockerfile creation and .NET's container publishing features. +- Explain CI/CD pipelines for NET applications. +- Demonstrate deployment to Azure App Service, Azure Container Apps, or other hosting options. +- Show how to implement health checks and readiness probes. +- Explain environment-specific configurations for different deployment stages. diff --git a/Core.sln b/Core.sln index 8ebd9ce0..a34fa7f3 100644 --- a/Core.sln +++ b/Core.sln @@ -219,6 +219,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.Options.Stor EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.Options.Serializers", "source\Options\CreativeCoders.Options.Serializers\CreativeCoders.Options.Serializers.csproj", "{3140873A-7C79-408F-B7F2-C51980EFF0C4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProcessUtils", "ProcessUtils", "{DD74AD8A-E88F-479A-82CE-32F818BE438D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.ProcessUtils", "source\ProcessUtils\CreativeCoders.ProcessUtils\CreativeCoders.ProcessUtils.csproj", "{A26B77F8-EA82-4E04-ABE4-2CFB660CA323}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__ai", "__ai", "{FCD8D558-7F1F-4D2E-B2EA-DE412BCEE1F7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "copilot", "copilot", "{7EA4E4D9-BC5F-401A-A143-99816D46ABC2}" + ProjectSection(SolutionItems) = preProject + .github\csharp.instructions.md = .github\csharp.instructions.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "junie", "junie", "{6CAB0BC8-210F-4AFF-902F-91F3BFF64CC8}" + ProjectSection(SolutionItems) = preProject + .junie\guidelines.md = .junie\guidelines.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessUtilsSampleApp", "samples\ProcessUtilsSampleApp\ProcessUtilsSampleApp.csproj", "{58E94E6E-9A9A-4E3F-ACB5-89E47CE67C39}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -519,6 +537,14 @@ Global {3140873A-7C79-408F-B7F2-C51980EFF0C4}.Debug|Any CPU.Build.0 = Debug|Any CPU {3140873A-7C79-408F-B7F2-C51980EFF0C4}.Release|Any CPU.ActiveCfg = Release|Any CPU {3140873A-7C79-408F-B7F2-C51980EFF0C4}.Release|Any CPU.Build.0 = Release|Any CPU + {A26B77F8-EA82-4E04-ABE4-2CFB660CA323}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A26B77F8-EA82-4E04-ABE4-2CFB660CA323}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A26B77F8-EA82-4E04-ABE4-2CFB660CA323}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A26B77F8-EA82-4E04-ABE4-2CFB660CA323}.Release|Any CPU.Build.0 = Release|Any CPU + {58E94E6E-9A9A-4E3F-ACB5-89E47CE67C39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58E94E6E-9A9A-4E3F-ACB5-89E47CE67C39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58E94E6E-9A9A-4E3F-ACB5-89E47CE67C39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58E94E6E-9A9A-4E3F-ACB5-89E47CE67C39}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -617,6 +643,11 @@ Global {70C54624-CAE5-4ED6-A087-88CD1470E2B7} = {23126211-23BF-4399-A227-6C609FB7505E} {83F2F98D-4C23-404F-9D06-B744C87E6EF1} = {23126211-23BF-4399-A227-6C609FB7505E} {3140873A-7C79-408F-B7F2-C51980EFF0C4} = {23126211-23BF-4399-A227-6C609FB7505E} + {DD74AD8A-E88F-479A-82CE-32F818BE438D} = {2A7105AA-05B6-469A-93F5-719723A4D90D} + {A26B77F8-EA82-4E04-ABE4-2CFB660CA323} = {DD74AD8A-E88F-479A-82CE-32F818BE438D} + {7EA4E4D9-BC5F-401A-A143-99816D46ABC2} = {FCD8D558-7F1F-4D2E-B2EA-DE412BCEE1F7} + {6CAB0BC8-210F-4AFF-902F-91F3BFF64CC8} = {FCD8D558-7F1F-4D2E-B2EA-DE412BCEE1F7} + {58E94E6E-9A9A-4E3F-ACB5-89E47CE67C39} = {72E179CA-7AE6-412A-856D-7BD13838E8E3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE24476B-9A4C-4146-B982-3461FAF8B3B0} diff --git a/samples/ProcessUtilsSampleApp/ProcessUtilsSampleApp.csproj b/samples/ProcessUtilsSampleApp/ProcessUtilsSampleApp.csproj new file mode 100644 index 00000000..628adbc4 --- /dev/null +++ b/samples/ProcessUtilsSampleApp/ProcessUtilsSampleApp.csproj @@ -0,0 +1,19 @@ + + + + Exe + enable + enable + + + + + + + + + + + + + diff --git a/samples/ProcessUtilsSampleApp/Program.cs b/samples/ProcessUtilsSampleApp/Program.cs new file mode 100644 index 00000000..311581af --- /dev/null +++ b/samples/ProcessUtilsSampleApp/Program.cs @@ -0,0 +1,43 @@ +using CreativeCoders.Core.Collections; +using CreativeCoders.DependencyInjection; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using Microsoft.Extensions.DependencyInjection; + +namespace ProcessUtilsSampleApp; + +internal static class Program +{ + private static void Main(string[] args) + { + var services = new ServiceCollection() as IServiceCollection; + + services.AddObjectFactory(); + + services.AddProcessUtils(); + + var sp = services.BuildServiceProvider(); + + var factory = sp.GetRequiredService(); + + var builder = factory.GetInstance>(); + + var executor = builder + .SetFileName("defaults") + .SetArguments(["domains"]) + .SetOutputParser(x => + { + x.SplitOptions = StringSplitOptions.RemoveEmptyEntries; + x.Separators = [","]; + x.TrimLines = true; + }) + .Build(); + + var lines = executor.Execute(); + + lines?.Order().ForEach(x => Console.WriteLine(x)); + + Console.ReadLine(); + } +} diff --git a/source/DependencyInjection/CreativeCoders.DependencyInjection/DefaultObjectFactory.cs b/source/DependencyInjection/CreativeCoders.DependencyInjection/DefaultObjectFactory.cs index 1f3f849e..325c30e5 100644 --- a/source/DependencyInjection/CreativeCoders.DependencyInjection/DefaultObjectFactory.cs +++ b/source/DependencyInjection/CreativeCoders.DependencyInjection/DefaultObjectFactory.cs @@ -8,7 +8,7 @@ internal class DefaultObjectFactory : IObjectFactory public DefaultObjectFactory(IObjectFactory objectFactory) { - _objectFactory = Ensure.NotNull(objectFactory, nameof(objectFactory)); + _objectFactory = Ensure.NotNull(objectFactory); } public T GetInstance() @@ -28,7 +28,7 @@ internal class DefaultObjectFactory : IObjectFactory public DefaultObjectFactory(IServiceProvider serviceProvider) { - _serviceProvider = Ensure.NotNull(serviceProvider, nameof(serviceProvider)); + _serviceProvider = Ensure.NotNull(serviceProvider); } public T GetInstance() diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/CreativeCoders.ProcessUtils.csproj b/source/ProcessUtils/CreativeCoders.ProcessUtils/CreativeCoders.ProcessUtils.csproj new file mode 100644 index 00000000..c36b863e --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/CreativeCoders.ProcessUtils.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs new file mode 100644 index 00000000..c92e0b53 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs @@ -0,0 +1,52 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils; + +[ExcludeFromCodeCoverage] +public sealed class DefaultProcess(Process process) : IProcess +{ + private readonly Process _process = Ensure.NotNull(process); + + public bool Start() => _process.Start(); + + public void Close() => _process.Close(); + + public bool CloseMainWindow() => _process.CloseMainWindow(); + + public void Refresh() => _process.Refresh(); + + public void Kill() => _process.Kill(); + + public void WaitForExit() => _process.WaitForExit(); + + public void WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) + => _process.WaitForExitAsync(cancellationToken); + + public void WaitForInputIdle() => _process.WaitForInputIdle(); + + public int ExitCode => _process.ExitCode; + + public int Id => _process.Id; + + public bool HasExited => _process.HasExited; + + public string MainWindowTitle => _process.MainWindowTitle; + + public string ProcessName => _process.ProcessName; + + public bool Responding => _process.Responding; + + public StreamReader StandardOutput => _process.StandardOutput; + + public StreamReader StandardError => _process.StandardError; + + public StreamWriter StandardInput => _process.StandardInput; + + public ProcessStartInfo StartInfo => _process.StartInfo; + + public void Dispose() => _process.Dispose(); +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcessFactory.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcessFactory.cs new file mode 100644 index 00000000..fbcabf5e --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcessFactory.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils; + +public class DefaultProcessFactory : IProcessFactory +{ + public IProcess CreateProcess(string fileName, string[] arguments) + { + Ensure.IsNotNullOrWhitespace(fileName); + + var process = new Process(); + process.StartInfo.FileName = fileName; + process.StartInfo.Arguments = string.Join(" ", arguments); + + return new DefaultProcess(process); + } + + public IProcess CreateProcess() + { + var process = new Process(); + + return new DefaultProcess(process); + } + + public IProcess StartProcess(string fileName, string[] arguments) + { + return StartProcess(new ProcessStartInfo(fileName, arguments)); + } + + public IProcess StartProcess(ProcessStartInfo startInfo) + { + var process = Process.Start(startInfo); + + return process == null + ? throw new InvalidOperationException("Failed to start process.") + : new DefaultProcess(process); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs new file mode 100644 index 00000000..73e535cd --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -0,0 +1,31 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.ProcessUtils.Execution; + +[PublicAPI] +public interface IProcessExecutor +{ + T? Execute(); + + Task ExecuteAsync(); + + ProcessExecutionResult ExecuteEx(); + + Task> ExecuteExAsync(); +} + +[PublicAPI] +public interface IProcessExecutor +{ + void Execute(); + + Task ExecuteAsync(); + + IProcess ExecuteEx(); + + Task ExecuteExAsync(); + + int ExecuteAndReturnExitCode(); + + Task ExecuteAndReturnExitCodeAsync(); +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs new file mode 100644 index 00000000..cd9f1014 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs @@ -0,0 +1,28 @@ +using JetBrains.Annotations; + +namespace CreativeCoders.ProcessUtils.Execution; + +[PublicAPI] +public interface IProcessExecutorBuilder +{ + IProcessExecutorBuilder SetFileName(string fileName); + + IProcessExecutorBuilder SetArguments(string[] arguments); + + IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser); + + IProcessExecutorBuilder SetOutputParser(Action? configure = null) + where TParser : IProcessOutputParser, new(); + + IProcessExecutor Build(); +} + +[PublicAPI] +public interface IProcessExecutorBuilder +{ + IProcessExecutorBuilder SetFileName(string fileName); + + IProcessExecutorBuilder SetArguments(string[] arguments); + + IProcessExecutor Build(); +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessOutputParser.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessOutputParser.cs new file mode 100644 index 00000000..01b79cc9 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessOutputParser.cs @@ -0,0 +1,6 @@ +namespace CreativeCoders.ProcessUtils.Execution; + +public interface IProcessOutputParser +{ + T? ParseOutput(string? output); +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/JsonOutputParser.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/JsonOutputParser.cs new file mode 100644 index 00000000..c71300eb --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/JsonOutputParser.cs @@ -0,0 +1,17 @@ +namespace CreativeCoders.ProcessUtils.Execution.Parsers; + +public class JsonOutputParser : IProcessOutputParser +{ + public T? ParseOutput(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return default; + } + + var data = System.Text.Json.JsonSerializer + .Deserialize(output); + + return data; + } +} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/PassThroughProcessOutputParser.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/PassThroughProcessOutputParser.cs new file mode 100644 index 00000000..6dd607ba --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/PassThroughProcessOutputParser.cs @@ -0,0 +1,9 @@ +namespace CreativeCoders.ProcessUtils.Execution.Parsers; + +public class PassThroughProcessOutputParser : IProcessOutputParser +{ + public string? ParseOutput(string? output) + { + return output; + } +} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/SplitLinesOutputParser.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/SplitLinesOutputParser.cs new file mode 100644 index 00000000..eedef2b9 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/SplitLinesOutputParser.cs @@ -0,0 +1,24 @@ +namespace CreativeCoders.ProcessUtils.Execution.Parsers; + +public class SplitLinesOutputParser : IProcessOutputParser +{ + public string[]? ParseOutput(string? output) + { + if (string.IsNullOrEmpty(output)) + { + return []; + } + + var lines = output.Split(Separators, SplitOptions); + + return TrimLines + ? lines.Select(x => x.Trim()).ToArray() + : lines; + } + + public string[] Separators { get; set; } = [Environment.NewLine]; + + public bool TrimLines { get; set; } = false; + + public StringSplitOptions SplitOptions { get; set; } = StringSplitOptions.None; +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutionResult.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutionResult.cs new file mode 100644 index 00000000..d520d533 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutionResult.cs @@ -0,0 +1,13 @@ +namespace CreativeCoders.ProcessUtils.Execution; + +public sealed class ProcessExecutionResult(IProcess process, T value) : IDisposable +{ + public T Value { get; } = value; + + public IProcess Process { get; } = process; + + public void Dispose() + { + Process.Dispose(); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs new file mode 100644 index 00000000..d828319e --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs @@ -0,0 +1,91 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFactory processFactory) + : ProcessExecutorBase(processExecutorInfo, processFactory), IProcessExecutor +{ + public void Execute() + { + using var _ = ExecuteEx(); + } + + public async Task ExecuteAsync() + { + using var _ = await ExecuteExAsync().ConfigureAwait(false); + } + + public IProcess ExecuteEx() + { + var process = StartProcess(); + + process.WaitForExit(); + + return process; + } + + public async Task ExecuteExAsync() + { + var process = StartProcess(); + + await process.WaitForExitAsync().ConfigureAwait(false); + + return process; + } + + public int ExecuteAndReturnExitCode() + { + using var process = ExecuteEx(); + + return process.ExitCode; + } + + public async Task ExecuteAndReturnExitCodeAsync() + { + using var process = await ExecuteExAsync().ConfigureAwait(false); + + return process.ExitCode; + } +} + +public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFactory processFactory) + : ProcessExecutorBase(processExecutorInfo, processFactory), IProcessExecutor +{ + private readonly IProcessOutputParser _outputParser = Ensure.NotNull(processExecutorInfo.OutputParser); + + public T? Execute() + { + using var result = ExecuteEx(); + + return result.Value; + } + + public async Task ExecuteAsync() + { + using var result = await ExecuteExAsync().ConfigureAwait(false); + + return result.Value; + } + + public ProcessExecutionResult ExecuteEx() + { + var process = StartProcess(); + + var output = process.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + + return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); + } + + public async Task> ExecuteExAsync() + { + var process = StartProcess(); + + var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + + await process.WaitForExitAsync().ConfigureAwait(false); + + return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs new file mode 100644 index 00000000..5d342120 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public abstract class ProcessExecutorBase( + ProcessExecutorInfo processExecutorInfo, + IProcessFactory processFactory) +{ + private readonly ProcessExecutorInfo _processExecutorInfo = Ensure.NotNull(processExecutorInfo); + + private readonly IProcessFactory _processFactory = Ensure.NotNull(processFactory); + + private ProcessStartInfo CreateProcessStartInfo() + { + var startupInfo = new ProcessStartInfo + { + FileName = _processExecutorInfo.FileName, + Arguments = string.Join(" ", _processExecutorInfo.Arguments), + RedirectStandardOutput = _processExecutorInfo.RedirectStandardOutput, + RedirectStandardError = _processExecutorInfo.RedirectStandardError, + RedirectStandardInput = _processExecutorInfo.RedirectStandardInput, + UseShellExecute = false, + CreateNoWindow = true + }; + + return startupInfo; + } + + protected IProcess StartProcess() + { + var startupInfo = CreateProcessStartInfo(); + + var process = _processFactory.StartProcess(startupInfo); + + return process ?? throw new InvalidOperationException("Failed to start process."); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs new file mode 100644 index 00000000..f0564655 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs @@ -0,0 +1,113 @@ +using CreativeCoders.Core; +using CreativeCoders.ProcessUtils.Execution.Parsers; + +namespace CreativeCoders.ProcessUtils.Execution; + +public class ProcessExecutorBuilder(IProcessFactory processFactory) + : ProcessExecutorBuilderBase, IProcessExecutorBuilder +{ + private readonly IProcessFactory _processFactory = Ensure.NotNull(processFactory); + + public IProcessExecutorBuilder SetFileName(string fileName) + { + FileName = fileName; + + return this; + } + + public IProcessExecutorBuilder SetArguments(string[] arguments) + { + Arguments = arguments; + + return this; + } + + public IProcessExecutor Build() + { + if (string.IsNullOrWhiteSpace(FileName)) + { + throw new InvalidOperationException("FileName must be set before building the executor."); + } + + var executorInfo = new ProcessExecutorInfo( + FileName, + Arguments ?? []); + + return new ProcessExecutor(executorInfo, _processFactory); + } +} + +public class ProcessExecutorBuilder(IProcessFactory processFactory) + : ProcessExecutorBuilderBase, IProcessExecutorBuilder +{ + private IProcessOutputParser? _outputParser; + + private readonly IProcessFactory _processFactory = Ensure.NotNull(processFactory); + + public IProcessExecutorBuilder SetFileName(string fileName) + { + FileName = fileName; + + return this; + } + + public IProcessExecutorBuilder SetArguments(string[] arguments) + { + Arguments = arguments; + + return this; + } + + public IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser) + { + _outputParser = parser; + + return this; + } + + public IProcessExecutorBuilder SetOutputParser(Action? configure = null) where TParser : IProcessOutputParser, new() + { + var parser = new TParser(); + + configure?.Invoke(parser); + + _outputParser = parser; + + return this; + } + + private void ReturnOutputAsText() + { + if (typeof(T) != typeof(string)) + { + throw new InvalidOperationException("OutputParser must be set to return output as text."); + } + + _outputParser = (IProcessOutputParser)new PassThroughProcessOutputParser(); + } + + public IProcessExecutor Build() + { + if (string.IsNullOrWhiteSpace(FileName)) + { + throw new InvalidOperationException("FileName must be set before building the executor."); + } + + if (_outputParser == null && typeof(T) == typeof(string)) + { + ReturnOutputAsText(); + } + + if (_outputParser == null) + { + throw new InvalidOperationException("OutputParser must be set before building the executor."); + } + + var executorInfo = new ProcessExecutorInfo( + FileName, + Arguments ?? [], + _outputParser ?? throw new InvalidOperationException("OutputParser must be set before building the executor.")); + + return new ProcessExecutor(executorInfo, _processFactory); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs new file mode 100644 index 00000000..51911e13 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs @@ -0,0 +1,8 @@ +namespace CreativeCoders.ProcessUtils.Execution; + +public abstract class ProcessExecutorBuilderBase +{ + protected string? FileName { get; set; } + + protected string[]? Arguments { get; set; } +} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs new file mode 100644 index 00000000..4cb67098 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs @@ -0,0 +1,20 @@ +namespace CreativeCoders.ProcessUtils.Execution; + +public class ProcessExecutorInfo(string fileName, string[] arguments, IProcessOutputParser outputParser) + : ProcessExecutorInfo(fileName, arguments) +{ + public IProcessOutputParser OutputParser { get; set; } = outputParser; +} + +public class ProcessExecutorInfo(string fileName, string[] arguments) +{ + public string FileName { get; } = fileName; + + public string[] Arguments { get; } = arguments; + + public bool RedirectStandardOutput { get; set; } = true; + + public bool RedirectStandardError { get; set; } = true; + + public bool RedirectStandardInput { get; set; } = true; +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs new file mode 100644 index 00000000..a3ad6cb5 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using JetBrains.Annotations; + +namespace CreativeCoders.ProcessUtils; + +[PublicAPI] +public interface IProcess : IDisposable +{ + bool Start(); + + void Close(); + + bool CloseMainWindow(); + + void Refresh(); + + void Kill(); + + void WaitForExit(); + + void WaitForExit(TimeSpan timeout); + + Task WaitForExitAsync(CancellationToken cancellationToken = default(CancellationToken)); + + void WaitForInputIdle(); + + int ExitCode { get; } + + int Id { get; } + + bool HasExited { get; } + + string MainWindowTitle { get; } + + string ProcessName { get; } + + bool Responding { get; } + + StreamReader StandardOutput { get; } + + StreamReader StandardError { get; } + + StreamWriter StandardInput { get; } + + ProcessStartInfo StartInfo { get; } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs new file mode 100644 index 00000000..614711cd --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; +using JetBrains.Annotations; + +namespace CreativeCoders.ProcessUtils; + +[PublicAPI] +public interface IProcessFactory +{ + IProcess CreateProcess(string fileName, string[] arguments); + + IProcess CreateProcess(); + + IProcess StartProcess(string fileName, string[] arguments); + + IProcess StartProcess(ProcessStartInfo startInfo); +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/ProcessUtilsServiceCollectionExtensions.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/ProcessUtilsServiceCollectionExtensions.cs new file mode 100644 index 00000000..0e6e1024 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/ProcessUtilsServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using CreativeCoders.ProcessUtils.Execution; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CreativeCoders.ProcessUtils; + +public static class ProcessUtilsServiceCollectionExtensions +{ + public static void AddProcessUtils(this IServiceCollection services) + { + services.TryAddSingleton(); + + services.TryAddTransient(); + + services.TryAddTransient(typeof(IProcessExecutorBuilder<>), typeof(ProcessExecutorBuilder<>)); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj b/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj index 96f0dae9..a1a4497f 100644 --- a/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj +++ b/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/DefaultProcessFactoryTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/DefaultProcessFactoryTests.cs new file mode 100644 index 00000000..45d5a945 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/DefaultProcessFactoryTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Diagnostics; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils; + +/// +/// Tests for to verify process creation and start behavior. +/// Uses a harmless OS command for start tests to avoid side effects. +/// +public class DefaultProcessFactoryTests +{ + [Fact] + public void CreateProcess_WithFileNameAndArgs_ReturnsDefaultProcess_WithConfiguredStartInfo() + { + // Arrange + const string fileName = "/usr/bin/echo"; + var args = new[] { "hello", "world" }; + + var factory = new DefaultProcessFactory(); + + // Act + using var process = factory.CreateProcess(fileName, args); + + // Assert + process + .Should() + .NotBeNull(); + + process + .Should() + .BeOfType(); + + // StartInfo must reflect provided values + process.StartInfo.FileName + .Should() + .Be(fileName); + + process.StartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); + } + + [Fact] + public void CreateProcess_WithoutFileName_Throws() + { + // Arrange + var factory = new DefaultProcessFactory(); + + // Act + var actNull = () => factory.CreateProcess(null!, Array.Empty()); + var actEmpty = () => factory.CreateProcess(string.Empty, Array.Empty()); + var actWhitespace = () => factory.CreateProcess(" ", Array.Empty()); + + // Assert + actNull.Should().ThrowExactly(); + actEmpty.Should().ThrowExactly(); + actWhitespace.Should().ThrowExactly(); + } + + [Fact] + public void CreateProcess_WithoutParams_ReturnsDefaultProcess() + { + // Arrange + var factory = new DefaultProcessFactory(); + + // Act + using var process = factory.CreateProcess(); + + // Assert + process + .Should() + .NotBeNull(); + + process + .Should() + .BeOfType(); + } + + [Fact] + public void StartProcess_WithFileNameAndArgs_StartsProcess_AndReturnsWrapper() + { + // Arrange + // Use a harmless command that exists on macOS/Linux; on unsupported OS just return + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsLinux()) + { + return; + } + + const string fileName = "/usr/bin/true"; // exits immediately with code 0 + var factory = new DefaultProcessFactory(); + + // Act + using var process = factory.StartProcess(fileName, Array.Empty()); + process.WaitForExit(); + + // Assert + process + .Should() + .BeOfType(); + + process.HasExited + .Should() + .BeTrue(); + + process.ExitCode + .Should() + .Be(0); + } + + [Fact] + public void StartProcess_WithStartInfo_StartsProcess_AndReturnsWrapper() + { + // Arrange + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsLinux()) + { + return; + } + + var startInfo = new ProcessStartInfo + { + FileName = "/bin/echo", + Arguments = "ok" + }; + + var factory = new DefaultProcessFactory(); + + // Act + using var process = factory.StartProcess(startInfo); + process.WaitForExit(); + + // Assert + process + .Should() + .BeOfType(); + + process.HasExited + .Should() + .BeTrue(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/JsonOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/JsonOutputParserTests.cs new file mode 100644 index 00000000..994b3064 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/JsonOutputParserTests.cs @@ -0,0 +1,99 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using JetBrains.Annotations; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution.Parsers; + +/// +/// Tests for verifying JSON deserialization behavior and edge cases. +/// +[SuppressMessage("ReSharper", "ConvertToLocalFunction")] +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class JsonOutputParserTests +{ + [UsedImplicitly] + private sealed record Person(string Name, int Age); + + /// + /// Returns default(T) for null input. + /// + [Fact] + public void ParseOutput_WithNull_ReturnsDefault() + { + // Arrange + var parser = new JsonOutputParser(); + + // Act + var result = parser.ParseOutput(null); + + // Assert + result + .Should() + .BeNull(); + } + + /// + /// Returns default(T) for whitespace input. + /// + [Fact] + public void ParseOutput_WithWhitespace_ReturnsDefault() + { + // Arrange + var parser = new JsonOutputParser(); + + // Act + var result = parser.ParseOutput(" \t\n"); + + // Assert + result + .Should() + .BeNull(); + } + + /// + /// Deserializes a valid JSON string to the requested type. + /// + [Fact] + public void ParseOutput_WithValidJson_ReturnsDeserializedObject() + { + // Arrange + var parser = new JsonOutputParser(); + const string json = "{\"Name\":\"Alice\",\"Age\":30}"; + + // Act + var result = parser.ParseOutput(json); + + // Assert + result + .Should() + .NotBeNull(); + + result!.Name + .Should() + .Be("Alice"); + + result.Age + .Should() + .Be(30); + } + + /// + /// Throws for invalid JSON input. + /// + [Fact] + public void ParseOutput_WithInvalidJson_ThrowsJsonException() + { + // Arrange + var parser = new JsonOutputParser(); + const string invalidJson = "{\"Name\":\"Alice\",\"Age\": }"; // malformed + + // Act + var act = () => parser.ParseOutput(invalidJson); + + // Assert + Assert.Throws(act); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/PassThroughProcessOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/PassThroughProcessOutputParserTests.cs new file mode 100644 index 00000000..ddf70ff1 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/PassThroughProcessOutputParserTests.cs @@ -0,0 +1,66 @@ +using AwesomeAssertions; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution.Parsers; + +/// +/// Tests for ensuring the parser returns the input unchanged. +/// +public class PassThroughProcessOutputParserTests +{ + /// + /// Ensures that returns the same instance for null input. + /// + [Fact] + public void ParseOutput_WithNull_ReturnsNull() + { + // Arrange + var parser = new PassThroughProcessOutputParser(); + + // Act + var result = parser.ParseOutput(null); + + // Assert + result + .Should() + .BeNull(); + } + + /// + /// Ensures that empty strings are returned unchanged. + /// + [Fact] + public void ParseOutput_WithEmptyString_ReturnsEmptyString() + { + // Arrange + var parser = new PassThroughProcessOutputParser(); + + // Act + var result = parser.ParseOutput(string.Empty); + + // Assert + result + .Should() + .Be(""); + } + + /// + /// Ensures that non-empty strings are returned unchanged. + /// + [Fact] + public void ParseOutput_WithText_ReturnsSameText() + { + // Arrange + var parser = new PassThroughProcessOutputParser(); + const string input = "some output"; + + // Act + var result = parser.ParseOutput(input); + + // Assert + result + .Should() + .Be(input); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/SplitLinesOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/SplitLinesOutputParserTests.cs new file mode 100644 index 00000000..689b0ee5 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/SplitLinesOutputParserTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution.Parsers; + +/// +/// Tests for to ensure line splitting and empty handling behave correctly. +/// +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class SplitLinesOutputParserTests +{ + /// + /// Returns an empty array if the input is null. + /// + [Fact] + public void ParseOutput_WithNull_ReturnsEmptyArray() + { + // Arrange + var parser = new SplitLinesOutputParser(); + + // Act + var result = parser.ParseOutput(null); + + // Assert + result + .Should() + .NotBeNull(); + + result! + .Length + .Should() + .Be(0); + } + + /// + /// Returns an empty array if the input is an empty string. + /// + [Fact] + public void ParseOutput_WithEmpty_ReturnsEmptyArray() + { + // Arrange + var parser = new SplitLinesOutputParser(); + + // Act + var result = parser.ParseOutput(string.Empty); + + // Assert + result + .Should() + .NotBeNull(); + + result! + .Length + .Should() + .Be(0); + } + + /// + /// Splits a single line into a single array element. + /// + [Fact] + public void ParseOutput_WithSingleLine_ReturnsSingleElement() + { + // Arrange + var parser = new SplitLinesOutputParser(); + const string line = "hello"; + + // Act + var result = parser.ParseOutput(line); + + // Assert + result + .Should() + .NotBeNull(); + + result! + .Length + .Should() + .Be(1); + + result[0] + .Should() + .Be(line); + } + + /// + /// Splits multiple lines using and removes empty entries. + /// + [Fact] + public void ParseOutput_WithMultipleLines_RemovesEmptyEntriesAndSplits() + { + // Arrange + var parser = new SplitLinesOutputParser() + { + SplitOptions = StringSplitOptions.RemoveEmptyEntries + }; + + var nl = Environment.NewLine; + string[] lines = ["line1", string.Empty, "line2", "line3", string.Empty]; + var input = string.Join(nl, lines); + + // Act + var result = parser.ParseOutput(input); + + // Assert + result + .Should() + .NotBeNull(); + + result! + .Should() + .HaveCount(3) + .And + .BeEquivalentTo("line1", "line2", "line3"); + } + + /// + /// Splits multiple lines using and removes empty entries. + /// + [Fact] + public void ParseOutput_WithMultipleLines_DontRemovesEmptyEntriesAndSplits() + { + // Arrange + var parser = new SplitLinesOutputParser(); + + var nl = Environment.NewLine; + var input = string.Join(nl, "line1", string.Empty, "line2", "line3", string.Empty); + + // Act + var result = parser.ParseOutput(input); + + // Assert + result + .Should() + .NotBeNull(); + + result! + .Should() + .HaveCount(5) + .And + .BeEquivalentTo("line1", string.Empty, "line2", "line3", string.Empty); + } + + /// + /// Splits multiple lines using and removes empty entries. + /// + [Fact] + public void ParseOutput_WithMultipleLines_SplitAndTrimLinesAndRemovesEmptyEntries() + { + // Arrange + var parser = new SplitLinesOutputParser() + { + SplitOptions = StringSplitOptions.RemoveEmptyEntries, + TrimLines = true + }; + + var nl = Environment.NewLine; + var input = string.Join(nl, " line1", string.Empty, "line2 ", " \tline3 ", string.Empty); + + // Act + var result = parser.ParseOutput(input); + + // Assert + result + .Should() + .NotBeNull(); + + result! + .Should() + .HaveCount(3) + .And + .BeEquivalentTo("line1", "line2", "line3"); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorBuilderTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorBuilderTests.cs new file mode 100644 index 00000000..ac17a395 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorBuilderTests.cs @@ -0,0 +1,275 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; + +/// +/// Tests for and . +/// Verifies guard clauses, configuration behavior, and integration with . +/// +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class ProcessExecutorBuilderTests +{ + [Fact] + public void Build_WithoutFileName_Throws() + { + // Arrange + var processFactory = A.Fake(); + var builder = new ProcessExecutorBuilder(processFactory); + + // Act + var act = () => builder.Build(); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void Build_WithFileNameAndArgs_CreatesExecutor_ThatStartsProcess() + { + // Arrange + const string fileName = "tool"; + var args = new[] { "x", "y" }; + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutorBuilder(processFactory) + .SetFileName(fileName) + .SetArguments(args) + .Build(); + + // Act + executor.Execute(); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void GenericBuild_WithoutFileName_Throws() + { + // Arrange + var processFactory = A.Fake(); + var builder = new ProcessExecutorBuilder(processFactory) + .SetOutputParser(A.Fake>()); + + // Act + var act = () => builder.Build(); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void GenericBuild_WithoutParser_Throws() + { + // Arrange + var processFactory = A.Fake(); + var builder = new ProcessExecutorBuilder(processFactory) + .SetFileName("tool"); + + // Act + var act = () => builder.Build(); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void GenericBuild_WithCustomParser_AndConfigure_ReturnsParsedResult() + { + // Arrange + const string fileName = "echo"; + var args = new[] { "val" }; + + // Provide output "21" and configure parser to multiply by 2 -> 42 + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("21\n"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var builder = new ProcessExecutorBuilder(processFactory) + .SetFileName(fileName) + .SetArguments(args) + .SetOutputParser(new TestIntParser { Multiplier = 2 }); + + var executor = builder.Build(); + + // Act + var result = executor.Execute(); + + // Assert + result + .Should() + .Be(42); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void GenericBuild_WithCustomConfiguredParser_ReturnsParsedResult() + { + // Arrange + const string fileName = "echo"; + var args = new[] { "val" }; + + // Provide output "21" and configure parser to multiply by 2 -> 42 + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("21\n"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var builder = new ProcessExecutorBuilder(processFactory) + .SetFileName(fileName) + .SetArguments(args) + .SetOutputParser(x => x.Multiplier = 2); + + var executor = builder.Build(); + + // Act + var result = executor.Execute(); + + // Assert + result + .Should() + .Be(42); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task GenericBuild_ReturnOutputAsText_ReturnsRawText() + { + // Arrange + const string fileName = "echo"; + var args = new[] { "hello" }; + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("hello world"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var executor = new ProcessExecutorBuilder(processFactory) + .SetFileName(fileName) + .SetArguments(args) + .SetOutputParser(new PassThroughProcessOutputParser()) + .Build(); + + // Act + var result = await executor.ExecuteAsync(); + + // Assert + result + .Should() + .Be("hello world"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Theory] + [InlineData("42")] + [InlineData("Test, 12345")] + [InlineData(" 42 QWERTZ \n Line 1234")] + public void Build_WhenTIsStringAndNoOutputParser_DoesNotThrowAndReturnsStringExecutor( + string processOutput) + { + // Arrange + var fakeProcessFactory = A.Fake(); + var fakeProcess = A.Fake(); + + var outputStream = new MemoryStream(Encoding.UTF8.GetBytes(processOutput)); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var builder = new ProcessExecutorBuilder(fakeProcessFactory) + .SetFileName("dummy.exe"); // required to pass FileName check + + A.CallTo(() => fakeProcessFactory.StartProcess(A._)) + .Returns(fakeProcess); + + // Act + var executor = builder.Build(); + + var executeResult = executor.Execute(); + + // Assert + executor + .Should() + .NotBeNull() + .And + .BeAssignableTo>(); + + executeResult + .Should() + .Be(processOutput); + } + + /// + /// Simple test parser for integers; parses text to int and multiplies by . + /// + private sealed class TestIntParser : IProcessOutputParser + { + public int Multiplier { get; set; } = 1; + + public int ParseOutput(string output) + { + ArgumentNullException.ThrowIfNull(output); + + var parsed = int.Parse(output.Trim()); + return parsed * Multiplier; + } + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs new file mode 100644 index 00000000..d40a1799 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs @@ -0,0 +1,189 @@ +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; + +public class ProcessExecutorGenericTests +{ + [Fact] + public void Execute_ReadsOutput_AndParsesResult() + { + // Arrange + const string fileName = "echo"; + var args = new[] { "hello" }; + + // Parser + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("42\n")) + .Returns(42); + + var info = new ProcessExecutorInfo(fileName, args, parser); + + // Fake process with output + var fakeProcess = A.Fake(); + + var outputStream = new MemoryStream("42\n"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.Execute(); + + // Assert + result + .Should() + .Be(42); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("42\n")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void ExecuteEx_ReadsOutput_AndParsesResult() + { + // Arrange + const string fileName = "echo"; + var args = new[] { "hello" }; + + // Parser + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("42\n")) + .Returns(42); + + var info = new ProcessExecutorInfo(fileName, args, parser); + + // Fake process with output + var fakeProcess = A.Fake(); + + var outputStream = new MemoryStream("42\n"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.ExecuteEx(); + + // Assert + result.Value + .Should() + .Be(42); + + result.Process + .Should() + .BeSameAs(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("42\n")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_ReadsOutputAsync_AndParsesResult() + { + // Arrange + var fileName = "echo"; + var args = new[] { "hello" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("hello world")) + .Returns("hello world"); + + var info = new ProcessExecutorInfo(fileName, args, parser); + + var fakeProcess = A.Fake(); + + var outputStream = new MemoryStream("hello world"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteAsync(); + + // Assert + result + .Should() + .Be("hello world"); + + A.CallTo(() => parser.ParseOutput("hello world")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteExAsync_ReadsOutput_AndParsesResult() + { + // Arrange + const string fileName = "echo"; + var args = new[] { "hello" }; + + // Parser + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("42\n")) + .Returns(42); + + var info = new ProcessExecutorInfo(fileName, args, parser); + + // Fake process with output + var fakeProcess = A.Fake(); + + var outputStream = new MemoryStream("42\n"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteExAsync(); + + // Assert + result.Value + .Should() + .Be(42); + + result.Process + .Should() + .BeSameAs(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("42\n")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs new file mode 100644 index 00000000..ed978ea4 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs @@ -0,0 +1,341 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class ProcessExecutorTests +{ + [Fact] + public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() + { + // Arrange + const string fileName = "my-tool"; + var args = new[] { "arg1", "arg2" }; + + var info = new ProcessExecutorInfo(fileName, args); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + executor.Execute(); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); + + // Defaults from ProcessExecutorBase + capturedStartInfo.RedirectStandardOutput + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardError + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardInput + .Should() + .BeTrue(); + + capturedStartInfo.UseShellExecute + .Should() + .BeFalse(); + + capturedStartInfo.CreateNoWindow + .Should() + .BeTrue(); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() + { + // Arrange + const string fileName = "my-tool"; + var args = new[] { "arg1", "arg2" }; + + var info = new ProcessExecutorInfo(fileName, args); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = executor.ExecuteEx(); + + // Assert + process + .Should() + .BeSameAs(fakeProcess); + + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); + + // Defaults from ProcessExecutorBase + capturedStartInfo.RedirectStandardOutput + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardError + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardInput + .Should() + .BeTrue(); + + capturedStartInfo.UseShellExecute + .Should() + .BeFalse(); + + capturedStartInfo.CreateNoWindow + .Should() + .BeTrue(); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + + [Fact] + public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsForExitAsync() + { + // Arrange + var fileName = "my-tool-async"; + var args = new[] { "a", "b" }; + + var info = new ProcessExecutorInfo(fileName, args); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + // Make WaitForExitAsync complete immediately + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + await executor.ExecuteAsync(); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); + + // Defaults from ProcessExecutorBase + capturedStartInfo.RedirectStandardOutput + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardError + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardInput + .Should() + .BeTrue(); + + capturedStartInfo.UseShellExecute + .Should() + .BeFalse(); + + capturedStartInfo.CreateNoWindow + .Should() + .BeTrue(); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteExAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsForExitAsync() + { + // Arrange + var fileName = "my-tool-async"; + var args = new[] { "a", "b" }; + + var info = new ProcessExecutorInfo(fileName, args); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + // Make WaitForExitAsync complete immediately + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = await executor.ExecuteExAsync(); + + // Assert + process + .Should() + .BeSameAs(fakeProcess); + + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); + + // Defaults from ProcessExecutorBase + capturedStartInfo.RedirectStandardOutput + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardError + .Should() + .BeTrue(); + + capturedStartInfo.RedirectStandardInput + .Should() + .BeTrue(); + + capturedStartInfo.UseShellExecute + .Should() + .BeFalse(); + + capturedStartInfo.CreateNoWindow + .Should() + .BeTrue(); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + + [Fact] + public void ExecuteAndReturnExitCode_StartsProcess_WithConfiguredStartInfo_ReturnsExitCode() + { + // Arrange + const string fileName = "my-tool"; + var args = new[] { "arg1", "arg2" }; + const int expectedExitCode = 1235; + + var info = new ProcessExecutorInfo(fileName, args); + + var fakeProcess = A.Fake(); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.ExitCode) + .Returns(expectedExitCode); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = executor.ExecuteAndReturnExitCode(); + + // Assert + exitCode + .Should() + .Be(expectedExitCode); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAndReturnExitCodeAsync_StartsProcess_WithConfiguredStartInfo_ReturnsExitCode() + { + // Arrange + var fileName = "my-tool-async"; + var args = new[] { "a", "b" }; + const int expectedExitCode = 1235; + + var info = new ProcessExecutorInfo(fileName, args); + + var fakeProcess = A.Fake(); + + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Returns(fakeProcess); + + // Make WaitForExitAsync complete immediately + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + A.CallTo(() => fakeProcess.ExitCode) + .Returns(expectedExitCode); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = await executor.ExecuteAndReturnExitCodeAsync(); + + // Assert + exitCode + .Should() + .Be(expectedExitCode); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessUtilsServiceCollectionExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessUtilsServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..97db7acb --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessUtilsServiceCollectionExtensionsTests.cs @@ -0,0 +1,159 @@ +using System; +using System.Linq; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils; + +/// +/// Tests for to verify DI registrations, +/// lifetimes, open-generic support and TryAdd semantics. +/// +public class ProcessUtilsServiceCollectionExtensionsTests +{ + [Fact] + public void AddProcessUtils_RegistersExpectedServices_WithCorrectLifetimes() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddProcessUtils(); + var provider = services.BuildServiceProvider(); + + // Assert + // IProcessFactory is a singleton of DefaultProcessFactory + var factory1 = provider.GetRequiredService(); + var factory2 = provider.GetRequiredService(); + + factory1 + .Should() + .BeOfType(); + + factory2 + .Should() + .BeSameAs(factory1); + + using (var scope = provider.CreateScope()) + { + var factoryScoped = scope.ServiceProvider.GetRequiredService(); + factoryScoped + .Should() + .BeSameAs(factory1); + } + + // IProcessExecutorBuilder is transient of ProcessExecutorBuilder + var builder1 = provider.GetRequiredService(); + var builder2 = provider.GetRequiredService(); + + builder1 + .Should() + .BeOfType(); + + builder2 + .Should() + .NotBeSameAs(builder1); + + using (var scope = provider.CreateScope()) + { + var scopedBuilder = scope.ServiceProvider.GetRequiredService(); + scopedBuilder + .Should() + .BeOfType(); + + scopedBuilder + .Should() + .NotBeSameAs(builder1); + } + + // Open generic IProcessExecutorBuilder is registered as transient + var gen1 = provider.GetRequiredService>(); + var gen2 = provider.GetRequiredService>(); + + gen1 + .Should() + .BeOfType>(); + + gen2 + .Should() + .NotBeSameAs(gen1); + } + + [Fact] + public void AddProcessUtils_DoesNotOverrideExistingRegistrations() + { + // Arrange + var services = new ServiceCollection(); + + var fakeFactory = A.Fake(); + var fakeBuilder = A.Fake(); + var fakeGenericBuilderInt = A.Fake>(); + + // Pre-register instances to verify TryAdd semantics + services.AddSingleton(fakeFactory); + services.AddSingleton(fakeBuilder); + services.AddSingleton>(fakeGenericBuilderInt); + + // Act + services.AddProcessUtils(); + var provider = services.BuildServiceProvider(); + + // Assert + // Existing registrations must be preserved + provider.GetRequiredService() + .Should() + .BeSameAs(fakeFactory); + + provider.GetRequiredService() + .Should() + .BeSameAs(fakeBuilder); + + provider.GetRequiredService>() + .Should() + .BeSameAs(fakeGenericBuilderInt); + + // Types not pre-registered should be provided by the extension + var genString = provider.GetRequiredService>(); + genString + .Should() + .BeOfType>(); + } + + [Fact] + public void AddProcessUtils_CalledMultipleTimes_IsIdempotent() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddProcessUtils(); + services.AddProcessUtils(); + + var provider = services.BuildServiceProvider(); + + // Assert + // Only a single descriptor per service should exist after multiple calls + services.Count(x => x.ServiceType == typeof(IProcessFactory)) + .Should() + .Be(1); + + services.Count(x => x.ServiceType == typeof(IProcessExecutorBuilder)) + .Should() + .Be(1); + + services.Count(x => x.ServiceType.IsGenericType && x.ServiceType.GetGenericTypeDefinition() == typeof(IProcessExecutorBuilder<>)) + .Should() + .Be(1); + + // Under idempotency, behavior stays the same + var factory1 = provider.GetRequiredService(); + var factory2 = provider.GetRequiredService(); + factory2 + .Should() + .BeSameAs(factory1); + } +}