From be2bc78c64db78fb843c441064e6fcb3a0b9f86e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:17:37 +0100 Subject: [PATCH 1/9] Add ProcessUtils library with process handling and execution APIs Introduce a new `CreativeCoders.ProcessUtils` library to provide process handling and execution abstractions. Features include `IProcess` interface, `DefaultProcess` implementation, process factories, executors, and output parsers (e.g., JSON, strings). Update solution file to include the new project. --- Core.sln | 10 ++ .../CreativeCoders.ProcessUtils.csproj | 13 +++ .../DefaultProcess.cs | 50 +++++++++ .../DefaultProcessFactory.cs | 39 +++++++ .../Execution/IProcessExecutor.cs | 15 +++ .../Execution/IProcessExecutorBuilder.cs | 21 ++++ .../Execution/IProcessOutputParser.cs | 6 + .../Execution/Parsers/JsonOutputParser.cs | 17 +++ .../Parsers/PassThroughProcessOutputParser.cs | 9 ++ .../Parsers/StringListOutputParser.cs | 17 +++ .../Execution/ProcessExecutor.cs | 49 ++++++++ .../Execution/ProcessExecutorBase.cs | 38 +++++++ .../Execution/ProcessExecutorBuilder.cs | 105 ++++++++++++++++++ .../Execution/ProcessExecutorBuilderBase.cs | 8 ++ .../Execution/ProcessExecutorInfo.cs | 20 ++++ .../CreativeCoders.ProcessUtils/IProcess.cs | 44 ++++++++ .../IProcessFactory.cs | 14 +++ 17 files changed, 475 insertions(+) create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/CreativeCoders.ProcessUtils.csproj create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcessFactory.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessOutputParser.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/JsonOutputParser.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/PassThroughProcessOutputParser.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/StringListOutputParser.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs diff --git a/Core.sln b/Core.sln index 8ebd9ce0..67a629be 100644 --- a/Core.sln +++ b/Core.sln @@ -219,6 +219,10 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -519,6 +523,10 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -617,6 +625,8 @@ 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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE24476B-9A4C-4146-B982-3461FAF8B3B0} 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..7242d93a --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils; + +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..0ac8c457 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -0,0 +1,15 @@ +namespace CreativeCoders.ProcessUtils.Execution; + +public interface IProcessExecutor +{ + T? Execute(); + + Task ExecuteAsync(); +} + +public interface IProcessExecutor +{ + void Execute(); + + Task ExecuteAsync(); +} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs new file mode 100644 index 00000000..ba7004fd --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs @@ -0,0 +1,21 @@ +namespace CreativeCoders.ProcessUtils.Execution; + +public interface IProcessExecutorBuilder +{ + IProcessExecutorBuilder SetFileName(string fileName); + + IProcessExecutorBuilder SetArguments(string[] arguments); + + IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser); + + IProcessExecutor Build(); +} + +public interface IProcessExecutorBuilder +{ + IProcessExecutorBuilder SetFileName(string fileName); + + IProcessExecutorBuilder SetArguments(string[] arguments); + + IProcessExecutor Build(); +} \ No newline at end of file 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/StringListOutputParser.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/StringListOutputParser.cs new file mode 100644 index 00000000..235c8fc8 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/StringListOutputParser.cs @@ -0,0 +1,17 @@ +namespace CreativeCoders.ProcessUtils.Execution.Parsers; + +public class StringListOutputParser : IProcessOutputParser +{ + public string[]? ParseOutput(string? output) + { + if (string.IsNullOrEmpty(output)) + { + return []; + } + + var lines = output + .Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + + return lines; + } +} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs new file mode 100644 index 00000000..4e405406 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs @@ -0,0 +1,49 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFactory processFactory) + : ProcessExecutorBase(processExecutorInfo, processFactory), IProcessExecutor +{ + public void Execute() + { + using var process = StartProcess(); + + process.WaitForExit(); + } + + public async Task ExecuteAsync() + { + using var process = StartProcess(); + + await process.WaitForExitAsync().ConfigureAwait(false); + } +} + +public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFactory processFactory) + : ProcessExecutorBase(processExecutorInfo, processFactory), IProcessExecutor +{ + private readonly IProcessOutputParser _outputParser = Ensure.NotNull(processExecutorInfo.OutputParser); + + public T? Execute() + { + using var process = StartProcess(); + + var output = process.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + + return _outputParser.ParseOutput(output); + } + + public async Task ExecuteAsync() + { + using var process = StartProcess(); + + var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + + await process.WaitForExitAsync().ConfigureAwait(false); + + return _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..e65b736d --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs @@ -0,0 +1,105 @@ +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; + } + + public IProcessExecutorBuilder ReturnOutputAsText() + { + _outputParser = (IProcessOutputParser)new PassThroughProcessOutputParser(); + + return this; + } + + public IProcessExecutor Build() + { + if (string.IsNullOrWhiteSpace(FileName)) + { + throw new InvalidOperationException("FileName must be set before building the executor."); + } + + 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..f8e741f1 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +namespace CreativeCoders.ProcessUtils; + +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; } +} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs new file mode 100644 index 00000000..b093eb76 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; + +namespace CreativeCoders.ProcessUtils; + +public interface IProcessFactory +{ + IProcess CreateProcess(string fileName, string[] arguments); + + IProcess CreateProcess(); + + IProcess StartProcess(string fileName, string[] arguments); + + IProcess StartProcess(ProcessStartInfo startInfo); +} \ No newline at end of file From 85020ab484a1f358a41f3f5364b60e616eeedc21 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:00:27 +0100 Subject: [PATCH 2/9] Add comprehensive C# development guidelines and unit tests for ProcessUtils library - Introduced detailed C# coding standards and development practices, covering naming conventions, formatting, project structure, and more. - Included new unit tests for `ProcessExecutor` in the `CreativeCoders.ProcessUtils` library to validate synchronous and asynchronous process execution, output parsing, and proper disposal. - Updated test project references to include `CreativeCoders.ProcessUtils`. --- .github/csharp.instructions.md | 114 ++++++++++++ .junie/guidelines.md | 114 ++++++++++++ .../CreativeCoders.Core.UnitTests.csproj | 1 + .../ProcessUtils/ProcessExecutorTests.cs | 172 ++++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 .github/csharp.instructions.md create mode 100644 .junie/guidelines.md create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs diff --git a/.github/csharp.instructions.md b/.github/csharp.instructions.md new file mode 100644 index 00000000..8d6f4c20 --- /dev/null +++ b/.github/csharp.instructions.md @@ -0,0 +1,114 @@ +--- +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. + +## 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..8d6f4c20 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,114 @@ +--- +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. + +## 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/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/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs new file mode 100644 index 00000000..f84d1cbb --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs @@ -0,0 +1,172 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils; + +public class ProcessExecutorTests +{ + [Fact] + public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() + { + // Arrange + var 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 + Assert.NotNull(capturedStartInfo); + Assert.Equal(fileName, capturedStartInfo!.FileName); + Assert.Equal(string.Join(" ", args), capturedStartInfo.Arguments); + + // Defaults from ProcessExecutorBase + Assert.True(capturedStartInfo.RedirectStandardOutput); + Assert.True(capturedStartInfo.RedirectStandardError); + Assert.True(capturedStartInfo.RedirectStandardInput); + Assert.False(capturedStartInfo.UseShellExecute); + Assert.True(capturedStartInfo.CreateNoWindow); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [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 + Assert.NotNull(capturedStartInfo); + Assert.Equal(fileName, capturedStartInfo!.FileName); + Assert.Equal(string.Join(" ", args), capturedStartInfo.Arguments); + + // Defaults from ProcessExecutorBase + Assert.True(capturedStartInfo.RedirectStandardOutput); + Assert.True(capturedStartInfo.RedirectStandardError); + Assert.True(capturedStartInfo.RedirectStandardInput); + Assert.False(capturedStartInfo.UseShellExecute); + Assert.True(capturedStartInfo.CreateNoWindow); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } +} + +public class ProcessExecutor_Generic_Tests +{ + [Fact] + public void Execute_ReadsOutput_AndParsesResult() + { + // Arrange + var 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(Encoding.UTF8.GetBytes("42\n")); + 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 + Assert.Equal(42, result); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("42\n")).MustHaveHappenedOnceExactly(); + 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(Encoding.UTF8.GetBytes("hello world")); + 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 + Assert.Equal("hello world", result); + A.CallTo(() => parser.ParseOutput("hello world")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } +} From 45dd41a678844196b674fb71ee1945524849a95d Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 30 Nov 2025 11:16:33 +0100 Subject: [PATCH 3/9] Update C# testing guidelines and include new projects in solution - Add guidelines for using `AwesomeAssertions`, `xUnit`, and `FakeItEasy` in C# testing. - Update solution file to include new organizational folders (`__ai`, `copilot`, `junie`) and related Markdown files. --- .github/csharp.instructions.md | 3 +++ .junie/guidelines.md | 3 +++ Core.sln | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/.github/csharp.instructions.md b/.github/csharp.instructions.md index 8d6f4c20..fef810d3 100644 --- a/.github/csharp.instructions.md +++ b/.github/csharp.instructions.md @@ -95,6 +95,9 @@ applyTo: '**/*.cs' - 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. ## Performance Optimization diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 8d6f4c20..fef810d3 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -95,6 +95,9 @@ applyTo: '**/*.cs' - 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. ## Performance Optimization diff --git a/Core.sln b/Core.sln index 67a629be..b114d495 100644 --- a/Core.sln +++ b/Core.sln @@ -223,6 +223,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProcessUtils", "ProcessUtil 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -627,6 +639,8 @@ Global {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE24476B-9A4C-4146-B982-3461FAF8B3B0} From 8d6d6ab1774df31e1f51ff926b94fbe546cb6ee6 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 30 Nov 2025 11:47:16 +0100 Subject: [PATCH 4/9] Add unit tests for ProcessExecutor and improve testing guidelines - Added tests for `ProcessExecutor` covering synchronous and asynchronous behavior, including output parsing and process disposal. - Updated C# testing guidelines to recommend the Arrange-Act-Assert pattern. --- .github/csharp.instructions.md | 1 + .../ProcessExecutorGenericTests.cs | 95 +++++++++++ .../ProcessUtils/ProcessExecutorTests.cs | 147 +++++++----------- 3 files changed, 152 insertions(+), 91 deletions(-) create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs diff --git a/.github/csharp.instructions.md b/.github/csharp.instructions.md index fef810d3..c50ae6dc 100644 --- a/.github/csharp.instructions.md +++ b/.github/csharp.instructions.md @@ -98,6 +98,7 @@ applyTo: '**/*.cs' - Use awesomeassertions for asserting expected results. - Use xUnit for unit testing. - Use FakeItEasy for mocking dependencies. +- Separate a test method into the blocks Arrange, Act and Assert. Mark this blocks with comments. ## Performance Optimization diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs new file mode 100644 index 00000000..567cb97f --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +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; + +public class ProcessExecutorGenericTests +{ + [Fact] + public void Execute_ReadsOutput_AndParsesResult() + { + // Arrange + var 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(Encoding.UTF8.GetBytes("42\n")); + 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 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(Encoding.UTF8.GetBytes("hello world")); + 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(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs index f84d1cbb..f743852f 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs @@ -1,11 +1,10 @@ using System.Diagnostics; -using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using CreativeCoders.ProcessUtils; using CreativeCoders.ProcessUtils.Execution; using FakeItEasy; +using AwesomeAssertions; using Xunit; namespace CreativeCoders.Core.UnitTests.ProcessUtils; @@ -35,16 +34,38 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() executor.Execute(); // Assert - Assert.NotNull(capturedStartInfo); - Assert.Equal(fileName, capturedStartInfo!.FileName); - Assert.Equal(string.Join(" ", args), capturedStartInfo.Arguments); + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); // Defaults from ProcessExecutorBase - Assert.True(capturedStartInfo.RedirectStandardOutput); - Assert.True(capturedStartInfo.RedirectStandardError); - Assert.True(capturedStartInfo.RedirectStandardInput); - Assert.False(capturedStartInfo.UseShellExecute); - Assert.True(capturedStartInfo.CreateNoWindow); + 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(); @@ -77,95 +98,39 @@ public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsFor await executor.ExecuteAsync(); // Assert - Assert.NotNull(capturedStartInfo); - Assert.Equal(fileName, capturedStartInfo!.FileName); - Assert.Equal(string.Join(" ", args), capturedStartInfo.Arguments); + capturedStartInfo + .Should() + .NotBeNull(); - // Defaults from ProcessExecutorBase - Assert.True(capturedStartInfo.RedirectStandardOutput); - Assert.True(capturedStartInfo.RedirectStandardError); - Assert.True(capturedStartInfo.RedirectStandardInput); - Assert.False(capturedStartInfo.UseShellExecute); - Assert.True(capturedStartInfo.CreateNoWindow); + capturedStartInfo!.FileName + .Should() + .Be(fileName); - A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); - A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); - } -} - -public class ProcessExecutor_Generic_Tests -{ - [Fact] - public void Execute_ReadsOutput_AndParsesResult() - { - // Arrange - var 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(Encoding.UTF8.GetBytes("42\n")); - var reader = new StreamReader(outputStream); - A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); - - var processFactory = A.Fake(); - A.CallTo(() => processFactory.StartProcess(A._)) - .Returns(fakeProcess); + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", args)); - var executor = new ProcessExecutor(info, processFactory); - - // Act - var result = executor.Execute(); - - // Assert - Assert.Equal(42, result); - A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); - A.CallTo(() => parser.ParseOutput("42\n")).MustHaveHappenedOnceExactly(); - 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(Encoding.UTF8.GetBytes("hello world")); - var reader = new StreamReader(outputStream); - A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + // Defaults from ProcessExecutorBase + capturedStartInfo.RedirectStandardOutput + .Should() + .BeTrue(); - A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) - .Returns(Task.CompletedTask); + capturedStartInfo.RedirectStandardError + .Should() + .BeTrue(); - var processFactory = A.Fake(); - A.CallTo(() => processFactory.StartProcess(A._)) - .Returns(fakeProcess); + capturedStartInfo.RedirectStandardInput + .Should() + .BeTrue(); - var executor = new ProcessExecutor(info, processFactory); + capturedStartInfo.UseShellExecute + .Should() + .BeFalse(); - // Act - var result = await executor.ExecuteAsync(); + capturedStartInfo.CreateNoWindow + .Should() + .BeTrue(); - // Assert - Assert.Equal("hello world", result); - A.CallTo(() => parser.ParseOutput("hello world")).MustHaveHappenedOnceExactly(); A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } From 555f4173c0a5ae4899119c3881a28c3e2bce28b7 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 30 Nov 2025 13:44:42 +0100 Subject: [PATCH 5/9] Remove `StringListOutputParser` and add unit tests for JSON, line-splitting, and passthrough output parsers - Deleted unused `StringListOutputParser` implementation. - Added `SplitLinesOutputParser` class for processing output into trimmed, split lines. - Added thorough unit tests for `JsonOutputParser`, `SplitLinesOutputParser`, and `PassThroughProcessOutputParser`. - Enhanced `ProcessExecutorBuilder` to automatically configure output parsing with improved exception handling. - Applied [PublicAPI] annotations across `IProcess`, `IProcessExecutor`, and related interfaces for better IDE support. --- .../Execution/IProcessExecutor.cs | 10 +- .../Execution/IProcessExecutorBuilder.cs | 17 +- .../Parsers/SplitLinesOutputParser.cs | 24 ++ .../Parsers/StringListOutputParser.cs | 17 -- .../Execution/ProcessExecutorBuilder.cs | 14 +- .../CreativeCoders.ProcessUtils/IProcess.cs | 34 ++- .../IProcessFactory.cs | 8 +- .../Parsers/JsonOutputParserTests.cs | 82 ++++++ .../PassThroughProcessOutputParserTests.cs | 57 ++++ .../Parsers/SplitLinesOutputParserTests.cs | 158 ++++++++++ .../ProcessExecutorBuilderTests.cs | 274 ++++++++++++++++++ .../ProcessExecutorGenericTests.cs | 8 +- .../ProcessUtils/ProcessExecutorTests.cs | 4 +- 13 files changed, 655 insertions(+), 52 deletions(-) create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/SplitLinesOutputParser.cs delete mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/StringListOutputParser.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs index 0ac8c457..fe42480c 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -1,15 +1,19 @@ -namespace CreativeCoders.ProcessUtils.Execution; +using JetBrains.Annotations; +namespace CreativeCoders.ProcessUtils.Execution; + +[PublicAPI] public interface IProcessExecutor { T? Execute(); - + Task ExecuteAsync(); } +[PublicAPI] public interface IProcessExecutor { void Execute(); Task ExecuteAsync(); -} \ No newline at end of file +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs index ba7004fd..cd9f1014 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs @@ -1,21 +1,28 @@ -namespace CreativeCoders.ProcessUtils.Execution; +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(); -} \ 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/Parsers/StringListOutputParser.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/StringListOutputParser.cs deleted file mode 100644 index 235c8fc8..00000000 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/Parsers/StringListOutputParser.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CreativeCoders.ProcessUtils.Execution.Parsers; - -public class StringListOutputParser : IProcessOutputParser -{ - public string[]? ParseOutput(string? output) - { - if (string.IsNullOrEmpty(output)) - { - return []; - } - - var lines = output - .Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); - - return lines; - } -} \ No newline at end of file diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs index e65b736d..f0564655 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs @@ -76,11 +76,14 @@ public IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser return this; } - public IProcessExecutorBuilder ReturnOutputAsText() + private void ReturnOutputAsText() { - _outputParser = (IProcessOutputParser)new PassThroughProcessOutputParser(); + if (typeof(T) != typeof(string)) + { + throw new InvalidOperationException("OutputParser must be set to return output as text."); + } - return this; + _outputParser = (IProcessOutputParser)new PassThroughProcessOutputParser(); } public IProcessExecutor Build() @@ -90,6 +93,11 @@ public IProcessExecutor Build() 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."); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs index f8e741f1..a3ad6cb5 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcess.cs @@ -1,44 +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; } -} \ No newline at end of file +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs index b093eb76..614711cd 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/IProcessFactory.cs @@ -1,14 +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); -} \ No newline at end of file +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs new file mode 100644 index 00000000..b3fb16b4 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Parsers; + +/// +/// Tests for verifying JSON deserialization behavior and edge cases. +/// +public class JsonOutputParserTests +{ + private sealed record Person(string Name, int Age); + + /// + /// Returns default(T) for null input. + /// + [Fact] + public void ParseOutput_WithNull_ReturnsDefault() + { + var parser = new JsonOutputParser(); + + var result = parser.ParseOutput(null); + + result + .Should() + .BeNull(); + } + + /// + /// Returns default(T) for whitespace input. + /// + [Fact] + public void ParseOutput_WithWhitespace_ReturnsDefault() + { + var parser = new JsonOutputParser(); + + var result = parser.ParseOutput(" \t\n"); + + result + .Should() + .BeNull(); + } + + /// + /// Deserializes a valid JSON string to the requested type. + /// + [Fact] + public void ParseOutput_WithValidJson_ReturnsDeserializedObject() + { + var parser = new JsonOutputParser(); + const string json = "{\"Name\":\"Alice\",\"Age\":30}"; + + var result = parser.ParseOutput(json); + + result + .Should() + .NotBeNull(); + + result!.Name + .Should() + .Be("Alice"); + + result.Age + .Should() + .Be(30); + } + + /// + /// Throws for invalid JSON input. + /// + [Fact] + public void ParseOutput_WithInvalidJson_ThrowsJsonException() + { + var parser = new JsonOutputParser(); + const string invalidJson = "{\"Name\":\"Alice\",\"Age\": }"; // malformed + + var act = () => parser.ParseOutput(invalidJson); + + Assert.Throws(() => act()); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs new file mode 100644 index 00000000..0099822f --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs @@ -0,0 +1,57 @@ +using AwesomeAssertions; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.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() + { + var parser = new PassThroughProcessOutputParser(); + + var result = parser.ParseOutput(null); + + result + .Should() + .BeNull(); + } + + /// + /// Ensures that empty strings are returned unchanged. + /// + [Fact] + public void ParseOutput_WithEmptyString_ReturnsEmptyString() + { + var parser = new PassThroughProcessOutputParser(); + + var result = parser.ParseOutput(string.Empty); + + result + .Should() + .Be(""); + } + + /// + /// Ensures that non-empty strings are returned unchanged. + /// + [Fact] + public void ParseOutput_WithText_ReturnsSameText() + { + var parser = new PassThroughProcessOutputParser(); + const string input = "some output"; + + var result = parser.ParseOutput(input); + + result + .Should() + .Be(input); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs new file mode 100644 index 00000000..ceb763a9 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils.Execution.Parsers; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.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() + { + var parser = new SplitLinesOutputParser(); + + var result = parser.ParseOutput(null); + + 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() + { + var parser = new SplitLinesOutputParser(); + + var result = parser.ParseOutput(string.Empty); + + result + .Should() + .NotBeNull(); + + result! + .Length + .Should() + .Be(0); + } + + /// + /// Splits a single line into a single array element. + /// + [Fact] + public void ParseOutput_WithSingleLine_ReturnsSingleElement() + { + var parser = new SplitLinesOutputParser(); + const string line = "hello"; + + var result = parser.ParseOutput(line); + + 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() + { + var parser = new SplitLinesOutputParser() + { + SplitOptions = StringSplitOptions.RemoveEmptyEntries + }; + + var nl = Environment.NewLine; + var input = string.Join(nl, "line1", string.Empty, "line2", "line3", string.Empty); + + var result = parser.ParseOutput(input); + + 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() + { + var parser = new SplitLinesOutputParser(); + + var nl = Environment.NewLine; + var input = string.Join(nl, "line1", string.Empty, "line2", "line3", string.Empty); + + var result = parser.ParseOutput(input); + + 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() + { + 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); + + var result = parser.ParseOutput(input); + + result + .Should() + .NotBeNull(); + + result! + .Should() + .HaveCount(3) + .And + .BeEquivalentTo("line1", "line2", "line3"); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs new file mode 100644 index 00000000..22b22629 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs @@ -0,0 +1,274 @@ +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; + +/// +/// 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/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs index 567cb97f..9655e503 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; @@ -17,7 +16,7 @@ public class ProcessExecutorGenericTests public void Execute_ReadsOutput_AndParsesResult() { // Arrange - var fileName = "echo"; + const string fileName = "echo"; var args = new[] {"hello"}; // Parser @@ -30,7 +29,7 @@ public void Execute_ReadsOutput_AndParsesResult() // Fake process with output var fakeProcess = A.Fake(); - var outputStream = new MemoryStream(Encoding.UTF8.GetBytes("42\n")); + var outputStream = new MemoryStream("42\n"u8.ToArray()); var reader = new StreamReader(outputStream); A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); @@ -68,7 +67,7 @@ public async Task ExecuteAsync_ReadsOutputAsync_AndParsesResult() var fakeProcess = A.Fake(); - var outputStream = new MemoryStream(Encoding.UTF8.GetBytes("hello world")); + var outputStream = new MemoryStream("hello world"u8.ToArray()); var reader = new StreamReader(outputStream); A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); @@ -88,6 +87,7 @@ public async Task ExecuteAsync_ReadsOutputAsync_AndParsesResult() result .Should() .Be("hello world"); + A.CallTo(() => parser.ParseOutput("hello world")).MustHaveHappenedOnceExactly(); A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs index f743852f..ed739202 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using CreativeCoders.ProcessUtils; @@ -9,13 +10,14 @@ namespace CreativeCoders.Core.UnitTests.ProcessUtils; +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ProcessExecutorTests { [Fact] public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() { // Arrange - var fileName = "my-tool"; + const string fileName = "my-tool"; var args = new[] {"arg1", "arg2"}; var info = new ProcessExecutorInfo(fileName, args); From eec261cfd556c47940ba2910ed2996165a7ee9f0 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 30 Nov 2025 13:55:47 +0100 Subject: [PATCH 6/9] Update C# testing guidelines and refactor unit tests - Updated C# testing guidelines to enforce the Arrange-Act-Assert pattern and added comments for clarity. - Refactored `PassThroughProcessOutputParserTests`, `JsonOutputParserTests`, and `SplitLinesOutputParserTests` to adhere to the updated pattern. - Added annotations and suppression attributes for better IDE integration and readability. - Revised test inputs to improve consistency and maintainability. --- .github/csharp.instructions.md | 2 +- .junie/guidelines.md | 1 + .../Parsers/JsonOutputParserTests.cs | 19 ++++++++++++++++- .../PassThroughProcessOutputParserTests.cs | 9 ++++++++ .../Parsers/SplitLinesOutputParserTests.cs | 21 ++++++++++++++++++- 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/csharp.instructions.md b/.github/csharp.instructions.md index c50ae6dc..8caf2846 100644 --- a/.github/csharp.instructions.md +++ b/.github/csharp.instructions.md @@ -98,7 +98,7 @@ applyTo: '**/*.cs' - Use awesomeassertions for asserting expected results. - Use xUnit for unit testing. - Use FakeItEasy for mocking dependencies. -- Separate a test method into the blocks Arrange, Act and Assert. Mark this blocks with comments. +- Always separate a test method into the blocks Arrange, Act and Assert. Mark this blocks with comments. ## Performance Optimization diff --git a/.junie/guidelines.md b/.junie/guidelines.md index fef810d3..8caf2846 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -98,6 +98,7 @@ applyTo: '**/*.cs' - 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 diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs index b3fb16b4..1b737bd6 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs @@ -1,6 +1,8 @@ +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.Parsers; @@ -8,8 +10,11 @@ namespace CreativeCoders.Core.UnitTests.ProcessUtils.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); /// @@ -18,10 +23,13 @@ private sealed record Person(string Name, int Age); [Fact] public void ParseOutput_WithNull_ReturnsDefault() { + // Arrange var parser = new JsonOutputParser(); + // Act var result = parser.ParseOutput(null); + // Assert result .Should() .BeNull(); @@ -33,10 +41,13 @@ public void ParseOutput_WithNull_ReturnsDefault() [Fact] public void ParseOutput_WithWhitespace_ReturnsDefault() { + // Arrange var parser = new JsonOutputParser(); + // Act var result = parser.ParseOutput(" \t\n"); + // Assert result .Should() .BeNull(); @@ -48,11 +59,14 @@ public void ParseOutput_WithWhitespace_ReturnsDefault() [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(); @@ -72,11 +86,14 @@ public void ParseOutput_WithValidJson_ReturnsDeserializedObject() [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.Throws(() => act()); + // Assert + Assert.Throws(act); } } diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs index 0099822f..749153be 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs @@ -15,10 +15,13 @@ public class PassThroughProcessOutputParserTests [Fact] public void ParseOutput_WithNull_ReturnsNull() { + // Arrange var parser = new PassThroughProcessOutputParser(); + // Act var result = parser.ParseOutput(null); + // Assert result .Should() .BeNull(); @@ -30,10 +33,13 @@ public void ParseOutput_WithNull_ReturnsNull() [Fact] public void ParseOutput_WithEmptyString_ReturnsEmptyString() { + // Arrange var parser = new PassThroughProcessOutputParser(); + // Act var result = parser.ParseOutput(string.Empty); + // Assert result .Should() .Be(""); @@ -45,11 +51,14 @@ public void ParseOutput_WithEmptyString_ReturnsEmptyString() [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/Parsers/SplitLinesOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs index ceb763a9..86dd454e 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs @@ -18,10 +18,13 @@ public class SplitLinesOutputParserTests [Fact] public void ParseOutput_WithNull_ReturnsEmptyArray() { + // Arrange var parser = new SplitLinesOutputParser(); + // Act var result = parser.ParseOutput(null); + // Assert result .Should() .NotBeNull(); @@ -38,10 +41,13 @@ public void ParseOutput_WithNull_ReturnsEmptyArray() [Fact] public void ParseOutput_WithEmpty_ReturnsEmptyArray() { + // Arrange var parser = new SplitLinesOutputParser(); + // Act var result = parser.ParseOutput(string.Empty); + // Assert result .Should() .NotBeNull(); @@ -58,11 +64,14 @@ public void ParseOutput_WithEmpty_ReturnsEmptyArray() [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(); @@ -83,16 +92,20 @@ public void ParseOutput_WithSingleLine_ReturnsSingleElement() [Fact] public void ParseOutput_WithMultipleLines_RemovesEmptyEntriesAndSplits() { + // Arrange var parser = new SplitLinesOutputParser() { SplitOptions = StringSplitOptions.RemoveEmptyEntries }; var nl = Environment.NewLine; - var input = string.Join(nl, "line1", string.Empty, "line2", "line3", string.Empty); + 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(); @@ -110,13 +123,16 @@ public void ParseOutput_WithMultipleLines_RemovesEmptyEntriesAndSplits() [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(); @@ -134,6 +150,7 @@ public void ParseOutput_WithMultipleLines_DontRemovesEmptyEntriesAndSplits() [Fact] public void ParseOutput_WithMultipleLines_SplitAndTrimLinesAndRemovesEmptyEntries() { + // Arrange var parser = new SplitLinesOutputParser() { SplitOptions = StringSplitOptions.RemoveEmptyEntries, @@ -143,8 +160,10 @@ public void ParseOutput_WithMultipleLines_SplitAndTrimLinesAndRemovesEmptyEntrie 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(); From 21021d7bd968473d180715c6d0374f651a8ce723 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 30 Nov 2025 15:40:03 +0100 Subject: [PATCH 7/9] Add `ProcessUtilsSampleApp` project and enhance DI extensions for `ProcessUtils` - Introduced the `ProcessUtilsSampleApp` project showcasing usage of `ProcessExecutor` and output parsers. - Updated `CreativeCoders.ProcessUtils` library with `AddProcessUtils` DI extensions for streamlined service registration. - Included the new sample application and references in the solution file. --- Core.sln | 7 +++ .../ProcessUtilsSampleApp.csproj | 19 ++++++++ samples/ProcessUtilsSampleApp/Program.cs | 43 +++++++++++++++++++ .../DefaultObjectFactory.cs | 4 +- ...ProcessUtilsServiceCollectionExtensions.cs | 17 ++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 samples/ProcessUtilsSampleApp/ProcessUtilsSampleApp.csproj create mode 100644 samples/ProcessUtilsSampleApp/Program.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/ProcessUtilsServiceCollectionExtensions.cs diff --git a/Core.sln b/Core.sln index b114d495..a34fa7f3 100644 --- a/Core.sln +++ b/Core.sln @@ -235,6 +235,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "junie", "junie", "{6CAB0BC8 .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 @@ -539,6 +541,10 @@ Global {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 @@ -641,6 +647,7 @@ Global {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..c67d562c --- /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.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/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<>)); + } +} From 66c74f9c205e0a8085972146ff52404ac33767e6 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:28:02 +0100 Subject: [PATCH 8/9] Add extensive unit tests for `ProcessExecutor` and enhance `CreativeCoders.ProcessUtils` - Added comprehensive unit tests for `ProcessExecutor`, including synchronous and asynchronous methods, output parsing, exit code validation, and process disposal. - Introduced `ProcessExecutionResult` for encapsulating processes and results with proper disposal. - Refactored existing implementations to improve code readability and align with testing best practices. - Applied minor formatting updates across classes and sample projects for consistency. --- samples/ProcessUtilsSampleApp/Program.cs | 2 +- .../DefaultProcess.cs | 2 + .../Execution/IProcessExecutor.cs | 12 + .../Execution/ProcessExecutionResult.cs | 13 ++ .../Execution/ProcessExecutor.cs | 58 ++++- .../DefaultProcessFactoryTests.cs | 143 ++++++++++++ .../ProcessExecutorGenericTests.cs | 98 ++++++++- .../ProcessUtils/ProcessExecutorTests.cs | 206 +++++++++++++++++- 8 files changed, 521 insertions(+), 13 deletions(-) create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutionResult.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/DefaultProcessFactoryTests.cs diff --git a/samples/ProcessUtilsSampleApp/Program.cs b/samples/ProcessUtilsSampleApp/Program.cs index c67d562c..311581af 100644 --- a/samples/ProcessUtilsSampleApp/Program.cs +++ b/samples/ProcessUtilsSampleApp/Program.cs @@ -36,7 +36,7 @@ private static void Main(string[] args) var lines = executor.Execute(); - lines.ForEach(x => Console.WriteLine(x)); + lines?.Order().ForEach(x => Console.WriteLine(x)); Console.ReadLine(); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs index 7242d93a..c92e0b53 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/DefaultProcess.cs @@ -1,8 +1,10 @@ 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); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs index fe42480c..73e535cd 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -8,6 +8,10 @@ public interface IProcessExecutor T? Execute(); Task ExecuteAsync(); + + ProcessExecutionResult ExecuteEx(); + + Task> ExecuteExAsync(); } [PublicAPI] @@ -16,4 +20,12 @@ public interface IProcessExecutor void Execute(); Task ExecuteAsync(); + + IProcess ExecuteEx(); + + Task ExecuteExAsync(); + + int ExecuteAndReturnExitCode(); + + Task ExecuteAndReturnExitCodeAsync(); } 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 index 4e405406..d828319e 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs @@ -7,16 +7,44 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFa { public void Execute() { - using var process = StartProcess(); + 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 ExecuteAsync() + public async Task ExecuteExAsync() { - using var process = StartProcess(); + 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; } } @@ -27,23 +55,37 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro public T? Execute() { - using var process = StartProcess(); + 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 _outputParser.ParseOutput(output); + return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } - public async Task ExecuteAsync() + public async Task> ExecuteExAsync() { - using var process = StartProcess(); + var process = StartProcess(); var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); await process.WaitForExitAsync().ConfigureAwait(false); - return _outputParser.ParseOutput(output); + return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } } 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/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs index 9655e503..73c30e5a 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs @@ -17,7 +17,7 @@ public void Execute_ReadsOutput_AndParsesResult() { // Arrange const string fileName = "echo"; - var args = new[] {"hello"}; + var args = new[] { "hello" }; // Parser var parser = A.Fake>(); @@ -52,12 +52,59 @@ public void Execute_ReadsOutput_AndParsesResult() 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 args = new[] { "hello" }; var parser = A.Fake>(); A.CallTo(() => parser.ParseOutput("hello world")) @@ -92,4 +139,51 @@ public async Task ExecuteAsync_ReadsOutputAsync_AndParsesResult() 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/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs index ed739202..592257a7 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs @@ -18,7 +18,7 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() { // Arrange const string fileName = "my-tool"; - var args = new[] {"arg1", "arg2"}; + var args = new[] { "arg1", "arg2" }; var info = new ProcessExecutorInfo(fileName, args); @@ -73,12 +73,76 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() 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 args = new[] { "a", "b" }; var info = new ProcessExecutorInfo(fileName, args); @@ -136,4 +200,142 @@ public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsFor 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(); + } } From aaea2c6776dddbd84082c0ec1c7240301bf0ff42 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:31:56 +0100 Subject: [PATCH 9/9] Add unit tests for `AddProcessUtils` and update namespaces for `ProcessUtils` tests - Added comprehensive tests for `AddProcessUtils` to verify DI registrations, lifetimes, idempotency, and TryAdd semantics. - Refactored and updated namespaces for `ProcessUtils` tests to improve organizational structure and align with the project's conventions. --- .../Parsers/JsonOutputParserTests.cs | 2 +- .../PassThroughProcessOutputParserTests.cs | 2 +- .../Parsers/SplitLinesOutputParserTests.cs | 2 +- .../ProcessExecutorBuilderTests.cs | 13 +- .../ProcessExecutorGenericTests.cs | 2 +- .../{ => Execution}/ProcessExecutorTests.cs | 4 +- ...ssUtilsServiceCollectionExtensionsTests.cs | 159 ++++++++++++++++++ 7 files changed, 172 insertions(+), 12 deletions(-) rename tests/CreativeCoders.Core.UnitTests/ProcessUtils/{ => Execution}/Parsers/JsonOutputParserTests.cs (97%) rename tests/CreativeCoders.Core.UnitTests/ProcessUtils/{ => Execution}/Parsers/PassThroughProcessOutputParserTests.cs (95%) rename tests/CreativeCoders.Core.UnitTests/ProcessUtils/{ => Execution}/Parsers/SplitLinesOutputParserTests.cs (98%) rename tests/CreativeCoders.Core.UnitTests/ProcessUtils/{ => Execution}/ProcessExecutorBuilderTests.cs (96%) rename tests/CreativeCoders.Core.UnitTests/ProcessUtils/{ => Execution}/ProcessExecutorGenericTests.cs (98%) rename tests/CreativeCoders.Core.UnitTests/ProcessUtils/{ => Execution}/ProcessExecutorTests.cs (99%) create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessUtilsServiceCollectionExtensionsTests.cs diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/JsonOutputParserTests.cs similarity index 97% rename from tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs rename to tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/JsonOutputParserTests.cs index 1b737bd6..994b3064 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/JsonOutputParserTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/JsonOutputParserTests.cs @@ -5,7 +5,7 @@ using JetBrains.Annotations; using Xunit; -namespace CreativeCoders.Core.UnitTests.ProcessUtils.Parsers; +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution.Parsers; /// /// Tests for verifying JSON deserialization behavior and edge cases. diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/PassThroughProcessOutputParserTests.cs similarity index 95% rename from tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs rename to tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/PassThroughProcessOutputParserTests.cs index 749153be..ddf70ff1 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/PassThroughProcessOutputParserTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/PassThroughProcessOutputParserTests.cs @@ -2,7 +2,7 @@ using CreativeCoders.ProcessUtils.Execution.Parsers; using Xunit; -namespace CreativeCoders.Core.UnitTests.ProcessUtils.Parsers; +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution.Parsers; /// /// Tests for ensuring the parser returns the input unchanged. diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/SplitLinesOutputParserTests.cs similarity index 98% rename from tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs rename to tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/SplitLinesOutputParserTests.cs index 86dd454e..689b0ee5 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Parsers/SplitLinesOutputParserTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/Parsers/SplitLinesOutputParserTests.cs @@ -4,7 +4,7 @@ using CreativeCoders.ProcessUtils.Execution.Parsers; using Xunit; -namespace CreativeCoders.Core.UnitTests.ProcessUtils.Parsers; +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution.Parsers; /// /// Tests for to ensure line splitting and empty handling behave correctly. diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorBuilderTests.cs similarity index 96% rename from tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs rename to tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorBuilderTests.cs index 22b22629..ac17a395 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorBuilderTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorBuilderTests.cs @@ -12,7 +12,7 @@ using FakeItEasy; using Xunit; -namespace CreativeCoders.Core.UnitTests.ProcessUtils; +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; /// /// Tests for and . @@ -42,7 +42,7 @@ public void Build_WithFileNameAndArgs_CreatesExecutor_ThatStartsProcess() { // Arrange const string fileName = "tool"; - var args = new[] {"x", "y"}; + var args = new[] { "x", "y" }; var fakeProcess = A.Fake(); @@ -116,7 +116,7 @@ public void GenericBuild_WithCustomParser_AndConfigure_ReturnsParsedResult() { // Arrange const string fileName = "echo"; - var args = new[] {"val"}; + var args = new[] { "val" }; // Provide output "21" and configure parser to multiply by 2 -> 42 var fakeProcess = A.Fake(); @@ -152,7 +152,7 @@ public void GenericBuild_WithCustomConfiguredParser_ReturnsParsedResult() { // Arrange const string fileName = "echo"; - var args = new[] {"val"}; + var args = new[] { "val" }; // Provide output "21" and configure parser to multiply by 2 -> 42 var fakeProcess = A.Fake(); @@ -188,7 +188,7 @@ public async Task GenericBuild_ReturnOutputAsText_ReturnsRawText() { // Arrange const string fileName = "echo"; - var args = new[] {"hello"}; + var args = new[] { "hello" }; var fakeProcess = A.Fake(); var outputStream = new MemoryStream("hello world"u8.ToArray()); @@ -223,7 +223,8 @@ public async Task GenericBuild_ReturnOutputAsText_ReturnsRawText() [InlineData("42")] [InlineData("Test, 12345")] [InlineData(" 42 QWERTZ \n Line 1234")] - public void Build_WhenTIsStringAndNoOutputParser_DoesNotThrowAndReturnsStringExecutor(string processOutput) + public void Build_WhenTIsStringAndNoOutputParser_DoesNotThrowAndReturnsStringExecutor( + string processOutput) { // Arrange var fakeProcessFactory = A.Fake(); diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs similarity index 98% rename from tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs rename to tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs index 73c30e5a..d40a1799 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorGenericTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs @@ -8,7 +8,7 @@ using FakeItEasy; using Xunit; -namespace CreativeCoders.Core.UnitTests.ProcessUtils; +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; public class ProcessExecutorGenericTests { diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs similarity index 99% rename from tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs rename to tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs index 592257a7..ed978ea4 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs @@ -2,13 +2,13 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using AwesomeAssertions; using CreativeCoders.ProcessUtils; using CreativeCoders.ProcessUtils.Execution; using FakeItEasy; -using AwesomeAssertions; using Xunit; -namespace CreativeCoders.Core.UnitTests.ProcessUtils; +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; [SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ProcessExecutorTests 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); + } +}