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